Skip to main content

brep_render/engine_state/
model_io.rs

1use super::*;
2
3// ===========================================================================
4// Import / export — the file-interchange lane (the ONE platform exception).
5// STEP/IGES are text; STL/OBJ bytes are submitted to the background runner for
6// RANSAC reconstruction, then return as validated STEP for IMPORT3D. Exports
7// collect the CURRENT model's resident solids and serialize them.
8// ===========================================================================
9impl EngineState {
10    /// Import an STL triangle mesh through topology-aware RANSAC recognition.
11    /// Unsupported regions remain as validated facets, so every repairable
12    /// source triangle reaches the resulting CAD body.
13    pub fn import_stl_feature(&mut self, bytes: &[u8]) -> Result<String, String> {
14        self.submit_mesh_import(crate::runner::MeshImportFormat::Stl, bytes.to_vec())
15    }
16
17    /// Import a Wavefront OBJ mesh through the same RANSAC reconstruction path.
18    pub fn import_obj_feature(&mut self, text: &str) -> Result<String, String> {
19        self.import_obj_bytes_feature(text.as_bytes())
20    }
21
22    /// Byte-oriented OBJ entry used by the picker so decoding also stays on the
23    /// background runner with parsing and reconstruction.
24    pub fn import_obj_bytes_feature(&mut self, bytes: &[u8]) -> Result<String, String> {
25        self.submit_mesh_import(crate::runner::MeshImportFormat::Obj, bytes.to_vec())
26    }
27
28    fn submit_mesh_import(
29        &mut self,
30        format: crate::runner::MeshImportFormat,
31        bytes: Vec<u8>,
32    ) -> Result<String, String> {
33        if bytes.is_empty() {
34            return Err("mesh import failed: file is empty".into());
35        }
36        let id = self.next_mesh_import_id;
37        self.next_mesh_import_id = self.next_mesh_import_id.wrapping_add(1);
38        self.pending_mesh_imports.insert(id);
39        self.runner
40            .submit_mesh_import(crate::runner::MeshImportRequest { id, format, bytes });
41        // InlineRunner completes now for tests/embedders. Production's native
42        // thread and browser Worker return immediately and are polled per frame.
43        self.pump();
44        Ok(serde_json::json!({ "meshImport": "submitted", "id": id }).to_string())
45    }
46
47    /// Import a STEP document into the model: append an `IMPORT3D` feature whose
48    /// `inputParams.stepText` is the raw ISO-10303-21 text (the exact headless
49    /// source the kernel importer reads — no `fileToImport` data-URL marshaling
50    /// needed), mint it a persistent-counter id, roll to it, and rebuild. Returns the
51    /// build report JSON (imported bodies + any per-feature error). A non-STEP
52    /// payload is refused up front so a bad upload never leaves a dead feature.
53    pub fn import_step_feature(&mut self, step_text: &str) -> Result<String, String> {
54        if !step_text.contains("ISO-10303-21") {
55            return Err("not a STEP file (missing the ISO-10303-21 header)".into());
56        }
57        let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
58        let feature = serde_json::json!({
59            "type": "IMPORT3D",
60            "inputParams": { "id": id, "stepText": step_text },
61            "persistentData": {},
62        });
63        // Frame the imported body once the (possibly async) run lands — see
64        // [`EngineState::pending_fit`]. An immediate fit here would frame the still
65        // empty scene under a background runner (native thread / wasm worker).
66        self.pending_fit = true;
67        self.add_feature(&feature.to_string())
68    }
69
70    /// Export the CURRENT model's resident solids to an ISO-10303-21 STEP
71    /// document. Collects the resident handles of the rolled-to model (a warm
72    /// re-run of the same prefix the display scene was built from — see
73    /// [`crate::pipeline::resident_solid_handles`]) and hands them to the kernel's
74    /// [`brep_kernel::export_step_handles`], so the exact NURBS topology is
75    /// serialized (never the display mesh). Errs clearly when the model is empty.
76    pub fn export_step_text(&self) -> Result<String, String> {
77        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
78            .map_err(|e| format!("export STEP: history request: {e}"))?;
79        let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
80            .into_iter()
81            .map(|(_, handle)| handle)
82            .collect();
83        if handles.is_empty() {
84            return Err("nothing to export: the model has no solids".into());
85        }
86        brep_kernel::export_step_handles(&handles, "Part", "MM", "")
87    }
88
89    /// Resident handle of the part's target sheet-metal body for a flat-pattern
90    /// export. Enumerates the current resident solids (a warm re-run of the same
91    /// prefix the display scene was built from, like the STEP lane) and keeps the
92    /// ones carrying a sheet-metal tree; uses the SELECTED sheet-metal body if the
93    /// selection names exactly one, else the SOLE sheet-metal body (the same
94    /// auto-target SM.CUTOUT uses). Errs with the exact `"no sheet-metal body in
95    /// the part"` when there is none, and loudly when several are ambiguous.
96    fn flat_pattern_target_handle(&self) -> Result<u32, String> {
97        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
98            .map_err(|e| format!("export flat pattern: history request: {e}"))?;
99        let sheet_metal: Vec<(String, u32)> = crate::pipeline::resident_solid_handles(&request)
100            .into_iter()
101            .filter(|(_, handle)| brep_kernel::is_sheet_metal_handle(*handle))
102            .collect();
103        if sheet_metal.is_empty() {
104            return Err("no sheet-metal body in the part".into());
105        }
106        // Prefer a selected sheet-metal body when the selection names exactly one.
107        let selected: Vec<u32> = sheet_metal
108            .iter()
109            .filter(|(name, _)| self.emphasis.selected_solids.contains(name))
110            .map(|(_, handle)| *handle)
111            .collect();
112        if let [handle] = selected.as_slice() {
113            return Ok(*handle);
114        }
115        match sheet_metal.as_slice() {
116            [(_, handle)] => Ok(*handle),
117            _ => Err(
118                "several sheet-metal bodies in the part — select the one to export".into(),
119            ),
120        }
121    }
122
123    /// Export the part's sheet-metal FLAT PATTERN (the unfold) as a DXF (R12
124    /// ASCII) 2D vector document. Runs the unfold TRANSIENTLY off the target
125    /// body's resident tree — no feature is added and history is not mutated. Errs
126    /// (`"no sheet-metal body in the part"`) when the part carries no sheet metal.
127    pub fn export_flat_pattern_dxf(&self) -> Result<String, String> {
128        brep_kernel::flat_pattern_dxf(self.flat_pattern_target_handle()?)
129    }
130
131    /// Export the part's sheet-metal flat pattern as an SVG — the DXF sibling of
132    /// [`Self::export_flat_pattern_dxf`].
133    pub fn export_flat_pattern_svg(&self) -> Result<String, String> {
134        brep_kernel::flat_pattern_svg(self.flat_pattern_target_handle()?)
135    }
136
137    /// Import an IGES document into the model: append an `IMPORT3D` feature whose
138    /// `inputParams.igesText` is the raw IGES text (the kernel importer reads it
139    /// via [`brep_kernel::import_iges`]), mint an id, roll to it, and rebuild.
140    /// Refuses a non-IGES payload up front so a bad upload never leaves a dead
141    /// feature.
142    pub fn import_iges_feature(&mut self, iges_text: &str) -> Result<String, String> {
143        if iges_text.contains("ISO-10303-21") {
144            return Err("not an IGES file (this looks like a STEP document)".into());
145        }
146        // IGES records carry an S/G/D/P/T section letter in column 73.
147        let looks_like_iges = iges_text.lines().any(|line| {
148            matches!(line.chars().nth(72), Some('S' | 'G' | 'D' | 'P' | 'T'))
149        });
150        if !looks_like_iges {
151            return Err("not an IGES file (no S/G/D/P/T section records found)".into());
152        }
153        let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
154        let feature = serde_json::json!({
155            "type": "IMPORT3D",
156            "inputParams": { "id": id, "igesText": iges_text },
157            "persistentData": {},
158        });
159        // Frame the imported body once the (possibly async) run lands — see
160        // [`EngineState::pending_fit`] (mirrors the STEP lane above).
161        self.pending_fit = true;
162        self.add_feature(&feature.to_string())
163    }
164
165    /// Export the CURRENT model's resident solids to an IGES 5.3 document of
166    /// trimmed NURBS surfaces — the IGES analogue of [`Self::export_step_text`],
167    /// handing the resident handles to [`brep_kernel::export_iges_handles`].
168    pub fn export_iges_text(&self) -> Result<String, String> {
169        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
170            .map_err(|e| format!("export IGES: history request: {e}"))?;
171        let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
172            .into_iter()
173            .map(|(_, handle)| handle)
174            .collect();
175        if handles.is_empty() {
176            return Err("nothing to export: the model has no solids".into());
177        }
178        brep_kernel::export_iges_handles(&handles, "Part", "MM", "")
179    }
180
181    /// Export the CURRENT display scene to an ASCII STL string (one `solid` with a
182    /// per-triangle geometric normal for every mesh triangle of every displayed
183    /// solid). STL is a triangle-soup format with no multi-body concept, so all
184    /// solids fold into a single `solid brep … endsolid brep`. String-shaped so it
185    /// crosses the same string `ModelStore` seam the STEP lane uses. Errs when the
186    /// scene has no triangles.
187    pub fn export_stl_text(&self) -> Result<String, String> {
188        let mut out = String::from("solid brep\n");
189        let mut triangles = 0usize;
190        for solid in self.scene.solids() {
191            let positions = &solid.mesh.positions;
192            for tri in solid.mesh.indices.chunks_exact(3) {
193                let a = positions[tri[0] as usize];
194                let b = positions[tri[1] as usize];
195                let c = positions[tri[2] as usize];
196                let normal = triangle_normal(a, b, c);
197                out.push_str(&format!(
198                    "  facet normal {} {} {}\n    outer loop\n",
199                    normal[0], normal[1], normal[2]
200                ));
201                for v in [a, b, c] {
202                    out.push_str(&format!("      vertex {} {} {}\n", v[0], v[1], v[2]));
203                }
204                out.push_str("    endloop\n  endfacet\n");
205                triangles += 1;
206            }
207        }
208        out.push_str("endsolid brep\n");
209        if triangles == 0 {
210            return Err("nothing to export: the scene has no triangles".into());
211        }
212        Ok(out)
213    }
214}
215
216/// Unit (or zero, for a degenerate triangle) geometric normal of triangle
217/// `(a, b, c)` — the per-facet normal an ASCII STL record carries.
218fn triangle_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
219    let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
220    let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
221    let n = [
222        u[1] * v[2] - u[2] * v[1],
223        u[2] * v[0] - u[0] * v[2],
224        u[0] * v[1] - u[1] * v[0],
225    ];
226    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
227    if len > 0.0 {
228        [n[0] / len, n[1] / len, n[2] / len]
229    } else {
230        [0.0, 0.0, 0.0]
231    }
232}
233
234// ===========================================================================
235// STRUCTURED STEP import — the assembly lane (kernel-plan
236// `step-assembly-import.md` §3.7).
237//
238// The flat lane above (`import_step_feature`) appends ONE IMPORT3D holding the
239// raw Part-21 text and lets the kernel bake every occurrence's world transform
240// into its own body: N bodies, no parts, no tree. This lane keeps the structure
241// instead — each unique geometry-bearing PRODUCT_DEFINITION becomes ONE
242// parts-library entry holding a NATIVE payload (`nativeBrep`, no STEP text
243// anywhere past this door), and each occurrence of it becomes an ACOMP instance
244// carrying the composed world pose. Six bolts are then one entry × six
245// instances, which is what makes the BOM, the structure tree, per-component
246// selection and constraints work on imported geometry.
247//
248// # FLAT or NESTED — the user's choice, both correct
249//
250// [`StepAssemblyImport::nested`] picks between two shapes of the same geometry
251// (kernel-plan §3.3):
252//
253// - **Flat** flattens the occurrence tree to its geometry-bearing leaves: one
254//   ACOMP per leaf occurrence, each carrying the COMPOSED world pose. Every
255//   part is stored once for the whole document.
256// - **Nested** keeps the tree: each assembly-node product becomes a part
257//   document that itself carries `{partsLibrary, features: [ACOMP…,
258//   IMPORT3D…]}`, built bottom-up by the same recursive builder, and the
259//   parent gets ONE ACOMP per sub-assembly occurrence. Build-spec §2.2's
260//   rigid nesting — the sub-assembly arrives already-solved and moves as one
261//   component, the live `ComponentMap` stays flat, and the structure tree
262//   expands it read-only from the namespace chain (`ACOMP2:ACOMP1:…`).
263//
264// Neither is the deprecated one. Nested shows the real tree; flat is the right
265// answer for a deep or pathological file, and it stores a part reused at two
266// levels ONCE, where nesting stores it once PER LEVEL (build-spec §2.2). For a
267// depth-1 tree the two lanes produce byte-identical documents — the cheapest
268// correctness check there is, and `nested_matches_flat_for_a_depth_one_tree`
269// asserts exactly it.
270//
271// # PROBE then CONSUME — because the parse is the expensive half
272//
273// The app must know the counts BEFORE it can offer the choice ("7 parts, 23
274// instances — import as assembly or as bodies?"), and re-reading multi-MB
275// Part-21 text after the user clicks would pay the file's single most expensive
276// cost twice. So [`EngineState::probe_step_assembly`] performs the ONE parse and
277// stashes the [`brep_kernel::StepAssembly`] in
278// [`EngineState::pending_step_assembly`];
279// [`EngineState::import_probed_step_assembly`] TAKES it. Cancel
280// ([`EngineState::discard_probed_step_assembly`]), a second probe, and a
281// document switch all drop it, so a user who cancels three imports is holding
282// zero parsed assemblies — a real consideration, since the stash keeps every
283// product's solids resident for as long as the dialog is open.
284//
285// # ONE rebuild for the whole import
286//
287// `add_feature` re-runs the entire history per call, so appending N instances
288// through it is O(N²). This lane appends them all through
289// [`EngineState::add_features`] — one push batch, one rebuild, one undo step.
290// (Not `set_history_json`: that is the document-SWITCH path, which clears the
291// kernel history cache and resets the runner's delta baseline.)
292//
293// # Fallback, never a silent zero
294//
295// No structure at all, or every geometry-bearing product failing to encode, both
296// end at today's flat lane. "A successful import that produces zero components"
297// is a failure wearing a result's clothes, so the zero-component case is an
298// `Err` for the dialog-driven entry point (the app owns the file text and re-runs
299// the flat import) and an automatic fall-back for the text-taking convenience.
300// ===========================================================================
301
302/// What the import dialog needs to describe a STEP file's structure — counts
303/// only, so the probe can answer without building anything.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub struct StepAssemblyProbe {
306    /// Unique geometry-bearing products → parts-library entries. The floor, not
307    /// the final count: a non-rigid occurrence bakes its own extra entry (§3.4).
308    pub parts: usize,
309    /// Geometry-bearing occurrences → ACOMP instance features.
310    pub instances: usize,
311    /// Longest root→node chain of occurrences. `1` is a flat assembly; `> 1`
312    /// means sub-assemblies exist, so [`StepAssemblyImport::nested`] changes
313    /// the shape of the result and the dialog's choice is worth offering.
314    pub nested_depth: usize,
315}
316
317/// The choices the import dialog collects.
318#[derive(Debug, Clone, Copy, Default)]
319pub struct StepAssemblyImport {
320    /// Build nested rigid sub-assembly documents (kernel-plan §3.3 Phase 2)
321    /// instead of flattening the tree to its leaf occurrences.
322    ///
323    /// `false` (the `Default`) is the flat lane, byte-for-byte unchanged. On a
324    /// depth-1 tree the two produce the same document, so this flag only ever
325    /// matters for a file that really has sub-assemblies.
326    pub nested: bool,
327}
328
329/// What an import did — the numbers the status line and notice report.
330#[derive(Debug, Clone, Default, PartialEq, Eq)]
331pub struct StepAssemblyReport {
332    /// Parts-library entries this import added or reused — the entries of the
333    /// USER'S document. On a nested import that is the top level only: a
334    /// sub-assembly's own entries live in ITS document's library, which the
335    /// parent never sees.
336    pub parts: usize,
337    /// ACOMP instance features appended to the user's document. Nested: one per
338    /// ROOT-level occurrence (a sub-assembly is one component, per build-spec
339    /// §2.2), not one per leaf body.
340    pub instances: usize,
341    /// Occurrences whose non-rigid factor was baked into a distinct part
342    /// (§3.4), summed over every level a nested import built.
343    pub baked_nonrigid: usize,
344    /// Products (or baked non-rigid variants of one) that did not encode to a
345    /// payload — skipped and counted, never fatal: the importer's
346    /// graceful-degradation contract, carried up to this altitude. Summed over
347    /// every level a nested import built.
348    pub failed_products: usize,
349    /// The first thing that went wrong, from the kernel's body-build errors or
350    /// this lane's own encode failures.
351    pub first_error: Option<String>,
352    /// The structured lane did not run: the file carries no usable structure, or
353    /// nothing in it encoded, so the bodies were imported through the flat lane
354    /// exactly as before. Only ever `true` from [`EngineState::import_step_assembly`],
355    /// which holds the text; the dialog-driven entry point returns `Err` instead
356    /// and lets its caller re-run the flat import it already has the text for.
357    pub flat_fallback: bool,
358}
359
360/// The outcome of consuming a parsed assembly, before it is shaped into either
361/// an `Err` (dialog lane) or a flat fallback (text lane) — so neither has to
362/// recognise "nothing imported" by matching an error string.
363enum Consumed {
364    Imported(StepAssemblyReport),
365    /// Every geometry-bearing product failed to encode: no components, so this
366    /// is not an import.
367    NoComponents {
368        failed_products: usize,
369        first_error: Option<String>,
370    },
371}
372
373/// A row-major 4×4 affine, the shape `StepOccurrence::placement` and
374/// `AffineTransform` both use.
375type Mat4 = [f64; 16];
376
377/// One node of the composed occurrence tree — a product at a world pose.
378struct PlacedProduct {
379    /// Index into `StepAssembly::products`.
380    product: usize,
381    /// Composed child-local → world transform.
382    world: Mat4,
383    /// Occurrence edges between a root and this node (`0` at a root).
384    depth: usize,
385    /// Every edge on the path here was rigid, so `world` IS a component pose.
386    /// False means the non-rigid factor must be baked into the part (§3.4).
387    rigid_path: bool,
388}
389
390/// A parts-library entry this import needs: a product, plus the bits of the
391/// non-rigid factor baked into it (all-zero linear block ⇒ none). Two
392/// occurrences of one product under DIFFERENT non-rigid factors are different
393/// parts — never a wrong-handed reuse.
394type PartKey = (usize, [u64; 9]);
395
396/// The `PartKey` factor slot for a plain rigid instance.
397const NO_FACTOR: [u64; 9] = [0; 9];
398
399impl EngineState {
400    /// Read a STEP file's product structure — THE parse of a structured import.
401    /// Stashes the parsed assembly (with every product's solids) for
402    /// [`Self::import_probed_step_assembly`] and returns the dialog's counts.
403    ///
404    /// `Ok(None)` = no usable structure (no NAUO edges, or none reaching built
405    /// geometry): the caller imports through the flat
406    /// [`Self::import_step_feature`] lane with the text it already holds, which
407    /// is byte-for-byte today's behaviour. `Err` only for text that is not a
408    /// Part 21 file at all — a BROKEN assembly degrades, it does not fail.
409    ///
410    /// Replaces any previously stashed assembly on EVERY outcome, `Ok(None)`
411    /// included: a stale stash surviving a probe of a different file is how a
412    /// consume silently imports the wrong one.
413    pub fn probe_step_assembly(
414        &mut self,
415        step_text: &str,
416    ) -> Result<Option<StepAssemblyProbe>, String> {
417        self.pending_step_assembly = None;
418        let Some(assembly) = brep_kernel::read_step_assembly(step_text)? else {
419            return Ok(None);
420        };
421        let probe = probe_counts(&assembly);
422        // The kernel already refuses a structure that reaches no geometry, so
423        // this is belt-and-braces: an assembly with zero instances would import
424        // as zero components, which is the silent failure this lane forbids.
425        if probe.instances == 0 {
426            return Ok(None);
427        }
428        self.pending_step_assembly = Some(assembly);
429        Ok(Some(probe))
430    }
431
432    /// Import the assembly [`Self::probe_step_assembly`] stashed: one
433    /// parts-library entry per unique product, one ACOMP instance per
434    /// occurrence, ONE rebuild. TAKES the stash, so a double-import is an error
435    /// rather than a double-insert.
436    ///
437    /// `doc_name` names products the file left unnamed (`{doc_name}-part-{id}`).
438    /// `opts.nested` chooses between the flat and nested shapes — see
439    /// [`StepAssemblyImport::nested`]. Errs when nothing is stashed, and when
440    /// every product failed to encode — the latter being the caller's cue to
441    /// re-run the flat import with the file text it holds.
442    /// `sink` receives every unique part document so the app can write it to
443    /// the model store and hand back a real `sourceKey`; pass [`EmbeddedOnly`]
444    /// to keep the parts embedded (what a caller with no store does).
445    pub fn import_probed_step_assembly(
446        &mut self,
447        doc_name: &str,
448        opts: StepAssemblyImport,
449        sink: &mut dyn PartSink,
450    ) -> Result<StepAssemblyReport, String> {
451        let assembly = self.pending_step_assembly.take().ok_or_else(|| {
452            "import STEP assembly: nothing probed (call probe_step_assembly first)".to_string()
453        })?;
454        match self.consume_step_assembly(assembly, doc_name, opts.nested, sink) {
455            Consumed::Imported(report) => Ok(report),
456            Consumed::NoComponents { first_error, .. } => Err(format!(
457                "import STEP assembly: no part of the assembly could be built{}",
458                first_error
459                    .map(|error| format!(" ({error})"))
460                    .unwrap_or_default()
461            )),
462        }
463    }
464
465    /// Drop a probed assembly and the solids it holds resident — the dialog's
466    /// Cancel. Idempotent.
467    pub fn discard_probed_step_assembly(&mut self) {
468        self.pending_step_assembly = None;
469    }
470
471    /// Probe + consume in one call, falling back to the flat lane by itself —
472    /// the HEADLESS/test entry point. The app uses the probe/consume pair
473    /// instead, because it has a dialog between the two halves.
474    ///
475    /// Still exactly one parse: this is `probe_step_assembly` followed by the
476    /// consume of what it stashed.
477    ///
478    /// Parts stay EMBEDDED here ([`EmbeddedOnly`]): this entry point has no
479    /// store handle and no way to ask for a destination. The app uses the
480    /// probe/consume pair with a real sink.
481    pub fn import_step_assembly(
482        &mut self,
483        step_text: &str,
484        doc_name: &str,
485        opts: StepAssemblyImport,
486    ) -> Result<StepAssemblyReport, String> {
487        let structured = self.probe_step_assembly(step_text)?.is_some();
488        let outcome = structured.then(|| {
489            let assembly = self
490                .pending_step_assembly
491                .take()
492                .expect("a Some probe stashed the assembly it counted");
493            self.consume_step_assembly(assembly, doc_name, opts.nested, &mut EmbeddedOnly)
494        });
495        match outcome {
496            Some(Consumed::Imported(report)) => Ok(report),
497            // No structure, or a structure nothing built out of: import the
498            // bodies exactly as the pre-assembly lane did.
499            Some(Consumed::NoComponents {
500                failed_products,
501                first_error,
502            }) => {
503                self.import_step_feature(step_text)?;
504                Ok(StepAssemblyReport {
505                    failed_products,
506                    first_error,
507                    flat_fallback: true,
508                    ..StepAssemblyReport::default()
509                })
510            }
511            None => {
512                self.import_step_feature(step_text)?;
513                Ok(StepAssemblyReport {
514                    flat_fallback: true,
515                    ..StepAssemblyReport::default()
516                })
517            }
518        }
519    }
520
521    /// The import itself (kernel-plan §3.7 steps 2-5), shared by both entry
522    /// points so neither has to recognise "nothing imported" from an error
523    /// string.
524    fn consume_step_assembly(
525        &mut self,
526        assembly: brep_kernel::StepAssembly,
527        doc_name: &str,
528        nested: bool,
529        sink: &mut dyn PartSink,
530    ) -> Consumed {
531        let mut first_error = assembly.first_error.clone();
532        // ONE writer for the whole import, so identical content is written to
533        // the store exactly once however many products or LEVELS share it.
534        let mut writer = PartWriter::new(sink);
535
536        // --- what to build ------------------------------------------------
537        // One row per component the USER'S document gets, each naming the
538        // library entry it needs. Flat walks the whole tree to its leaves;
539        // nested stops at the root's own children and folds everything below
540        // each of them into that child's part document.
541        let plan = if nested {
542            plan_nested(&assembly, doc_name, &mut first_error, &mut writer)
543        } else {
544            plan_flat(&assembly, &mut first_error)
545        };
546        let Plan {
547            wanted,
548            factors,
549            documents,
550            mut failed_products,
551            baked_below_root,
552        } = plan;
553
554        // --- build the library entries -------------------------------------
555        // In (pd_ref, factor) order so an import is deterministic regardless of
556        // the tree's emit order, and ONCE per key however many instances use it.
557        let mut keys: Vec<PartKey> = wanted.iter().map(|(key, _)| *key).collect();
558        keys.sort_unstable();
559        keys.dedup();
560        let mut entry_names: std::collections::HashMap<PartKey, String> =
561            std::collections::HashMap::new();
562        {
563            // THE metadata bracket. `native_import_payload` seals whatever record
564            // this thread's scene-metadata store holds for each name it stamps —
565            // right for a snapshot of the live scene, catastrophic here: a new
566            // part whose stamped face names collide with names already in THIS
567            // document would silently carry the current document's metadata.
568            // Scoped to the encode alone; the rebuild below stamps records the
569            // document must keep, and this guard's drop would discard them.
570            //
571            // The nested lane's payloads are encoded inside `plan_nested`,
572            // which holds a bracket of its own for exactly the same reason.
573            let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
574            for key in &keys {
575                // Nested pre-built the whole document (a sub-assembly's is a
576                // recursive `{partsLibrary, features}`); flat builds the §3.2
577                // native part document right here.
578                let built = match documents.get(key) {
579                    Some((name, document)) => install_part(name, document, &mut writer),
580                    None => {
581                        let product = assembly
582                            .products
583                            .iter()
584                            .find(|product| product.pd_ref == key.0)
585                            .expect("every key names a product of this assembly");
586                        build_library_entry(product, factors.get(key), doc_name, &mut writer)
587                    }
588                };
589                match built {
590                    Ok(name) => {
591                        entry_names.insert(*key, name);
592                    }
593                    Err(error) => {
594                        failed_products += 1;
595                        note(&mut first_error, error);
596                    }
597                }
598            }
599        }
600        if entry_names.is_empty() {
601            return Consumed::NoComponents {
602                failed_products,
603                first_error,
604            };
605        }
606
607        // --- append every instance in ONE history mutation -----------------
608        // `insert_component`'s rule, verbatim: ground the FIRST component only
609        // when the document has none yet. Grounding a second one over-constrains
610        // the next solve.
611        let mut ground_next = !(0..self.history.len()).any(|index| {
612            matches!(
613                self.history.feature_type(index).as_deref(),
614                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
615            )
616        });
617        let mut features: Vec<serde_json::Value> = Vec::with_capacity(wanted.len());
618        let mut baked_nonrigid = 0usize;
619        for (key, pose) in &wanted {
620            let Some(part_name) = entry_names.get(key) else {
621                continue; // this product failed to encode; counted above
622            };
623            let transform = match brep_kernel::AffineTransform::new(*pose) {
624                Ok(transform) => transform,
625                Err(error) => {
626                    note(&mut first_error, format!("occurrence pose: {error}"));
627                    continue;
628                }
629            };
630            if key.1 != NO_FACTOR {
631                baked_nonrigid += 1;
632            }
633            features.push(serde_json::json!({
634                "type": "ACOMP",
635                "inputParams": {
636                    "id": self.history.next_feature_id("ACOMP"),
637                    "partName": part_name,
638                    "transform": brep_kernel::transform_to_pose_params(&transform),
639                    "isFixed": ground_next,
640                },
641                "persistentData": {}
642            }));
643            ground_next = false;
644        }
645        if features.is_empty() {
646            return Consumed::NoComponents {
647                failed_products,
648                first_error,
649            };
650        }
651
652        // The library block must ride the request so the display runner ingests
653        // the new entries on the very next run (as `insert_component` does).
654        // Written only now that there are components to reference them, so an
655        // import that produced nothing leaves the document untouched.
656        if let Ok(library) =
657            serde_json::from_str::<serde_json::Value>(&brep_kernel::parts_library_json())
658        {
659            self.history.set_parts_library(library);
660        }
661        // Frame the assembly once the (possibly async) run lands — see
662        // [`EngineState::pending_fit`], same reasoning as `import_step_feature`.
663        self.pending_fit = true;
664        let instances = features.len();
665        let baked_nonrigid = baked_nonrigid + baked_below_root;
666        self.add_features(&features);
667        Consumed::Imported(StepAssemblyReport {
668            // DISTINCT entries, not distinct keys: `add_part_to_library` reuses
669            // an entry whose content already matches, so two products that are
670            // the same geometry collapse to one part (§3.5's free content dedup).
671            parts: entry_names
672                .values()
673                .collect::<std::collections::HashSet<_>>()
674                .len(),
675            instances,
676            baked_nonrigid,
677            failed_products,
678            first_error,
679            flat_fallback: false,
680        })
681    }
682}
683
684/// What one import decided to build, before any of it is installed: the rows
685/// the user's document gets, and whatever each lane needed to work out on the
686/// way there.
687#[derive(Default)]
688struct Plan {
689    /// One row per component of the USER'S document, in emit order.
690    wanted: Vec<(PartKey, Mat4)>,
691    /// FLAT only: the non-rigid factor a key's part must bake (§3.4). The
692    /// nested lane bakes inside its own builder and hands the finished document
693    /// over in `documents` instead.
694    factors: std::collections::HashMap<PartKey, Mat4>,
695    /// NESTED only: `(entry name, part document)` per key, already built — a
696    /// leaf's §3.2 native document, or a sub-assembly's recursive
697    /// `{partsLibrary, features}`.
698    documents: std::collections::HashMap<PartKey, (String, serde_json::Value)>,
699    /// Products that did not encode while planning (nested builds payloads
700    /// during the plan; flat builds them during the install).
701    failed_products: usize,
702    /// Non-rigid occurrences baked BELOW the root — nested only, since the flat
703    /// lane has no below-the-root and counts its bakes at install time.
704    baked_below_root: usize,
705}
706
707/// **FLAT** (kernel-plan §3.3 Phase 1): flatten the occurrence tree to its
708/// geometry-bearing nodes, each carrying the COMPOSED world pose. Byte-for-byte
709/// the lane A6 shipped.
710fn plan_flat(assembly: &brep_kernel::StepAssembly, first_error: &mut Option<String>) -> Plan {
711    let mut plan = Plan::default();
712    for placed in &compose_world_occurrences(assembly) {
713        let product = &assembly.products[placed.product];
714        if product.bodies.is_empty() {
715            continue; // a pure assembly node contributes structure, not a component
716        }
717        let (key, pose) = if placed.rigid_path {
718            ((product.pd_ref, NO_FACTOR), placed.world)
719        } else {
720            // §3.4: world = rigid · factor. Bake `factor` into a distinct part
721            // and give the instance the rigid residue, so a mirrored instance
722            // never lands on its unmirrored twin.
723            match split_rigid(&placed.world) {
724                // Non-rigid edges that cancel out along the path leave an
725                // identity factor: that is an ordinary instance of the ordinary
726                // part, not a bake.
727                Ok((rigid, factor)) if is_identity(&factor) => {
728                    ((product.pd_ref, NO_FACTOR), rigid)
729                }
730                Ok((rigid, factor)) => {
731                    let key = (product.pd_ref, factor_key(&factor));
732                    plan.factors.insert(key, factor);
733                    (key, rigid)
734                }
735                Err(error) => {
736                    note(first_error, error);
737                    continue;
738                }
739            }
740        };
741        plan.wanted.push((key, pose));
742    }
743    plan
744}
745
746/// **NESTED** (kernel-plan §3.3 Phase 2): the live document plays the ROOT, so
747/// it gets one component per root-level row and nothing deeper —
748///
749/// - a root's OWN bodies become a leaf part at identity (exactly the flat
750///   lane's treatment of interior geometry at the root), and
751/// - each root-child occurrence becomes ONE component: a leaf part when the
752///   child has no children of its own, else a rigid sub-assembly whose part
753///   document carries its own `partsLibrary` and its own ACOMPs.
754///
755/// Emit order matches [`plan_flat`]'s DFS pre-order — root before its children,
756/// children by ascending `nauo_ref` — which is what makes the two lanes produce
757/// the SAME document for a depth-1 tree.
758fn plan_nested(
759    assembly: &brep_kernel::StepAssembly,
760    doc_name: &str,
761    first_error: &mut Option<String>,
762    writer: &mut PartWriter<'_>,
763) -> Plan {
764    // The same bracket the install loop holds, for the same reason: every
765    // payload this builder encodes (at every level) must see an empty ambient
766    // scene-metadata store, or a nested leaf whose stamped face names collide
767    // with the live document's silently inherits the live document's records.
768    let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
769    let mut build = NestedBuild {
770        assembly,
771        doc_name,
772        writer,
773        memo: std::collections::HashMap::new(),
774        factors: std::collections::HashMap::new(),
775        entries: 0,
776        bytes: 0,
777        failed_products: 0,
778        baked_nonrigid: 0,
779        first_error: None,
780    };
781    let mut plan = Plan::default();
782    for &root in &assembly.roots {
783        let mut rows: Vec<(DocKey, Mat4)> = Vec::new();
784        // The root's OWN bodies become a leaf part at identity — exactly the
785        // flat lane's treatment, and the reason a depth-1 tree comes out the
786        // same either way.
787        if !assembly.products[root].bodies.is_empty() {
788            rows.push((
789                DocKey::Leaf((assembly.products[root].pd_ref, NO_FACTOR)),
790                MAT4_IDENTITY,
791            ));
792        }
793        // Root-level bakes are counted by the install loop's own pass over
794        // `wanted` (they are ordinary top-level rows); only bakes BELOW the root
795        // — which never become rows of the user's document — are counted here.
796        let mut root_level_bakes = 0usize;
797        build.place_children(root, &[root], &mut rows, &mut root_level_bakes);
798        for (key, pose) in rows {
799            let part = match build.document(key, &mut vec![root]) {
800                Ok(Some(document)) => document,
801                // A subtree with no geometry anywhere places nothing — the flat
802                // lane says the same thing by emitting no component for it.
803                Ok(None) => continue,
804                Err(error) => {
805                    build.failed_products += 1;
806                    note(&mut build.first_error, error);
807                    continue;
808                }
809            };
810            let part_key = key.part_key(assembly);
811            plan.documents.insert(part_key, part);
812            plan.wanted.push((part_key, pose));
813        }
814    }
815    plan.failed_products = build.failed_products;
816    plan.baked_below_root = build.baked_nonrigid;
817    if let Some(error) = build.first_error {
818        note(first_error, error);
819    }
820    plan
821}
822
823/// How deep the recursive builder will go before it refuses. `read_step_assembly`
824/// guards cycles inside its own walk and [`NestedBuild::document`] guards them
825/// again along the recursion path, so this is the SECOND line: a malformed file
826/// that is merely pathologically deep (rather than cyclic) must not run the
827/// native stack out. Sixty-four levels of embedded documents is already far past
828/// anything a real CAD assembly carries — and each level embeds the whole
829/// subtree below it, so the document would be unusable long before then.
830const MAX_NESTED_DEPTH: usize = 64;
831
832/// How many DISTINCT part documents a nested import may build. Bounds the
833/// builder's work; it does NOT bound the result's size — see
834/// [`MAX_NESTED_BYTES`], which is the guard that matters.
835const MAX_NESTED_ENTRIES: usize = 10_000;
836
837/// How many bytes of part document a nested import may EMBED, summed over every
838/// `partsLibrary` entry it writes at every level.
839///
840/// This is the guard neither the depth cap nor the entry count provides. A
841/// product reachable at many different depths is stored once PER LEVEL
842/// (build-spec §2.2) — the memo builds its document once, but each parent
843/// embeds a COPY, so a diamond-shaped structure well inside the depth cap can
844/// still multiply out geometrically. Charging the embedded bytes is the only
845/// place that multiplication is visible, so it is charged where it happens.
846const MAX_NESTED_BYTES: usize = 256 * 1024 * 1024;
847
848/// What a nested part document is memoised under. A product is either a leaf
849/// (no occurrence children) or an assembly node, never both, so the two
850/// variants can never name the same product — except at a ROOT, whose own
851/// bodies become a leaf part while the root itself is an assembly node. That
852/// case is exactly why this is an enum and not a bare [`PartKey`].
853#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
854enum DocKey {
855    /// A geometry-bearing product placed as a part: `(pd_ref, baked factor)`.
856    Leaf(PartKey),
857    /// A product placed as a rigid sub-assembly, by index into `products`.
858    Assembly(usize),
859}
860
861impl DocKey {
862    /// The parts-library identity this document is stored under. Always keyed on
863    /// the `pd_ref` (never the product INDEX, which lives in a different number
864    /// space and would collide with some other product's `pd_ref`). An assembly
865    /// node never carries a baked factor — a non-rigid edge into one is skipped,
866    /// see [`NestedBuild::place_children`] — so `NO_FACTOR` is exact.
867    fn part_key(self, assembly: &brep_kernel::StepAssembly) -> PartKey {
868        match self {
869            DocKey::Leaf(key) => key,
870            DocKey::Assembly(product) => (assembly.products[product].pd_ref, NO_FACTOR),
871        }
872    }
873}
874
875/// The recursive builder behind [`plan_nested`]: turns one product into the part
876/// document that represents it, bottom-up, memoised so a product reached from
877/// several parents is built ONCE however many places embed it.
878struct NestedBuild<'a, 'w> {
879    assembly: &'a brep_kernel::StepAssembly,
880    doc_name: &'a str,
881    /// Where a CHILD library entry's document is written, shared with the
882    /// top-level install loop so one part is one file at every level.
883    writer: &'a mut PartWriter<'w>,
884    /// `None` = this subtree carries no geometry at all, so nothing places it.
885    memo: std::collections::HashMap<DocKey, Option<(String, serde_json::Value)>>,
886    /// The non-rigid factor behind every baked [`DocKey::Leaf`] key, so the
887    /// builder never has to reconstruct a matrix out of its own hash key.
888    factors: std::collections::HashMap<PartKey, Mat4>,
889    entries: usize,
890    /// Bytes of part document embedded so far — the [`MAX_NESTED_BYTES`] charge.
891    bytes: usize,
892    failed_products: usize,
893    baked_nonrigid: usize,
894    first_error: Option<String>,
895}
896
897impl NestedBuild<'_, '_> {
898    /// The part document for `key`, built once and reused. `ancestors` is the
899    /// recursion path — the cycle guard, and the depth the cap is measured on.
900    ///
901    /// A cyclic file gets ONE deterministic truncation: the memo keeps whichever
902    /// path reached a node first, and that path's skipped back-edge is the one
903    /// every embedding sees. Deterministic and finite is the whole contract for
904    /// input that is malformed by construction.
905    fn document(
906        &mut self,
907        key: DocKey,
908        ancestors: &mut Vec<usize>,
909    ) -> Result<Option<(String, serde_json::Value)>, String> {
910        if let Some(hit) = self.memo.get(&key) {
911            return Ok(hit.clone());
912        }
913        if ancestors.len() >= MAX_NESTED_DEPTH {
914            return Err(format!(
915                "nested import: sub-assembly nesting deeper than {MAX_NESTED_DEPTH} levels \
916                 (import as bodies, or import flat)"
917            ));
918        }
919        let built = match key {
920            DocKey::Leaf(part) => self.leaf_document(part),
921            DocKey::Assembly(product) => {
922                ancestors.push(product);
923                let built = self.assembly_document(product, ancestors);
924                ancestors.pop();
925                built
926            }
927        }?;
928        self.memo.insert(key, built.clone());
929        Ok(built)
930    }
931
932    /// A geometry-bearing product as the §3.2 part document — the same one the
933    /// flat lane installs, built by the same helper, so a depth-1 nested import
934    /// and a flat one store byte-identical entries.
935    fn leaf_document(
936        &mut self,
937        key: PartKey,
938    ) -> Result<Option<(String, serde_json::Value)>, String> {
939        let product = self
940            .assembly
941            .products
942            .iter()
943            .find(|product| product.pd_ref == key.0)
944            .expect("every key names a product of this assembly");
945        if product.bodies.is_empty() {
946            return Ok(None);
947        }
948        let factor = self.factors.get(&key).copied();
949        self.spend_entry()?;
950        native_part_document(product, factor.as_ref(), self.doc_name).map(Some)
951    }
952
953    /// An assembly-node product as a rigid sub-assembly document: its OWN bodies
954    /// as plain native IMPORT3D features (the interior-node geometry Phase 1
955    /// could only make a SIBLING of its own children), one ACOMP per child
956    /// occurrence, and the children's documents in this level's own
957    /// `partsLibrary`.
958    ///
959    /// The entries carry NO snapshot. An entry with an unreadable snapshot heals
960    /// from its embedded document (`assembly_component.rs`'s SELF-HEAL lane),
961    /// and for a native part that heal is a decode + re-encode — so the level
962    /// above bakes this whole subtree into ITS snapshot on insert, and these
963    /// inner caches would only ever be rebuilt to be thrown away. Kernel-plan §6
964    /// names this exact economy ("omit the persisted snapshot for an entry whose
965    /// document is a single native IMPORT3D"); nesting is where it pays, because
966    /// otherwise every level stores the level below it twice.
967    fn assembly_document(
968        &mut self,
969        product: usize,
970        ancestors: &mut Vec<usize>,
971    ) -> Result<Option<(String, serde_json::Value)>, String> {
972        let node = &self.assembly.products[product];
973        let mut library = serde_json::Map::new();
974        let mut features: Vec<serde_json::Value> = Vec::new();
975
976        // The node's own bodies first, matching the flat lane's "a node before
977        // its children" emit order.
978        if !node.bodies.is_empty() {
979            let payload = brep_kernel::native_import_payload_with_appearance(
980                "IMPORT3D1",
981                &node.bodies,
982                &node.appearances,
983            )
984            .map_err(|error| format!("part '{}': {error}", part_name(node, self.doc_name)))?;
985            features.push(serde_json::json!({
986                "type": "IMPORT3D",
987                "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
988                "persistentData": {},
989            }));
990        }
991
992        // One ACOMP per child occurrence, children by ascending `nauo_ref`.
993        let mut rows: Vec<(DocKey, Mat4)> = Vec::new();
994        let mut bakes = 0usize;
995        self.place_children(product, ancestors, &mut rows, &mut bakes);
996        self.baked_nonrigid += bakes;
997        let mut names: std::collections::HashMap<DocKey, String> =
998            std::collections::HashMap::new();
999        // `add_part_to_library`'s content reuse, applied to this level's block:
1000        // two products that are the SAME geometry collapse to one entry (§3.5's
1001        // free dedup), and every instance of either references it.
1002        let mut by_signature: std::collections::HashMap<String, String> =
1003            std::collections::HashMap::new();
1004        let mut components = 0usize;
1005        for (key, pose) in rows {
1006            let name = match names.get(&key) {
1007                Some(name) => name.clone(),
1008                None => {
1009                    let built = match self.document(key, ancestors) {
1010                        Ok(Some(built)) => built,
1011                        Ok(None) => continue,
1012                        Err(error) => {
1013                            self.failed_products += 1;
1014                            note(&mut self.first_error, error);
1015                            continue;
1016                        }
1017                    };
1018                    let serialized = built.1.to_string();
1019                    let signature = document_signature(&serialized);
1020                    let name = match by_signature.get(&signature) {
1021                        Some(name) => name.clone(),
1022                        None => {
1023                            // Charged HERE, at the embedding, because that is
1024                            // where a product stored once per level multiplies.
1025                            self.spend_bytes(serialized.len())?;
1026                            // Unique WITHIN this level's library — parent and
1027                            // child libraries are independent (build-spec §2.2),
1028                            // so a name taken upstairs is free down here.
1029                            let name = unique_entry_name(&library, &built.0);
1030                            // A nested child is a part like any other: it gets
1031                            // its own store document and a REAL sourceKey, so
1032                            // Open Part and update-components work the same way
1033                            // however deep it sits.
1034                            let source_key =
1035                                self.writer.key_for(&name, &serialized, &signature);
1036                            library.insert(
1037                                name.clone(),
1038                                serde_json::json!({
1039                                    "sourceKey": source_key,
1040                                    "sourceSignature": signature.clone(),
1041                                    "document": built.1,
1042                                    "snapshot": "",
1043                                }),
1044                            );
1045                            by_signature.insert(signature, name.clone());
1046                            name
1047                        }
1048                    };
1049                    names.insert(key, name.clone());
1050                    name
1051                }
1052            };
1053            let Ok(transform) = brep_kernel::AffineTransform::new(pose) else {
1054                note(
1055                    &mut self.first_error,
1056                    format!("sub-assembly '{name}': occurrence pose is not an affine"),
1057                );
1058                continue;
1059            };
1060            components += 1;
1061            features.push(serde_json::json!({
1062                "type": "ACOMP",
1063                "inputParams": {
1064                    // Its OWN counter, so the ids read `ACOMP1..n` whether or
1065                    // not this node also owns bodies. (The id must match
1066                    // `ACOMP<digits>`: it IS the namespace prefix.)
1067                    "id": format!("ACOMP{components}"),
1068                    "partName": name,
1069                    "transform": brep_kernel::transform_to_pose_params(&transform),
1070                    // Written EXPLICITLY rather than left to the kernel's
1071                    // auto-ground rule, which keys on ABSENCE: the first
1072                    // component of an assembly is grounded, and every other one
1073                    // must not be, or the next solve is over-constrained.
1074                    "isFixed": components == 1,
1075                },
1076                "persistentData": {},
1077            }));
1078        }
1079
1080        // A node whose whole subtree failed to produce geometry places nothing.
1081        // Returning `None` rather than a feature-less document matters: an empty
1082        // document is a hard error inside `add_part_to_library`, which would turn
1083        // "there was nothing here" into "the import failed".
1084        if features.is_empty() {
1085            return Ok(None);
1086        }
1087        self.spend_entry()?;
1088        Ok(Some((
1089            part_name(node, self.doc_name),
1090            serde_json::json!({ "partsLibrary": library, "features": features }),
1091        )))
1092    }
1093
1094    /// The child occurrences of `product`, as `(document key, pose)` rows in the
1095    /// kernel walk's order — ascending `nauo_ref`, with the same ancestor cycle
1096    /// guard. The pose is the occurrence's own child→parent placement: nesting
1097    /// is precisely what stops it having to be composed.
1098    fn place_children(
1099        &mut self,
1100        product: usize,
1101        ancestors: &[usize],
1102        rows: &mut Vec<(DocKey, Mat4)>,
1103        bakes: &mut usize,
1104    ) {
1105        let mut children: Vec<&brep_kernel::StepOccurrence> = self
1106            .assembly
1107            .occurrences
1108            .iter()
1109            .filter(|occurrence| occurrence.parent == product)
1110            .collect();
1111        children.sort_by_key(|occurrence| occurrence.nauo_ref);
1112        for occurrence in children {
1113            if ancestors.contains(&occurrence.child) {
1114                note(
1115                    &mut self.first_error,
1116                    format!(
1117                        "occurrence #{} closes a cycle in the product structure and was skipped",
1118                        occurrence.nauo_ref
1119                    ),
1120                );
1121                continue;
1122            }
1123            let child = &self.assembly.products[occurrence.child];
1124            let is_assembly = self
1125                .assembly
1126                .occurrences
1127                .iter()
1128                .any(|edge| edge.parent == occurrence.child);
1129            if occurrence.rigid {
1130                let key = if is_assembly {
1131                    DocKey::Assembly(occurrence.child)
1132                } else {
1133                    DocKey::Leaf((child.pd_ref, NO_FACTOR))
1134                };
1135                rows.push((key, occurrence.placement));
1136                continue;
1137            }
1138            // §3.4 on a single edge: a leaf bakes its non-rigid factor into its
1139            // own part, exactly as the flat lane does with the composed pose.
1140            match split_rigid(&occurrence.placement) {
1141                Ok((rigid, factor)) if is_identity(&factor) => {
1142                    let key = if is_assembly {
1143                        DocKey::Assembly(occurrence.child)
1144                    } else {
1145                        DocKey::Leaf((child.pd_ref, NO_FACTOR))
1146                    };
1147                    rows.push((key, rigid));
1148                }
1149                // A mirrored/scaled SUB-ASSEMBLY would have to push its factor
1150                // down through a whole document tree, rewriting every level's
1151                // poses. Nothing in the corpus does it, and a wrong answer here
1152                // would be a silently mis-handed assembly: skip and say so, so
1153                // the user can re-import flat (which bakes it correctly).
1154                Ok(_) if is_assembly => {
1155                    note(
1156                        &mut self.first_error,
1157                        format!(
1158                            "occurrence #{} places sub-assembly '{}' with a non-rigid transform, \
1159                             which a nested import cannot represent — import flat instead",
1160                            occurrence.nauo_ref,
1161                            part_name(child, self.doc_name)
1162                        ),
1163                    );
1164                }
1165                Ok((rigid, factor)) => {
1166                    *bakes += 1;
1167                    let key = (child.pd_ref, factor_key(&factor));
1168                    self.factors.insert(key, factor);
1169                    rows.push((DocKey::Leaf(key), rigid));
1170                }
1171                Err(error) => note(&mut self.first_error, error),
1172            }
1173        }
1174    }
1175
1176    /// Charge one built part document against [`MAX_NESTED_ENTRIES`].
1177    fn spend_entry(&mut self) -> Result<(), String> {
1178        self.entries += 1;
1179        if self.entries > MAX_NESTED_ENTRIES {
1180            return Err(format!(
1181                "nested import: more than {MAX_NESTED_ENTRIES} distinct parts \
1182                 (import as bodies, or import flat)"
1183            ));
1184        }
1185        Ok(())
1186    }
1187
1188    /// Charge one embedded part document against [`MAX_NESTED_BYTES`].
1189    fn spend_bytes(&mut self, bytes: usize) -> Result<(), String> {
1190        self.bytes = self.bytes.saturating_add(bytes);
1191        if self.bytes > MAX_NESTED_BYTES {
1192            return Err(format!(
1193                "nested import: the embedded sub-assembly documents exceed \
1194                 {} MB (import as bodies, or import flat)",
1195                MAX_NESTED_BYTES / (1024 * 1024)
1196            ));
1197        }
1198        Ok(())
1199    }
1200}
1201
1202/// A part name not yet used in THIS level's library: `requested`, else
1203/// `requested-2`, `requested-3`, … — the kernel `parts_library::unique_name`
1204/// convention, applied to an embedded block the kernel never sees inserted.
1205fn unique_entry_name(library: &serde_json::Map<String, serde_json::Value>, requested: &str) -> String {
1206    if !library.contains_key(requested) {
1207        return requested.to_string();
1208    }
1209    (2..)
1210        .map(|counter| format!("{requested}-{counter}"))
1211        .find(|candidate| !library.contains_key(candidate))
1212        .expect("the counter loop is unbounded")
1213}
1214
1215/// Keep the FIRST thing that went wrong (the report carries one, and the first
1216/// is the one that explains the rest).
1217fn note(slot: &mut Option<String>, error: String) {
1218    if slot.is_none() {
1219        *slot = Some(error);
1220    }
1221}
1222
1223/// Encode one product as a parts-library entry and return the EFFECTIVE entry
1224/// name the instances must reference (`add_part_to_library` disambiguates a name
1225/// clash and REUSES an entry with identical content, which is where cross-import
1226/// dedup comes from).
1227///
1228/// `factor`, when present, is the non-rigid part of an occurrence's placement:
1229/// applied to the geometry HERE, so the instance can carry a rigid pose (§3.4).
1230fn build_library_entry(
1231    product: &brep_kernel::StepProduct,
1232    factor: Option<&Mat4>,
1233    doc_name: &str,
1234    writer: &mut PartWriter<'_>,
1235) -> Result<String, String> {
1236    let (name, document) = native_part_document(product, factor, doc_name)?;
1237    install_part(&name, &document, writer)
1238}
1239
1240/// The §3.2 part document for one product's OWN bodies: ONE IMPORT3D whose only
1241/// input is the native payload, plus the library name it wants. No STEP text is
1242/// stored anywhere — a rebuild of this part is a base64 decode, not a re-parse.
1243///
1244/// `factor`, when present, is the non-rigid part of an occurrence's placement:
1245/// applied to the geometry HERE, so the instance can carry a rigid pose (§3.4).
1246///
1247/// Split out from [`build_library_entry`] because the nested lane needs the
1248/// DOCUMENT before it installs anything — a leaf's document is embedded in its
1249/// parent's `partsLibrary`, where there is no `add_part_to_library` to call.
1250/// One producer, so a leaf part is byte-identical however deep it lands.
1251fn native_part_document(
1252    product: &brep_kernel::StepProduct,
1253    factor: Option<&Mat4>,
1254    doc_name: &str,
1255) -> Result<(String, serde_json::Value), String> {
1256    let mut name = part_name(product, doc_name);
1257    let bodies = match factor {
1258        None => product.bodies.clone(),
1259        Some(factor) => {
1260            let transform = brep_kernel::AffineTransform::new(*factor)
1261                .map_err(|error| format!("part '{name}': non-rigid factor: {error}"))?;
1262            let mirrored = transform.determinant3() < 0.0;
1263            name.push_str(if mirrored { " (mirrored)" } else { " (scaled)" });
1264            product
1265                .bodies
1266                .iter()
1267                .map(|body| {
1268                    // A mirror MUST reverse orientation or `transform_brep`
1269                    // refuses it (an unreversed reflection inverts the solid).
1270                    brep_kernel::transform_brep(body, transform, mirrored)
1271                        .map_err(|error| format!("part '{name}': {error}"))
1272                })
1273                .collect::<Result<Vec<_>, _>>()?
1274        }
1275    };
1276    // The product's STEP colours ride into the payload with the geometry (the
1277    // snapshot captures the records the stamp writes), so a coloured part keeps
1278    // its colour through the parts library and every reload.
1279    let payload = brep_kernel::native_import_payload_with_appearance(
1280        "IMPORT3D1",
1281        &bodies,
1282        &product.appearances,
1283    )
1284    .map_err(|error| format!("part '{name}': {error}"))?;
1285    let document = serde_json::json!({
1286        "features": [{
1287            "type": "IMPORT3D",
1288            "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
1289            "persistentData": {},
1290        }]
1291    });
1292    Ok((name, document))
1293}
1294
1295/// Install a part document as a parts-library entry of the OPEN document and
1296/// return the EFFECTIVE entry name the instances must reference
1297/// (`add_part_to_library` disambiguates a name clash and REUSES an entry with
1298/// identical content, which is where cross-import dedup comes from).
1299///
1300/// The `sourceKey` comes from the [`PartSink`]: an imported part is written to
1301/// the store as its own document and carries a REAL key, exactly like a part
1302/// inserted from the parts library, so there is no second kind of part. A sink
1303/// that declines (no store, or a failed write) yields `""` — the embedded-only
1304/// entry this lane used to produce unconditionally, and the case
1305/// `UpdateComponents` already skips.
1306fn install_part(
1307    name: &str,
1308    document: &serde_json::Value,
1309    writer: &mut PartWriter<'_>,
1310) -> Result<String, String> {
1311    let document = document.to_string();
1312    let signature = document_signature(&document);
1313    let source_key = writer.key_for(name, &document, &signature);
1314    brep_kernel::add_part_to_library(name, &source_key, &signature, &document)
1315        .map_err(|error| format!("part '{name}': {error:?}"))
1316}
1317
1318/// Where an imported assembly's unique parts are written, so each becomes a
1319/// document in its own right rather than a payload embedded in one assembly.
1320///
1321/// A trait, and not a `&dyn ModelStore`, because the store lives in `BREP_app`
1322/// and this crate is BELOW it — `BREP_app` depends on `BREP_render`, so naming
1323/// the store here would be a dependency cycle. The import therefore asks for a
1324/// key and the app answers with one, which is also what keeps the destination
1325/// (and any prompt for it) entirely the app's business.
1326pub trait PartSink {
1327    /// Store `document_json` under a name derived from `part_name` and return
1328    /// the stable key it can be read back by. `None` declines — no store, or a
1329    /// write that failed — and the entry stays embedded-only.
1330    fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String>;
1331}
1332
1333/// The sink that stores nothing: every entry stays embedded-only. The default
1334/// for headless callers and tests, which have no store to write to.
1335pub struct EmbeddedOnly;
1336
1337impl PartSink for EmbeddedOnly {
1338    fn store_part(&mut self, _part_name: &str, _document_json: &str) -> Option<String> {
1339        None
1340    }
1341}
1342
1343/// A [`PartSink`] plus the CONTENT DEDUP that must ride with it.
1344///
1345/// `add_part_to_library` reuses an entry whose `(sourceKey, sourceSignature)`
1346/// both match, which is where §3.5's free dedup came from while every imported
1347/// part carried the same empty key. Give each part its own key and that reuse
1348/// stops: the same product under two `PRODUCT_DEFINITION`s would become two
1349/// entries AND two identical files.
1350///
1351/// So the dedup moves in front of the write, keyed on the document signature
1352/// alone. Identical content is written ONCE and every occurrence of it gets the
1353/// SAME key — which then makes `add_part_to_library`'s own `(key, signature)`
1354/// reuse fire exactly as before. Dedup ACROSS imports keeps working for the
1355/// same reason: a re-import derives the same file name, so the same key and
1356/// signature come back and the resident entry is reused.
1357struct PartWriter<'a> {
1358    sink: &'a mut dyn PartSink,
1359    by_signature: std::collections::HashMap<String, String>,
1360}
1361
1362impl<'a> PartWriter<'a> {
1363    fn new(sink: &'a mut dyn PartSink) -> Self {
1364        Self {
1365            sink,
1366            by_signature: std::collections::HashMap::new(),
1367        }
1368    }
1369
1370    /// The `sourceKey` for a part with this content — writing it exactly once
1371    /// however many products share it.
1372    fn key_for(&mut self, name: &str, document_json: &str, signature: &str) -> String {
1373        if let Some(key) = self.by_signature.get(signature) {
1374            return key.clone();
1375        }
1376        let key = self
1377            .sink
1378            .store_part(name, document_json)
1379            .unwrap_or_default();
1380        self.by_signature.insert(signature.to_string(), key.clone());
1381        key
1382    }
1383}
1384
1385/// The library name for a product: its `PRODUCT.name`, else a stem built from
1386/// the imported document's name so an unnamed product is still identifiable.
1387fn part_name(product: &brep_kernel::StepProduct, doc_name: &str) -> String {
1388    let named = product.name.trim();
1389    if !named.is_empty() {
1390        return named.to_string();
1391    }
1392    match doc_name.trim() {
1393        "" => format!("part-{}", product.pd_ref),
1394        stem => format!("{stem}-part-{}", product.pd_ref),
1395    }
1396}
1397
1398/// The dialog's counts, taken from the SAME walk the import runs, so the numbers
1399/// the user was shown are the numbers they get (bar an encode failure, and bar
1400/// the extra entry a non-rigid occurrence bakes).
1401fn probe_counts(assembly: &brep_kernel::StepAssembly) -> StepAssemblyProbe {
1402    let mut parts = std::collections::HashSet::new();
1403    let mut instances = 0usize;
1404    let mut nested_depth = 0usize;
1405    for placed in compose_world_occurrences(assembly) {
1406        let product = &assembly.products[placed.product];
1407        if product.bodies.is_empty() {
1408            continue;
1409        }
1410        parts.insert(product.pd_ref);
1411        instances += 1;
1412        nested_depth = nested_depth.max(placed.depth);
1413    }
1414    StepAssemblyProbe {
1415        parts: parts.len(),
1416        instances,
1417        nested_depth,
1418    }
1419}
1420
1421/// Depth-first from the roots, composing each occurrence's child→parent
1422/// placement into a world transform — the consumer half of `read_step_assembly`,
1423/// which deliberately transforms nothing.
1424///
1425/// Emit order, child ordering (by `nauo_ref`) and the ancestor cycle guard mirror
1426/// the kernel's own `walk_occurrences`, which is what makes the components this
1427/// lane produces the same solids, in the same order, as the flat lane's — the
1428/// kernel asserts that equivalence BIT-for-bit
1429/// (`step_import/tests/assembly_structure.rs`), and
1430/// `structured_import_matches_the_flat_lane_geometry` below re-asserts it from
1431/// this side, where a divergence would actually land.
1432fn compose_world_occurrences(assembly: &brep_kernel::StepAssembly) -> Vec<PlacedProduct> {
1433    struct Node {
1434        placed: PlacedProduct,
1435        ancestors: Vec<usize>,
1436    }
1437    let mut out = Vec::new();
1438    let mut stack: Vec<Node> = assembly
1439        .roots
1440        .iter()
1441        .rev()
1442        .map(|&product| Node {
1443            placed: PlacedProduct {
1444                product,
1445                world: MAT4_IDENTITY,
1446                depth: 0,
1447                rigid_path: true,
1448            },
1449            ancestors: vec![product],
1450        })
1451        .collect();
1452    while let Some(node) = stack.pop() {
1453        let (product, world, depth, rigid_path) = (
1454            node.placed.product,
1455            node.placed.world,
1456            node.placed.depth,
1457            node.placed.rigid_path,
1458        );
1459        out.push(node.placed);
1460        let mut children: Vec<&brep_kernel::StepOccurrence> = assembly
1461            .occurrences
1462            .iter()
1463            .filter(|occurrence| occurrence.parent == product)
1464            .collect();
1465        children.sort_by_key(|occurrence| occurrence.nauo_ref);
1466        for occurrence in children.into_iter().rev() {
1467            if node.ancestors.contains(&occurrence.child) {
1468                continue; // the cycle guard the kernel's walk applies
1469            }
1470            let mut ancestors = node.ancestors.clone();
1471            ancestors.push(occurrence.child);
1472            stack.push(Node {
1473                placed: PlacedProduct {
1474                    product: occurrence.child,
1475                    world: mat4_mul(&world, &occurrence.placement),
1476                    depth: depth + 1,
1477                    // The kernel's per-edge rigidity flag, carried down the path:
1478                    // a composed pose is a component pose only when every edge
1479                    // on the way to it was one.
1480                    rigid_path: rigid_path && occurrence.rigid,
1481                },
1482                ancestors,
1483            });
1484        }
1485    }
1486    out
1487}
1488
1489const MAT4_IDENTITY: Mat4 = [
1490    1.0, 0.0, 0.0, 0.0, //
1491    0.0, 1.0, 0.0, 0.0, //
1492    0.0, 0.0, 1.0, 0.0, //
1493    0.0, 0.0, 0.0, 1.0,
1494];
1495
1496/// Row-major 4×4 product `a · b`.
1497fn mat4_mul(a: &Mat4, b: &Mat4) -> Mat4 {
1498    let mut out = [0.0; 16];
1499    for row in 0..4 {
1500        for column in 0..4 {
1501            out[row * 4 + column] = (0..4)
1502                .map(|k| a[row * 4 + k] * b[k * 4 + column])
1503                .sum();
1504        }
1505    }
1506    out
1507}
1508
1509/// Split a non-rigid world placement into `world = rigid · factor`, where
1510/// `rigid` is a component pose (rotation + translation, det +1) and `factor` is
1511/// a purely linear residue carrying the mirror/scale/shear.
1512///
1513/// Gram-Schmidt on the linear block's columns gives `A = Q·U` with `U` upper
1514/// triangular and positively-diagonalled; when `Q` came out left-handed the pair
1515/// is re-signed through `D = diag(-1, 1, 1)` (`Q' = Q·D`, `U' = D·U`, still
1516/// `Q'U' = A`) so the ROTATION is a rotation and the reflection rides in the
1517/// factor. A mirror composed with a rotation therefore yields the same factor
1518/// whatever the rotation, which keeps every such instance on ONE baked part.
1519fn split_rigid(world: &Mat4) -> Result<(Mat4, Mat4), String> {
1520    let column = |index: usize| [world[index], world[4 + index], world[8 + index]];
1521    let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1522    let axpy = |a: [f64; 3], scale: f64, b: [f64; 3]| {
1523        [a[0] - scale * b[0], a[1] - scale * b[1], a[2] - scale * b[2]]
1524    };
1525    let (a1, a2, a3) = (column(0), column(1), column(2));
1526
1527    let r11 = dot(a1, a1).sqrt();
1528    let mut q1 = normalize(a1, r11)?;
1529    let r12 = dot(q1, a2);
1530    let v2 = axpy(a2, r12, q1);
1531    let r22 = dot(v2, v2).sqrt();
1532    let q2 = normalize(v2, r22)?;
1533    let r13 = dot(q1, a3);
1534    let r23 = dot(q2, a3);
1535    let v3 = axpy(axpy(a3, r13, q1), r23, q2);
1536    let r33 = dot(v3, v3).sqrt();
1537    let q3 = normalize(v3, r33)?;
1538
1539    // det Q = q1 · (q2 × q3); -1 means Q is a reflection, not a rotation.
1540    let cross = [
1541        q2[1] * q3[2] - q2[2] * q3[1],
1542        q2[2] * q3[0] - q2[0] * q3[2],
1543        q2[0] * q3[1] - q2[1] * q3[0],
1544    ];
1545    let (mut r11, mut r12, mut r13) = (r11, r12, r13);
1546    if dot(q1, cross) < 0.0 {
1547        q1 = [-q1[0], -q1[1], -q1[2]];
1548        r11 = -r11;
1549        r12 = -r12;
1550        r13 = -r13;
1551    }
1552    let rigid = [
1553        q1[0], q2[0], q3[0], world[3], //
1554        q1[1], q2[1], q3[1], world[7], //
1555        q1[2], q2[2], q3[2], world[11], //
1556        0.0, 0.0, 0.0, 1.0,
1557    ];
1558    let factor = [
1559        r11, r12, r13, 0.0, //
1560        0.0, r22, r23, 0.0, //
1561        0.0, 0.0, r33, 0.0, //
1562        0.0, 0.0, 0.0, 1.0,
1563    ];
1564    Ok((rigid, factor))
1565}
1566
1567/// Unit vector, or a clear error for the degenerate column a near-singular
1568/// placement produces (skipped and counted, never fatal).
1569fn normalize(vector: [f64; 3], length: f64) -> Result<[f64; 3], String> {
1570    if !(length > 1e-12) || !length.is_finite() {
1571        return Err("occurrence placement is singular (a degenerate axis)".into());
1572    }
1573    Ok([vector[0] / length, vector[1] / length, vector[2] / length])
1574}
1575
1576/// Is this affine the identity to 1e-9 — the tolerance the kernel's own
1577/// rigidity gate uses?
1578fn is_identity(matrix: &Mat4) -> bool {
1579    matrix
1580        .iter()
1581        .zip(MAT4_IDENTITY.iter())
1582        .all(|(value, want)| (value - want).abs() <= 1e-9)
1583}
1584
1585/// The linear block of a baked factor as an exact bit key — two occurrences
1586/// share a baked part only when their factor is bit-identical, so a wrong-handed
1587/// reuse is not reachable through rounding.
1588fn factor_key(factor: &Mat4) -> [u64; 9] {
1589    let mut key = [0u64; 9];
1590    for (slot, index) in key.iter_mut().zip([0, 1, 2, 4, 5, 6, 8, 9, 10]) {
1591        *slot = factor[index].to_bits();
1592    }
1593    key
1594}
1595
1596#[cfg(test)]
1597mod io_tests {
1598    use super::*;
1599
1600    /// A full history document for a single P.CU cube of side `size` (volume
1601    /// `size^3`), fed to [`EngineState::set_history_json`].
1602    fn cube_history(id: &str, size: f64) -> String {
1603        serde_json::json!({
1604            "expressions": "",
1605            "configurator": {},
1606            "features": [{
1607                "type": "P.CU",
1608                "inputParams": {
1609                    "id": id,
1610                    "sizeX": size, "sizeY": size, "sizeZ": size,
1611                    "transform": {
1612                        "position": [0.0, 0.0, 0.0],
1613                        "rotationEuler": [0.0, 0.0, 0.0],
1614                        "scale": [1.0, 1.0, 1.0]
1615                    },
1616                    "boolean": { "targets": [], "operation": "NONE" }
1617                },
1618                "persistentData": {}
1619            }]
1620        })
1621        .to_string()
1622    }
1623
1624    /// STEP text for an axis-aligned box `sx × sy × sz`, via the kernel exporter.
1625    fn box_step(sx: f64, sy: f64, sz: f64) -> String {
1626        let solid =
1627            brep_kernel::make_box_brep(brep_kernel::Vec3::new(0.0, 0.0, 0.0), sx, sy, sz)
1628                .unwrap();
1629        brep_kernel::export_step(&[solid], "part", "MM", "").unwrap()
1630    }
1631
1632    /// Volume of the single solid `import_step` recovers from STEP text.
1633    fn imported_volume(step_text: &str) -> f64 {
1634        let solids = brep_kernel::import_step(step_text).unwrap();
1635        assert_eq!(solids.len(), 1, "STEP round-trips to one solid");
1636        brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume
1637    }
1638
1639    /// Importing a STEP box appends an IMPORT3D feature that yields the body in
1640    /// the model: the scene shows one solid whose bbox matches the box. A
1641    /// non-STEP payload is refused up front, leaving no dead feature behind.
1642    #[test]
1643    fn import_step_feature_adds_the_body_to_the_model() {
1644        let step = box_step(4.0, 3.0, 2.0);
1645        let mut state = EngineState::new();
1646        state.import_step_feature(&step).unwrap();
1647        assert_eq!(state.scene.solids().len(), 1, "one imported body");
1648        let size = state.scene.solids()[0].bbox.size();
1649        assert!(
1650            (size[0] - 4.0).abs() < 1e-4
1651                && (size[1] - 3.0).abs() < 1e-4
1652                && (size[2] - 2.0).abs() < 1e-4,
1653            "imported bbox {size:?} != 4x3x2"
1654        );
1655
1656        let mut empty = EngineState::new();
1657        assert!(empty.import_step_feature("not a step file").is_err());
1658        assert_eq!(empty.history_len(), 0, "a bad import adds no feature");
1659    }
1660
1661    /// THE COLOUR SEAM (kernel-plan §3.8, A10). The kernel stamps an imported
1662    /// STEP colour into ITS name-keyed scene-metadata store, which is
1663    /// thread-local to whoever ran the history and is not the store the Info
1664    /// window edits. `SceneRunner::run` reads it runner-side and ships it in the
1665    /// `RunOutput`; `apply_run_output` folds it in here. Without that seam the
1666    /// colour exists and nothing can see it.
1667    ///
1668    /// `freecad_partdesign_body.step` styles its MANIFOLD_SOLID_BREP with
1669    /// `COLOUR_RGB(0.8, 0.8, 0.8)` = `#CCCCCC` (and a near-black CURVE_STYLE that
1670    /// must not win). One body, no product structure — the flat import lane.
1671    #[test]
1672    fn imported_step_colour_reaches_the_engine_metadata_store() {
1673        let step = include_str!(concat!(
1674            env!("CARGO_MANIFEST_DIR"),
1675            "/../BREP_kernel/tests/fixtures/step-import/freecad_partdesign_body.step"
1676        ));
1677        let mut state = EngineState::new();
1678        state.import_step_feature(step).expect("fixture imports");
1679        let name = state.scene.solids()[0].name.clone();
1680        assert_eq!(
1681            state.metadata.attribute(&name, "color"),
1682            Some("#CCCCCC"),
1683            "the imported body colour must reach the store the Info window reads"
1684        );
1685
1686        // NON-overwriting: a user's edit survives the next run of the same
1687        // history (which re-stamps the imported colour kernel-side).
1688        state.set_metadata_attribute(&name, "color", "#123456");
1689        state.roll_to(0);
1690        state.roll_to(state.history_len());
1691        assert_eq!(
1692            state.metadata.attribute(&name, "color"),
1693            Some("#123456"),
1694            "a user-edited colour must win over the re-stamped import"
1695        );
1696    }
1697
1698    /// Mesh imports run recognition/reconstruction first, then enter the same
1699    /// history lane as a native STEP import. This small OBJ cube exercises the
1700    /// complete UI-facing path without relying on an external fixture.
1701    #[test]
1702    fn import_obj_feature_reconstructs_mesh_into_a_cad_body() {
1703        let cube = r#"
1704v 0 0 0
1705v 1 0 0
1706v 1 1 0
1707v 0 1 0
1708v 0 0 1
1709v 1 0 1
1710v 1 1 1
1711v 0 1 1
1712f 1 3 2
1713f 1 4 3
1714f 5 6 7
1715f 5 7 8
1716f 1 2 6
1717f 1 6 5
1718f 2 3 7
1719f 2 7 6
1720f 3 4 8
1721f 3 8 7
1722f 4 1 5
1723f 4 5 8
1724"#;
1725        let mut state = EngineState::new();
1726        state.import_obj_feature(cube).unwrap();
1727
1728        assert_eq!(
1729            state.history_len(),
1730            1,
1731            "mesh import adds one undoable feature"
1732        );
1733        assert_eq!(
1734            state.scene.solids().len(),
1735            1,
1736            "reconstruction yields one body"
1737        );
1738        let size = state.scene.solids()[0].bbox.size();
1739        assert!(
1740            size.iter().all(|axis| (*axis - 1.0).abs() < 1e-4),
1741            "bbox: {size:?}"
1742        );
1743        assert!(
1744            state.history_request_json().contains("ISO-10303-21"),
1745            "history stores the validated reconstructed BREP as STEP"
1746        );
1747    }
1748
1749    /// Binary STL follows the byte-preserving path and uses the source f32
1750    /// precision floor before RANSAC recognition. Production's ThreadRunner
1751    /// returns immediately, then the regular engine pump applies both stages.
1752    #[test]
1753    #[cfg(not(target_arch = "wasm32"))]
1754    fn import_binary_stl_feature_reconstructs_mesh_into_a_cad_body() {
1755        let bytes = include_bytes!("../../../tests/fixtures/stl/PartDesignExample-Body.stl");
1756        let mut state = EngineState::new();
1757        state.set_runner(Box::new(crate::runner::ThreadRunner::new()));
1758        let submitted = std::time::Instant::now();
1759        state.import_stl_feature(bytes).unwrap();
1760
1761        assert!(state.mesh_imports_pending(), "RANSAC is running off-thread");
1762        assert_eq!(
1763            state.history_len(),
1764            0,
1765            "no feature is added before reconstruction"
1766        );
1767        assert!(
1768            submitted.elapsed() < std::time::Duration::from_secs(1),
1769            "submission must not wait for RANSAC"
1770        );
1771        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
1772        while state.mesh_imports_pending() || state.run_pending() {
1773            assert!(
1774                std::time::Instant::now() < deadline,
1775                "background import timed out"
1776            );
1777            state.pump();
1778            std::thread::sleep(std::time::Duration::from_millis(2));
1779        }
1780        assert_eq!(state.history_len(), 1);
1781        assert_eq!(state.scene.solids().len(), 1);
1782        assert!(state.history_request_json().contains("ISO-10303-21"));
1783    }
1784
1785    /// Exporting a box model produces STEP text that `import_step` round-trips to
1786    /// one solid of the same volume. An empty model has nothing to export.
1787    #[test]
1788    fn export_step_text_round_trips_a_box_model() {
1789        let mut state = EngineState::new();
1790        state.set_history_json(&cube_history("Box", 10.0)).unwrap();
1791        let step = state.export_step_text().unwrap();
1792        assert!(step.contains("ISO-10303-21"), "STEP header present");
1793        let volume = imported_volume(&step);
1794        assert!((volume - 1000.0).abs() < 1e-3, "exported volume {volume} != 1000");
1795
1796        let empty = EngineState::new();
1797        assert!(empty.export_step_text().is_err(), "empty model errs on export");
1798    }
1799
1800    /// Round trip: import a STEP box into the model, export the model back to
1801    /// STEP, re-import — the solid count and volume are preserved.
1802    #[test]
1803    fn import_export_import_preserves_count_and_volume() {
1804        let step_in = box_step(5.0, 4.0, 3.0); // volume 60
1805        let mut state = EngineState::new();
1806        state.import_step_feature(&step_in).unwrap();
1807        assert_eq!(state.scene.solids().len(), 1);
1808
1809        let step_out = state.export_step_text().unwrap();
1810        let solids = brep_kernel::import_step(&step_out).unwrap();
1811        assert_eq!(solids.len(), 1, "solid count preserved");
1812        let volume = brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume;
1813        assert!((volume - 60.0).abs() < 1e-3, "round-trip volume {volume} != 60");
1814    }
1815
1816    /// Round trip through IGES: build a box model, export to IGES, then
1817    /// re-import via both the kernel and the import feature — the solid count and
1818    /// volume are preserved. An empty model errs; a non-IGES payload is refused.
1819    #[test]
1820    fn export_iges_text_round_trips_a_box_model() {
1821        let mut state = EngineState::new();
1822        state.set_history_json(&cube_history("Box", 5.0)).unwrap(); // volume 125
1823        let iges = state.export_iges_text().unwrap();
1824        assert_eq!(iges.chars().nth(72), Some('S'), "first record is the start section");
1825
1826        let solids = brep_kernel::import_iges(&iges).unwrap();
1827        assert_eq!(solids.len(), 1, "one solid round-trips through IGES");
1828        let volume = brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume;
1829        assert!((volume - 125.0).abs() < 1e-3, "IGES round-trip volume {volume} != 125");
1830
1831        let mut other = EngineState::new();
1832        other.import_iges_feature(&iges).unwrap();
1833        assert_eq!(other.scene.solids().len(), 1, "imported one body via the feature");
1834
1835        let empty = EngineState::new();
1836        assert!(empty.export_iges_text().is_err(), "empty model errs on IGES export");
1837        assert!(
1838            other.import_iges_feature("not an iges file").is_err(),
1839            "a non-IGES payload is refused"
1840        );
1841    }
1842
1843    /// A minimal sheet-metal part: a 40×25 rectangle sketch extruded to a 2mm tab
1844    /// (SM.TAB), whose resident body carries a sheet-metal tree for the unfold.
1845    fn sheet_metal_tab_history() -> String {
1846        serde_json::json!({
1847            "expressions": "", "configurator": {},
1848            "features": [
1849                {
1850                    "type": "S",
1851                    "inputParams": { "id": "SkTab" },
1852                    "persistentData": {
1853                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1854                        "sketch": {
1855                            "points": [
1856                                {"id":1,"x":0.0,"y":0.0,"fixed":true},
1857                                {"id":2,"x":40.0,"y":0.0,"fixed":true},
1858                                {"id":3,"x":40.0,"y":25.0,"fixed":true},
1859                                {"id":4,"x":0.0,"y":25.0,"fixed":true}
1860                            ],
1861                            "geometries": [
1862                                {"id":10,"type":"line","points":[1,2]},
1863                                {"id":11,"type":"line","points":[2,3]},
1864                                {"id":12,"type":"line","points":[3,4]},
1865                                {"id":13,"type":"line","points":[4,1]}
1866                            ],
1867                            "constraints": []
1868                        }
1869                    },
1870                    "timestamp": null
1871                },
1872                {
1873                    "type": "SM.TAB",
1874                    "inputParams": { "id": "tab1", "profile": "SkTab", "thickness": 2.0, "placementMode": "midplane" },
1875                    "persistentData": {},
1876                    "timestamp": null
1877                }
1878            ]
1879        })
1880        .to_string()
1881    }
1882
1883    /// The flat-pattern export finds the part's sheet-metal body, unfolds it
1884    /// transiently, and returns well-formed DXF (R12) and SVG text. A non
1885    /// sheet-metal model (a plain box) errs with the exact target-missing message.
1886    #[test]
1887    fn export_flat_pattern_dxf_and_svg_for_a_sheet_metal_part() {
1888        let mut state = EngineState::new();
1889        state.set_history_json(&sheet_metal_tab_history()).unwrap();
1890
1891        let dxf = state.export_flat_pattern_dxf().unwrap();
1892        assert!(dxf.contains("AC1009"), "DXF R12 header present");
1893        assert!(dxf.contains("\nPOLYLINE\n"), "DXF has a polyline entity");
1894        assert!(dxf.trim_end().ends_with("EOF"), "DXF terminates with EOF");
1895
1896        let svg = state.export_flat_pattern_svg().unwrap();
1897        assert!(svg.starts_with("<svg"), "SVG opens with the svg root");
1898        assert!(svg.contains("<path"), "SVG has a path per loop");
1899
1900        // The export must NOT have mutated history (transient unfold).
1901        assert_eq!(state.history_len(), 2, "flat-pattern export adds no feature");
1902
1903        // A non sheet-metal model has no target body.
1904        let mut box_model = EngineState::new();
1905        box_model.set_history_json(&cube_history("Box", 10.0)).unwrap();
1906        let err = box_model.export_flat_pattern_dxf().unwrap_err();
1907        assert_eq!(err, "no sheet-metal body in the part", "clear no-target error");
1908    }
1909
1910    /// `is_sheet_metal_object` marks a sheet-metal body — and its faces/edges —
1911    /// straight off the display scene (no history re-run), while a plain box is
1912    /// not. This is the run-free, thread-safe gate the SM edit features (Flange /
1913    /// Fillet / Chamfer) key on.
1914    #[test]
1915    fn is_sheet_metal_object_marks_the_sheet_body_not_a_box() {
1916        let mut state = EngineState::new();
1917        state.set_history_json(&sheet_metal_tab_history()).unwrap();
1918
1919        // The tab leaves exactly one sheet-metal body; find it in the scene.
1920        let sheet = state
1921            .scene
1922            .solids()
1923            .iter()
1924            .find(|s| s.is_sheet_metal)
1925            .expect("the SM.TAB body carries the sheet-metal marker");
1926        let solid_name = sheet.name.clone();
1927        let face_name = sheet.faces.iter().find(|f| !f.name.is_empty()).map(|f| f.name.clone());
1928        let edge_name = sheet.edges.iter().find(|e| !e.name.is_empty()).map(|e| e.name.clone());
1929
1930        assert!(state.is_sheet_metal_object(&solid_name), "the solid is sheet metal");
1931        if let Some(face) = face_name {
1932            assert!(state.is_sheet_metal_object(&face), "a face of it is sheet metal");
1933        }
1934        if let Some(edge) = edge_name {
1935            assert!(state.is_sheet_metal_object(&edge), "an edge of it is sheet metal");
1936        }
1937        // Empty / unknown names are never sheet metal.
1938        assert!(!state.is_sheet_metal_object(""), "empty name is not sheet metal");
1939        assert!(!state.is_sheet_metal_object("nope"), "unknown name is not sheet metal");
1940
1941        // A plain box is not sheet metal.
1942        let mut box_model = EngineState::new();
1943        box_model.set_history_json(&cube_history("Box", 10.0)).unwrap();
1944        assert!(!box_model.is_sheet_metal_object("Box"), "a plain box is not sheet metal");
1945    }
1946
1947    /// ASCII STL export of a box model is well-formed (`solid brep … endsolid
1948    /// brep`) with the box's 12 triangles / 36 vertices. An empty scene errs.
1949    #[test]
1950    fn export_stl_text_emits_ascii_facets() {
1951        let mut state = EngineState::new();
1952        state.set_history_json(&cube_history("Box", 6.0)).unwrap();
1953        let stl = state.export_stl_text().unwrap();
1954        assert!(stl.starts_with("solid brep"), "STL opens with the solid header");
1955        assert!(stl.trim_end().ends_with("endsolid brep"), "STL closes the solid");
1956        assert_eq!(
1957            stl.matches("facet normal").count(),
1958            12,
1959            "a box tessellates to 12 triangles"
1960        );
1961        assert_eq!(stl.matches("vertex").count(), 36, "3 vertices per triangle");
1962
1963        let empty = EngineState::new();
1964        assert!(empty.export_stl_text().is_err(), "empty scene errs on STL export");
1965    }
1966
1967    // -----------------------------------------------------------------------
1968    // Structured STEP assembly import (kernel-plan `step-assembly-import.md`
1969    // §3.7 / §5) — the engine seam: probe, consume, ONE rebuild, flat fallback.
1970    // -----------------------------------------------------------------------
1971
1972    /// A STEP fixture from the kernel's corpus, read at RUNTIME: that corpus is
1973    /// test-only root data which deliberately stays outside every crate package
1974    /// archive, so it must not be `include_str!`d into this crate.
1975    fn step_fixture(name: &str) -> String {
1976        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1977            .join("../BREP_kernel/tests/fixtures/step-import")
1978            .join(name);
1979        std::fs::read_to_string(&path)
1980            .unwrap_or_else(|error| panic!("read fixture {}: {error}", path.display()))
1981    }
1982
1983    /// The kernel's parts library, parsed.
1984    fn library() -> serde_json::Map<String, serde_json::Value> {
1985        serde_json::from_str::<serde_json::Value>(&brep_kernel::parts_library_json())
1986            .expect("the parts library serializes as JSON")
1987            .as_object()
1988            .cloned()
1989            .expect("the parts library is an object")
1990    }
1991
1992    /// Every ACOMP feature's `partName`, in history order.
1993    fn component_part_names(state: &EngineState) -> Vec<String> {
1994        serde_json::from_str::<serde_json::Value>(&state.history_request_json())
1995            .expect("history JSON")["features"]
1996            .as_array()
1997            .expect("features array")
1998            .iter()
1999            .filter(|feature| feature["type"] == "ACOMP")
2000            .map(|feature| feature["inputParams"]["partName"].as_str().unwrap().to_string())
2001            .collect()
2002    }
2003
2004    /// `name → how many ACOMP instances reference it`.
2005    fn instance_counts(state: &EngineState) -> std::collections::BTreeMap<String, usize> {
2006        let mut counts = std::collections::BTreeMap::new();
2007        for name in component_part_names(state) {
2008            *counts.entry(name).or_insert(0usize) += 1;
2009        }
2010        counts
2011    }
2012
2013    /// The native payload a library entry's part DOCUMENT carries (the §3.2
2014    /// shape: one IMPORT3D whose only input is `nativeBrep`).
2015    fn entry_payload(entry: &serde_json::Value) -> String {
2016        entry["document"]["features"][0]["inputParams"]["nativeBrep"]
2017            .as_str()
2018            .expect("the part document is one native IMPORT3D")
2019            .to_string()
2020    }
2021
2022    /// The multiset of scene-solid `(bbox center, bbox size)` rounded to 1e-4,
2023    /// sorted — the shape-and-place fingerprint of a displayed model.
2024    fn placed_bboxes(state: &EngineState) -> Vec<[i64; 6]> {
2025        let mut out: Vec<[i64; 6]> = state
2026            .scene
2027            .solids()
2028            .iter()
2029            .map(|solid| {
2030                let (center, size) = (solid.bbox.center(), solid.bbox.size());
2031                let q = |value: f64| (value * 1.0e4).round() as i64;
2032                [
2033                    q(center[0]),
2034                    q(center[1]),
2035                    q(center[2]),
2036                    q(size[0]),
2037                    q(size[1]),
2038                    q(size[2]),
2039                ]
2040            })
2041            .collect();
2042        out.sort_unstable();
2043        out
2044    }
2045
2046    /// A hand-built [`brep_kernel::StepAssembly`]: a root assembly node with no
2047    /// geometry, one geometry-bearing child product, and one occurrence of that
2048    /// child per `placements` entry. The seam the kernel's own fixtures cannot
2049    /// reach — no fixture in the corpus carries a NON-RIGID occurrence, and a
2050    /// name COLLISION with the live document needs the part's geometry to be
2051    /// chosen, not discovered.
2052    fn synthetic_assembly(
2053        bodies: Vec<brep_kernel::BrepSolid>,
2054        placements: &[([f64; 16], bool)],
2055    ) -> brep_kernel::StepAssembly {
2056        brep_kernel::StepAssembly {
2057            products: vec![
2058                brep_kernel::StepProduct {
2059                    pd_ref: 1,
2060                    name: "root".into(),
2061                    id: "root".into(),
2062                    bodies: Vec::new(),
2063                    appearances: Vec::new(),
2064                    failed_bodies: 0,
2065                },
2066                brep_kernel::StepProduct {
2067                    pd_ref: 2,
2068                    name: "widget".into(),
2069                    id: "widget".into(),
2070                    bodies,
2071                    appearances: Vec::new(),
2072                    failed_bodies: 0,
2073                },
2074            ],
2075            occurrences: placements
2076                .iter()
2077                .enumerate()
2078                .map(|(index, (placement, rigid))| brep_kernel::StepOccurrence {
2079                    nauo_ref: 10 + index,
2080                    parent: 0,
2081                    child: 1,
2082                    designator: format!("widget-{index}"),
2083                    placement: *placement,
2084                    rigid: *rigid,
2085                })
2086                .collect(),
2087            roots: vec![0],
2088            first_error: None,
2089        }
2090    }
2091
2092    /// THE O(N²) guard. `add_feature` re-runs the whole history per call, so an
2093    /// import that looped it would bump `applied_generation` once per instance.
2094    /// The batch bumps it EXACTLY once for the whole file — and leaves exactly
2095    /// one undo step, so the user backs the import out in one press.
2096    #[test]
2097    fn import_step_assembly_runs_one_rebuild() {
2098        let text = step_fixture("as1-ug-214.stp");
2099        let mut state = EngineState::new();
2100        let before = state.applied_generation();
2101
2102        let report = state
2103            .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2104            .expect("as1-ug-214 imports as an assembly");
2105        assert!(!report.flat_fallback, "as1-ug-214 carries a product structure");
2106        assert!(
2107            report.instances > 5,
2108            "the fixture is a real assembly: {} instances",
2109            report.instances
2110        );
2111        assert_eq!(
2112            state.applied_generation(),
2113            before + 1,
2114            "ONE rebuild for {} instances, not one per instance",
2115            report.instances
2116        );
2117        assert_eq!(
2118            component_part_names(&state).len(),
2119            report.instances,
2120            "every reported instance is an ACOMP feature"
2121        );
2122
2123        assert!(state.can_undo(), "the import is undoable");
2124        state.undo();
2125        assert!(
2126            component_part_names(&state).is_empty(),
2127            "the whole import undoes in ONE step"
2128        );
2129    }
2130
2131    /// The parse happens ONCE: the probe performs it and stashes the structure;
2132    /// the consume TAKES that stash and never sees the text again (its signature
2133    /// cannot — the structural proof). A second consume therefore errs rather
2134    /// than importing the file twice.
2135    #[test]
2136    fn import_step_assembly_parses_once() {
2137        let text = step_fixture("as1-ug-214.stp");
2138        let mut state = EngineState::new();
2139
2140        let probe = state
2141            .probe_step_assembly(&text)
2142            .expect("as1-ug-214 parses")
2143            .expect("as1-ug-214 carries structure");
2144        assert!(
2145            state.pending_step_assembly.is_some(),
2146            "the probe stashes THE parse for the consume"
2147        );
2148        assert!(probe.parts > 0 && probe.instances >= probe.parts);
2149        assert!(
2150            probe.nested_depth > 1,
2151            "as1-ug-214 has sub-assemblies: depth {}",
2152            probe.nested_depth
2153        );
2154
2155        let report = state
2156            .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut EmbeddedOnly)
2157            .expect("the probed assembly imports");
2158        assert!(
2159            state.pending_step_assembly.is_none(),
2160            "the consume TAKES the stash"
2161        );
2162        assert_eq!(
2163            (report.parts, report.instances),
2164            (probe.parts, probe.instances),
2165            "the dialog's counts are the import's counts"
2166        );
2167        assert_eq!(report.baked_nonrigid, 0, "every as1 occurrence is rigid");
2168        assert_eq!(report.failed_products, 0, "every product encodes");
2169
2170        assert!(
2171            state
2172                .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut EmbeddedOnly)
2173                .is_err(),
2174            "a second consume has nothing to import — never a double insert"
2175        );
2176    }
2177
2178    /// The stash never outlives the file it was parsed from: a second probe
2179    /// REPLACES it (including a probe that finds no structure — the case that
2180    /// would otherwise consume the PREVIOUS file), Cancel drops it, and a
2181    /// document switch drops it.
2182    #[test]
2183    fn probing_replaces_the_stash_and_never_accumulates() {
2184        let assembly = step_fixture("as1-ug-214.stp");
2185        let part = step_fixture("analytic_cube.step");
2186        let mut state = EngineState::new();
2187
2188        assert!(state.probe_step_assembly(&assembly).unwrap().is_some());
2189        assert!(
2190            state.probe_step_assembly(&part).unwrap().is_none(),
2191            "a part file has no structure"
2192        );
2193        assert!(
2194            state.pending_step_assembly.is_none(),
2195            "a structureless probe must CLEAR the stash, or the next consume \
2196             imports the previous file"
2197        );
2198
2199        assert!(state.probe_step_assembly(&assembly).unwrap().is_some());
2200        state.discard_probed_step_assembly();
2201        assert!(state.pending_step_assembly.is_none(), "Cancel drops the parse");
2202
2203        assert!(state.probe_step_assembly(&assembly).unwrap().is_some());
2204        state.set_history_json(&cube_history("Box", 4.0)).unwrap();
2205        assert!(
2206            state.pending_step_assembly.is_none(),
2207            "a document switch drops a parse that belonged to the old document"
2208        );
2209    }
2210
2211    /// A single-part STEP file has no structure to keep, so the import lands on
2212    /// today's flat lane, byte-for-byte: one IMPORT3D carrying the text, no
2213    /// library entries, no components.
2214    #[test]
2215    fn import_step_assembly_falls_back_to_flat_for_a_part_file() {
2216        let text = step_fixture("analytic_cube.step");
2217        let mut state = EngineState::new();
2218
2219        assert!(
2220            state.probe_step_assembly(&text).unwrap().is_none(),
2221            "the probe reports no structure, so the app never offers the dialog"
2222        );
2223        let report = state
2224            .import_step_assembly(&text, "analytic_cube", StepAssemblyImport::default())
2225            .expect("the part file still imports");
2226
2227        assert!(report.flat_fallback, "the flat lane ran");
2228        assert_eq!((report.parts, report.instances), (0, 0));
2229        assert!(library().is_empty(), "no parts-library entry for a flat import");
2230        assert_eq!(state.history_len(), 1, "one IMPORT3D feature");
2231        assert!(
2232            state.history_request_json().contains("stepText"),
2233            "the flat lane stores the STEP text, exactly as before"
2234        );
2235        assert!(!state.scene.solids().is_empty(), "the bodies are in the model");
2236    }
2237
2238    /// The dedup that makes an imported assembly an ASSEMBLY: one library entry
2239    /// per unique geometry-bearing product, one ACOMP per occurrence of it. On
2240    /// the six-bolt classic that is six instances of ONE stored bolt.
2241    #[test]
2242    fn structured_import_dedups_parts() {
2243        let text = step_fixture("as1-ug-214.stp");
2244        let mut state = EngineState::new();
2245        let report = state
2246            .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2247            .expect("as1-ug-214 imports as an assembly");
2248
2249        let library = library();
2250        assert_eq!(
2251            library.len(),
2252            report.parts,
2253            "one entry per unique geometry-bearing product"
2254        );
2255        let counts = instance_counts(&state);
2256        assert_eq!(
2257            counts.values().sum::<usize>(),
2258            report.instances,
2259            "one ACOMP per geometry-bearing occurrence"
2260        );
2261        assert_eq!(
2262            counts.len(),
2263            library.len(),
2264            "every entry is instanced, and every instance names an entry"
2265        );
2266        assert_eq!(
2267            counts.get("bolt").copied(),
2268            Some(6),
2269            "the six-bolt classic: ONE stored bolt, six instances — {counts:?}"
2270        );
2271        assert_eq!(
2272            counts.get("nut").copied(),
2273            Some(8),
2274            "eight nuts (six on the bolts, two on the rods): {counts:?}"
2275        );
2276        assert_eq!(
2277            counts.get("l_bracket").copied(),
2278            Some(2),
2279            "two L-brackets: {counts:?}"
2280        );
2281        assert_eq!(
2282            (report.parts, report.instances),
2283            (5, 18),
2284            "as1-ug-214: 5 distinct parts in 18 places"
2285        );
2286
2287        // This import ran with the `EmbeddedOnly` sink, so every entry is
2288        // embedded-only and holds the §3.2 part document.
2289        //
2290        // CHANGED MEANING (was: "an imported part is ALWAYS embedded-only",
2291        // §3.5): an import now writes its unique parts to the store and gives
2292        // each a real `sourceKey` when the caller supplies a sink — see
2293        // `a_sink_gives_every_unique_part_a_real_source_key`. What survives
2294        // here is the OTHER half of that contract: a caller with NO store
2295        // (this one, and every headless caller) still gets an entry that
2296        // update-components skips instead of badging falsely outdated.
2297        for (name, entry) in &library {
2298            assert_eq!(
2299                entry["sourceKey"], "",
2300                "'{name}': with no sink there is no file, so the entry must \
2301                 stay embedded-only or update-components badges it as falsely \
2302                 outdated"
2303            );
2304            assert_eq!(
2305                entry["sourceSignature"],
2306                serde_json::Value::String(document_signature(&entry["document"].to_string())),
2307                "'{name}' signature is the ONE signature fn over its document"
2308            );
2309            assert!(
2310                !entry_payload(entry).is_empty(),
2311                "'{name}' carries a native payload"
2312            );
2313            assert!(
2314                !entry["document"].to_string().contains("ISO-10303-21"),
2315                "'{name}' must store NATIVE geometry, never the STEP text"
2316            );
2317        }
2318    }
2319
2320    /// A recording [`PartSink`]: hands back a key derived from the part name
2321    /// and keeps every document it was offered — the app's store writer,
2322    /// minus the store.
2323    #[derive(Default)]
2324    struct RecordingSink {
2325        written: std::collections::BTreeMap<String, String>,
2326        offers: usize,
2327    }
2328
2329    impl PartSink for RecordingSink {
2330        fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
2331            self.offers += 1;
2332            let key = format!("/models/{part_name}.BREP.json");
2333            self.written.insert(key.clone(), document_json.to_string());
2334            Some(key)
2335        }
2336    }
2337
2338    /// WITH a sink, every unique part is written to the store and carries a
2339    /// REAL `sourceKey` — the owner's "no distinction between part kinds".
2340    ///
2341    /// This is the half that REPLACES the old §3.5 decision (an imported part
2342    /// was embedded-only by design). What it must not break is the dedup that
2343    /// decision used to give for free: `add_part_to_library` reuses on
2344    /// `(sourceKey, sourceSignature)`, and every part having its OWN key would
2345    /// have turned six instances of one bolt into six entries and six
2346    /// identical files. So the six-bolt classic is asserted here too — five
2347    /// entries, five writes, five keys.
2348    #[test]
2349    fn a_sink_gives_every_unique_part_a_real_source_key_without_losing_dedup() {
2350        let text = step_fixture("as1-ug-214.stp");
2351        let mut state = EngineState::new();
2352        let mut sink = RecordingSink::default();
2353        state.probe_step_assembly(&text).unwrap();
2354        let report = state
2355            .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut sink)
2356            .expect("structured import");
2357
2358        assert_eq!(
2359            (report.parts, report.instances),
2360            (5, 18),
2361            "still 5 distinct parts in 18 places — the sink must not split them"
2362        );
2363        assert_eq!(
2364            sink.written.len(),
2365            5,
2366            "one file per DISTINCT part, not per occurrence: {:?}",
2367            sink.written.keys().collect::<Vec<_>>()
2368        );
2369        assert_eq!(
2370            sink.offers, 5,
2371            "and the store is offered each part exactly once — identical \
2372             content is written once, not written and then deduped"
2373        );
2374
2375        let library: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
2376            &brep_kernel::parts_library_json(),
2377        )
2378        .unwrap();
2379        assert_eq!(library.len(), 5, "five entries, one per part");
2380        for (name, entry) in &library {
2381            let key = entry["sourceKey"].as_str().unwrap_or_default();
2382            assert!(!key.is_empty(), "'{name}' must carry a real sourceKey");
2383            let stored = sink
2384                .written
2385                .get(key)
2386                .unwrap_or_else(|| panic!("'{name}' key '{key}' names a written file"));
2387            // The signature stamped on the entry hashes the EXACT bytes that
2388            // were written, or the app's write-through guard ("has the file
2389            // moved on?") is wrong from the first save.
2390            assert_eq!(
2391                entry["sourceSignature"],
2392                serde_json::Value::String(document_signature(stored)),
2393                "'{name}': the entry's signature and the stored file must \
2394                 describe the same content"
2395            );
2396        }
2397    }
2398
2399    /// A sink that DECLINES one part (a failed write) leaves that entry
2400    /// embedded-only and imports everything else — a storage failure costs one
2401    /// part's file, never the import.
2402    #[test]
2403    fn a_declining_sink_leaves_that_part_embedded_and_imports_the_rest() {
2404        struct PickySink;
2405        impl PartSink for PickySink {
2406            fn store_part(&mut self, part_name: &str, _document: &str) -> Option<String> {
2407                (part_name != "bolt").then(|| format!("/models/{part_name}.BREP.json"))
2408            }
2409        }
2410        let text = step_fixture("as1-ug-214.stp");
2411        let mut state = EngineState::new();
2412        state.probe_step_assembly(&text).unwrap();
2413        let report = state
2414            .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut PickySink)
2415            .expect("structured import");
2416        assert_eq!(
2417            (report.parts, report.instances),
2418            (5, 18),
2419            "the import is unaffected by one refused write"
2420        );
2421        let library: serde_json::Map<String, serde_json::Value> =
2422            serde_json::from_str(&brep_kernel::parts_library_json()).unwrap();
2423        assert_eq!(
2424            library["bolt"]["sourceKey"], "",
2425            "the refused part falls back to embedded-only"
2426        );
2427        for (name, entry) in library.iter().filter(|(name, _)| name.as_str() != "bolt") {
2428            assert!(
2429                !entry["sourceKey"].as_str().unwrap_or_default().is_empty(),
2430                "'{name}' still got its file"
2431            );
2432        }
2433    }
2434
2435    /// The structured lane places the same geometry the flat lane does. The
2436    /// kernel proves the equivalence bit-for-bit against `resolve_assembly`;
2437    /// this asserts it from the side where a divergence would actually land —
2438    /// the composed world poses this crate walks out of the occurrence tree.
2439    /// (Volume alone would not: it is invariant under the rigid transforms a
2440    /// mis-composed placement gets wrong.)
2441    #[test]
2442    fn structured_import_matches_the_flat_lane_geometry() {
2443        let text = step_fixture("as1-ug-214.stp");
2444
2445        let mut flat = EngineState::new();
2446        flat.import_step_feature(&text).expect("flat import");
2447
2448        let mut structured = EngineState::new();
2449        structured
2450            .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2451            .expect("structured import");
2452
2453        assert_eq!(
2454            structured.scene.solids().len(),
2455            flat.scene.solids().len(),
2456            "same body count"
2457        );
2458        assert_eq!(
2459            placed_bboxes(&structured),
2460            placed_bboxes(&flat),
2461            "every component must sit where the flat lane's baked body sits"
2462        );
2463    }
2464
2465    /// §5's convergence gate. A library entry whose snapshot is gone re-executes
2466    /// its embedded document (the ACOMP self-heal) and rewrites the snapshot; for
2467    /// a NATIVE part that heal is a decode + re-encode, so it must land on the
2468    /// same solids under the same names — and a SECOND heal must reproduce the
2469    /// first byte-for-byte.
2470    ///
2471    /// The strong form holds here: the heal reproduces the INSERT's snapshot
2472    /// exactly, which is why the import goes through `add_part_to_library` rather
2473    /// than injecting a hand-made snapshot. (Both are supersets of the raw
2474    /// payload — the isolated run stamps `sourceFeatureId` records the payload
2475    /// never carried — so convergence, not payload == snapshot, is the invariant.)
2476    #[test]
2477    fn native_part_document_heals_and_converges() {
2478        let text = step_fixture("as1-ug-214.stp");
2479        let mut state = EngineState::new();
2480        state
2481            .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2482            .expect("as1-ug-214 imports as an assembly");
2483        let inserted = library();
2484
2485        // The §5 lever: reopen the document with every entry's snapshot CLEARED,
2486        // which is what an unreadable cache looks like to the ACOMP fast lane.
2487        // `set_history_json` drops the kernel library and re-seeds it from the
2488        // block, so the run that follows must heal every entry.
2489        let heal_once = |state: &mut EngineState| -> serde_json::Map<String, serde_json::Value> {
2490            let mut document: serde_json::Value =
2491                serde_json::from_str(&state.history_request_json()).expect("document JSON");
2492            for (_, entry) in document["partsLibrary"]
2493                .as_object_mut()
2494                .expect("the document carries the library")
2495                .iter_mut()
2496            {
2497                entry["snapshot"] = serde_json::Value::String(String::new());
2498            }
2499            state.set_history_json(&document.to_string()).expect("reopen");
2500            let healed = library();
2501            for (name, entry) in &healed {
2502                assert!(
2503                    !entry["snapshot"].as_str().unwrap_or_default().is_empty(),
2504                    "'{name}' must have healed its cleared snapshot"
2505                );
2506            }
2507            healed
2508        };
2509
2510        let first = heal_once(&mut state);
2511        let second = heal_once(&mut state);
2512        assert_eq!(
2513            first, second,
2514            "a second heal must reproduce the first BYTE for byte"
2515        );
2516        assert_eq!(first.len(), inserted.len(), "the heal keeps the same entries");
2517
2518        for (name, entry) in &first {
2519            assert_eq!(
2520                entry["snapshot"],
2521                inserted[name.as_str()]["snapshot"],
2522                "'{name}': the heal must reproduce what the INSERT stored — the \
2523                 whole reason the import goes through add_part_to_library"
2524            );
2525            // The healed snapshot restores to the payload's solids under the
2526            // payload's names: the geometry survived the round trip.
2527            let payload = brep_kernel::restore_solids(&entry_payload(entry))
2528                .expect("the stored payload decodes");
2529            let healed = brep_kernel::restore_solids(entry["snapshot"].as_str().unwrap())
2530                .expect("the healed snapshot decodes");
2531            let names = |snapshot: &brep_kernel::RestoredSnapshot| -> Vec<String> {
2532                snapshot.solids.iter().map(|solid| solid.name.clone()).collect()
2533            };
2534            assert_eq!(names(&healed), names(&payload), "'{name}': identical body names");
2535            for (healed, stored) in healed.solids.iter().zip(payload.solids.iter()) {
2536                let (healed_data, healed_names) = brep_kernel::encode_solid(&healed.solid).unwrap();
2537                let (stored_data, stored_names) = brep_kernel::encode_solid(&stored.solid).unwrap();
2538                assert_eq!(healed_data, stored_data, "'{name}': identical geometry");
2539                assert_eq!(
2540                    healed_names.faces, stored_names.faces,
2541                    "'{name}': identical face names"
2542                );
2543                assert_eq!(
2544                    healed_names.edges, stored_names.edges,
2545                    "'{name}': identical edge names"
2546                );
2547            }
2548            assert!(
2549                healed.metadata.len() >= payload.metadata.len(),
2550                "'{name}': the heal's snapshot is a superset (sourceFeatureId records)"
2551            );
2552        }
2553    }
2554
2555    /// THE ambient-metadata hazard. `native_import_payload` seals whatever record
2556    /// this thread's scene-metadata store holds for each name it stamps — right
2557    /// for a snapshot of the live scene, catastrophic for a NEW part whose
2558    /// stamped names collide with the CURRENT document's. The import brackets the
2559    /// encode; here the same call made WITHOUT that bracket captures the live
2560    /// records, which is what makes the assertion mean something.
2561    #[test]
2562    fn imported_part_payloads_never_capture_the_live_documents_metadata() {
2563        let step = box_step(4.0, 3.0, 2.0);
2564        let bodies = brep_kernel::import_step(&step).expect("the box imports");
2565
2566        // A LIVE document built from the very same geometry: its history run
2567        // stamps `sourceFeatureId` records under exactly the names a part built
2568        // from these bodies will stamp.
2569        let mut state = EngineState::new();
2570        state.import_step_feature(&step).expect("flat import");
2571
2572        // The collision is real: an UNBRACKETED encode of these bodies picks up
2573        // the live document's records. (If this ever comes back empty the test
2574        // has gone vacuous and must be re-armed, not deleted.)
2575        let leaked = brep_kernel::restore_solids(
2576            &brep_kernel::native_import_payload("IMPORT3D1", &bodies).unwrap(),
2577        )
2578        .unwrap();
2579        assert!(
2580            !leaked.metadata.is_empty(),
2581            "the live document must actually hold records under these names"
2582        );
2583
2584        state.pending_step_assembly = Some(synthetic_assembly(bodies.clone(), &[(MAT4_IDENTITY, true)]));
2585        state
2586            .import_probed_step_assembly("collide", StepAssemblyImport::default(), &mut EmbeddedOnly)
2587            .expect("the synthetic assembly imports");
2588
2589        let entry = library().into_iter().next().expect("one entry").1;
2590        let stored = brep_kernel::restore_solids(&entry_payload(&entry)).expect("payload decodes");
2591        assert!(
2592            stored.metadata.is_empty(),
2593            "the part's payload must carry the PART's metadata (it has none), \
2594             never the live document's: {:?}",
2595            stored.metadata
2596        );
2597
2598        // The bracket RESTORED the store rather than eating it — the same
2599        // unbracketed encode still sees the live document's records.
2600        let after = brep_kernel::restore_solids(
2601            &brep_kernel::native_import_payload("IMPORT3D1", &bodies).unwrap(),
2602        )
2603        .unwrap();
2604        assert_eq!(
2605            after.metadata, leaked.metadata,
2606            "the live document's scene metadata must survive the import"
2607        );
2608    }
2609
2610    /// §3.4: an occurrence whose placement is not rigid has no ACOMP pose, so its
2611    /// non-rigid factor is baked into a DISTINCT library entry and the instance
2612    /// carries the rigid residue. Never a wrong-handed reuse of the unmirrored
2613    /// twin. No fixture in the corpus carries one, so the occurrence is
2614    /// synthetic — which is also the only honest way to test it.
2615    #[test]
2616    fn nonrigid_occurrence_bakes_a_distinct_part() {
2617        let bodies = brep_kernel::import_step(&box_step(4.0, 3.0, 2.0)).expect("box imports");
2618        // x → −x about the origin, then translated: a mirror, det = −1.
2619        let mirrored = [
2620            -1.0, 0.0, 0.0, 20.0, //
2621            0.0, 1.0, 0.0, 0.0, //
2622            0.0, 0.0, 1.0, 0.0, //
2623            0.0, 0.0, 0.0, 1.0,
2624        ];
2625        let mut state = EngineState::new();
2626        state.pending_step_assembly = Some(synthetic_assembly(
2627            bodies,
2628            &[(MAT4_IDENTITY, true), (mirrored, false)],
2629        ));
2630        let report = state
2631            .import_probed_step_assembly("mirror", StepAssemblyImport::default(), &mut EmbeddedOnly)
2632            .expect("the mirrored assembly imports");
2633
2634        assert_eq!(report.instances, 2, "both occurrences become components");
2635        assert_eq!(report.baked_nonrigid, 1, "one of them baked its factor");
2636        assert_eq!(report.parts, 2, "the mirrored instance is its OWN part");
2637        let counts = instance_counts(&state);
2638        assert_eq!(
2639            counts.get("widget").copied(),
2640            Some(1),
2641            "the plain instance keeps the plain part: {counts:?}"
2642        );
2643        assert_eq!(
2644            counts.get("widget (mirrored)").copied(),
2645            Some(1),
2646            "the mirrored instance gets its own entry: {counts:?}"
2647        );
2648        // Two bodies, and the mirrored one sits where the placement put it:
2649        // x → 20 − x, so the two centres straddle x = 10.
2650        assert_eq!(state.scene.solids().len(), 2);
2651        let mut centers: Vec<f64> = state
2652            .scene
2653            .solids()
2654            .iter()
2655            .map(|solid| solid.bbox.center()[0])
2656            .collect();
2657        centers.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
2658        assert!(
2659            (centers[0] + centers[1] - 20.0).abs() < 1e-3
2660                && (centers[1] - centers[0]).abs() > 1e-3,
2661            "the mirror must land at 20 − x̄, not on top of its twin: {centers:?}"
2662        );
2663    }
2664
2665    // -----------------------------------------------------------------------
2666    // A8 — nested rigid sub-assemblies (kernel-plan §3.3 Phase 2)
2667    // -----------------------------------------------------------------------
2668
2669    /// `{ nested: true }`.
2670    const NESTED: StepAssemblyImport = StepAssemblyImport { nested: true };
2671
2672    /// The `partsLibrary` block of a part DOCUMENT — the child library a nested
2673    /// sub-assembly document carries (empty map when it carries none).
2674    fn child_library(document: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
2675        document["partsLibrary"]
2676            .as_object()
2677            .cloned()
2678            .unwrap_or_default()
2679    }
2680
2681    /// The `partName`s the ACOMP features of a part DOCUMENT reference, in
2682    /// document order.
2683    fn child_components(document: &serde_json::Value) -> Vec<String> {
2684        document["features"]
2685            .as_array()
2686            .map(Vec::as_slice)
2687            .unwrap_or_default()
2688            .iter()
2689            .filter(|feature| feature["type"] == "ACOMP")
2690            .map(|feature| feature["inputParams"]["partName"].as_str().unwrap().to_string())
2691            .collect()
2692    }
2693
2694    /// One component's geometry as the world places it: volume, centroid and
2695    /// vertex bbox of every solid the component's part contributes, posed by the
2696    /// component's transform. EXACT `f64` — the snapshot restores the same
2697    /// solids the flat lane baked, the pose is the kernel's own
2698    /// `AffineTransform`, and the bbox is taken over exact vertex points, so
2699    /// this reads the GEOMETRY rather than a tessellation of it.
2700    ///
2701    /// Call it while `state`'s import is the LAST one this thread ran: the
2702    /// kernel parts library is a thread-local the next `EngineState` clears and
2703    /// refills, so a second import invalidates the first state's entries.
2704    fn world_placed(state: &mut EngineState) -> Vec<[f64; 10]> {
2705        state.ensure_assembly_synced();
2706        let library = library();
2707        let mut out = Vec::new();
2708        for record in state.assembly_components() {
2709            let entry = &library[record.part_name.as_str()];
2710            let restored = brep_kernel::restore_solids(entry["snapshot"].as_str().unwrap())
2711                .expect("every entry's snapshot decodes");
2712            let mirrored = record.transform.determinant3() < 0.0;
2713            for solid in &restored.solids {
2714                let posed = brep_kernel::transform_brep(&solid.solid, record.transform, mirrored)
2715                    .expect("a component pose is rigid");
2716                let mass =
2717                    brep_kernel::solid_mass_properties_full(&posed).expect("mass properties");
2718                let mut lo = [f64::INFINITY; 3];
2719                let mut hi = [f64::NEG_INFINITY; 3];
2720                for vertex in &posed.vertices {
2721                    for axis in 0..3 {
2722                        let value = [vertex.point.x, vertex.point.y, vertex.point.z][axis];
2723                        lo[axis] = lo[axis].min(value);
2724                        hi[axis] = hi[axis].max(value);
2725                    }
2726                }
2727                out.push([
2728                    mass.volume,
2729                    mass.centroid.x,
2730                    mass.centroid.y,
2731                    mass.centroid.z,
2732                    lo[0],
2733                    lo[1],
2734                    lo[2],
2735                    hi[0],
2736                    hi[1],
2737                    hi[2],
2738                ]);
2739            }
2740        }
2741        // Sorted on a COARSE key so the pairing is stable, then compared at the
2742        // tight tolerance by the caller.
2743        out.sort_by_key(|row| row.map(|value| (value * 1.0e6).round() as i64));
2744        out
2745    }
2746
2747    /// Every row of `a` matches `b` to `tolerance`.
2748    fn assert_placed_eq(a: &[[f64; 10]], b: &[[f64; 10]], tolerance: f64, what: &str) {
2749        assert_eq!(a.len(), b.len(), "{what}: solid count");
2750        for (index, (left, right)) in a.iter().zip(b.iter()).enumerate() {
2751            for (column, (l, r)) in left.iter().zip(right.iter()).enumerate() {
2752                assert!(
2753                    (l - r).abs() <= tolerance,
2754                    "{what}: solid {index} column {column}: {l} != {r}"
2755                );
2756            }
2757        }
2758    }
2759
2760    /// §5's `nested_import_builds_child_libraries`. `as1-ug-214.stp` is the real
2761    /// three-level article: `as1-ug` → { plate, lb_assem ×2, rod_assem }, where
2762    /// `lb_assem` → { l_bracket, nba ×3 } and `nba` → { bolt, nut }.
2763    ///
2764    /// The root must therefore get ONE component per sub-assembly OCCURRENCE
2765    /// (not per leaf body), each sub-document must carry its OWN `partsLibrary`,
2766    /// and the entity names must chain a namespace per level.
2767    #[test]
2768    fn nested_import_builds_child_libraries() {
2769        let text = step_fixture("as1-ug-214.stp");
2770        let mut state = EngineState::new();
2771        let report = state
2772            .import_step_assembly(&text, "as1-ug", NESTED)
2773            .expect("as1-ug-214 imports as a nested assembly");
2774
2775        assert!(!report.flat_fallback, "the structured lane ran");
2776        assert_eq!(
2777            (report.parts, report.instances),
2778            (3, 4),
2779            "the ROOT's children: plate, lb_assem ×2, rod_assem — a sub-assembly \
2780             is ONE component (build-spec §2.2), not one per leaf body"
2781        );
2782        assert_eq!(report.failed_products, 0, "every product encodes");
2783        assert_eq!(report.baked_nonrigid, 0, "every as1 occurrence is rigid");
2784        assert_eq!(
2785            instance_counts(&state),
2786            [("lb_assem".to_string(), 2), ("plate".to_string(), 1), ("rod_assem".to_string(), 1)]
2787                .into_iter()
2788                .collect::<std::collections::BTreeMap<_, _>>(),
2789        );
2790
2791        // Level 2 — `lb_assem` carries its OWN library and its own components.
2792        let library = library();
2793        let lb = &library["lb_assem"]["document"];
2794        assert_eq!(
2795            child_library(lb).keys().cloned().collect::<Vec<_>>(),
2796            vec!["l_bracket".to_string(), "nba".to_string()],
2797            "the sub-document's library is its own (build-spec §2.2: a part \
2798             reused across levels is stored once PER level)"
2799        );
2800        assert_eq!(
2801            child_components(lb),
2802            vec!["l_bracket", "nba", "nba", "nba"],
2803            "one ACOMP per child occurrence — three nut-bolt assemblies"
2804        );
2805
2806        // Level 3 — `nba` is a sub-assembly OF a sub-assembly.
2807        let nba = &child_library(lb)["nba"]["document"];
2808        assert_eq!(
2809            child_library(nba).keys().cloned().collect::<Vec<_>>(),
2810            vec!["bolt".to_string(), "nut".to_string()],
2811        );
2812        assert_eq!(child_components(nba), vec!["bolt", "nut"]);
2813        // The deepest entries are the §3.2 native part documents — the leaves of
2814        // the recursion, identical in shape to what a flat import stores.
2815        // CHANGED MEANING, as above: a nested child is a part like any other
2816        // and takes a real `sourceKey` from a sink. This import has none, so
2817        // the entries stay embedded — which is what the empty key now asserts.
2818        for (name, entry) in child_library(nba) {
2819            assert_eq!(
2820                entry["sourceKey"], "",
2821                "'{name}': no sink, so the nested child stays embedded"
2822            );
2823            assert!(!entry_payload(&entry).is_empty(), "'{name}' is a native part");
2824        }
2825
2826        // The namespace CHAINS, one segment per level: a bolt inside `nba`
2827        // inside `lb_assem` inside the document.
2828        let names: Vec<&str> = state
2829            .scene
2830            .solids()
2831            .iter()
2832            .map(|solid| solid.name.as_str())
2833            .collect();
2834        assert!(
2835            names.iter().any(|name| name.matches("ACOMP").count() == 3),
2836            "a three-level chain must appear in the scene names: {names:?}"
2837        );
2838        assert!(
2839            names.iter().any(|name| name.starts_with("ACOMP2:ACOMP2:ACOMP1:")),
2840            "the chained prefix the structure tree reads back: {names:?}"
2841        );
2842        assert_eq!(
2843            names.len(),
2844            18,
2845            "the same 18 bodies the flat lane produces, reached through the tree"
2846        );
2847    }
2848
2849    /// Phase 1's output IS Phase 2's output for a depth-1 tree — the cheapest
2850    /// correctness check the nesting slice has, asserted on the whole document
2851    /// (features, poses, library entries, snapshots) rather than a summary.
2852    ///
2853    /// `AssemblyExample-Assembly.step` is a real single-level assembly; the
2854    /// synthetic pair covers the case the fixture cannot, a root that owns
2855    /// bodies AND children (interior geometry at the root, which BOTH lanes
2856    /// place as a component of its own).
2857    #[test]
2858    fn nested_matches_flat_for_a_depth_one_tree() {
2859        let text = step_fixture("AssemblyExample-Assembly.step");
2860        let mut state = EngineState::new();
2861        let probe = state
2862            .probe_step_assembly(&text)
2863            .unwrap()
2864            .expect("the fixture carries structure");
2865        assert_eq!(probe.nested_depth, 1, "the fixture must be depth 1");
2866
2867        let mut flat = EngineState::new();
2868        let flat_report = flat
2869            .import_step_assembly(&text, "example", StepAssemblyImport::default())
2870            .expect("flat");
2871        let flat_document = flat.history_request_json();
2872
2873        let mut nested = EngineState::new();
2874        let nested_report = nested
2875            .import_step_assembly(&text, "example", NESTED)
2876            .expect("nested");
2877        assert_eq!(nested_report, flat_report, "identical report");
2878        assert_eq!(
2879            nested.history_request_json(),
2880            flat_document,
2881            "a depth-1 nested import must produce the FLAT document, byte for byte"
2882        );
2883
2884        // Same again with a root that owns geometry of its own AND a mirrored
2885        // occurrence — the two branches the fixture cannot reach, and the only
2886        // ones where the nested lane runs its own §3.4 bake rather than the flat
2887        // lane's. Byte equality covers both for free.
2888        let bodies = brep_kernel::import_step(&box_step(4.0, 3.0, 2.0)).expect("box imports");
2889        let placed = [
2890            1.0, 0.0, 0.0, 12.0, //
2891            0.0, 1.0, 0.0, 0.0, //
2892            0.0, 0.0, 1.0, 0.0, //
2893            0.0, 0.0, 0.0, 1.0,
2894        ];
2895        let mirrored = [
2896            -1.0, 0.0, 0.0, 30.0, //
2897            0.0, 1.0, 0.0, 0.0, //
2898            0.0, 0.0, 1.0, 0.0, //
2899            0.0, 0.0, 0.0, 1.0,
2900        ];
2901        let with_root_bodies = || {
2902            let mut assembly =
2903                synthetic_assembly(bodies.clone(), &[(placed, true), (mirrored, false)]);
2904            assembly.products[0].bodies = bodies.clone();
2905            assembly
2906        };
2907        let mut flat = EngineState::new();
2908        flat.pending_step_assembly = Some(with_root_bodies());
2909        flat.import_probed_step_assembly("root", StepAssemblyImport::default(), &mut EmbeddedOnly)
2910            .expect("flat");
2911        let flat_document = flat.history_request_json();
2912
2913        let mut nested = EngineState::new();
2914        nested.pending_step_assembly = Some(with_root_bodies());
2915        let report = nested
2916            .import_probed_step_assembly("root", NESTED, &mut EmbeddedOnly)
2917            .expect("nested");
2918        assert_eq!(report.baked_nonrigid, 1, "the mirrored occurrence baked");
2919        assert_eq!(
2920            nested.history_request_json(),
2921            flat_document,
2922            "interior geometry AT THE ROOT, and a mirrored leaf, are the same \
2923             components in both lanes"
2924        );
2925    }
2926
2927    /// THE §6 limitation this slice removes. A product that owns bodies AND
2928    /// children is a real thing in real files, and Phase 1 could only make its
2929    /// geometry a SIBLING of its own children in the structure tree. Nested puts
2930    /// the bodies where they belong: plain native IMPORT3D features inside that
2931    /// node's own document, alongside its ACOMPs.
2932    ///
2933    /// No fixture reaches it — `as1-ug-214`'s interior nodes (`lb_assem`, `nba`,
2934    /// `rod_assem`) are all pure assembly nodes — so the shape is synthetic:
2935    /// root → mid (bodies + one child) → leaf.
2936    #[test]
2937    fn interior_node_geometry_lives_inside_its_own_document() {
2938        let mid_bodies = brep_kernel::import_step(&box_step(6.0, 6.0, 1.0)).expect("plate");
2939        let leaf_bodies = brep_kernel::import_step(&box_step(2.0, 2.0, 2.0)).expect("stud");
2940        let shift = |x: f64, z: f64| {
2941            [
2942                1.0, 0.0, 0.0, x, //
2943                0.0, 1.0, 0.0, 0.0, //
2944                0.0, 0.0, 1.0, z, //
2945                0.0, 0.0, 0.0, 1.0,
2946            ]
2947        };
2948        let assembly = || brep_kernel::StepAssembly {
2949            products: vec![
2950                brep_kernel::StepProduct {
2951                    pd_ref: 1,
2952                    name: "root".into(),
2953                    id: "root".into(),
2954                    bodies: Vec::new(),
2955                    appearances: Vec::new(),
2956                    failed_bodies: 0,
2957                },
2958                brep_kernel::StepProduct {
2959                    pd_ref: 2,
2960                    name: "mid".into(),
2961                    id: "mid".into(),
2962                    // Bodies AND children — the interior node.
2963                    bodies: mid_bodies.clone(),
2964                    appearances: Vec::new(),
2965                    failed_bodies: 0,
2966                },
2967                brep_kernel::StepProduct {
2968                    pd_ref: 3,
2969                    name: "stud".into(),
2970                    id: "stud".into(),
2971                    bodies: leaf_bodies.clone(),
2972                    appearances: Vec::new(),
2973                    failed_bodies: 0,
2974                },
2975            ],
2976            occurrences: vec![
2977                brep_kernel::StepOccurrence {
2978                    nauo_ref: 10,
2979                    parent: 0,
2980                    child: 1,
2981                    designator: "mid-1".into(),
2982                    placement: shift(20.0, 0.0),
2983                    rigid: true,
2984                },
2985                brep_kernel::StepOccurrence {
2986                    nauo_ref: 11,
2987                    parent: 1,
2988                    child: 2,
2989                    designator: "stud-1".into(),
2990                    placement: shift(2.0, 1.0),
2991                    rigid: true,
2992                },
2993            ],
2994            roots: vec![0],
2995            first_error: None,
2996        };
2997
2998        let mut nested = EngineState::new();
2999        nested.pending_step_assembly = Some(assembly());
3000        let report = nested
3001            .import_probed_step_assembly("interior", NESTED, &mut EmbeddedOnly)
3002            .expect("nested import");
3003        assert_eq!(
3004            (report.parts, report.instances),
3005            (1, 1),
3006            "ONE component — `mid` and everything under it"
3007        );
3008
3009        // `mid`'s document: its own bodies as an IMPORT3D, its child as an ACOMP.
3010        let document = &library()["mid"]["document"];
3011        let kinds: Vec<&str> = document["features"]
3012            .as_array()
3013            .unwrap()
3014            .iter()
3015            .map(|feature| feature["type"].as_str().unwrap())
3016            .collect();
3017        assert_eq!(
3018            kinds,
3019            vec!["IMPORT3D", "ACOMP"],
3020            "the node's OWN bodies ride in ITS document, alongside its children"
3021        );
3022        assert_eq!(child_components(document), vec!["stud"]);
3023        assert!(
3024            !document["features"][0]["inputParams"]["nativeBrep"]
3025                .as_str()
3026                .unwrap_or_default()
3027                .is_empty(),
3028            "the interior geometry is a native payload, not STEP text"
3029        );
3030
3031        // In the scene the two sit at DIFFERENT namespace depths under the one
3032        // component — the parent's body one segment in, the child's two — which
3033        // is exactly the parent/sibling distinction Phase 1 could not express.
3034        let names: Vec<&str> = nested
3035            .scene
3036            .solids()
3037            .iter()
3038            .map(|solid| solid.name.as_str())
3039            .collect();
3040        assert!(names.contains(&"ACOMP1:IMPORT3D1"), "mid's own body: {names:?}");
3041        assert!(
3042            names.contains(&"ACOMP1:ACOMP1:IMPORT3D1"),
3043            "the stud, one level deeper: {names:?}"
3044        );
3045
3046        // And it lands where the flat lane puts it. Read the nested geometry
3047        // BEFORE the flat import: the kernel parts library is a thread-local the
3048        // next `EngineState` clears and refills.
3049        let nested_geometry = world_placed(&mut nested);
3050        let mut flat = EngineState::new();
3051        flat.pending_step_assembly = Some(assembly());
3052        flat.import_probed_step_assembly("interior", StepAssemblyImport::default(), &mut EmbeddedOnly)
3053            .expect("flat import");
3054        assert_eq!(
3055            placed_bboxes(&nested),
3056            placed_bboxes(&flat),
3057            "interior geometry must sit where the flat lane's composed pose puts it"
3058        );
3059        assert_placed_eq(
3060            &nested_geometry,
3061            &world_placed(&mut flat),
3062            1.0e-9,
3063            "interior node, nested vs flat",
3064        );
3065    }
3066
3067    /// A non-rigid edge into a SUB-ASSEMBLY has no nested representation: the
3068    /// factor would have to be pushed down through a whole document tree,
3069    /// rewriting every level's poses. It is skipped with an explanation rather
3070    /// than silently mis-handed, and the flat lane — which composes the pose and
3071    /// bakes it into the leaf part — is where that file belongs. No fixture in
3072    /// the corpus carries one, so the occurrence is synthetic.
3073    #[test]
3074    fn a_mirrored_sub_assembly_is_reported_not_silently_mis_handed() {
3075        let bodies = brep_kernel::import_step(&box_step(4.0, 3.0, 2.0)).expect("box imports");
3076        let mirror = [
3077            -1.0, 0.0, 0.0, 30.0, //
3078            0.0, 1.0, 0.0, 0.0, //
3079            0.0, 0.0, 1.0, 0.0, //
3080            0.0, 0.0, 0.0, 1.0,
3081        ];
3082        // root → subasm (MIRRORED) → widget, plus a plain leaf under the root so
3083        // the import still lands rather than degenerating to "nothing built".
3084        let assembly = || brep_kernel::StepAssembly {
3085            products: vec![
3086                brep_kernel::StepProduct {
3087                    pd_ref: 1,
3088                    name: "root".into(),
3089                    id: "root".into(),
3090                    bodies: Vec::new(),
3091                    appearances: Vec::new(),
3092                    failed_bodies: 0,
3093                },
3094                brep_kernel::StepProduct {
3095                    pd_ref: 2,
3096                    name: "subasm".into(),
3097                    id: "subasm".into(),
3098                    bodies: Vec::new(),
3099                    appearances: Vec::new(),
3100                    failed_bodies: 0,
3101                },
3102                brep_kernel::StepProduct {
3103                    pd_ref: 3,
3104                    name: "widget".into(),
3105                    id: "widget".into(),
3106                    bodies: bodies.clone(),
3107                    appearances: Vec::new(),
3108                    failed_bodies: 0,
3109                },
3110            ],
3111            occurrences: vec![
3112                brep_kernel::StepOccurrence {
3113                    nauo_ref: 10,
3114                    parent: 0,
3115                    child: 1,
3116                    designator: "sub".into(),
3117                    placement: mirror,
3118                    rigid: false,
3119                },
3120                brep_kernel::StepOccurrence {
3121                    nauo_ref: 11,
3122                    parent: 0,
3123                    child: 2,
3124                    designator: "loose".into(),
3125                    placement: MAT4_IDENTITY,
3126                    rigid: true,
3127                },
3128                brep_kernel::StepOccurrence {
3129                    nauo_ref: 12,
3130                    parent: 1,
3131                    child: 2,
3132                    designator: "inner".into(),
3133                    placement: MAT4_IDENTITY,
3134                    rigid: true,
3135                },
3136            ],
3137            roots: vec![0],
3138            first_error: None,
3139        };
3140
3141        let mut state = EngineState::new();
3142        state.pending_step_assembly = Some(assembly());
3143        let report = state
3144            .import_probed_step_assembly("mirror-sub", NESTED, &mut EmbeddedOnly)
3145            .expect("the rest of the file still imports");
3146        assert_eq!(report.instances, 1, "only the plain leaf lands");
3147        assert_eq!(report.baked_nonrigid, 0, "a sub-assembly is never baked");
3148        assert!(
3149            report
3150                .first_error
3151                .as_deref()
3152                .is_some_and(|error| error.contains("import flat instead")),
3153            "the user is told what to do instead: {:?}",
3154            report.first_error
3155        );
3156
3157        // The FLAT lane handles it: the mirror composes onto the leaf and bakes.
3158        let mut flat = EngineState::new();
3159        flat.pending_step_assembly = Some(assembly());
3160        let flat_report = flat
3161            .import_probed_step_assembly("mirror-sub", StepAssemblyImport::default(), &mut EmbeddedOnly)
3162            .expect("flat");
3163        assert_eq!(
3164            (flat_report.instances, flat_report.baked_nonrigid),
3165            (2, 1),
3166            "flat places both and bakes the mirrored one"
3167        );
3168    }
3169
3170    /// The nested lane places the same geometry the flat lane does — read as
3171    /// exact `f64` volume / centroid / bbox off the restored part geometry under
3172    /// the kernel's own component poses, not off a tessellation. Three levels of
3173    /// baked snapshots and pose round trips have to agree with one composed
3174    /// world transform.
3175    #[test]
3176    fn nested_import_matches_the_flat_lane_geometry() {
3177        let text = step_fixture("as1-ug-214.stp");
3178
3179        let mut flat = EngineState::new();
3180        flat.import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
3181            .expect("flat import");
3182        let flat_geometry = world_placed(&mut flat);
3183
3184        let mut nested = EngineState::new();
3185        nested
3186            .import_step_assembly(&text, "as1-ug", NESTED)
3187            .expect("nested import");
3188        let nested_geometry = world_placed(&mut nested);
3189
3190        assert_eq!(flat_geometry.len(), 18, "as1-ug-214 places 18 bodies");
3191        assert_placed_eq(&nested_geometry, &flat_geometry, 1.0e-9, "nested vs flat");
3192        // The displayed scene agrees too — the same assertion the flat lane's
3193        // own oracle test makes, from the side the user sees.
3194        assert_eq!(placed_bboxes(&nested), placed_bboxes(&flat));
3195    }
3196
3197    /// A nested sub-assembly heals like any other part: its entry's snapshot is
3198    /// a decode + re-encode of a document that is itself ACOMPs over native
3199    /// leaves, so a cleared cache must reproduce the insert's bytes and a second
3200    /// heal must reproduce the first.
3201    #[test]
3202    fn nested_part_documents_heal_and_converge() {
3203        let text = step_fixture("as1-ug-214.stp");
3204        let mut state = EngineState::new();
3205        state
3206            .import_step_assembly(&text, "as1-ug", NESTED)
3207            .expect("nested import");
3208        let inserted = library();
3209
3210        let heal_once = |state: &mut EngineState| {
3211            let mut document: serde_json::Value =
3212                serde_json::from_str(&state.history_request_json()).expect("document JSON");
3213            for (_, entry) in document["partsLibrary"].as_object_mut().unwrap().iter_mut() {
3214                entry["snapshot"] = serde_json::Value::String(String::new());
3215            }
3216            state.set_history_json(&document.to_string()).expect("reopen");
3217            library()
3218        };
3219        let first = heal_once(&mut state);
3220        let second = heal_once(&mut state);
3221        assert_eq!(first, second, "a second heal reproduces the first");
3222        assert_eq!(
3223            first, inserted,
3224            "a heal of a SUB-ASSEMBLY entry reproduces what the insert stored — \
3225             the inner entries carry no snapshot, so this is the whole recursive \
3226             re-execution converging"
3227        );
3228        for (name, entry) in &first {
3229            assert!(
3230                !entry["snapshot"].as_str().unwrap_or_default().is_empty(),
3231                "'{name}' healed its cleared snapshot"
3232            );
3233        }
3234    }
3235
3236    /// The recursion needs its OWN guard: `read_step_assembly` guards cycles
3237    /// inside its walk, but a document builder that recurses per level would
3238    /// blow the native stack on a malformed file long before the walk ever
3239    /// noticed. Both shapes must degrade to a clean result, never a crash.
3240    #[test]
3241    fn nested_import_guards_cycles_and_depth() {
3242        let bodies = brep_kernel::import_step(&box_step(2.0, 2.0, 2.0)).expect("box imports");
3243        let shift = |x: f64| {
3244            [
3245                1.0, 0.0, 0.0, x, //
3246                0.0, 1.0, 0.0, 0.0, //
3247                0.0, 0.0, 1.0, 0.0, //
3248                0.0, 0.0, 0.0, 1.0,
3249            ]
3250        };
3251        // A chain `root -> n1 -> n2 -> ... -> n{levels}`, the last link carrying
3252        // the geometry, plus an optional back-edge from the tail to `n1`.
3253        let chain = |levels: usize, cycle: bool| {
3254            let mut products: Vec<brep_kernel::StepProduct> = (0..=levels)
3255                .map(|index| brep_kernel::StepProduct {
3256                    pd_ref: index + 1,
3257                    name: format!("n{index}"),
3258                    id: format!("n{index}"),
3259                    bodies: (index == levels).then(|| bodies.clone()).unwrap_or_default(),
3260                    appearances: Vec::new(),
3261                    failed_bodies: 0,
3262                })
3263                .collect();
3264            products[0].name = "root".into();
3265            let mut occurrences: Vec<brep_kernel::StepOccurrence> = (0..levels)
3266                .map(|index| brep_kernel::StepOccurrence {
3267                    nauo_ref: 100 + index,
3268                    parent: index,
3269                    child: index + 1,
3270                    designator: format!("link{index}"),
3271                    placement: shift(1.0),
3272                    rigid: true,
3273                })
3274                .collect();
3275            if cycle {
3276                occurrences.push(brep_kernel::StepOccurrence {
3277                    nauo_ref: 90,
3278                    parent: levels,
3279                    child: 1,
3280                    designator: "back".into(),
3281                    placement: shift(1.0),
3282                    rigid: true,
3283                });
3284            }
3285            brep_kernel::StepAssembly {
3286                products,
3287                occurrences,
3288                roots: vec![0],
3289                first_error: None,
3290            }
3291        };
3292
3293        // A CYCLE: the back-edge is skipped and said so, and the import lands.
3294        let mut state = EngineState::new();
3295        state.pending_step_assembly = Some(chain(3, true));
3296        let report = state
3297            .import_probed_step_assembly("cyclic", NESTED, &mut EmbeddedOnly)
3298            .expect("a cyclic structure still imports what it can");
3299        assert_eq!(report.instances, 1, "the root places its one child");
3300        assert!(
3301            report
3302                .first_error
3303                .as_deref()
3304                .is_some_and(|error| error.contains("cycle")),
3305            "the skipped back-edge is reported: {:?}",
3306            report.first_error
3307        );
3308        assert!(!state.scene.solids().is_empty(), "the geometry still arrives");
3309
3310        // PATHOLOGICALLY DEEP: refused cleanly, no stack overflow, and the flat
3311        // lane (which composes rather than embeds) still handles it.
3312        let mut state = EngineState::new();
3313        state.pending_step_assembly = Some(chain(MAX_NESTED_DEPTH + 40, false));
3314        let error = state
3315            .import_probed_step_assembly("deep", NESTED, &mut EmbeddedOnly)
3316            .expect_err("a 100-level nesting has no usable document");
3317        assert!(
3318            error.contains("no part of the assembly could be built"),
3319            "the dialog's cue to fall back to the flat import: {error}"
3320        );
3321        let mut state = EngineState::new();
3322        state.pending_step_assembly = Some(chain(MAX_NESTED_DEPTH + 40, false));
3323        assert!(
3324            state
3325                .import_probed_step_assembly("deep", StepAssemblyImport::default(), &mut EmbeddedOnly)
3326                .is_ok(),
3327            "the FLAT lane composes instead of embedding, so depth costs it nothing"
3328        );
3329    }
3330
3331    /// The batch mutation on its own: N features, ONE rebuild, ONE undo step;
3332    /// an empty batch is a no-op that neither runs nor checkpoints.
3333    #[test]
3334    fn add_features_appends_a_batch_in_one_rebuild() {
3335        let mut state = EngineState::new();
3336        state.set_history_json(&cube_history("Box", 4.0)).unwrap();
3337        let before = state.applied_generation();
3338
3339        state.add_features(&[]);
3340        assert_eq!(
3341            (state.applied_generation(), state.history_len()),
3342            (before, 1),
3343            "an empty batch neither re-runs nor appends"
3344        );
3345
3346        let features: Vec<serde_json::Value> = (0..3)
3347            .map(|index| {
3348                serde_json::json!({
3349                    "type": "P.CU",
3350                    "inputParams": {
3351                        "id": format!("Cube{index}"),
3352                        "sizeX": 2.0, "sizeY": 2.0, "sizeZ": 2.0,
3353                        "transform": {
3354                            "position": [10.0 * index as f64, 0.0, 0.0],
3355                            "rotationEuler": [0.0, 0.0, 0.0],
3356                            "scale": [1.0, 1.0, 1.0]
3357                        },
3358                        "boolean": { "targets": [], "operation": "NONE" }
3359                    },
3360                    "persistentData": {}
3361                })
3362            })
3363            .collect();
3364        state.add_features(&features);
3365
3366        assert_eq!(state.history_len(), 4, "all three appended");
3367        assert_eq!(
3368            state.applied_generation(),
3369            before + 1,
3370            "ONE rebuild for the batch"
3371        );
3372        assert_eq!(state.scene.solids().len(), 4);
3373        state.undo();
3374        assert_eq!(state.history_len(), 1, "the batch undoes in ONE step");
3375    }
3376}
3377
3378// ===========================================================================
3379// Feature dimensions (FD-1) — the ◎ DIMENSION-gizmo mode.
3380//
3381// When a primitive-solid feature is armed in DIMENSION mode (the ◎'s second
3382// cycle state), its key numeric params render as draggable dimension
3383// annotations: a leader from world `pointA → pointB` whose length is the param
3384// value, editing `fieldKey`. The geometry lives in `crate::feature_dimensions`
3385// (ported from the previous feature-dimension annotation builder); THIS block owns the
3386// engine surface: reporting the annotations (JSON + the `feature-dim-leaders`
3387// overlay), dragging a handle (project the pointer onto the `a → b` world axis →
3388// new param value), and value-editing a label (numeric literal OR a live
3389// expression via the kernel `eval_expression`). Every mutator re-runs the
3390// history (the model updates live) and re-projects the leaders. Kept in ONE
3391// appended block so concurrent edits to the primary impl land clean.
3392// ===========================================================================