Working folder (phase 1): CSV/raw/step/step_finali all used to live split across two different implicit locations (CSV in the "repo root" found by walking up for CLAUDE.md, raw/step next to the input distinta file) with neither shown to the user. That repo-root lookup also silently breaks once the app runs as a standalone published exe on a machine with no repo/ CLAUDE.md at all. Replaced both with one explicit "cartella di lavoro", auto-suggested (last used, remembered the same way as the DB path) and changeable from phase 1, with no dependency on any repo marker file. Rename: CadSearchGui was the working name from before the app had a product identity; renamed folders, csproj files, namespace and the AppData settings folder to PrepStep throughout, matching the exe name already in use. Product-facing name stays "Cevolani StepPrep". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
164 lines
8.9 KiB
C#
164 lines
8.9 KiB
C#
#:package Microsoft.Data.Sqlite@10.0.12
|
|
// dotnet run scripts/ExtractCreoStrings.cs -- <file.asm> [cad_index.db] [output.txt]
|
|
//
|
|
// Estrae dal file .asm i codici dei componenti referenziati, espandendo ricorsivamente
|
|
// ogni sotto-assieme (.asm) trovato nell'indice, finché non restano solo PRT (foglie) o
|
|
// codici non presenti nell'indice. Stessa euristica di PrepStep/AssemblyComponentScanner.cs:
|
|
// i codici veri compaiono più volte nel file, il rumore binario quasi sempre una sola volta;
|
|
// gli ID interni "ModelView#<numero>" sono esclusi via lookbehind - vedi scripts/Analyze.cs.
|
|
// Ogni codice trovato viene validato contro l'indice cad_index.db.
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
if (args.Length < 1)
|
|
{
|
|
Console.WriteLine("Uso: dotnet run ExtractCreoStrings.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";
|
|
|
|
// Seconda alternativa: componenti normalizzati definiti come family table in Creo (viti/
|
|
// dadi/rosette UNI, ma anche cuscinetti CUSCI_...). Compaiono come "Componente ID N
|
|
// (R12--_-_U6592<UNI6592>.PRT)": "R12--_-_U6592" è il nome della singola istanza (riga
|
|
// della family table, non un file separato su disco), il file reale indicizzato è il
|
|
// generico "uni6592.prt.<versione>"/"cusci_acbb7.prt.<versione>" - va quindi estratto solo
|
|
// il contenuto tra "<" e ">".
|
|
// Terza alternativa: varianti dimensionali con suffisso puramente numerico (es. cinghie
|
|
// "2702220182_8188", dove "8188" è una misura, non una lettera di revisione/sotto-assieme
|
|
// come "_C01"/"_D100") - la lettera dopo "_" è quindi opzionale, non obbligatoria. La
|
|
// lettera di revisione e il suffisso "_" possono anche comparire insieme sullo stesso
|
|
// codice (es. "9284200366A_C01", componente di un sotto-assieme già di per sé revisionato).
|
|
var codiceRegex = new Regex(
|
|
@"(?<!ModelView#)\b\d{8,12}[A-Za-z]{0,2}(?:_[A-Za-z]?\d{2,4})?\b|(?<=<)(?:UNI\d+|CUSCI_[A-Za-z0-9]+)(?=>)",
|
|
RegexOptions.Compiled);
|
|
const int occorrenzeMinime = 2;
|
|
|
|
HashSet<string> EstraiCodiciReferenziati(string percorsoAsm)
|
|
{
|
|
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();
|
|
|
|
// Ripetersi >=2 volte nel file non basta per un codice "nudo" (senza suffisso "_..."):
|
|
// riferimenti al proprio assieme padre (nome sessione/file) o a codici superati/
|
|
// rinominati (caso "fantasma", docs/formato-asm.md) ripetono comunque il codice più
|
|
// volte pur non essendo mai un componente vero. Per questi si richiede 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 è,
|
|
// vedi sotto), 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 "_..." né alle istanze family table 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ì l'occorrenza ripetuta resta l'unico
|
|
// segnale disponibile.
|
|
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 + "<");
|
|
|
|
candidati.RemoveWhere(c => Regex.IsMatch(c, @"^\d{8,12}[A-Za-z]{0,2}$") && !HaPlacement(c));
|
|
return candidati;
|
|
}
|
|
|
|
using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly");
|
|
connection.Open();
|
|
|
|
// "files_ultima_versione" è una VIEW con ROW_NUMBER() OVER (...), che SQLite non
|
|
// materializza: ogni query la ricalcola sull'intera tabella "files" (~230ms anche per un
|
|
// solo codice, su ~300k righe). Batchare tutti i codici trovati in un file in un'unica
|
|
// query paga quel costo una volta per file invece che una volta per codice - decisivo
|
|
// sugli assiemi con centinaia di sotto-componenti referenziati.
|
|
Dictionary<string, List<(string Tipo, string Percorso)>> CercaComponenti(IReadOnlyCollection<string> codici)
|
|
{
|
|
var risultato = new Dictionary<string, List<(string, string)>>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var codice in codici)
|
|
risultato[codice] = new List<(string, string)>();
|
|
if (codici.Count == 0)
|
|
return risultato;
|
|
|
|
using var command = connection.CreateCommand();
|
|
var nomiParametri = codici.Select((_, i) => $"@c{i}").ToList();
|
|
// Lo stesso codice+tipo può esistere in più cartelle radice (duplicati/copie storiche
|
|
// sulla share, es. un generico family table ripetuto in 5 progetti scollegati): ordinato
|
|
// per data_modifica DESC, si tiene solo il più recente per codice+tipo (stessa regola di
|
|
// Form1.RisolviAlberoComponenti, vedi docs/formato-asm.md).
|
|
command.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione "
|
|
+ $"WHERE LOWER(codice) IN ({string.Join(",", nomiParametri)}) AND LOWER(tipo_file) IN ('prt', 'asm') "
|
|
+ "ORDER BY codice, tipo_file, data_modifica DESC";
|
|
int i = 0;
|
|
foreach (var codice in codici)
|
|
command.Parameters.AddWithValue(nomiParametri[i++], codice.ToLowerInvariant());
|
|
|
|
var tipiVisti = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
|
using var reader = command.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
string codiceDb = reader.GetString(0);
|
|
string tipo = reader.GetString(1).ToLowerInvariant();
|
|
var visti = tipiVisti.TryGetValue(codiceDb, out var v) ? v : tipiVisti[codiceDb] = new HashSet<string>();
|
|
if (visti.Add(tipo))
|
|
risultato[codiceDb].Add((tipo, reader.GetString(2)));
|
|
}
|
|
return risultato;
|
|
}
|
|
|
|
// Espansione ricorsiva: ogni sotto-assieme (.asm) trovato viene a sua volta letto e i suoi
|
|
// componenti aggiunti alla coda, finché non restano solo PRT (foglie) o codici non presenti
|
|
// nell'indice - stessa logica di Form1.RisolviAlberoComponenti.
|
|
var codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0];
|
|
// Un assieme con suffisso "_D..." referenzia sempre al suo interno anche l'assieme
|
|
// generale (codice nudo, senza suffisso) a cui appartiene - riferimento fantasma verso
|
|
// l'alto, non un componente vero (vedi docs/formato-asm.md). Va escluso subito: se
|
|
// seguito, riporta dentro l'intero albero del macchinario invece del solo sotto-assieme
|
|
// richiesto (visto su 9284208188_D600 -> 9284208188, che da solo genera 598 falsi
|
|
// positivi invece di ~90 componenti reali).
|
|
var codiceRadice = codiceProprio.Split('_')[0];
|
|
var componentiTrovati = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var nonTrovati = new List<string>();
|
|
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 candidati = EstraiCodiciReferenziati(asm)
|
|
.Where(codiciVisti.Add)
|
|
.ToList();
|
|
var trovatiPerCodice = CercaComponenti(candidati);
|
|
foreach (var codice in candidati)
|
|
{
|
|
var componenti = trovatiPerCodice[codice];
|
|
if (componenti.Count == 0)
|
|
{
|
|
nonTrovati.Add(codice);
|
|
continue;
|
|
}
|
|
componentiTrovati.Add(codice);
|
|
foreach (var (tipo, percorso) in componenti)
|
|
if (tipo == "asm")
|
|
daScansionare.Enqueue(percorso);
|
|
}
|
|
}
|
|
|
|
File.WriteAllLines(outPath, componentiTrovati);
|
|
Console.WriteLine($"{componentiTrovati.Count} componenti scritti in {outPath}");
|
|
if (nonTrovati.Count > 0)
|
|
Console.WriteLine($"Codici trovati nel file ma non nell'indice (scartati): {string.Join(", ", nonTrovati)}");
|
|
return 0;
|