From 8a8a7d041796bcec9e3f79a28cdf0f437f2be94c Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 18 Sep 2026 08:41:01 +0200 Subject: [PATCH] Recognize commercial parts with alphabetic names in .asm scanning Pro-E numeric codes cover most components, but parts downloaded as STEP from online catalogs (rings, bushings, knobs...) get alphabetic Creo names with no predictable shape. Add a second extraction path that tokenizes the binary generically and validates candidates against the known codes in cad_index.db, applying the same placement/occurrence rule used for bare numeric codes. Numeric extraction is unchanged, so missing-from-index reporting for renamed/moved Pro-E codes still works. Also load all known prt/asm codes once per scan instead of one query per candidate, in both RisolviAlberoComponenti and VerificaCompletezzaAssiemi. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 9 +- .../AssemblyComponentScannerTests.cs | 47 +++++- CadSearchGui/AssemblyComponentScanner.cs | 142 ++++++++++++------ CadSearchGui/Form1.cs | 90 ++++------- docs/formato-asm.md | 50 +++++- scripts/BomViaDb.cs | 113 ++++++++++++++ scripts/Query.cs | 18 +++ 7 files changed, 363 insertions(+), 106 deletions(-) create mode 100644 scripts/BomViaDb.cs create mode 100644 scripts/Query.cs diff --git a/CLAUDE.md b/CLAUDE.md index f1e0479..c9c721e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,9 +105,12 @@ disco di default `X:\`, hardcoded in `Form1.RadiceDiscoDefault`). `scripts/` contiene C# file-based apps (.NET 10, `dotnet run scripts/Nome.cs -- arg1 arg2`), senza csproj — dipendenze NuGet extra si aggiungono con `#:package Nome@versione` in cima al file. `ExtractCreoStrings.cs` estrae la BOM di un `.asm` risolvendo ricorsivamente i -sotto-assiemi contro `cad_index.db` (stessa logica di `Form1.AssiemeCompleto`); `Analyze.cs` -dumpa il contesto testo/hex attorno a un pattern in un file binario, per investigare a mano -nuovi falsi positivi prima di toccare la regex. +sotto-assiemi contro `cad_index.db` (stessa logica di `Form1.AssiemeCompleto`, solo codici +numerici Pro-E); `BomViaDb.cs` fa lo stesso ma con la tokenizzazione generica validata contro +il DB, usata per intercettare anche i nomi alfabetici delle parti commerciali (vedi +`docs/formato-asm.md`); `Analyze.cs` dumpa il contesto testo/hex attorno a un pattern in un +file binario, per investigare a mano nuovi falsi positivi prima di toccare la regex; `Query.cs` +esegue una query SQL ad-hoc contro `cad_index.db` da riga di comando. Il formato binario `.asm`, l'euristica di estrazione codici e i falsi positivi noti (ID vista `ModelView#`, self-reference, riferimenti fantasma a codici superati/rinominati — sintatticamente diff --git a/CadSearchGui.Tests/AssemblyComponentScannerTests.cs b/CadSearchGui.Tests/AssemblyComponentScannerTests.cs index b3ff19c..c8bcc9e 100644 --- a/CadSearchGui.Tests/AssemblyComponentScannerTests.cs +++ b/CadSearchGui.Tests/AssemblyComponentScannerTests.cs @@ -92,7 +92,7 @@ public class AssemblyComponentScannerTests "Componente ID 1 (R12--_-_U6592.PRT) x Componente ID 2 (R12--_-_U6592.PRT)"); try { - var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso); + var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso, codiciNoti: new[] { "UNI6592" }); Assert.Contains("UNI6592", codici); Assert.DoesNotContain("R12--_-_U6592", codici); } @@ -103,4 +103,49 @@ public class AssemblyComponentScannerTests public void File_inesistente_lancia_eccezione_IO() => Assert.Throws(() => AssemblyComponentScanner.EstraiCodiciReferenziati(@"C:\non\esiste\pippo.asm.1")); + + [Fact] + public void Riconosce_parte_commerciale_con_nome_alfabetico_se_nel_db() + { + // Visto su un caso reale: le parti commerciali scaricate come STEP (es. anelli, + // boccole) hanno nomi Creo alfabetici, non un codice numerico Pro-E - si + // riconoscono solo se il nome esiste davvero come codice indicizzato. + string percorso = ScriviAsmFinto( + "blah Componente ID 11 (ANELLO_ESTERNO-1-SOLID1.PRT) blah RUMORE_CASUALE blah"); + try + { + var codiciNoti = new[] { "ANELLO_ESTERNO-1-SOLID1" }; + var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso, codiciNoti); + Assert.Contains("ANELLO_ESTERNO-1-SOLID1", codici); + } + finally { File.Delete(percorso); } + } + + [Fact] + public void Scarta_token_alfabetico_nel_db_ma_senza_placement() + { + // Un token che coincide per puro caso con un codice DB corto/generico (es. una + // keyword interna di Creo) non basta: senza un vero placement resta rumore. + string percorso = ScriviAsmFinto("start_part blah blah start_part"); + try + { + var codiciNoti = new[] { "start_part" }; + var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso, codiciNoti); + Assert.DoesNotContain("start_part", codici); + } + finally { File.Delete(percorso); } + } + + [Fact] + public void Ignora_token_alfabetici_non_presenti_nel_db() + { + string percorso = ScriviAsmFinto( + "blah Componente ID 1 (QUALCOSA_DI_INESISTENTE-1.PRT) blah"); + try + { + var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso, codiciNoti: new[] { "ALTRO_CODICE" }); + Assert.DoesNotContain("QUALCOSA_DI_INESISTENTE-1", codici); + } + finally { File.Delete(percorso); } + } } diff --git a/CadSearchGui/AssemblyComponentScanner.cs b/CadSearchGui/AssemblyComponentScanner.cs index 41b90a9..ab3619b 100644 --- a/CadSearchGui/AssemblyComponentScanner.cs +++ b/CadSearchGui/AssemblyComponentScanner.cs @@ -6,59 +6,86 @@ namespace CadSearchGui; /// /// 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: si scansiona il file come testo -/// cercando pattern che assomigliano a un codice (stesso stile usato nei nomi file, -/// vedi Indexer.FilenameRegex) e si lascia poi al chiamante il compito di verificarli -/// contro l'indice DB, che scarta automaticamente i falsi positivi (numeri che non -/// corrispondono a nessun codice reale). +/// come stringhe ASCII leggibili al suo interno. /// -/// Verificato su un caso reale confrontando con un report BOM generato da Creo: i +/// Due famiglie di codici, due strategie: +/// +/// 1) La maggioranza (>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 i candidati con una sola occorrenza. +/// 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: gli ID interni di viste salvate, nel formato -/// "ModelView#<numero>", hanno la stessa forma di un codice (8-12 cifre) e +/// Falso positivo noto ed escluso (famiglia 1): gli ID interni di viste salvate, nel +/// formato "ModelView#<numero>", 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<UNI6592>", /// "2510060303<CUSCI_ACBB7>") è la singola istanza, non un file separato su disco - il /// file reale indicizzato è il generico "uni6592.prt.<versione>"/ -/// "cusci_acbb7.prt.<versione>", quindi va estratto solo il contenuto tra "<" e -/// ">". Vedi docs/formato-asm.md. +/// "cusci_acbb7.prt.<versione>". Con la tokenizzazione generica "UNI6592"/ +/// "CUSCI_ACBB7" vengono catturati da soli: "<" e ">" 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 bracket UNI) 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 (vedi docs/formato-asm.md). Per questi si -/// richiede anche un vero placement, in una delle quattro forme osservate nel binario: -/// "Componente ID N (<codice>...)"/"Component id N (<codice>...)" (la stringa -/// cambia con la localizzazione Creo del file); il codice ripetuto due volte di seguito -/// separato da un singolo byte NUL ("<codice>\0<codice>\0", osservato per -/// componenti di family table non-UNI, es. cuscinetti "CUSCI_..."); il campo -/// "family_name\0<codice>\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 "<codice><GENERICO>" - -/// l'istanza di una family table non-UNI vista nel nome (es. "2510060303<CUSCI_ACBB7>", -/// un cuscinetto): il codice dell'istanza non è mai indicizzato a sé (solo il generico tra -/// "<" e ">" 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. Non si applica alle varianti con -/// suffisso o bracket UNI: in alcuni file il placement riporta solo il nome -/// istanza/generico senza il dettaglio del suffisso o del bracket, che vive altrove nei -/// metadati - lì resta necessaria la sola euristica delle occorrenze ripetute. +/// 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 (<codice>...)"/ +/// "Component id N (<codice>...)" (la stringa cambia con la localizzazione Creo del +/// file); il codice ripetuto due volte di seguito separato da un singolo byte NUL +/// ("<codice>\0<codice>\0", osservato per componenti di family table non-UNI, +/// es. cuscinetti "CUSCI_..."); il campo "family_name\0<codice>\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 "<codice><GENERICO>" - l'istanza di una family table non-UNI vista +/// nel nome (es. "2510060303<CUSCI_ACBB7>", un cuscinetto): il codice dell'istanza +/// non è mai indicizzato a sé (solo il generico tra "<" e ">" 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. /// static class AssemblyComponentScanner { - private static readonly Regex CodiceRegex = new( - @"(?)", + private static readonly Regex CodiceNumericoRegex = new( + @"(?Codice radice (parte prima del primo "_") di un codice componente. Un @@ -70,15 +97,13 @@ static class AssemblyComponentScanner /// risale fino all'assieme generale e riporta dentro l'intero albero del macchinario. public static string CodiceRadice(string codice) => codice.Split('_')[0]; - public static HashSet EstraiCodiciReferenziati(string percorsoAsm) + /// 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. + public static HashSet EstraiCodiciReferenziati(string percorsoAsm, ICollection? codiciNoti = null) { var testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm)); - var occorrenze = new Dictionary(); - foreach (Match m in CodiceRegex.Matches(testo)) - { - occorrenze[m.Value] = occorrenze.GetValueOrDefault(m.Value) + 1; - } - var candidati = occorrenze.Where(kv => kv.Value >= OccorrenzeMinime).Select(kv => kv.Key).ToHashSet(); bool HaPlacement(string codice) => Regex.IsMatch(testo, $@"Component(?:e)? id \d+ \([^()]*{Regex.Escape(codice)}[^()]*\)", RegexOptions.IgnoreCase) @@ -86,7 +111,40 @@ static class AssemblyComponentScanner || testo.Contains("family_name\0" + codice + "\0") || testo.Contains(codice + "<"); - candidati.RemoveWhere(c => CodiceNudoRegex.IsMatch(c) && !HaPlacement(c)); - return candidati; + var risultato = new HashSet(StringComparer.OrdinalIgnoreCase); + + var occorrenzeNumeriche = new Dictionary(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(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; } } diff --git a/CadSearchGui/Form1.cs b/CadSearchGui/Form1.cs index ce8be92..ec9ac67 100644 --- a/CadSearchGui/Form1.cs +++ b/CadSearchGui/Form1.cs @@ -662,6 +662,31 @@ public partial class Form1 : Form ImpostaStatoImport($"{eliminati} file eliminati da {string.Join(" e ", cartelle)}.", Color.DarkGreen); } + /// Tutti i codici prt/asm noti all'indice, con i relativi percorsi (ultima + /// versione, un solo file per tipo - stesso criterio di dedup per data_modifica DESC + /// usato ovunque in questa classe). Caricato in un'unica query invece che una per + /// codice: serve sia a risolvere i percorsi dei componenti trovati, sia a + /// AssemblyComponentScanner per validare i token generici (parti commerciali). + private static Dictionary> CaricaCodiciNoti(string dbPath) + { + var codiciNoti = new Dictionary>(StringComparer.OrdinalIgnoreCase); + using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione " + + "WHERE LOWER(tipo_file) IN ('prt', 'asm') ORDER BY codice, tipo_file, data_modifica DESC"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + string codice = reader.GetString(0); + string tipo = reader.GetString(1).ToLowerInvariant(); + var lista = codiciNoti.TryGetValue(codice, out var l) ? l : codiciNoti[codice] = new(); + if (!lista.Any(x => x.Tipo == tipo)) + lista.Add((tipo, reader.GetString(2))); + } + return codiciNoti; + } + /// Risolve ricorsivamente l'albero dei componenti a partire dai file .asm indicati /// (già copiati in "raw"): per ogni assieme legge i codici referenziati al suo interno, li /// cerca nell'indice, copia in "raw" quelli trovati e, se sono a loro volta assiemi, li @@ -681,42 +706,19 @@ public partial class Form1 : Form if (daScansionare.Count == 0 || _dbPath is null) return (trovati, codiciNonTrovati); - using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly"); - connection.Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT tipo_file, percorso_completo FROM files_ultima_versione " - + "WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ('prt', 'asm') " - + "ORDER BY tipo_file, data_modifica DESC"; - var pCodice = command.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text); + var codiciNoti = CaricaCodiciNoti(_dbPath); int processati = 0; while (daScansionare.Count > 0) { var asm = daScansionare.Dequeue(); processati++; - foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm)) + foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm, codiciNoti.Keys)) { if (!codiciVisti.Add(codice)) continue; - pCodice.Value = codice; - // Lo stesso codice+tipo può esistere in più cartelle radice (duplicati sulla - // share): ordinato per data_modifica DESC, si tiene solo il più recente per tipo. - var componenti = new List<(string Tipo, string Percorso)>(); - var tipiVisti = new HashSet(); - using (var reader = command.ExecuteReader()) - { - while (reader.Read()) - { - string tipoLower = reader.GetString(0).ToLowerInvariant(); - if (tipiVisti.Add(tipoLower)) - { - componenti.Add((tipoLower, reader.GetString(1))); - } - } - } - - if (componenti.Count == 0) + if (!codiciNoti.TryGetValue(codice, out var componenti)) { codiciNonTrovati.Add(codice); continue; @@ -790,39 +792,11 @@ public partial class Form1 : Form return; } - using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly"); - connection.Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT tipo_file, percorso_completo FROM files_ultima_versione " - + "WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ('prt', 'asm') " - + "ORDER BY tipo_file, data_modifica DESC"; - var pCodice = command.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text); - - var cacheComponenti = new Dictionary>(); + var codiciNoti = CaricaCodiciNoti(_dbPath); var cacheCodiciFile = new Dictionary>(); - List<(string Tipo, string Percorso)> CercaComponenti(string codice) - { - if (cacheComponenti.TryGetValue(codice, out var giaTrovati)) - return giaTrovati; - - pCodice.Value = codice; - var risultato = new List<(string Tipo, string Percorso)>(); - var tipiVisti = new HashSet(); - using (var reader = command.ExecuteReader()) - { - while (reader.Read()) - { - string tipo = reader.GetString(0).ToLowerInvariant(); - if (tipiVisti.Add(tipo)) - { - risultato.Add((tipo, reader.GetString(1))); - } - } - } - cacheComponenti[codice] = risultato; - return risultato; - } + List<(string Tipo, string Percorso)> CercaComponenti(string codice) => + codiciNoti.TryGetValue(codice, out var trovati) ? trovati : new List<(string, string)>(); bool AssiemeCompleto(string percorsoAsmIniziale) { @@ -843,7 +817,7 @@ public partial class Form1 : Form { try { - codiciReferenziati = AssemblyComponentScanner.EstraiCodiciReferenziati(file); + codiciReferenziati = AssemblyComponentScanner.EstraiCodiciReferenziati(file, codiciNoti.Keys); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/docs/formato-asm.md b/docs/formato-asm.md index 52fed2f..23e3a2e 100644 --- a/docs/formato-asm.md +++ b/docs/formato-asm.md @@ -179,6 +179,52 @@ table il placement può mancare del dettaglio, quindi resta necessaria la sola euristica delle occorrenze ripetute - meno precisa, ma l'unica applicabile senza rischiare falsi negativi su casi come questi. +## Parti commerciali con nome alfabetico (validazione via DB) + +Oltre il 90% dei codici indicizzati sono numerici Pro-E (regex sopra), ma una minoranza +sono parti commerciali scaricate come STEP da cataloghi online (anelli, boccole, guarnizioni, +manopole...) che in Creo hanno un nome **alfabetico**, assegnato dal file STEP di origine, non +un codice del gestionale — es. `NOME_PARTE-1-SOLID1`, senza nessuna cifra riconoscibile. Non +esiste un pattern comune da ipotizzare a priori per questi nomi: variano da fornitore a +fornitore. + +Per questa famiglia l'estrazione si inverte: invece di restringere la regex a una forma nota +(come per i codici numerici), si tokenizza il binario in modo generico — + +``` +regex: [A-Za-z0-9_-]{3,} +``` + +— e si lascia decidere al DB (`cad_index.db`, già caricato in memoria una sola volta dal +chiamante, non per-file) se il token è un codice reale. Senza questo filtro il rumore binario +sarebbe ingestibile: su un assieme con poche migliaia di stringhe, la tokenizzazione generica +produce tipicamente qualche migliaio di candidati, di cui solo una manciata sopravvive al +controllo DB. + +L'essere nel DB da solo non basta, per lo stesso motivo dei codici numerici "nudi": codici +corti o generici (es. una keyword interna di Creo come `start_part`, o un codice indicizzato +per errore/caso limite) possono comparire per puro rumore. Si applica quindi la stessa regola +di validazione della famiglia numerica: + +- un token con un suffisso di revisione/variante (`_[A-Za-z]?\d{2,4}` in coda, es. `_C01`, + `_D100`) o un prefisso di family table nota (`UNI`, `CUSCI_`) è considerato "forma nota": + basta che occorra almeno 2 volte nel file; +- un token "nudo" (nessuno dei due segnali) richiede invece un vero placement testuale, come + per i codici numerici nudi (stesse quattro forme viste sopra). + +Una parte commerciale referenziata ma **non ancora indicizzata** non è distinguibile dal +rumore con questo metodo: resta invisibile, come lo era prima che questa famiglia venisse +gestita. La famiglia numerica invece resta rilevabile anche quando manca dall'indice +(rinominata/spostata), perché si riconosce dalla forma senza bisogno del DB — è per questo +che le due famiglie restano due passaggi distinti invece di un unico tokenizer generico +sempre validato contro il DB: unificarle farebbe perdere la segnalazione dei codici numerici +mancanti (vedi "Riferimenti fantasma" sopra). + +`AssemblyComponentScanner.EstraiCodiciReferenziati` accetta quindi un parametro opzionale +`codiciNoti` (l'insieme di tutti i codici prt/asm noti all'indice) usato solo per questa +seconda famiglia; se omesso, si estraggono solo i codici numerici. `scripts/BomViaDb.cs` +è il prototipo standalone usato per validare l'approccio prima di portarlo nella GUI. + ## Componenti normalizzati (family table UNI, CUSCI, ...) I componenti di minuteria standard (viti, dadi, rosette UNI) e i cuscinetti @@ -225,8 +271,8 @@ livello) serve una BFS: Stessa logica di estrazione candidati ed euristica del codice radice (sopra), duplicata in due punti del codice per scopi diversi: -- [`AssemblyComponentScanner.EstraiCodiciReferenziati`](../CadSearchGui/AssemblyComponentScanner.cs) + `CodiceRadice` — estrazione dei candidati da un file, usata dalla BFS in [`Form1.RisolviAlberoComponenti`](../CadSearchGui/Form1.cs) (fase "Trova componenti referenziati") e [`Form1.AssiemeCompleto`](../CadSearchGui/Form1.cs) (fase "Verifica completezza"). Entrambe seminano l'insieme dei codici visitati con codice + radice di ogni file di partenza. -- [`scripts/ExtractCreoStrings.cs`](../scripts/ExtractCreoStrings.cs) — stessa BFS ricorsiva standalone da riga di comando (copia indipendente della stessa euristica, nessun `ProjectReference` verso la GUI). Usato per confrontare l'euristica contro l'albero Creo di un singolo file durante il reverse engineering, senza dover passare dalla UI/dal DB `raw`. +- [`AssemblyComponentScanner.EstraiCodiciReferenziati`](../CadSearchGui/AssemblyComponentScanner.cs) + `CodiceRadice` — estrazione dei candidati da un file, usata dalla BFS in [`Form1.RisolviAlberoComponenti`](../CadSearchGui/Form1.cs) (fase "Trova componenti referenziati") e [`Form1.AssiemeCompleto`](../CadSearchGui/Form1.cs) (fase "Verifica completezza"). Entrambe seminano l'insieme dei codici visitati con codice + radice di ogni file di partenza, e caricano una volta sola tutti i codici noti all'indice (`Form1.CaricaCodiciNoti`) sia per validare i token generici delle parti commerciali sia per risolvere i percorsi dei componenti trovati, senza una query SQL per candidato. +- [`scripts/ExtractCreoStrings.cs`](../scripts/ExtractCreoStrings.cs) e [`scripts/BomViaDb.cs`](../scripts/BomViaDb.cs) — stessa BFS ricorsiva standalone da riga di comando (copia indipendente della stessa euristica, nessun `ProjectReference` verso la GUI). Usati per confrontare l'euristica contro l'albero Creo di un singolo file durante il reverse engineering, senza dover passare dalla UI/dal DB `raw`. Nota: le query contro `files_ultima_versione` filtrano solo per `(cartella_radice, codice, tipo_file)` - se lo stesso codice esiste in più diff --git a/scripts/BomViaDb.cs b/scripts/BomViaDb.cs new file mode 100644 index 0000000..37213a1 --- /dev/null +++ b/scripts/BomViaDb.cs @@ -0,0 +1,113 @@ +#:package Microsoft.Data.Sqlite@10.0.12 +// dotnet run scripts/BomViaDb.cs -- [cad_index.db] [output.txt] +// +// PROVA: a differenza di ExtractCreoStrings.cs (che restringe i candidati a un solo +// pattern di codice, tarato sui codici numerici Pro-E), qui l'estrazione è generica - +// qualunque run di lettere/cifre/underscore/trattino - e il DB decide se è un codice +// vero, così si intercettano anche i nomi alfabetici delle parti commerciali scaricate +// come STEP (es. "ANELLO_ESTERNO-1-SOLID1"). L'essere nel DB da solo non basta però: +// codici corti o generici (es. "907", "NONE") comparirebbero per puro rumore binario, +// quindi si richiede comunque il placement esplicito nel file, come in +// ExtractCreoStrings.cs. Stessa espansione ricorsiva sui sotto-assiemi. +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Data.Sqlite; + +if (args.Length < 1) +{ + Console.WriteLine("Uso: dotnet run BomViaDb.cs -- [cad_index.db] [output.txt]"); + return 1; +} + +string asmIniziale = args[0]; +string dbPath = args.Length > 1 ? args[1] : "cad_index.db"; +string outPath = args.Length > 2 ? args[2] : "BOM.txt"; + +// Oltre il 90% dei codici in indice sono numerici Pro-E, ma una minoranza (parti +// commerciali scaricate come STEP, es. "ANELLO_ESTERNO-1-SOLID1") ha nomi alfabetici +// con trattini/underscore. Niente forma di codice ipotizzata a mano: si tokenizza in +// modo generico (run di lettere/cifre/underscore/trattino) e si lascia al DB decidere +// cos'è un codice vero - stesso principio già usato per il filtro, esteso anche +// all'estrazione dei candidati. Cattura anche "UNI6592"/"CUSCI_ACBB7" dentro +// "" gratis: '<' e '>' non fanno parte del charset e delimitano il token. +var codiceRegex = new Regex(@"[A-Za-z0-9_-]{3,}", RegexOptions.Compiled); + +using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); +connection.Open(); + +// Tutti i codici prt/asm noti all'indice, caricati una volta sola: il lookup per ogni +// candidato estratto dal binario diventa O(1) invece di una query per file. +var codiciNoti = new Dictionary>(StringComparer.OrdinalIgnoreCase); +using (var command = connection.CreateCommand()) +{ + command.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione " + + "WHERE LOWER(tipo_file) IN ('prt', 'asm') ORDER BY codice, tipo_file, data_modifica DESC"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + string codice = reader.GetString(0); + string tipo = reader.GetString(1).ToLowerInvariant(); + var lista = codiciNoti.TryGetValue(codice, out var l) ? l : codiciNoti[codice] = new(); + if (!lista.Any(x => x.Tipo == tipo)) + lista.Add((tipo, reader.GetString(2))); + } +} +Console.WriteLine($"Codici prt/asm noti nell'indice: {codiciNoti.Count}"); + +// Stesso controllo di placement di ExtractCreoStrings.cs/AssemblyComponentScanner.cs: +// la sola presenza del codice nel DB non basta, perché codici corti o generici +// (es. "907", "NONE", "start_part") possono comparire per puro rumore binario. Un +// componente vero è sempre referenziato in una di queste forme (vedi +// docs/formato-asm.md): "Componente ID N (...)"/"Component id N (...)", +// il codice ripetuto due volte separato da un byte NUL, il campo +// "family_name\0\0", o il codice seguito da un bracket "". +bool HaPlacement(string testo, 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 codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0]; +// Stessa esclusione di ExtractCreoStrings.cs: un assieme "_D..." referenzia sempre al +// suo interno anche l'assieme generale a cui appartiene (riferimento fantasma verso +// l'alto), da escludere subito o si risale tutto l'albero del macchinario. +var codiceRadice = codiceProprio.Split('_')[0]; + +var componentiTrovati = new SortedSet(StringComparer.OrdinalIgnoreCase); +var scartatiNonInIndice = new SortedSet(StringComparer.OrdinalIgnoreCase); +var scartatiSenzaPlacement = new SortedSet(StringComparer.OrdinalIgnoreCase); +var codiciVisti = new HashSet(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice }; +var daScansionare = new Queue(new[] { asmIniziale }); + +while (daScansionare.Count > 0) +{ + var asm = daScansionare.Dequeue(); + var testo = Encoding.Latin1.GetString(File.ReadAllBytes(asm)); + var candidati = codiceRegex.Matches(testo).Select(m => m.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var candidato in candidati) + { + if (!codiciVisti.Add(candidato)) + continue; + if (!codiciNoti.TryGetValue(candidato, out var componenti)) + { + scartatiNonInIndice.Add(candidato); + continue; + } + if (!HaPlacement(testo, candidato)) + { + scartatiSenzaPlacement.Add(candidato); + continue; + } + componentiTrovati.Add(candidato); + foreach (var (tipo, percorso) in componenti) + if (tipo == "asm") + daScansionare.Enqueue(percorso); + } +} + +File.WriteAllLines(outPath, componentiTrovati); +Console.WriteLine($"{componentiTrovati.Count} componenti scritti in {outPath}"); +Console.WriteLine($"Candidati nell'indice ma senza placement (scartati, {scartatiSenzaPlacement.Count}): {string.Join(", ", scartatiSenzaPlacement.Take(30))}" + + (scartatiSenzaPlacement.Count > 30 ? ", ..." : "")); +Console.WriteLine($"Candidati non nell'indice (rumore binario): {scartatiNonInIndice.Count}"); +return 0; diff --git a/scripts/Query.cs b/scripts/Query.cs new file mode 100644 index 0000000..5510e74 --- /dev/null +++ b/scripts/Query.cs @@ -0,0 +1,18 @@ +#:package Microsoft.Data.Sqlite@10.0.12 +// dotnet run scripts/Query.cs -- +using Microsoft.Data.Sqlite; + +using var connection = new SqliteConnection($"Data Source={args[0]};Mode=ReadOnly"); +connection.Open(); +using var command = connection.CreateCommand(); +command.CommandText = args[1]; +using var reader = command.ExecuteReader(); +int righe = 0; +while (reader.Read()) +{ + righe++; + var valori = Enumerable.Range(0, reader.FieldCount).Select(i => reader.IsDBNull(i) ? "" : reader.GetValue(i).ToString()); + Console.WriteLine(string.Join(" | ", valori)); +} +Console.WriteLine($"({righe} righe)"); +return 0;