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