diff --git a/docs/architettura.md b/docs/architettura.md index 8ac39ce..bace18c 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -151,6 +151,11 @@ dipendenze extra con `#:package Nome@versione` in testa al file): Pro-E). - **BomViaDb.cs** — come sopra ma con la tokenizzazione generica validata contro il DB, per intercettare anche i nomi alfabetici delle parti commerciali. +- **CreoBom.cs** — indipendente dagli altri: legge esattamente la struttura a record del + binario `.asm` (sezione `members` + parametro `ConfigName`, vedi + `docs/formato-asm-record.md`) invece di contare occorrenze di codici nel testo. Risolve i + sottoassiemi cercandoli in cartelle locali (`-p`), non contro `cad_index.db`. Non sostituisce + l'euristica di `AssemblyComponentScanner` nella GUI né negli altri script. - **Analyze.cs** — dump del contesto testo/hex attorno a un pattern in un file binario, per investigare a mano nuovi falsi positivi prima di toccare la regex di `AssemblyComponentScanner`. diff --git a/docs/formato-asm-record.md b/docs/formato-asm-record.md new file mode 100644 index 0000000..039ee25 --- /dev/null +++ b/docs/formato-asm-record.md @@ -0,0 +1,318 @@ +# Decodifica strutturale del .asm Creo: record, membri, family table + +Questo documento descrive il formato **record per record** del `.asm` Creo, ricavato per +reverse engineering su un campione di assiemi reali e verificato confrontando l'output con +le BOM esportate da Creo per gli stessi file. Sostituisce, per l'estrazione dei componenti, +l'euristica testuale ("codice che compare almeno due volte") descritta in +[`formato-asm.md`](formato-asm.md): quella era una statistica sui nomi, questa legge la +struttura effettiva dei record. + +Implementazione: [`scripts/CreoBom.cs`](../scripts/CreoBom.cs). + +**Stato della validazione.** Durante il reverse engineering, il livello 1 estratto da 3 +assiemi con BOM Creo di riferimento coincideva **esattamente**: stesse voci, stesso nome +istanza, stesse quantità, stesso ordine. Altri 5 `.asm`, mai usati per ricavare le regole, +parsavano con il conteggio dichiarato uguale a quello trovato. Tutto ciò che segue oltre +questo perimetro (§7) è segnalato esplicitamente come ipotesi non dimostrata. + +--- + +## 1. Intestazione + +Il file si apre con un header ASCII a righe terminate da `\` + newline: + +``` +#UGC:2 ASSEMBLY 2038 1760 805 1 1 15 4000 2022414 000002d0 \ +#- VERS 0 0 \ +#- HOST \ +#- LINK \ +#- DBID \ +#- REVS 0, \ +``` + +`ASSEMBLY` distingue il tipo di modello. Gli altri campi numerici non sono +stati decodificati e non servono per la BOM. Dopo l'header inizia il corpo +binario. **Il corpo non è compresso**: i nomi dei modelli sono in chiaro, +1 byte per carattere (Latin1 — `Encoding.Latin1.GetString`, stesso metodo già +usato in `AssemblyComponentScanner.cs` per leggere il file come testo senza +alterarne i byte). + +## 2. Il formato dei record + +Il corpo è una serializzazione a marcatori. I byte `\xe0`-`\xff` sono +marcatori di struttura, tutti gli altri sono dati. I marcatori osservati: + +| byte | significato dedotto | +|------|---------------------| +| `\xe0` | inizio campo: `\xe0 \0 ` | +| `\xe1` | valore nullo / vuoto | +| `\xe2` | inizio struttura o unione (per i parametri: il byte dopo è il discriminante) | +| `\xe3` | inizio struttura, usato per gli elementi di un array | +| `\xf1` | apre un contenitore (visto prima di un array) | +| `\xf7` | id di schema: `\xf7 ` identifica il layout dei record che seguono | +| `\xf8` | array: `\xf8 ... ` | +| `\xfb` | lo schema è definito in linea nel primo elemento dell'array | +| `\xf4` | chiude una sezione (seguito da `#\n#\n`) | + +Il byte `` dopo `\xe0` codifica il tipo del valore. Osservati: +`\x00` struttura/nullable, `\x01` intero, `\x02` array numerico, +`\x0a` stringa terminata da `\0`. + +### 2.1 Interi + +Gli interi sono big-endian con la **lunghezza codificata nei bit alti del primo +byte**, per non collidere con i marcatori `\xe0`+: + +| primo byte | byte totali | valore | +|------------|-------------|--------| +| `0x00`-`0x7f` | 1 | il byte stesso | +| `0x80`-`0xbf` | 2 | `(b0 & 0x3f) << 8 \| b1` | +| `0xc0`-`0xdf` | 3 | `(b0 & 0x1f) << 16 \| b1 << 8 \| b2` | + +Esempi verificati: `\x2e` = 46; `\x80\x81` = 129; `\xc0\x4d\x51` = 19793. +Questa regola è stata **ricavata** dalla necessità di far quadrare il +conteggio dei membri: con la lettura a 1 byte fisso i campioni grandi si +disallineavano. Non è confermata da una specifica; regge sui campioni +disponibili al momento del reverse engineering. + +In `CreoBom.cs` è `CreoAsm.AsInt`, applicata al testo decodificato Latin1 (ogni `char` +della stringa corrisponde 1:1 a un byte, quindi indicizzare la stringa o l'array di byte +originale dà lo stesso offset). + +### 2.2 Compressione per schema + +Un array `\xf8 \xf7 \xfb` scrive il **primo** elemento con tutti +i nomi di campo per esteso; gli elementi successivi sono **posizionali**, senza +nomi. È il motivo per cui nel file si vedono i nomi dei componenti "nudi", uno +dopo l'altro, senza etichetta. Per leggere l'elemento N bisogna conoscere lo +schema definito dall'elemento 1. + +--- + +## 3. L'elenco dei componenti: la chiave `members` + +La chiave `members` è l'elenco dei **componenti diretti** dell'assieme +(livello 1), nell'ordine di montaggio. + +``` +\xe0 \x00 "members" \0 + \xf1 \xf8 \xf7 \xfb + \xe3 \xe0\x01 "id"\0 + \xe0\x01 "type"\0 + \xe0\x0a "name"\0 \0 + \xe0\x0a "family_name"\0 \0 + \xe0\x00 "tha_ref"\0 \xe1 + \xe0\x02 "e1"\0 ... "e2" ... "e3" ... "origin" ... + \xe3 \0 \0 + \xe3 \0 \0 + ... +``` + +Campi utili: + +- **`id`** — identificatore del componente **dentro questo assieme**. È lo + stesso numero che Creo mostra come "Componente ID N". Serve da chiave per + collegare il membro ad altre sezioni (vedi family table, §4). +- **`type`** — `1` = sottoassieme (`.asm`), `2` = parte (`.prt`). Sui campioni + osservati compaiono solo questi due valori. +- **`name`** — il **file su disco**, senza estensione. Per le family table è + il **generico** (`UNI6592`), non l'istanza. +- **`family_name`** — sui campioni disponibili è **sempre uguale a `name`**. + Non porta quindi l'informazione sull'istanza; il nome è fuorviante. +- `e1`, `e2`, `e3`, `origin` — la matrice di posizionamento (assi + origine). + Non decodificati: non servono per la BOM, ma occupano il resto del record e + hanno lunghezza variabile. + +`N` (il conteggio dichiarato) è il numero totale di **occorrenze**, non di +codici distinti: una parte montata 4 volte conta 4 e compare in 4 record con id +diversi. Il conteggio va usato come controllo di correttezza: se i record +trovati non sono esattamente `N`, la lettura si è disallineata e va segnalata +un'eccezione invece di restituire un risultato parziale silenzioso — vedi +`CreoAsm.LeggiMembri`, che lancia `InvalidDataException` in questo caso. + +La sezione termina a `\xf4\x04#\n#MdlStatus\n`. + +### 3.1 Come lo legge `CreoAsm.LeggiMembri` + +Il parser non ricostruisce lo schema completo (servirebbe decodificare le +matrici). Si ancora invece al pattern `\xe3 \0 \0` +(`CreoAsm.MemberRegex`, una regex .NET sul testo Latin1 del file) e salta il +resto del record fino al `\xe3` successivo che soddisfa il pattern. +La doppia stringa terminata da `\0` rende i falsi positivi improbabili, e il +conteggio dichiarato `N` fa da rete di sicurezza. + +> `ponytail:` scansione a pattern invece del parser di schema — il punto debole +> è che si appoggia al conteggio per accorgersi di un errore. Da sostituire con +> la lettura dello schema se dovessero comparire campioni che non quadrano. + +--- + +## 4. Family table: dove sta il nome dell'istanza + +Le parti normalizzate (viti, rondelle, spine, boccole) sono **istanze di family +table**. In Creo esiste un solo file, il **generico** (`UNI6592.prt`), che +contiene una tabella di varianti; ogni variante è un'**istanza** +(`R8--_-_U6592_AISI304`). La BOM elenca le istanze, il disco contiene il +generico. La sezione `members` cita **solo il generico**: senza risolvere +`ConfigName` (sotto), il livello 1 produrrebbe `UNI6592` dove la BOM dice +`R8--_-_U6592_AISI304`. + +Il collegamento sta in un record di **parametro** applicato al membro: + +``` +\xe3 "ConfigName"\0 \xe2 \x33 [\xf7 ] \0 \x82\x7b \xe1\xe1 +``` + +- `\xe2\x33` — parametro di tipo stringa (`\x33` = `'3'`). Confronto: `\xe2\x32` + è un parametro numerico, es. `DENSITA`. +- `` — il nome esatto che la BOM stampa. +- `` — l'`id` del record `members` a cui il parametro si applica, + con la codifica intera di §2.1. **È questo il collegamento.** +- `\xf7 ` compare solo sulla prima occorrenza (schema definito in + linea), poi è omesso. +- `\x82\x7b` — costante (= 635) identica su tutte le occorrenze di tutti i + campioni. Significato ignoto, presumibilmente attributi/flag del parametro. + `CreoAsm.ConfigNameRegex` la usa come ancora di fine record; se un campione + futuro avesse un valore diverso, il match salterebbe silenziosamente (nessun + contatore di sicurezza analogo a quello di `members` copre questo caso). + +`ConfigName` compare **esattamente** per i membri che sono istanze, e per +nessun altro. L'algoritmo è quindi: leggere `members`, costruire la mappa +`id membro → nome istanza` dai record `ConfigName` +(`CreoAsm.LeggiMembri`, dizionario `istanze`), e sostituire il nome dove la +mappa ha una voce. Il generico resta disponibile come `Membro.File`. + +> **Avvertenza.** `ConfigName` sta in mezzo a parametri palesemente aziendali +> (`TIPO_PARTE`, `CLASSE_RICAR`, `GR_PRODOTTO`, `TECN_RIORDINO`), quindi +> potrebbe non essere un meccanismo standard di Creo ma una convenzione +> interna. Regge sui campioni disponibili, ma su file provenienti da +> un'altra installazione va riverificato prima di fidarsene. + +### 4.1 Una seconda pista, che NON va usata per la BOM + +Altrove nel file compaiono stringhe nella forma `istanza`: + +``` +\xe2\x02 "R8--_-_U6592_AISI304"\0 +``` + +Documenta la stessa relazione istanza→generico in modo autonomo ed è utile come +riscontro incrociato (è lo stesso pattern `` già sfruttato +dall'euristica testuale in `formato-asm.md` per le family table non-UNI). +**Non è però una lista dei componenti**: su un campione produceva 26 voci +contro 17 membri reali, includeva alcuni nipoti (figli dei sottoassiemi) ma +non tutti, e ripeteva le stesse voci più volte. È la lista dei riferimenti +geometrici dei vincoli di accoppiamento, non una distinta. Usarla per la BOM +dà risultati sbagliati sia in eccesso sia in difetto. + +--- + +## 5. Il nome proprio del modello + +La BOM intesta i blocchi col nome **del modello**, non del file: il file +`9244248550b.asm.11` produce `Assieme 9244248550B contiene:`. Il nome sta nel +primo record `objname` del file: + +``` +\x00 "objname"\0 \xe2 \xe0\x01 "object_type"\0 \xe0\x0a "name"\0 \0 +``` + +La **prima** occorrenza è il modello stesso; quelle successive sono altri +oggetti referenziati (disegni, modelli citati). `CreoAsm.NomeProprio` cerca +solo la prima occorrenza (`Regex.Match`, non `Matches`), verificato su tutti i +campioni disponibili durante il reverse engineering. + +--- + +## 6. Generare la BOM: ricorsione e formato di output + +`members` dà un solo livello. La BOM di Creo è ricorsiva: apre anche i `.asm` +dei sottoassiemi. Implementato in `CreoAsm.GeneraBom`/`Render` in +[`scripts/CreoBom.cs`](../scripts/CreoBom.cs). + +### 6.1 Struttura del file BOM + +- Codifica **UTF-8 con BOM** (`EF BB BF`), fine riga **CRLF**. +- Due righe vuote in testa, poi il primo blocco. +- **Tre** righe vuote fra un blocco e il successivo, **due** prima del + riepilogo. Il file termina con un solo CRLF. +- Riga voce: `{qta:6} {tipo:>12} {nome}` — cioè quantità allineata a destra su + 6 caratteri, spazio, tipo allineato a destra su 12, due spazi, nome. +- Intestazioni: `Assieme contiene:` per la radice, + `Sottoassieme contiene:` per gli altri. + +### 6.2 Ordine dei blocchi + +Visita **depth-first pre-order**: si emette il blocco di un sottoassieme e +subito dopo, ricorsivamente, quelli dei suoi sottoassiemi, prima di passare al +fratello successivo. Ogni modello compare **una volta sola** anche se montato +più volte (deduplica per nome, `CreoAsm.Blocchi`). + +> La deduplica è un'**assunzione**: nessuno dei campioni disponibili durante il +> reverse engineering montava lo stesso sottoassieme due volte, quindi non è +> dimostrata su un caso reale. + +### 6.3 Riepilogo finale + +`Summary of items for :` elenca **solo le parti**, con la quantità +totale moltiplicata lungo l'albero (una parte in 4 esemplari dentro un +sottoassieme montato 2 volte conta 8), nell'ordine di **prima comparsa** nella +visita depth-first (`CreoAsm.Sommario`). Una parte che compare a più livelli +occupa la posizione della sua prima comparsa e somma le quantità. + +### 6.4 Risoluzione dei file dei sottoassiemi + +È il punto che il formato **non** risolve: il `.asm` registra il nome del +modello, non il percorso. Servono quindi cartelle di ricerca fornite +dall'esterno (`-p`/`--path`). `CreoAsm.CreaFinder` indicizza `.asm.` +senza distinzione di maiuscole e, a parità di nome, prende la **revisione più +alta** presente. + +> Scelta discutibile: la revisione più alta in cartella non è +> necessariamente quella con cui l'assieme padre è stato salvato. Il `.asm` +> padre non sembra registrare la revisione attesa del figlio, o non è stata +> trovata. Vedi `formato-asm.md` §"Risoluzione ricorsiva dell'albero" per le +> trappole già incontrate storicamente su questo punto (con l'euristica +> testuale, ma il problema della revisione attesa è indipendente dal metodo +> di estrazione). + +Se un sottoassieme non si trova, il blocco viene emesso con +`*** NON RISOLTO: file .asm non trovato ***` e le sue parti restano fuori dal +riepilogo. Il risultato è dichiaratamente incompleto invece che silenziosamente +sbagliato. + +--- + +## 7. Cosa resta sconosciuto + +Elencato esplicitamente per non far passare per dimostrato ciò che non lo è: + +- **Stato soppresso / escluso.** Nessuna evidenza cercata o trovata. Non si sa + se un componente soppresso compaia in `members`, né con quale marcatore. Sui + campioni disponibili il totale coincideva con la BOM, il che *suggerisce* che + non ce ne fossero, non che siano gestiti. +- **La matrice di posizionamento** (`e1`, `e2`, `e3`, `origin`): formato + numerico non decodificato. +- **`family_name`**: sempre uguale a `name` sui campioni; non si sa in quale + situazione divergerebbe. +- **La costante `\x82\x7b`** nei record `ConfigName`. +- **I campi numerici dell'header** dopo `ASSEMBLY`. +- **Occorrenze multiple di `members`.** `CreoAsm.LeggiMembri` prende la prima. + Sui campioni disponibili funziona, ma non è stato verificato se le + rappresentazioni semplificate (simplified rep) generino sezioni `members` + aggiuntive. +- **La deduplica dei blocchi** e **la revisione attesa dei figli** (§6.2, §6.4). +- **Quale revisione del figlio** il padre si aspetta: non trovata nel `.asm`. + +## 8. Uso + +```powershell +dotnet run scripts/CreoBom.cs -- raw/9244228558.asm.20 -p M:\modelli -o out\9244228558.bom.1 +dotnet run scripts/CreoBom.cs -- raw/9244228558.asm.20 -p M:\modelli # stampa a video +dotnet run scripts/Analyze.cs -- raw/9244228558.asm.20 "ConfigName" # dump dei byte per investigare a mano +``` + +Senza `-p`/`--path`, la ricerca dei sottoassiemi usa la cartella del file di partenza. +`-p` è ripetibile per più cartelle. `-o` scrive il file col BOM UTF-8 e CRLF descritti +al §6.1; senza `-o` il testo va su stdout (con `\n` al posto di `\r\n`, per leggibilità +in console). diff --git a/docs/formato-asm.md b/docs/formato-asm.md index cc0182f..61121b2 100644 --- a/docs/formato-asm.md +++ b/docs/formato-asm.md @@ -1,10 +1,23 @@ # Come leggere un .asm Creo per dedurne i componenti Il file `.asm` di Pro/ENGINEER-Creo è un formato binario proprietario, non -documentato pubblicamente. Non esiste un parser strutturale completo in questo -progetto: l'estrazione dei componenti si basa su un'euristica testuale, -validata a mano confrontando l'output con l'albero reale mostrato da Creo. -Questo documento riassume cosa si è osservato e i suoi limiti. +documentato pubblicamente. Questo documento descrive l'euristica testuale usata da +`AssemblyComponentScanner.cs` (GUI, fase "Trova componenti referenziati"/"Verifica +completezza") e dagli script che risolvono l'albero componenti contro `cad_index.db` +(`ExtractCreoStrings.cs`, `BomViaDb.cs`): conta le occorrenze di stringhe candidate nel +testo binario, validata a mano confrontando l'output con l'albero reale mostrato da Creo. +Riassume cosa si è osservato e i suoi limiti, in particolare i falsi positivi che +un conteggio testuale non può escludere con certezza (riferimenti fantasma, componenti +soppressi con placement residuo — vedi sotto). + +Un reverse engineering successivo ha invece decodificato la **struttura a record** del +file (non solo pattern testuali): legge esattamente la sezione `members` di un assieme, +senza contare occorrenze né rischiare i falsi positivi descritti in questo documento. +Implementato in [`scripts/CreoBom.cs`](../scripts/CreoBom.cs), documentato in +[`formato-asm-record.md`](formato-asm-record.md). Resta però uno script di analisi +indipendente: legge solo `.asm` locali dato un percorso di ricerca esplicito (non +`cad_index.db`), e non è integrato in `AssemblyComponentScanner`/nella GUI, che +continuano a usare l'euristica descritta qui. ## Struttura osservata diff --git a/scripts/CreoBom.cs b/scripts/CreoBom.cs new file mode 100644 index 0000000..3d669ba --- /dev/null +++ b/scripts/CreoBom.cs @@ -0,0 +1,291 @@ +// dotnet run scripts/CreoBom.cs -- [-p cartella]... [-o out.bom.1] +// +// Parser esatto del formato binario .asm di Creo, ricavato per reverse engineering (vedi +// docs/formato-asm-record.md per la spiegazione byte-per-byte dei record, verificata +// confrontando l'output con le BOM esportate da Creo). Legge la sezione "members" di un +// assieme (id/tipo/nome dei componenti diretti, con risoluzione del nome istanza di family +// table dal parametro "ConfigName") e, dato un elenco di cartelle dove cercare i +// sottoassiemi, genera la stessa distinta base (BOM) esportata da Creo. +// +// Alternativa esatta all'euristica testuale di PrepStep/AssemblyComponentScanner.cs (docs/ +// formato-asm.md): quella conta le occorrenze dei codici nel testo, questa legge la +// struttura dei record. Per ora resta uno script di analisi indipendente: non sostituisce +// AssemblyComponentScanner nella GUI (fase 4) ne' negli altri script. +using System.Text; +using System.Text.RegularExpressions; + +if (args.Length == 0) +{ + Console.WriteLine("Uso: dotnet run scripts/CreoBom.cs -- [-p cartella]... [-o out.bom.1]"); + return 1; +} + +string fileIniziale = args[0]; +var cartelle = new List(); +string? outPath = null; +for (int i = 1; i < args.Length; i++) +{ + if ((args[i] == "-p" || args[i] == "--path") && i + 1 < args.Length) + cartelle.Add(args[++i]); + else if (args[i] == "-o" && i + 1 < args.Length) + outPath = args[++i]; +} +if (cartelle.Count == 0) + cartelle.Add(Path.GetDirectoryName(Path.GetFullPath(fileIniziale)) ?? "."); + +string testoBom = CreoAsm.GeneraBom(fileIniziale, cartelle); +if (outPath is not null) +{ + File.WriteAllBytes(outPath, new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(testoBom)).ToArray()); + Console.WriteLine($"scritto {outPath} ({testoBom.Split("\r\n").Length} righe)"); +} +else +{ + Console.Write(testoBom.Replace("\r\n", "\n")); +} +return 0; + +/// Lettura del formato .asm Creo (sezione "members" + parametro "ConfigName") e resa +/// nel formato della BOM esportata da Creo. Vedi docs/formato-asm-record.md per il dettaglio +/// dei record e i limiti noti (stato soppresso non gestito, matrice di posizionamento non +/// decodificata, deduplica dei blocchi non dimostrata su assiemi che montano lo stesso +/// sottoassieme due volte). +public static class CreoAsm +{ + public sealed record Membro(int Id, int Tipo, string Nome, string File); + + // interi big-endian, lunghezza nei bit alti del primo byte (0xe0+ e' riservato ai + // marcatori di struttura): 0x00-0x7f 1 byte, 0x80-0xbf 2 byte, 0xc0-0xdf 3. + private const string Int = @"([\x00-\x7f]|[\x80-\xbf][\x00-\xff]|[\xc0-\xdf][\x00-\xff][\x00-\xff])"; + + // \xe3 id type name\0 family_name\0 + private static readonly Regex MemberRegex = new( + @"\xe3" + Int + Int + @"([\x20-\x7e]+)\x00([\x20-\x7e]+)\x00", RegexOptions.Compiled); + + // parametro ConfigName: nome istanza family table + id del membro a cui si applica. + private static readonly Regex ConfigNameRegex = new( + @"\xe3ConfigName\x00\xe23(?:\xf7[\x00-\xff])?([\x20-\x7e]+)\x00" + Int + @"\x82\x7b", + RegexOptions.Compiled); + + // nome proprio del modello (quello che la BOM stampa in intestazione). + private static readonly Regex ObjNameRegex = new( + @"\x00objname\x00\xe2\xe0\x01object_type\x00[\x00-\x7f]\xe0\x0aname\x00([^\x00]+)\x00", + RegexOptions.Compiled); + + // primo elemento dell'array "members": porta le chiavi per esteso. + private static readonly Regex FirstMemberRegex = new( + @"\x01id\x00" + Int + @"\xe0\x01type\x00" + Int + + @"\xe0\x0aname\x00([^\x00]+)\x00\xe0\x0afamily_name\x00([^\x00]+)\x00", + RegexOptions.Compiled); + + private static readonly Dictionary TipoNome = new() { [1] = "Sottoassieme", [2] = "Parte" }; + + private static int AsInt(string bytes) + { + int mask = bytes.Length switch { 1 => 0x7F, 2 => 0x3F, 3 => 0x1F, _ => throw new ArgumentException("lunghezza intero non valida") }; + int valore = bytes[0] & mask; + for (int i = 1; i < bytes.Length; i++) + valore = (valore << 8) | bytes[i]; + return valore; + } + + /// Nome proprio del modello (quello che Creo stampa in intestazione BOM), + /// dalla prima occorrenza del record "objname". + public static string NomeProprio(byte[] dati) + { + var m = ObjNameRegex.Match(Encoding.Latin1.GetString(dati)); + if (!m.Success) + throw new InvalidDataException("record 'objname' non trovato"); + return m.Groups[1].Value; + } + + /// Componenti diretti di un assieme, in ordine di montaggio. "Nome" e' il nome + /// che mostrerebbe la BOM (istanza di family table se presente), "File" e' il generico + /// realmente su disco (coincide con "Nome" per i componenti che non sono istanze). + public static List LeggiMembri(byte[] dati) + { + string testo = Encoding.Latin1.GetString(dati); + + var istanze = new Dictionary(); + foreach (Match m in ConfigNameRegex.Matches(testo)) + istanze[AsInt(m.Groups[2].Value)] = m.Groups[1].Value; + + const string chiaveMembers = "\0members\0"; + int start = testo.IndexOf(chiaveMembers, StringComparison.Ordinal); + if (start < 0) + throw new InvalidDataException("sezione 'members' assente"); + start += chiaveMembers.Length; + + int conteggio = dati[start + 2]; // \xf1 \xf8 + int end = testo.IndexOf("#MdlStatus", start, StringComparison.Ordinal); + string region = testo.Substring(start, (end > 0 ? end : testo.Length) - start); + + var grezzi = new List<(int Id, int Tipo, string Nome)>(); + + // il primo record e' esteso: ricava i campi dalle chiavi, i successivi sono posizionali + var primo = FirstMemberRegex.Match(region); + if (primo.Success) + { + grezzi.Add((AsInt(primo.Groups[1].Value), AsInt(primo.Groups[2].Value), primo.Groups[3].Value)); + region = region.Substring(primo.Index + primo.Length); + } + + foreach (Match m in MemberRegex.Matches(region)) + { + grezzi.Add((AsInt(m.Groups[1].Value), AsInt(m.Groups[2].Value), m.Groups[3].Value)); + if (grezzi.Count == conteggio) + break; + } + + if (grezzi.Count != conteggio) + throw new InvalidDataException($"attesi {conteggio} membri, trovati {grezzi.Count}: lettura disallineata"); + + return grezzi.Select(g => new Membro(g.Id, g.Tipo, istanze.GetValueOrDefault(g.Id, g.Nome), g.Nome)).ToList(); + } + + /// Voci distinte con quantita', nell'ordine di prima comparsa (come la BOM). + public static List<((int Tipo, string Nome) Chiave, int Qta)> Rollup(IEnumerable membri) + { + var ordine = new List<(int, string)>(); + var qty = new Dictionary<(int, string), int>(); + foreach (var m in membri) + { + var chiave = (m.Tipo, m.Nome); + if (!qty.ContainsKey(chiave)) + ordine.Add(chiave); + qty[chiave] = qty.GetValueOrDefault(chiave) + 1; + } + return ordine.Select(c => (c, qty[c])).ToList(); + } + + // --- risoluzione dei file ------------------------------------------- + + /// nome modello -> membri, cercando <nome>.asm.<rev> nelle cartelle + /// date. A parita' di nome vince la revisione piu' alta presente. Nome null se il file + /// non c'e' in nessuna delle cartelle. + private static Func?> CreaFinder(IEnumerable cartelle) + { + var indice = new Dictionary(StringComparer.OrdinalIgnoreCase); + var revisioni = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dir in cartelle) + { + if (!Directory.Exists(dir)) + continue; + foreach (var f in Directory.GetFiles(dir, "*.asm.*")) + { + if (!int.TryParse(Path.GetExtension(f).TrimStart('.'), out int rev)) + continue; + string chiave = Path.GetFileName(f).Split('.')[0]; + if (!revisioni.TryGetValue(chiave, out int attuale) || rev > attuale) + { + indice[chiave] = f; + revisioni[chiave] = rev; + } + } + } + + var cache = new Dictionary?>(); + return nome => + { + if (!cache.TryGetValue(nome, out var mem)) + { + mem = indice.TryGetValue(nome, out var percorso) ? LeggiMembri(File.ReadAllBytes(percorso)) : null; + cache[nome] = mem; + } + return mem; + }; + } + + // --- costruzione dell'albero ----------------------------------------- + + private sealed record Blocco(string Tipo, string Nome, List<((int Tipo, string Nome) Chiave, int Qta)>? Righe); + + /// Blocchi in ordine depth-first pre-order. Ogni modello compare una volta sola + /// (deduplica per nome) anche se montato piu' volte. + private static List Blocchi(string root, Func?> get) + { + var risultato = new List(); + var visti = new HashSet(); + + void Visita(string nome, string tipo) + { + if (!visti.Add(nome)) + return; // Creo stampa ogni modello una volta sola + var mem = get(nome); + if (mem is null) + { + risultato.Add(new Blocco(tipo, nome, null)); + return; + } + var righe = Rollup(mem); + risultato.Add(new Blocco(tipo, nome, righe)); + foreach (var (chiave, _) in righe) + if (chiave.Tipo == 1) + Visita(chiave.Nome, "Sottoassieme"); + } + + Visita(root, "Assieme"); + return risultato; + } + + /// Totale per parte, moltiplicato lungo l'albero, in ordine di prima comparsa: una + /// parte che compare a piu' livelli occupa la posizione della sua prima comparsa e somma + /// le quantita'. + private static void Sommario(string root, Func?> get, int mult, + List ordine, Dictionary acc) + { + var mem = get(root); + if (mem is null) + return; + foreach (var (chiave, qta) in Rollup(mem)) + { + if (chiave.Tipo == 2) + { + if (!acc.ContainsKey(chiave.Nome)) + ordine.Add(chiave.Nome); + acc[chiave.Nome] = acc.GetValueOrDefault(chiave.Nome) + qta * mult; + } + else + { + Sommario(chiave.Nome, get, mult * qta, ordine, acc); + } + } + } + + // --- resa nel formato di Creo ------------------------------------------ + + private static string RigaVoce(int qta, string tipo, string nome) => $"{qta,6} {tipo,12} {nome}"; + + /// Testo della BOM, identico byte per byte a quello esportato da Creo (UTF-8 con + /// BOM, CRLF - vedi GeneraBom per la scrittura su file). + private static string Render(string root, Func?> get) + { + var pezzi = new List(); + foreach (var blocco in Blocchi(root, get)) + { + string testa = $"{blocco.Tipo} {blocco.Nome} contiene:"; + if (blocco.Righe is null) + { + pezzi.Add(testa + "\r\n" + new string(' ', 5) + "*** NON RISOLTO: file .asm non trovato ***"); + continue; + } + var righe = blocco.Righe.Select(r => RigaVoce(r.Qta, TipoNome[r.Chiave.Tipo], r.Chiave.Nome)); + pezzi.Add(string.Join("\r\n", new[] { testa }.Concat(righe))); + } + + var ordine = new List(); + var acc = new Dictionary(); + Sommario(root, get, 1, ordine, acc); + var totale = ordine.Select(n => RigaVoce(acc[n], "Parte", n)); + pezzi.Add(string.Join("\r\n", new[] { $"Summary of items for {root}:" }.Concat(totale))); + + // due righe vuote iniziali, tre fra i blocchi, due prima del riepilogo + return "\r\n\r\n" + string.Join("\r\n\r\n\r\n\r\n", pezzi.Take(pezzi.Count - 1)) + "\r\n\r\n\r\n" + pezzi[^1] + "\r\n"; + } + + public static string GeneraBom(string percorsoAsm, IEnumerable cartelle) + { + byte[] dati = File.ReadAllBytes(percorsoAsm); + return Render(NomeProprio(dati), CreaFinder(cartelle)); + } +}