Files
prep-step/PrepStep/AssemblyComponentScanner.cs
davideandClaude Sonnet 5 5c9661eb12 Fix euristica BOM, unifica script/GUI su un'unica fonte, aggiungi test integrazione/E2E
Due bug reali trovati confrontando l'estrazione BOM con la BOM ufficiale Creo:
un componente scartato per placement fallito in un file veniva bloccato per
sempre anche quando il file giusto aveva un vero placement (perdendo interi
sotto-alberi come 9256510047), e i codici con suffisso (_C01 ecc.) richiedevano
sempre placement esplicito invece dell'eccezione per occorrenze ripetute già
presente nella GUI. Entrambi corretti.

AssemblyComponentScanner reso pubblico e scripts/BomViaDb.cs, ExtractCreoStrings.cs
riscritti per referenziarlo via "#:project" invece di duplicare l'euristica:
un'unica fonte di verità condivisa da GUI e script standalone.

Aggiunti test di integrazione per Indexer.ScanAsync (mai coperto oltre al
parsing del nome file) e test end-to-end che ricostruiscono l'intera pipeline
disco -> indice -> risoluzione albero componenti senza toccare la UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:43:54 +02:00

155 lines
9.2 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
namespace PrepStep;
/// <summary>
/// Estrae i codici componente referenziati dentro un file .asm Pro-E/Creo.
/// Il formato è per lo più binario, ma i codici dei sotto-componenti compaiono
/// come stringhe ASCII leggibili al suo interno.
///
/// Due famiglie di codici, due strategie:
///
/// 1) La maggioranza (&gt;90%) sono codici numerici Pro-E del gestionale, con una forma
/// nota (stesso stile usato nei nomi file, vedi Indexer.FilenameRegex): si riconoscono
/// da soli, senza bisogno del DB, e possono quindi essere segnalati come "referenziati
/// ma non trovati nell'indice" quando un codice risulta rinominato/spostato - il
/// chiamante fa la ricerca nel DB e riporta i mancanti.
///
/// 2) Una minoranza sono parti commerciali scaricate come STEP da cataloghi online
/// (viti, anelli, boccole...), che in Creo hanno nomi alfabetici senza una forma
/// riconoscibile a priori (es. "ANELLO_ESTERNO-1-SOLID1", non un numero). Per questi
/// non esiste un pattern da indovinare: si tokenizza in modo generico (qualunque run
/// di lettere/cifre/underscore/trattino) e si lascia decidere al DB, già caricato dal
/// chiamante, se il token è un codice vero - altrimenti il rumore binario sarebbe
/// ingestibile. Un token così "convalidato" è per costruzione già nell'indice, quindi
/// non contribuisce mai ai "non trovati": per le parti commerciali mancanti dall'indice
/// non c'è modo di distinguerle dal rumore, restano semplicemente invisibili come lo
/// erano prima.
///
/// Verificato su un caso reale confrontando con l'albero componenti di Creo: i
/// riferimenti veri compaiono più volte nel file (una volta per feature/placement),
/// mentre il rumore binario casuale che assomiglia a un codice compare quasi sempre
/// una sola volta. Si scartano quindi, per entrambe le famiglie, i candidati "nudi" (senza
/// un suffisso di revisione/variante o una forma family-table nota) che non abbiano anche
/// un vero placement testuale nel binario.
///
/// Falso positivo noto ed escluso (famiglia 1): gli ID interni di viste salvate, nel
/// formato "ModelView#&lt;numero&gt;", hanno la stessa forma di un codice (8-12 cifre) e
/// compaiono più volte come un riferimento vero. Vedi docs/formato-asm.md.
///
/// Viti/dadi/rosette normalizzati UNI (e cuscinetti CUSCI_...) sono family table Creo: il
/// nome che compare nel binario (es. "R6--_-_U6592&lt;UNI6592&gt;",
/// "2510060303&lt;CUSCI_ACBB7&gt;") è la singola istanza, non un file separato su disco - il
/// file reale indicizzato è il generico "uni6592.prt.&lt;versione&gt;"/
/// "cusci_acbb7.prt.&lt;versione&gt;". Con la tokenizzazione generica "UNI6592"/
/// "CUSCI_ACBB7" vengono catturati da soli: "&lt;" e "&gt;" non fanno parte del charset del
/// token e ne delimitano naturalmente i confini. Vedi docs/formato-asm.md.
///
/// Lettera di revisione e suffisso "_..." possono comparire insieme sullo stesso codice
/// (es. "9284200366A_C01"), e il suffisso dopo "_" può essere puramente numerico (es.
/// "2702220182_8188", una variante dimensionale, non lettera+cifre come "_C01"/"_D100").
///
/// Un codice "nudo" (senza suffisso "_..." e senza forma family-table nota) ripetuto più
/// volte non basta: può trattarsi del riferimento al nome di un altro assieme che lo
/// contiene (non noto a priori, quindi non escludibile per confronto diretto come il
/// proprio codice) o di un residuo di feature soppresse/superate, oppure - per la
/// tokenizzazione generica - di puro rumore binario che coincide con un codice DB corto o
/// generico (vedi docs/formato-asm.md). Per questi si richiede anche un vero placement, in
/// una delle quattro forme osservate nel binario: "Componente ID N (&lt;codice&gt;...)"/
/// "Component id N (&lt;codice&gt;...)" (la stringa cambia con la localizzazione Creo del
/// file); il codice ripetuto due volte di seguito separato da un singolo byte NUL
/// ("&lt;codice&gt;\0&lt;codice&gt;\0", osservato per componenti di family table non-UNI,
/// es. cuscinetti "CUSCI_..."); il campo "family_name\0&lt;codice&gt;\0" (osservato una
/// sola volta per file, su un sotto-assieme referenziato come family table generica invece
/// che con un placement testuale esplicito); oppure il codice seguito immediatamente da un
/// bracket "&lt;codice&gt;&lt;GENERICO&gt;" - l'istanza di una family table non-UNI vista
/// nel nome (es. "2510060303&lt;CUSCI_ACBB7&gt;", un cuscinetto): il codice dell'istanza
/// non è mai indicizzato a sé (solo il generico tra "&lt;" e "&gt;" lo è), ma questo
/// segnale lo fa comunque sopravvivere fino alla ricerca nel DB invece di scomparire in
/// silenzio come "nudo senza placement" - lì verrà comunque scartato come "non in indice",
/// ma in modo tracciabile.
/// </summary>
// Pubblica (non solo internal) perché è anche l'unica fonte di verità per l'euristica di
// estrazione, referenziata via "#:project" dagli script standalone (scripts/BomViaDb.cs,
// scripts/ExtractCreoStrings.cs) invece che duplicata al loro interno - vedi
// docs/formato-asm.md, sezione sulla trappola del "codice duplicato in tre punti".
public static class AssemblyComponentScanner
{
private static readonly Regex CodiceNumericoRegex = new(
@"(?<!ModelView#)\b\d{8,12}[A-Za-z]{0,2}(?:_[A-Za-z]?\d{2,4})?\b",
RegexOptions.Compiled);
private static readonly Regex CodiceNudoRegex = new(@"^\d{8,12}[A-Za-z]{0,2}$", RegexOptions.Compiled);
// Nessuna forma ipotizzata a mano per le parti commerciali: qualunque run di
// lettere/cifre/underscore/trattino è un candidato, validato poi contro il DB.
private static readonly Regex TokenGenericoRegex = new(@"[A-Za-z0-9_-]{3,}", RegexOptions.Compiled);
// Suffisso di revisione/variante (es. "_C01", "_D100", "_8188") o prefisso di family
// table nota (UNI/CUSCI_): segnale forte abbastanza da non richiedere un placement
// esplicito, come per i codici numerici nudi con lo stesso suffisso.
private static readonly Regex SuffissoRegex = new(@"_[A-Za-z]?\d{2,4}$", RegexOptions.Compiled);
private const int OccorrenzeMinime = 2;
/// <summary>Codice radice (parte prima del primo "_") di un codice componente. Un
/// assieme con suffisso "_D.../_C..." referenzia sempre al suo interno anche l'assieme
/// generale a cui appartiene (codice radice, senza suffisso) - riferimento fantasma
/// verso l'alto, non un componente vero (vedi docs/formato-asm.md). Chi fa una
/// risoluzione ricorsiva dell'albero deve seminare l'insieme dei codici già visitati
/// anche con la radice del codice di partenza, non solo col codice stesso, altrimenti
/// risale fino all'assieme generale e riporta dentro l'intero albero del macchinario.</summary>
public static string CodiceRadice(string codice) => codice.Split('_')[0];
/// <param name="codiciNoti">Tutti i codici prt/asm noti all'indice (caricati una sola
/// volta dal chiamante, non per-file): usati per validare i token generici della
/// famiglia 2 (parti commerciali). Non serve per la famiglia 1 (codici numerici), che
/// si riconosce dalla forma senza bisogno del DB.</param>
public static HashSet<string> EstraiCodiciReferenziati(string percorsoAsm, ICollection<string>? codiciNoti = null)
{
var testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm));
bool HaPlacement(string codice) =>
Regex.IsMatch(testo, $@"Component(?:e)? id \d+ \([^()]*{Regex.Escape(codice)}[^()]*\)", RegexOptions.IgnoreCase)
|| testo.Contains(codice + "\0" + codice + "\0")
|| testo.Contains("family_name\0" + codice + "\0")
|| testo.Contains(codice + "<");
var risultato = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var occorrenzeNumeriche = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in CodiceNumericoRegex.Matches(testo))
occorrenzeNumeriche[m.Value] = occorrenzeNumeriche.GetValueOrDefault(m.Value) + 1;
foreach (var (codice, occorrenze) in occorrenzeNumeriche)
{
if (occorrenze < OccorrenzeMinime)
continue;
if (CodiceNudoRegex.IsMatch(codice) && !HaPlacement(codice))
continue;
risultato.Add(codice);
}
if (codiciNoti is { Count: > 0 })
{
var occorrenzeGeneriche = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in TokenGenericoRegex.Matches(testo))
{
if (codiciNoti.Contains(m.Value))
occorrenzeGeneriche[m.Value] = occorrenzeGeneriche.GetValueOrDefault(m.Value) + 1;
}
foreach (var (codice, occorrenze) in occorrenzeGeneriche)
{
if (risultato.Contains(codice))
continue;
bool formaNota = SuffissoRegex.IsMatch(codice)
|| codice.StartsWith("UNI", StringComparison.OrdinalIgnoreCase)
|| codice.StartsWith("CUSCI_", StringComparison.OrdinalIgnoreCase);
if (formaNota ? occorrenze >= OccorrenzeMinime : HaPlacement(codice))
risultato.Add(codice);
}
}
return risultato;
}
}