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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-18 08:41:01 +02:00
1 parent c75dd21551
commit 8a8a7d0417
7 files changed
+363 -106

No files matched your search

+6 -3
View File
@@ -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
@@ -92,7 +92,7 @@ public class AssemblyComponentScannerTests
"Componente ID 1 (R12--_-_U6592<UNI6592>.PRT) x Componente ID 2 (R12--_-_U6592<UNI6592>.PRT)");
try
{
var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso);
var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso, codiciNoti: new[] { "UNI6592" });
Assert.Contains("UNI6592", codici);
Assert.DoesNotContain("R12--_-_U6592<UNI6592>", codici);
}
@@ -103,4 +103,49 @@ public class AssemblyComponentScannerTests
public void File_inesistente_lancia_eccezione_IO() =>
Assert.Throws<DirectoryNotFoundException>(() =>
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); }
}
}
+100 -42
View File
@@ -6,59 +6,86 @@ namespace CadSearchGui;
/// <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: 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 (&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 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#&lt;numero&gt;", 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#&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;", quindi va estratto solo il contenuto tra "&lt;" e
/// "&gt;". Vedi docs/formato-asm.md.
/// "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 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 (&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. 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 (&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>
static class AssemblyComponentScanner
{
private static readonly Regex CodiceRegex = new(
@"(?<!ModelView#)\b\d{8,12}[A-Za-z]{0,2}(?:_[A-Za-z]?\d{2,4})?\b|(?<=<)(?:UNI\d+|CUSCI_[A-Za-z0-9]+)(?=>)",
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
@@ -70,15 +97,13 @@ static class AssemblyComponentScanner
/// risale fino all'assieme generale e riporta dentro l'intero albero del macchinario.</summary>
public static string CodiceRadice(string codice) => codice.Split('_')[0];
public static HashSet<string> EstraiCodiciReferenziati(string percorsoAsm)
/// <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));
var occorrenze = new Dictionary<string, int>();
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<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;
}
}
+32 -58
View File
@@ -662,6 +662,31 @@ public partial class Form1 : Form
ImpostaStatoImport($"{eliminati} file eliminati da {string.Join(" e ", cartelle)}.", Color.DarkGreen);
}
/// <summary>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).</summary>
private static Dictionary<string, List<(string Tipo, string Percorso)>> CaricaCodiciNoti(string dbPath)
{
var codiciNoti = new Dictionary<string, List<(string Tipo, string Percorso)>>(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;
}
/// <summary>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<string>();
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<string, List<(string Tipo, string Percorso)>>();
var codiciNoti = CaricaCodiciNoti(_dbPath);
var cacheCodiciFile = new Dictionary<string, HashSet<string>>();
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<string>();
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)
{
+48 -2
View File
@@ -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ù
+113
View File
@@ -0,0 +1,113 @@
#:package Microsoft.Data.Sqlite@10.0.12
// dotnet run scripts/BomViaDb.cs -- <file.asm> [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 -- <file.asm> [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
// "<UNI6592>" 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<string, List<(string Tipo, string Percorso)>>(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 (<codice>...)"/"Component id N (<codice>...)",
// il codice ripetuto due volte separato da un byte NUL, il campo
// "family_name\0<codice>\0", o il codice seguito da un bracket "<codice><GENERICO>".
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<string>(StringComparer.OrdinalIgnoreCase);
var scartatiNonInIndice = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var scartatiSenzaPlacement = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var codiciVisti = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
var daScansionare = new Queue<string>(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;
+18
View File
@@ -0,0 +1,18 @@
#:package Microsoft.Data.Sqlite@10.0.12
// dotnet run scripts/Query.cs -- <cad_index.db> <sql>
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;