Compare commits

5 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 60dfd26e8b Snellisci CLAUDE.md: rimuovi duplicazione con docs/architettura.md e db-cad-index.md
La sezione Architettura ripeteva quasi verbatim il dettaglio file-per-file
gia' in docs/architettura.md e docs/db-cad-index.md, gonfiando il contesto
caricato in ogni sessione senza aggiungere informazione. Sostituita con un
riepilogo delle 5 fasi del wizard e rimandi puntuali; nessun contenuto perso,
solo spostato dove viveva gia' in dettaglio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 15:41:22 +02:00
davideandClaude Sonnet 5 35009b5cd3 Aggiungi CreoBom.cs: parser esatto del formato .asm via record binari
Porting C# del reverse engineering della struttura a record del .asm Creo
(sezione "members" + parametro "ConfigName" per le family table): legge
esattamente i componenti diretti e genera la stessa BOM esportata da Creo,
senza le euristiche testuali (conteggio occorrenze) di AssemblyComponentScanner.
Resta uno script di analisi indipendente, non integrato nella GUI.

Aggiorna anche formato-asm.md (non e' piu' vero che "non esiste un parser
strutturale") e architettura.md (script mancante nell'elenco).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 15:41:14 +02:00
davideandClaude Sonnet 5 3cfc040fb0 Aggiungi documentazione tecnica su cad_index.db
Schema, logica di scansione/upsert incrementale di Indexer.ScanAsync,
come l'app individua il DB all'avvio (incluso il caveat sulla cartella
di build in dev) e le query piu' usate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:37:08 +02:00
davideandClaude Sonnet 5 3ea582e645 Usa la cartella del DB come fallback per la cartella di lavoro
Se non c'e' una cartella di lavoro salvata (o punta a una cartella
cancellata), AppContext.BaseDirectory finiva spesso nella cartella di
build (bin/Release/...) invece che dove l'utente tiene effettivamente
il proprio lavoro. Ora il fallback usa la cartella del DB caricato.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:33:18 +02:00
davideandClaude Sonnet 5 9ecdccb6bb Migliora UI: finestra aggiornamento indice, barra nav wizard, fase Verifica
- ProgressForm: gerarchia visiva, contrasto testo, barra piatta blu al posto
  della ProgressBar nativa verde, footer separato dal bottone Annulla.
- Barra Avanti/Indietro: footer separato con linea, padding allineato ai
  pannelli di ogni fase, riordinata sopra la barra di stato come convenzione
  standard invece che sotto.
- Fase 5 (Verifica completezza): bottone principale reso primario, lista
  file .asm passata da griglia di icone troncate a lista a riga singola
  leggibile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:16:32 +02:00
9 changed files with 952 additions and 154 deletions

No files matched your search

+37 -103
View File
@@ -38,112 +38,46 @@ pubblica.
## Architettura
Il programma è tutto in `PrepStep/` (nessun altro progetto nella solution — in
passato esisteva anche una CLI separata e uno script Python di indicizzazione,
entrambi rimossi: ora l'unico programma è questo, con GUI e indicizzazione integrate).
Il programma è tutto in `PrepStep/` (nessun altro progetto nella solution). Dettaglio
completo file-per-file in [`docs/architettura.md`](docs/architettura.md); qui solo il flusso
che conta per capire il prodotto.
- **Program.cs** — entry point WinForms standard, avvia `Form1`.
- **Form1.cs** / **Form1.Designer.cs** — finestra principale, costruita come wizard: una
sola fase alla volta occupa tutta la finestra (`_panelFasi[]`/`NomiFasi`), si avanza
con "Avanti" solo quando la fase corrente è pronta (`AvantiConsentito`). La UI è
costruita interamente in codice in `Form1.BuildUi()` (nessun designer visuale usato).
`Form1.Designer.cs` contiene solo lo scheletro minimo (`InitializeComponent`/`Dispose`)
generato dal template. Le fasi, in ordine:
1. **Database** — carica/aggiorna l'indice, mostra data ultimo aggiornamento; qui vive
anche la **cartella di lavoro** (`_cartellaLavoro`, mostrata in `_lblCartellaLavoro`,
cambiabile con "Cambia cartella..." → `ScegliCartellaLavoro`): destinazione di CSV,
`raw/` per tutte le fasi successive (gli STEP esportati a mano con Creo vanno in
`step/`, dentro la stessa cartella). All'avvio
`InizializzaCartellaLavoro` propone l'ultima usata (ricordata in
`%AppData%/PrepStep/ultima_cartella_lavoro.txt`, stesso pattern di
`ultimo_db.txt` per il DB) se esiste ancora, altrimenti `AppContext.BaseDirectory`
— non dipende da un marker di repo (niente più `CLAUDE.md`/`TrovaRadiceRepo`),
quindi funziona identica anche sull'exe pubblicato standalone (`PrepStep.exe`)
copiato su un'altra macchina. Qui vive anche "Pulisci raw e step" (manutenzione,
non fa parte del flusso a valle).
2. **Importa Distinta Base** — `DistintaBaseImporter.ConvertiInCsv` genera il CSV dei
componenti nella cartella di lavoro; se il nome del file segue la convenzione del
gestionale (`DBP_<codice>_<timestamp>`), l'assieme generale viene aggiunto come riga
in più nel CSV stesso (il foglio elenca solo i componenti sotto l'assieme, non
l'assieme in sé). Da qui in avanti l'assieme generale è un componente come tutti
gli altri, nessun caso speciale nel resto del codice.
3. **Crea cartella raw** — cerca nell'indice il percorso CAD di ogni componente del CSV
(`TrovaPercorsiCad`) e lo copia in `raw/` dentro la cartella di lavoro
(`CopiaFileTrovati`, parallela — vedi `docs/copia-raw-parallela.md`).
4. **Trova componenti referenziati** — legge euristicamente i metadati binari dei `.asm`
già in `raw/` (`AssemblyComponentScanner`) per risolvere ricorsivamente i
sotto-assiemi/parti che contengono (`RisolviAlberoComponenti`), copiandoli anch'essi
in `raw/`.
5. **Verifica completezza** — appena si entra nella fase, `AggiornaAnteprimaAsm`
mostra in una `ListView` a icone i file `.asm` della distinta importata (esclusi i
sotto-assiemi di fase 4) già presenti in `raw/` — letti dal filesystem locale, non
dall'indice/share — trascinabili (drag&drop, `ItemDrag` → `DoDragDrop` con
`DataFormats.FileDrop`) direttamente su Creo o Esplora risorse, per l'esportazione
STEP con Creo Distributed Batch. "Verifica completezza assiemi" controlla
ricorsivamente (sui percorsi originali in indice, non su `raw/`) se tutti i
sotto-componenti di ogni assieme della distinta sono nell'indice.
- **Indexer.cs** — logica di scansione del disco e scrittura/aggiornamento del DB
SQLite (`Indexer.ScanAsync`). Cammina tutte le cartelle di primo livello trovate sotto
la radice (scoperte dinamicamente con `Directory.GetDirectories`, non un elenco fisso)
con un walk manuale a stack (`Walk`, più veloce di `EnumerateFiles(..., AllDirectories)`
su share di rete con centinaia di migliaia di file), estrae da ogni nome file il
pattern Pro-E `<codice>.<tipo>.<versione>` via regex, e fa un upsert incrementale
riga per riga (confronto per `percorso_completo` + dimensione + data modifica).
Riporta il progresso tramite `IProgress<ScanProgress>`, marshalato automaticamente
sul thread UI. Contiene anche lo schema SQL del DB (tabella `files` + vista
`files_ultima_versione`, che seleziona solo l'ultima versione per
`(cartella_radice, codice, tipo_file)` usando `ROW_NUMBER() OVER (...)` — non un
self-join con `GROUP BY`, che su ~200k righe degenera in tempi quasi quadratici).
- **ProgressForm.cs** — dialog modale mostrato durante l'aggiornamento indice: barra
di progresso, conteggi nuovi/aggiornati/invariati/rimossi, tempo trascorso e stima
del tempo rimanente (calcolata dal ritmo file/secondo rispetto al totale atteso,
preso dal conteggio della scansione precedente salvato nel DB).
- **UiStile.cs** — stile bottoni condiviso da `Form1` e dai dialog secondari
(`Primario`/`Secondario`, entrambi `AutoSize` col padding invece di una `Size` fissa
in pixel, per evitare che il testo vada a capo e si tagli quando non ci sta) e
`Arrotonda` per gli angoli dei bottoni "Avanti"/"Indietro" del wizard.
- **app.ico** — icona dell'app (logo Cevolani + pezzo + STEP, sfondo trasparente),
referenziata da `<ApplicationIcon>` in `PrepStep.csproj` e riletta a runtime in
`Form1.BuildUi()` via `Icon.ExtractAssociatedIcon(Application.ExecutablePath)` per
la titlebar (stessa icona dell'exe, nessun file duplicato nell'output).
`Form1.cs` è un wizard a 5 fasi (una sola visibile alla volta, `_panelFasi[]`, si avanza con
"Avanti" solo se `AvantiConsentito`), UI costruita interamente in codice (`BuildUi`, nessun
designer visuale):
### Schema del DB (`cad_index.db`)
Tabella `files`: un record per ogni file trovato, con `percorso_completo` (chiave
univoca), `cartella_radice`, `famiglia` (primo livello di sottocartella),
`codice`/`tipo_file`/`versione` estratti dal nome file, dimensione, data modifica e
timestamp dell'ultima scansione (`scansionato_il`, usato per rilevare ed eliminare i
file non più presenti su disco a fine scansione).
Vista `files_ultima_versione`: stessa struttura, filtrata alla sola versione più
recente per codice+tipo.
### Percorso del DB
All'avvio la app cerca `cad_index.db` accanto all'eseguibile o nella working
directory corrente; se non lo trova, l'utente può selezionarlo con "Apri DB...".
Il bottone "Aggiorna indice" rilancia `Indexer.ScanAsync` sullo stesso path (radice
disco di default `X:\`, hardcoded in `Form1.RadiceDiscoDefault`).
1. **Database** — carica/aggiorna `cad_index.db` (`Indexer.ScanAsync`, radice disco di
default `X:\` hardcoded in `RadiceDiscoDefault`); qui vive anche la **cartella di lavoro**
(destinazione di CSV/`raw/`/`step/`), proposta dall'ultima usata o dalla cartella del DB se
non c'è ancora nulla di salvato — vedi `docs/db-cad-index.md` per il dettaglio di schema e
risoluzione del percorso DB.
2. **Importa Distinta Base** — `DistintaBaseImporter.ConvertiInCsv` genera il CSV
componente;descrizione nella cartella di lavoro; se il file segue la convenzione del
gestionale (`DBP_<codice>_<timestamp>`), aggiunge l'assieme generale come riga in più (da
quel momento è un componente come tutti gli altri, nessun caso speciale nel resto del
codice).
3. **Crea cartella raw** — `TrovaPercorsiCad` + `CopiaFileTrovati` (parallela, vedi
`docs/copia-raw-parallela.md`) copiano in `raw/` i file CAD dei componenti del CSV trovati
nell'indice.
4. **Trova componenti referenziati** — `AssemblyComponentScanner` legge euristicamente i
`.asm` in `raw/` per risolvere ricorsivamente sotto-assiemi/parti (`RisolviAlberoComponenti`),
copiandoli anch'essi in `raw/`. Euristica e falsi positivi noti in `docs/formato-asm.md`.
5. **Verifica completezza** — mostra i `.asm` della distinta già in `raw/`, trascinabili su
Creo per l'esportazione STEP (Distributed Batch, manuale, fuori dall'app); "Verifica
completezza assiemi" controlla ricorsivamente sull'indice (non su `raw/`) se tutti i
sotto-componenti di ogni assieme sono presenti.
## Script standalone (`scripts/`)
`scripts/` contiene C# file-based apps (.NET 10, `dotnet run scripts/Nome.cs -- arg1 arg2`),
senza csproj proprio — dipendenze NuGet extra si aggiungono con `#:package Nome@versione` in
cima al file. `ExtractCreoStrings.cs` e `BomViaDb.cs` risolvono ricorsivamente l'albero
componenti di un `.asm` contro `cad_index.db` da riga di comando (stessa logica di
`Form1.RisolviAlberoComponenti`/`AssiemeCompleto`): entrambi referenziano
`PrepStep/PrepStep.csproj` via `#:project` (più `#:property TargetFramework=net10.0-windows`,
richiesto perché il progetto principale è WinForms) invece di duplicare l'euristica di
estrazione/placement, che vive solo in `PrepStep/AssemblyComponentScanner.cs` — un'unica
fonte di verità condivisa da GUI e script, vedi `docs/formato-asm.md`. Le due varianti restano
distinte solo nel candidato passato a `EstraiCodiciReferenziati`: `ExtractCreoStrings.cs` non
passa `codiciNoti` (riconosce solo i codici numerici Pro-E, dalla forma, senza bisogno del
DB); `BomViaDb.cs` lo passa, per intercettare anche i nomi alfabetici delle parti commerciali
validati contro l'indice. `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.
`scripts/` contiene C# file-based apps (.NET 10, `dotnet run scripts/Nome.cs -- arg1 arg2`,
lanciati dalla root del repo — **non** da dentro `PrepStep/`, che ha un `.csproj` `WinExe`:
`dotnet run` lì avvia la GUI vera invece dello script). Elenco e dettaglio di ciascuno in
`docs/architettura.md`.
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
identici a un componente vero, non filtrabili in modo affidabile) sono documentati in
`docs/formato-asm.md`.
Il pattern da preservare: `ExtractCreoStrings.cs`/`BomViaDb.cs` referenziano
`PrepStep/PrepStep.csproj` via `#:project` invece di duplicare l'euristica di
estrazione/placement, che vive **solo** in `PrepStep/AssemblyComponentScanner.cs` — un'unica
fonte di verità condivisa da GUI e script (vedi `docs/formato-asm.md` per l'euristica e i
falsi positivi noti). `CreoBom.cs` è l'eccezione deliberata: legge la struttura a record del
binario invece di contare occorrenze testuali (`docs/formato-asm-record.md`), non passa da
`AssemblyComponentScanner`/`cad_index.db` e non la sostituisce nella GUI.
+55 -23
View File
@@ -56,8 +56,8 @@ public partial class Form1 : Form
BuildUi();
Load += (_, _) =>
{
InizializzaCartellaLavoro();
InizializzaDatabase();
InizializzaCartellaLavoro();
};
}
@@ -91,15 +91,23 @@ public partial class Form1 : Form
_lblFase.Font = new Font(Font, FontStyle.Bold);
headerPanel.Controls.Add(_lblFase);
// Barra di navigazione come footer separato dal contenuto (stesso pattern di
// ProgressForm): linea sottile in cima + sfondo bianco, invece dei bottoni appoggiati
// direttamente sul contenuto/sulla barra di stato senza alcuna divisione visiva.
// Padding destro = 16, lo stesso usato dai pannelli di ogni fase (Padding(16)): prima
// era 12, quindi "Avanti" sporgeva 4px oltre il margine destro del contenuto sopra.
var navContainer = new Panel { Dock = DockStyle.Bottom, Height = 64, BackColor = Color.White };
var navSeparatore = new Panel
{
Dock = DockStyle.Top,
Height = 1,
BackColor = Color.FromArgb(220, 220, 220),
};
var navPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
// I bottoni si auto-dimensionano al testo (UiStile): l'altezza qui deve stare
// comoda sopra al loro MinimumSize (32) più il padding, altrimenti vengono
// tagliati sul fondo della barra invece di restarci dentro.
Height = 56,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(12, 10, 12, 10),
Padding = new Padding(16, 14, 16, 14),
};
_btnAvanti.Text = "Avanti ›";
UiStile.Primario(_btnAvanti);
@@ -113,6 +121,8 @@ public partial class Form1 : Form
UiStile.Arrotonda(_btnIndietro);
_btnIndietro.Click += (_, _) => VaiAllaFase(_faseAttuale - 1);
navPanel.Controls.Add(_btnIndietro);
navContainer.Controls.Add(navPanel);
navContainer.Controls.Add(navSeparatore);
var statusPanel = new Panel { Dock = DockStyle.Bottom, Height = 28, BackColor = SystemColors.ControlLight };
_lblStatus.Dock = DockStyle.Fill;
@@ -133,9 +143,12 @@ public partial class Form1 : Form
_faseContainer.Controls.Add(panel);
}
// Ordine dei Dock.Bottom: l'ultimo aggiunto è il più esterno (il vero bordo inferiore
// della finestra). La barra di stato va tra contenuto e azioni; i bottoni Avanti/Indietro
// restano il footer più esterno, come in ogni wizard/dialog standard di Windows.
Controls.Add(_faseContainer);
Controls.Add(navPanel);
Controls.Add(statusPanel);
Controls.Add(navContainer);
Controls.Add(headerPanel);
DragEnter += (_, e) =>
@@ -180,7 +193,7 @@ public partial class Form1 : Form
private void AggiornaAnteprimaAsm()
{
_lstAnteprimaAsm.Items.Clear();
_lstAnteprimaAsm.LargeImageList!.Images.Clear();
_lstAnteprimaAsm.SmallImageList!.Images.Clear();
if (_ultimaCartellaRaw is null || !Directory.Exists(_ultimaCartellaRaw) || _righeCorrenti is null)
return;
@@ -204,8 +217,8 @@ public partial class Form1 : Form
// ponytail: file irraggiungibile (share offline) -> icona generica, non blocca gli altri
}
int indice = _lstAnteprimaAsm.LargeImageList.Images.Count;
_lstAnteprimaAsm.LargeImageList.Images.Add(icona?.ToBitmap() ?? SystemIcons.WinLogo.ToBitmap());
int indice = _lstAnteprimaAsm.SmallImageList.Images.Count;
_lstAnteprimaAsm.SmallImageList.Images.Add(icona?.ToBitmap() ?? SystemIcons.WinLogo.ToBitmap());
icona?.Dispose();
_lstAnteprimaAsm.Items.Add(new ListViewItem(Path.GetFileName(percorso), indice) { Tag = percorso });
}
@@ -515,9 +528,12 @@ public partial class Form1 : Form
};
top.Controls.Add(lblIstruzioniCompletezza);
// Primario: è l'azione principale di questa fase, non una scelta secondaria — prima
// era uno stile grigio che si confondeva con lo sfondo.
_btnCompletezza.Text = "Verifica completezza assiemi";
_btnCompletezza.Margin = new Padding(0, 0, 0, 12);
UiStile.Secondario(_btnCompletezza);
UiStile.Primario(_btnCompletezza);
UiStile.Arrotonda(_btnCompletezza);
_btnCompletezza.Click += (_, _) => VerificaCompletezzaAssiemi();
top.Controls.Add(_btnCompletezza);
@@ -530,15 +546,28 @@ public partial class Form1 : Form
};
top.Controls.Add(lblAnteprimaAsm);
// Dock=Fill invece di una Size fissa: la lista occupa tutto lo spazio verticale
// rimasto sotto le istruzioni, così si vedono più file .asm senza scroll anche a
// finestra ingrandita, invece dei ~160px fissi di prima.
_lstAnteprimaAsm.View = View.LargeIcon;
// View.Details invece di LargeIcon: con codici Pro-E lunghi e simili tra loro
// ("9244218...", "9244228..."), le icone in griglia troncavano tutti i nomi allo stesso
// modo (illeggibili, indistinguibili) e lasciavano un grande vuoto sotto le poche righe.
// Una lista a riga singola mostra il nome per intero, in linea con lo stile delle altre
// griglie dell'app (_gridImport/_gridRaw), restando comunque trascinabile.
_lstAnteprimaAsm.View = View.Details;
_lstAnteprimaAsm.MultiSelect = true;
_lstAnteprimaAsm.LargeImageList = new ImageList { ImageSize = new Size(32, 32) };
_lstAnteprimaAsm.FullRowSelect = true;
_lstAnteprimaAsm.GridLines = false;
_lstAnteprimaAsm.HeaderStyle = ColumnHeaderStyle.Nonclickable;
_lstAnteprimaAsm.SmallImageList = new ImageList { ImageSize = new Size(20, 20) };
_lstAnteprimaAsm.Columns.Add("File .asm", -2);
_lstAnteprimaAsm.Dock = DockStyle.Fill;
_lstAnteprimaAsm.Margin = new Padding(0, 4, 0, 12);
_lstAnteprimaAsm.BorderStyle = BorderStyle.FixedSingle;
_lstAnteprimaAsm.BorderStyle = BorderStyle.None;
// La colonna unica segue sempre la larghezza disponibile (niente scroll orizzontale
// né spazio sprecato a destra quando la finestra viene ingrandita).
_lstAnteprimaAsm.Resize += (_, _) =>
{
if (_lstAnteprimaAsm.Columns.Count > 0)
_lstAnteprimaAsm.Columns[0].Width = _lstAnteprimaAsm.ClientSize.Width;
};
_lstAnteprimaAsm.ItemDrag += (_, e) =>
{
var percorsi = _lstAnteprimaAsm.SelectedItems.Cast<ListViewItem>()
@@ -975,14 +1004,17 @@ public partial class Form1 : Form
private static readonly string PercorsoImpostazioni = Path.Combine(CartellaImpostazioni, "ultimo_db.txt");
private static readonly string PercorsoImpostazioniCartella = Path.Combine(CartellaImpostazioni, "ultima_cartella_lavoro.txt");
/// <summary>Propone l'ultima cartella di lavoro usata (se ancora esiste su disco) o, la prima
/// volta, la cartella dell'eseguibile: non richiede mai un CLAUDE.md/repo (a differenza del
/// vecchio TrovaRadiceRepo), quindi funziona identica anche sull'exe pubblicato standalone
/// copiato su un'altra macchina. Sempre modificabile con "Cambia cartella..." in fase 1.</summary>
/// <summary>Propone l'ultima cartella di lavoro usata (se ancora esiste su disco) o, se non
/// c'è ancora una preferenza salvata (o punta a una cartella cancellata), la cartella dove si
/// trova il DB (più sensata dell'eseguibile: quella è dove l'utente tiene il proprio lavoro,
/// non dov'è installata la app). Richiede quindi che InizializzaDatabase sia già stata
/// chiamata. Sempre modificabile con "Cambia cartella..." in fase 1.</summary>
private void InizializzaCartellaLavoro()
{
var ultima = LeggiUltimaCartellaLavoro();
_cartellaLavoro = ultima is not null && Directory.Exists(ultima) ? ultima : AppContext.BaseDirectory;
_cartellaLavoro = ultima is not null && Directory.Exists(ultima)
? ultima
: (_dbPath is not null ? Path.GetDirectoryName(_dbPath) : null) ?? AppContext.BaseDirectory;
AggiornaLabelCartellaLavoro();
}
+80 -23
View File
@@ -2,80 +2,137 @@ namespace PrepStep;
public sealed class ProgressForm : Form
{
private readonly Label _lblTitolo = new();
private readonly Label _lblCartella = new();
private readonly ProgressBar _progressBar = new();
private readonly Panel _barraTraccia = new();
private readonly Panel _barraRiempimento = new();
private readonly Label _lblPercentuale = new();
private readonly Label _lblConteggio = new();
private readonly Label _lblDettaglio = new();
private readonly Label _lblEta = new();
private readonly Button _btnAnnulla = new();
private readonly Panel _separatore = new();
private readonly Panel _piePagina = new();
private readonly ToolTip _tipCartella = new();
private const int Margine = 24;
// SystemColors.GrayText (~3.9:1 su bianco) è sotto la soglia WCAG AA 4.5:1 per testo
// normale: usiamo un grigio più scuro, leggibile ma comunque secondario.
private static readonly Color GrigioTesto = Color.FromArgb(90, 90, 90);
public event EventHandler? Annullato;
public ProgressForm()
{
Text = "Aggiornamento indice in corso...";
Text = "Aggiornamento indice";
FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterParent;
MinimizeBox = false;
MaximizeBox = false;
ControlBox = false;
ClientSize = new Size(460, 190);
BackColor = Color.White;
ClientSize = new Size(480, 270);
_lblTitolo.Text = "Aggiornamento indice in corso...";
_lblTitolo.Location = new Point(Margine, 22);
_lblTitolo.AutoSize = true;
_lblTitolo.Font = new Font(Font.FontFamily, 11f, FontStyle.Bold);
_lblCartella.Text = "Preparazione...";
_lblCartella.Location = new Point(16, 16);
_lblCartella.Location = new Point(Margine, 52);
// AutoSize=false + AutoEllipsis: un percorso lungo (share di rete) va troncato con "..."
// invece di sforare il bordo del dialog, che è FixedDialog e non si può allargare.
// Il testo completo resta disponibile nel tooltip (stesso pattern di Form1._lblImportStatus).
_lblCartella.AutoSize = false;
_lblCartella.AutoEllipsis = true;
_lblCartella.Size = new Size(428, 20);
_lblCartella.Font = new Font(Font, FontStyle.Bold);
_lblCartella.Size = new Size(ClientSize.Width - Margine * 2, 20);
_lblCartella.ForeColor = GrigioTesto;
_progressBar.Location = new Point(16, 44);
_progressBar.Size = new Size(428, 24);
_progressBar.Minimum = 0;
_progressBar.Maximum = 1000;
_progressBar.Style = ProgressBarStyle.Marquee;
// Barra piatta disegnata a mano (traccia grigia + riempimento) invece della ProgressBar
// nativa: quella di Windows è verde e in stile "a blocchi", stona con il blu del resto
// dell'app (UiStile.Primario). Larghezza = margine destro reale meno lo spazio per
// "100%" in grassetto, così barra+percentuale rispettano lo stesso margine destro del
// resto del contenuto (prima finivano ~25px prima, sbilanciate rispetto al margine
// sinistro di 24px).
_barraTraccia.Location = new Point(Margine, 90);
_barraTraccia.Size = new Size(ClientSize.Width - Margine * 2 - 46, 10);
_barraTraccia.BackColor = Color.FromArgb(230, 230, 230);
_barraRiempimento.Location = Point.Empty;
_barraRiempimento.Size = new Size(0, _barraTraccia.Height);
_barraRiempimento.BackColor = UiStile.ColorePrimario;
_barraTraccia.Controls.Add(_barraRiempimento);
_lblConteggio.Location = new Point(16, 80);
_lblPercentuale.AutoSize = true;
_lblPercentuale.Font = new Font(Font, FontStyle.Bold);
_lblPercentuale.TextAlign = ContentAlignment.MiddleRight;
_lblConteggio.Location = new Point(Margine, 122);
_lblConteggio.AutoSize = true;
_lblDettaglio.Location = new Point(16, 104);
_lblDettaglio.Location = new Point(Margine, 146);
_lblDettaglio.AutoSize = true;
_lblDettaglio.ForeColor = SystemColors.GrayText;
_lblDettaglio.ForeColor = GrigioTesto;
_lblEta.Location = new Point(16, 128);
_lblEta.Location = new Point(Margine, 174);
_lblEta.AutoSize = true;
_lblEta.Font = new Font(Font, FontStyle.Bold);
// Footer separato dal contenuto (linea + sfondo leggermente diverso) invece del
// bottone appiccicato al bordo inferiore del dialog: ancorato in basso, quindi la
// spaziatura sopra/sotto resta corretta anche se la ClientSize cambia. Altezza footer
// 60px e ClientSize 270 scelti per lasciare sotto l'ultima riga (eta, Bottom≈189) lo
// stesso margine (~20px) usato sopra il titolo, invece dei ~7px sbilanciati di prima.
_separatore.BackColor = Color.FromArgb(230, 230, 230);
_separatore.Size = new Size(ClientSize.Width, 1);
_separatore.Location = new Point(0, ClientSize.Height - 60);
_separatore.Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom;
_piePagina.BackColor = Color.FromArgb(247, 247, 248);
_piePagina.Size = new Size(ClientSize.Width, 59);
_piePagina.Location = new Point(0, ClientSize.Height - 59);
_piePagina.Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom;
_btnAnnulla.Text = "Annulla";
_btnAnnulla.Location = new Point(354, 152);
_btnAnnulla.Size = new Size(90, 28);
UiStile.Secondario(_btnAnnulla);
UiStile.Arrotonda(_btnAnnulla);
_btnAnnulla.Click += (_, e) => Annullato?.Invoke(this, e);
_piePagina.Controls.Add(_btnAnnulla);
Controls.AddRange(new Control[]
{
_lblCartella, _progressBar, _lblConteggio, _lblDettaglio, _lblEta, _btnAnnulla
_lblTitolo, _lblCartella, _barraTraccia, _lblPercentuale,
_lblConteggio, _lblDettaglio, _lblEta, _separatore, _piePagina
});
// Posizionato dopo l'aggiunta ai Controls: con AutoSize il Width vero si conosce
// solo a layout avvenuto, non subito dopo la creazione del bottone.
_btnAnnulla.Location = new Point(_piePagina.Width - Margine - _btnAnnulla.Width,
(_piePagina.Height - _btnAnnulla.Height) / 2);
_btnAnnulla.Anchor = AnchorStyles.Right | AnchorStyles.Top;
}
public void Aggiorna(ScanProgress p)
{
_lblCartella.Text = $"Scansione {p.Cartella}...";
_lblCartella.Text = $"Scansione: {p.Cartella}";
_tipCartella.SetToolTip(_lblCartella, _lblCartella.Text);
if (p.PrevistiTotali > 0)
{
_progressBar.Style = ProgressBarStyle.Continuous;
double frazione = Math.Min(1.0, (double)p.FattiTotali / p.PrevistiTotali);
_progressBar.Value = (int)(frazione * _progressBar.Maximum);
_barraRiempimento.Width = (int)(frazione * _barraTraccia.Width);
_lblPercentuale.Text = $"{frazione * 100:0}%";
}
else
{
_progressBar.Style = ProgressBarStyle.Marquee;
// ponytail: prima del conteggio totale (poco probabile: richiede la scansione
// precedente salvata nel DB) la barra resta vuota invece di un'animazione
// indeterminata — se serve, aggiungere un Timer che pulsa _barraRiempimento.
_barraRiempimento.Width = 0;
_lblPercentuale.Text = "";
}
// Riallineato al margine destro reale ogni volta: AutoSize aggiorna Width non appena
// Text cambia, quindi la Location va ricalcolata qui (non solo una volta nel costruttore).
_lblPercentuale.Location = new Point(
ClientSize.Width - Margine - _lblPercentuale.Width, _barraTraccia.Top - 3);
_lblConteggio.Text = p.PrevistiTotali > 0
? $"{p.FattiTotali:N0} / ~{p.PrevistiTotali:N0} file"
+5 -1
View File
@@ -7,6 +7,10 @@ namespace PrepStep;
/// (blu pieno) sia per le azioni secondarie, al posto del bottone grigio di default.</summary>
internal static class UiStile
{
/// <summary>Blu del bottone primario, esposto qui per essere riusato anche da elementi
/// non-bottone (es. la barra di avanzamento di ProgressForm) invece di duplicare l'RGB.</summary>
public static readonly Color ColorePrimario = Color.FromArgb(0, 99, 177);
// AutoSize+Padding invece di una Size fissa in pixel: il bottone si dimensiona sempre
// al proprio testo (a qualunque lunghezza/font), niente più testo che va a capo e viene
// tagliato in verticale quando non ci sta nella larghezza indovinata a mano.
@@ -25,7 +29,7 @@ internal static class UiStile
b.FlatAppearance.BorderSize = 0;
b.FlatAppearance.MouseOverBackColor = Color.FromArgb(0, 84, 152);
b.FlatAppearance.MouseDownBackColor = Color.FromArgb(0, 70, 130);
b.BackColor = Color.FromArgb(0, 99, 177);
b.BackColor = ColorePrimario;
b.ForeColor = Color.White;
b.Font = new Font(b.Font, FontStyle.Bold);
b.Cursor = Cursors.Hand;
+5
View File
@@ -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`.
+144
View File
@@ -0,0 +1,144 @@
# Il database `cad_index.db`
Documentazione tecnica del DB SQLite che indicizza i file CAD: schema, come viene
popolato/aggiornato (`Indexer.ScanAsync`), come l'app lo trova all'avvio e i problemi più
comuni quando i risultati sembrano "mancanti".
## Schema
Tabella `files` (schema SQL in `Indexer.Schema`, `PrepStep/Indexer.cs`): un record per ogni
file trovato sotto la radice del disco (default `X:\`, `Form1.RadiceDiscoDefault`).
| Colonna | Contenuto |
|---|---|
| `id` | chiave primaria autoincrementale |
| `percorso_completo` | percorso assoluto del file — **chiave univoca**, usata per upsert |
| `percorso_relativo` | percorso relativo alla cartella di primo livello (`cartella_radice`) |
| `nome_file` | solo il nome del file |
| `cartella_radice` | prima cartella sotto la radice del disco (es. `__B.end`, `Commerciali`, ...) |
| `famiglia` | primo livello di sottocartella dentro `cartella_radice` (null se il file è direttamente dentro `cartella_radice`) |
| `codice` | estratto dal nome file col pattern Pro-E `<codice>.<tipo>.<versione>` |
| `tipo_file` | es. `prt`, `asm`, o `altro` se il nome non rispetta il pattern |
| `versione` | numero di versione Pro-E (intero), null se non rispetta il pattern |
| `dimensione_byte` | dimensione del file |
| `data_modifica` | `LastWriteTimeUtc` del file, formato ISO 8601 |
| `scansionato_il` | timestamp UTC dell'ultima scansione in cui il file è stato visto |
Indici su `codice`, `tipo_file`, `cartella_radice` (le colonne usate nelle query di ricerca
componenti e nel filtro per cartella).
Vista `files_ultima_versione`: stessa struttura di `files`, filtrata alla sola riga con
`versione` più alta per ogni `(cartella_radice, codice, tipo_file)`, tramite `ROW_NUMBER()
OVER (PARTITION BY ... ORDER BY versione DESC)`. Tutte le ricerche di componenti (`Form1.
TrovaPercorsiCad`, `Form1.CaricaCodiciNoti`) interrogano questa vista, non `files`
direttamente, così un componente con più versioni sulla share risolve sempre all'ultima.
Deliberatamente una `ROW_NUMBER()` invece di un self-join con `GROUP BY`: su ~200k righe un
self-join degenera in tempi quasi quadratici, `ROW_NUMBER()` resta lineare.
Nota: `cartella_radice` fa parte della partition, quindi se lo stesso codice+tipo esiste
in **due cartelle radice diverse** (duplicati sulla share), la vista restituisce una riga per
ciascuna — non una sola in assoluto. `TrovaPercorsiCad` gestisce questo caso deduplicando per
`tipo_file` lato C# (vedi sotto).
## Come viene popolato: `Indexer.ScanAsync`
Chiamato da "Aggiorna indice" (fase 1) e riutilizza lo stesso `dbPath` già caricato.
1. Crea lo schema se non esiste (`CREATE TABLE/VIEW IF NOT EXISTS`) — quindi funziona sia su
un DB vuoto (prima esecuzione) sia su uno esistente (aggiornamento incrementale).
2. Cancella subito tutte le righe con `cartella_radice LIKE 'temp\_%'`: le cartelle
`temp_paris`, `temp_davide`, `temp_studi esterni`, ecc. sono lavori personali/temporanei che
possono contenere copie datate con lo stesso codice numerico di file "ufficiali" altrove
sulla share, causando falsi positivi nella risoluzione dei sotto-componenti (vedi
`docs/formato-asm.md`). Vengono escluse a monte, non solo filtrate nelle singole query.
3. Elenca le cartelle di primo livello sotto la radice con `Directory.GetDirectories` (non un
elenco fisso in codice: una nuova cartella sulla share entra nell'indice al prossimo
aggiornamento senza bisogno di modificare `PrepStep`), escludendo quelle che iniziano per
`temp_`.
4. Per ogni cartella di primo livello, cammina l'albero con uno stack manuale (`Walk`, in
`Indexer.cs`) invece di `Directory.EnumerateFiles(..., SearchOption.AllDirectories)`: più
veloce su una share di rete con centinaia di migliaia di file, e ogni sottocartella
irraggiungibile (permessi, link rotti) viene saltata con un `try/catch` invece di far fallire
l'intera scansione.
5. Per ogni file trovato, estrae `codice`/`tipo`/`versione` dal nome (`ParseFilename`, vedi
sotto) e fa un **upsert riga per riga** dentro una transazione per cartella: confronta
`dimensione_byte` + `data_modifica` con quanto già salvato per lo stesso
`percorso_completo`—
- non esiste ancora → `INSERT` (contatore "nuovi")
- esiste ma dimensione/data sono cambiate → `UPDATE` (contatore "aggiornati")
- esiste e non è cambiato nulla → solo `scansionato_il` viene aggiornato (contatore
"invariati"), per non riscrivere l'intera riga inutilmente
6. Al termine di ogni cartella, cancella dal DB le righe di quella `cartella_radice` il cui
`scansionato_il` non è quello di questa scansione (`DELETE ... WHERE scansionato_il !=
@now`): sono file rimossi dal disco dall'ultima scansione. Il conteggio finisce nel
contatore "rimossi". Questo avviene **per cartella**, non a scansione intera completata: se
annulli a metà, le cartelle già scansionate hanno comunque gli eliminati ripuliti.
7. Il progresso (`ScanProgress`, con conteggi nuovi/aggiornati/invariati/rimossi, file
fatti/previsti e tempo trascorso) viene riportato ogni 200 file tramite `IProgress<T>`,
marshalato automaticamente sul thread UI, e mostrato in `ProgressForm` (che stima anche il
tempo rimanente dal ritmo file/secondo rispetto al totale atteso, preso dal conteggio della
scansione precedente salvato nel DB).
### Estrazione codice/tipo/versione: `ParseFilename`
Regex: `^(?<codice>.+)\.(?<tipo>[A-Za-z0-9]+)\.(?<versione>\d+)$` applicata al nome file
(non al percorso). Esempio: `9251208030c.asm.5` → `codice="9251208030c"`, `tipo="asm"`,
`versione=5`. Il gruppo `codice` è **greedy**, quindi cattura tutto quello che precede l'ultimo
`.tipo.versione` — se un componente ha una lettera di revisione attaccata nel nome del file
(es. `9248908002a.asm.3`), quella lettera fa parte del `codice`, non è un gruppo separato.
Se il nome file non rispetta il pattern (niente `.tipo.numero` finale), l'intero nome finisce
in `codice` e `tipo_file` diventa `"altro"` (usato per capire quanti file "strani" ci sono
sulla share, non tipicamente cercato dalla UI).
## Come l'app trova il DB all'avvio
`Form1.InizializzaDatabase` prova, in ordine, i seguenti candidati e usa il primo che esiste
su disco:
1. l'ultimo DB caricato con successo, salvato in
`%AppData%/PrepStep/ultimo_db.txt` (`Form1.LeggiUltimoDbUsato`/`SalvaUltimoDbUsato`)
2. `cad_index.db` accanto all'eseguibile (`AppContext.BaseDirectory`)
3. `cad_index.db` nella working directory corrente
Se nessuno dei tre esiste, la fase 1 resta bloccata su "Database non trovato" finché l'utente
non preme "Apri DB..." (`Form1.ScegliDatabase`, file picker libero) o "Aggiorna indice"
(`Form1.AggiornaIndice`, che crea `cad_index.db` da zero accanto all'eseguibile se non c'è
ancora un `_dbPath`).
**Attenzione in sviluppo (dotnet run / IDE):** `AppContext.BaseDirectory` è la cartella di
build (`PrepStep/bin/Release/net10.0-windows` o `Debug/...`), non la radice del repo. Se in
quella cartella finisce per errore una copia di `cad_index.db` (es. creata lì da una vecchia
sessione), l'app la userà invece di quella nella radice del repo, e le due copie divergeranno
a ogni "Aggiorna indice" fatto nell'uno o nell'altro ambiente. Se i risultati di una ricerca
sembrano incoerenti con quello che ti aspetti di vedere sul DB "giusto", controlla con "Apri
DB..." quale file è effettivamente caricato (mostrato in fase 1 accanto al conteggio dei file
indicizzati) prima di sospettare un bug nella ricerca.
## Query più usate
- **Verifica completezza / crea raw** (`Form1.TrovaPercorsiCad`): per ogni codice del CSV,
`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`.
Il match sul `codice` è **esatto** (case-insensitive, non un LIKE): se il codice del CSV ha
una lettera di revisione in più o in meno rispetto al nome del file su disco, non c'è match e
la cella "Percorso CAD" resta vuota — non è un errore della query, è che il DB non ha nessuna
riga con quell'esatto `codice`. In presenza di duplicati su più `cartella_radice`, tiene solo
il primo per `tipo_file` (il più recente, dato l'`ORDER BY data_modifica DESC`) e scarta gli
altri.
- **Risoluzione codici noti per lo scanner assiemi** (`Form1.CaricaCodiciNoti`, usata sia dalla
GUI in fase 4 sia da `scripts/BomViaDb.cs`): carica in un'unica query tutti i codici
prt/asm noti con i relativi percorsi, invece di una query per codice — usata per validare i
token generici trovati nei binari `.asm` (vedi `docs/formato-asm.md`).
## Debug ad-hoc
Per interrogare il DB direttamente da riga di comando senza aprire un tool SQLite esterno:
```powershell
dotnet run scripts/Query.cs -- cad_index.db "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione WHERE codice LIKE '925%' LIMIT 20"
```
Utile per capire in fretta se un codice mancante nella UI non è nell'indice affatto (nessuna
riga, nemmeno con un `LIKE` più permissivo) oppure c'è ma con un `codice` leggermente diverso
da quello atteso (es. lettera di revisione, maiuscole/minuscole, spazi).
+318
View File
@@ -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 <tipo> <nome campo>\0 <valore>` |
| `\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 <n>` identifica il layout dei record che seguono |
| `\xf8` | array: `\xf8 <conteggio> ... <elementi>` |
| `\xfb` | lo schema è definito in linea nel primo elemento dell'array |
| `\xf4` | chiude una sezione (seguito da `#\n#<NomeSezione>\n`) |
Il byte `<tipo>` 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 <n> \xf7 <idSchema> \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 <N> \xf7 <idSchema> \xfb
\xe3 \xe0\x01 "id"\0 <int>
\xe0\x01 "type"\0 <int>
\xe0\x0a "name"\0 <stringa>\0
\xe0\x0a "family_name"\0 <stringa>\0
\xe0\x00 "tha_ref"\0 \xe1
\xe0\x02 "e1"\0 ... "e2" ... "e3" ... "origin" ...
\xe3 <id> <type> <name>\0 <family_name>\0 <resto posizionale>
\xe3 <id> <type> <name>\0 <family_name>\0 <resto posizionale>
...
```
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 <int> <int> <stringa>\0 <stringa>\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 <idSchema>] <nome istanza>\0 <id membro> \x82\x7b \xe1\xe1
```
- `\xe2\x33` — parametro di tipo stringa (`\x33` = `'3'`). Confronto: `\xe2\x32`
è un parametro numerico, es. `DENSITA`.
- `<nome istanza>` — il nome esatto che la BOM stampa.
- `<id membro>` — l'`id` del record `members` a cui il parametro si applica,
con la codifica intera di §2.1. **È questo il collegamento.**
- `\xf7 <idSchema>` 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<generico>`:
```
\xe2\x02 "R8--_-_U6592_AISI304<UNI6592>"\0
```
Documenta la stessa relazione istanza→generico in modo autonomo ed è utile come
riscontro incrociato (è lo stesso pattern `<codice><GENERICO>` 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 <int> \xe0\x0a "name"\0 <nome>\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 <nome> contiene:` per la radice,
`Sottoassieme <nome> 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 <radice>:` 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 `<nome>.asm.<rev>`
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).
+17 -4
View File
@@ -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
+291
View File
@@ -0,0 +1,291 @@
// dotnet run scripts/CreoBom.cs -- <file.asm.rev> [-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 -- <file.asm.rev> [-p cartella]... [-o out.bom.1]");
return 1;
}
string fileIniziale = args[0];
var cartelle = new List<string>();
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;
/// <summary>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).</summary>
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<int, string> 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;
}
/// <summary>Nome proprio del modello (quello che Creo stampa in intestazione BOM),
/// dalla prima occorrenza del record "objname".</summary>
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;
}
/// <summary>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).</summary>
public static List<Membro> LeggiMembri(byte[] dati)
{
string testo = Encoding.Latin1.GetString(dati);
var istanze = new Dictionary<int, string>();
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 <conteggio>
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();
}
/// <summary>Voci distinte con quantita', nell'ordine di prima comparsa (come la BOM).</summary>
public static List<((int Tipo, string Nome) Chiave, int Qta)> Rollup(IEnumerable<Membro> 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 -------------------------------------------
/// <summary>nome modello -> membri, cercando &lt;nome&gt;.asm.&lt;rev&gt; 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.</summary>
private static Func<string, List<Membro>?> CreaFinder(IEnumerable<string> cartelle)
{
var indice = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var revisioni = new Dictionary<string, int>(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<string, List<Membro>?>();
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);
/// <summary>Blocchi in ordine depth-first pre-order. Ogni modello compare una volta sola
/// (deduplica per nome) anche se montato piu' volte.</summary>
private static List<Blocco> Blocchi(string root, Func<string, List<Membro>?> get)
{
var risultato = new List<Blocco>();
var visti = new HashSet<string>();
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;
}
/// <summary>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'.</summary>
private static void Sommario(string root, Func<string, List<Membro>?> get, int mult,
List<string> ordine, Dictionary<string, int> 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}";
/// <summary>Testo della BOM, identico byte per byte a quello esportato da Creo (UTF-8 con
/// BOM, CRLF - vedi GeneraBom per la scrittura su file).</summary>
private static string Render(string root, Func<string, List<Membro>?> get)
{
var pezzi = new List<string>();
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<string>();
var acc = new Dictionary<string, int>();
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<string> cartelle)
{
byte[] dati = File.ReadAllBytes(percorsoAsm);
return Render(NomeProprio(dati), CreaFinder(cartelle));
}
}