// 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)); } }