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        // The file's AP242 PMI, lifted ONCE into the document's pmi block with
91        // references naming the faces / edges / vertices the feature will stamp
92        // (the STEP text is never re-read for it). Read BEFORE the add so the
93        // add's undo checkpoint precedes both writes: one undo removes the
94        // feature and its PMI together.
95        let lifted = brep_kernel::read_step_pmi(step_text, &id).unwrap_or(None);
96        // Frame the imported body once the (possibly async) run lands — see
97        // [`EngineState::pending_fit`]. An immediate fit here would frame the still
98        // empty scene under a background runner (native thread / wasm worker).
99        self.pending_fit = true;
100        let report = self.add_feature(&feature.to_string());
101        if let Some(lifted) = lifted {
102            self.pmi_merge_imported(lifted);
103        }
104        report
105    }
106
107    /// Export the CURRENT model's resident solids to an ISO-10303-21 STEP
108    /// document. Collects the resident handles of the rolled-to model (a warm
109    /// re-run of the same prefix the display scene was built from — see
110    /// [`crate::pipeline::resident_solid_handles`]) and hands them to the kernel's
111    /// [`brep_kernel::export_step_handles`], so the exact NURBS topology is
112    /// serialized (never the display mesh). Errs clearly when the model is empty.
113    pub fn export_step_text(&mut self) -> Result<String, String> {
114        self.export_step_text_named("Part")
115    }
116
117    /// [`Self::export_step_text`] with the document's own name, which becomes
118    /// the root `PRODUCT`'s name (and the file's `FILE_NAME`).
119    ///
120    /// A document with ASSEMBLY COMPONENTS takes the STRUCTURED lane: each
121    /// parts-library entry is written once as its own product, in its own local
122    /// frame, and every instance becomes a `NEXT_ASSEMBLY_USAGE_OCCURRENCE`
123    /// carrying its pose — nested sub-assemblies included. Without components
124    /// the flat single-product writer is used, exactly as before.
125    pub fn export_step_text_named(&mut self, document_name: &str) -> Result<String, String> {
126        // BEFORE the resident re-run below: the sync executes the history too,
127        // and it is the live component projection (post-solve poses) that the
128        // structured lane places instances by.
129        self.ensure_assembly_synced();
130        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
131            .map_err(|e| format!("export STEP: history request: {e}"))?;
132        let named = crate::pipeline::resident_solid_handles(&request);
133        if named.is_empty() {
134            return Err("nothing to export: the model has no solids".into());
135        }
136        // The document's PMI rides along as AP242 semantic PMI + saved views,
137        // resolved against the same resident solids the file is written from
138        // (the warm re-run above left the tail's report on this thread).
139        let report = request
140            .pmi
141            .as_ref()
142            .map(|_| brep_kernel::execute_history(&request).pmi)
143            .flatten();
144        let pmi = match (request.pmi.as_ref(), report.as_ref()) {
145            (Some(state), Some(report)) => Some(brep_kernel::StepPmi { state, report }),
146            _ => None,
147        };
148        if self.assembly_components.is_empty() {
149            return brep_kernel::export_step_named_handles(
150                &named,
151                document_name,
152                "MM",
153                "",
154                pmi.as_ref(),
155            )
156            .map(|report| report.text);
157        }
158        let components: Vec<(String, String, brep_kernel::Mat4)> = self
159            .assembly_components
160            .iter()
161            .map(|record| {
162                (
163                    record.id.clone(),
164                    record.part_name.clone(),
165                    record.transform.elements,
166                )
167            })
168            .collect();
169        brep_kernel::export_step_assembly_handles(
170            document_name,
171            &named,
172            &components,
173            "MM",
174            "",
175            pmi.as_ref(),
176        )
177        .map(|report| report.text)
178    }
179
180    /// Resident handle of the part's target sheet-metal body for a flat-pattern
181    /// export. Enumerates the current resident solids (a warm re-run of the same
182    /// prefix the display scene was built from, like the STEP lane) and keeps the
183    /// ones carrying a sheet-metal tree; uses the SELECTED sheet-metal body if the
184    /// selection names exactly one, else the SOLE sheet-metal body (the same
185    /// auto-target SM.CUTOUT uses). Errs with the exact `"no sheet-metal body in
186    /// the part"` when there is none, and loudly when several are ambiguous.
187    fn flat_pattern_target_handle(&self) -> Result<u32, String> {
188        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
189            .map_err(|e| format!("export flat pattern: history request: {e}"))?;
190        let sheet_metal: Vec<(String, u32)> = crate::pipeline::resident_solid_handles(&request)
191            .into_iter()
192            .filter(|(_, handle)| brep_kernel::is_sheet_metal_handle(*handle))
193            .collect();
194        if sheet_metal.is_empty() {
195            return Err("no sheet-metal body in the part".into());
196        }
197        // Prefer a selected sheet-metal body when the selection names exactly one.
198        let selected: Vec<u32> = sheet_metal
199            .iter()
200            .filter(|(name, _)| self.emphasis.selected_solids.contains(name))
201            .map(|(_, handle)| *handle)
202            .collect();
203        if let [handle] = selected.as_slice() {
204            return Ok(*handle);
205        }
206        match sheet_metal.as_slice() {
207            [(_, handle)] => Ok(*handle),
208            _ => Err(
209                "several sheet-metal bodies in the part — select the one to export".into(),
210            ),
211        }
212    }
213
214    /// Export the part's sheet-metal FLAT PATTERN (the unfold) as a DXF (R12
215    /// ASCII) 2D vector document. Runs the unfold TRANSIENTLY off the target
216    /// body's resident tree — no feature is added and history is not mutated. Errs
217    /// (`"no sheet-metal body in the part"`) when the part carries no sheet metal.
218    pub fn export_flat_pattern_dxf(&self) -> Result<String, String> {
219        brep_kernel::flat_pattern_dxf(self.flat_pattern_target_handle()?)
220    }
221
222    /// Export the part's sheet-metal flat pattern as an SVG — the DXF sibling of
223    /// [`Self::export_flat_pattern_dxf`].
224    pub fn export_flat_pattern_svg(&self) -> Result<String, String> {
225        brep_kernel::flat_pattern_svg(self.flat_pattern_target_handle()?)
226    }
227
228    /// Import an IGES document into the model: append an `IMPORT3D` feature whose
229    /// `inputParams.igesText` is the raw IGES text (the kernel importer reads it
230    /// via [`brep_kernel::import_iges`]), mint an id, roll to it, and rebuild.
231    /// Refuses a non-IGES payload up front so a bad upload never leaves a dead
232    /// feature.
233    pub fn import_iges_feature(&mut self, iges_text: &str) -> Result<String, String> {
234        if iges_text.contains("ISO-10303-21") {
235            return Err("not an IGES file (this looks like a STEP document)".into());
236        }
237        // IGES records carry an S/G/D/P/T section letter in column 73.
238        let looks_like_iges = iges_text.lines().any(|line| {
239            matches!(line.chars().nth(72), Some('S' | 'G' | 'D' | 'P' | 'T'))
240        });
241        if !looks_like_iges {
242            return Err("not an IGES file (no S/G/D/P/T section records found)".into());
243        }
244        let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
245        let feature = serde_json::json!({
246            "type": "IMPORT3D",
247            "inputParams": { "id": id, "igesText": iges_text },
248            "persistentData": {},
249        });
250        // Frame the imported body once the (possibly async) run lands — see
251        // [`EngineState::pending_fit`] (mirrors the STEP lane above).
252        self.pending_fit = true;
253        self.add_feature(&feature.to_string())
254    }
255
256    /// Export the CURRENT model's resident solids to an IGES 5.3 document of
257    /// trimmed NURBS surfaces — the IGES analogue of [`Self::export_step_text`],
258    /// handing the resident handles to [`brep_kernel::export_iges_handles`].
259    pub fn export_iges_text(&self) -> Result<String, String> {
260        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
261            .map_err(|e| format!("export IGES: history request: {e}"))?;
262        let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
263            .into_iter()
264            .map(|(_, handle)| handle)
265            .collect();
266        if handles.is_empty() {
267            return Err("nothing to export: the model has no solids".into());
268        }
269        brep_kernel::export_iges_handles(&handles, "Part", "MM", "")
270    }
271
272    /// Export the CURRENT display scene to an ASCII STL string (one `solid` with a
273    /// per-triangle geometric normal for every mesh triangle of every displayed
274    /// solid). STL is a triangle-soup format with no multi-body concept, so all
275    /// solids fold into a single `solid brep … endsolid brep`. String-shaped so it
276    /// crosses the same string `ModelStore` seam the STEP lane uses. Errs when the
277    /// scene has no triangles.
278    pub fn export_stl_text(&self) -> Result<String, String> {
279        let mut out = String::from("solid brep\n");
280        let mut triangles = 0usize;
281        for solid in self.scene.solids() {
282            let positions = &solid.mesh.positions;
283            for tri in solid.mesh.indices.chunks_exact(3) {
284                let a = positions[tri[0] as usize];
285                let b = positions[tri[1] as usize];
286                let c = positions[tri[2] as usize];
287                let normal = triangle_normal(a, b, c);
288                out.push_str(&format!(
289                    "  facet normal {} {} {}\n    outer loop\n",
290                    normal[0], normal[1], normal[2]
291                ));
292                for v in [a, b, c] {
293                    out.push_str(&format!("      vertex {} {} {}\n", v[0], v[1], v[2]));
294                }
295                out.push_str("    endloop\n  endfacet\n");
296                triangles += 1;
297            }
298        }
299        out.push_str("endsolid brep\n");
300        if triangles == 0 {
301            return Err("nothing to export: the scene has no triangles".into());
302        }
303        Ok(out)
304    }
305}
306
307/// Unit (or zero, for a degenerate triangle) geometric normal of triangle
308/// `(a, b, c)` — the per-facet normal an ASCII STL record carries.
309fn triangle_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
310    let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
311    let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
312    let n = [
313        u[1] * v[2] - u[2] * v[1],
314        u[2] * v[0] - u[0] * v[2],
315        u[0] * v[1] - u[1] * v[0],
316    ];
317    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
318    if len > 0.0 {
319        [n[0] / len, n[1] / len, n[2] / len]
320    } else {
321        [0.0, 0.0, 0.0]
322    }
323}
324
325// ===========================================================================
326// STRUCTURED STEP import — the assembly lane (kernel-plan
327// `step-assembly-import.md` §3.7).
328//
329// The flat lane above (`import_step_feature`) appends ONE IMPORT3D holding the
330// raw Part-21 text and lets the kernel bake every occurrence's world transform
331// into its own body: N bodies, no parts, no tree. This lane keeps the structure
332// instead — each unique geometry-bearing PRODUCT_DEFINITION becomes ONE
333// parts-library entry holding a NATIVE payload (`nativeBrep`, no STEP text
334// anywhere past this door), and each occurrence of it becomes an ACOMP instance
335// carrying the composed world pose. Six bolts are then one entry × six
336// instances, which is what makes the BOM, the structure tree, per-component
337// selection and constraints work on imported geometry.
338//
339// # FLAT or NESTED — the user's choice, both correct
340//
341// [`StepAssemblyImport::nested`] picks between two shapes of the same geometry
342// (kernel-plan §3.3):
343//
344// - **Flat** flattens the occurrence tree to its geometry-bearing leaves: one
345//   ACOMP per leaf occurrence, each carrying the COMPOSED world pose. Every
346//   part is stored once for the whole document.
347// - **Nested** keeps the tree: each assembly-node product becomes a part
348//   document that itself carries `{partsLibrary, features: [ACOMP…,
349//   IMPORT3D…]}`, built bottom-up by the same recursive builder, and the
350//   parent gets ONE ACOMP per sub-assembly occurrence. Build-spec §2.2's
351//   rigid nesting — the sub-assembly arrives already-solved and moves as one
352//   component, the live `ComponentMap` stays flat, and the structure tree
353//   expands it read-only from the namespace chain (`ACOMP2:ACOMP1:…`).
354//
355// Neither is the deprecated one. Nested shows the real tree; flat is the right
356// answer for a deep or pathological file, and it stores a part reused at two
357// levels ONCE, where nesting stores it once PER LEVEL (build-spec §2.2). For a
358// depth-1 tree the two lanes produce byte-identical documents — the cheapest
359// correctness check there is, and `nested_matches_flat_for_a_depth_one_tree`
360// asserts exactly it.
361//
362// # PROBE then CONSUME — because the parse is the expensive half
363//
364// The app must know the counts BEFORE it can offer the choice ("7 parts, 23
365// instances — import as assembly or as bodies?"), and re-reading multi-MB
366// Part-21 text after the user clicks would pay the file's single most expensive
367// cost twice. So [`EngineState::probe_step_assembly`] performs the ONE parse and
368// stashes the [`brep_kernel::StepAssembly`] in
369// [`EngineState::pending_step_assembly`];
370// [`EngineState::import_probed_step_assembly`] TAKES it. Cancel
371// ([`EngineState::discard_probed_step_assembly`]), a second probe, and a
372// document switch all drop it, so a user who cancels three imports is holding
373// zero parsed assemblies — a real consideration, since the stash keeps every
374// product's solids resident for as long as the dialog is open.
375//
376// # ONE rebuild for the whole import
377//
378// `add_feature` re-runs the entire history per call, so appending N instances
379// through it is O(N²). This lane appends them all through
380// [`EngineState::add_features`] — one push batch, one rebuild, one undo step.
381// (Not `set_history_json`: that is the document-SWITCH path, which clears the
382// kernel history cache and resets the runner's delta baseline.)
383//
384// # Fallback, never a silent zero
385//
386// No structure at all, or every geometry-bearing product failing to encode, both
387// end at today's flat lane. "A successful import that produces zero components"
388// is a failure wearing a result's clothes, so the zero-component case is an
389// `Err` for the dialog-driven entry point (the app owns the file text and re-runs
390// the flat import) and an automatic fall-back for the text-taking convenience.
391// ===========================================================================
392
393/// What the import dialog needs to describe a STEP file's structure — counts
394/// only, so the probe can answer without building anything.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub struct StepAssemblyProbe {
397    /// Unique geometry-bearing products → parts-library entries. The floor, not
398    /// the final count: a non-rigid occurrence bakes its own extra entry (§3.4).
399    pub parts: usize,
400    /// Geometry-bearing occurrences → ACOMP instance features.
401    pub instances: usize,
402    /// Longest root→node chain of occurrences. `1` is a flat assembly; `> 1`
403    /// means sub-assemblies exist, so [`StepAssemblyImport::nested`] changes
404    /// the shape of the result and the dialog's choice is worth offering.
405    pub nested_depth: usize,
406}
407
408/// The choices the import dialog collects.
409#[derive(Debug, Clone, Copy, Default)]
410pub struct StepAssemblyImport {
411    /// Build nested rigid sub-assembly documents (kernel-plan §3.3 Phase 2)
412    /// instead of flattening the tree to its leaf occurrences.
413    ///
414    /// `false` (the `Default`) is the flat lane, byte-for-byte unchanged. On a
415    /// depth-1 tree the two produce the same document, so this flag only ever
416    /// matters for a file that really has sub-assemblies.
417    pub nested: bool,
418}
419
420/// What an import did — the numbers the status line and notice report.
421#[derive(Debug, Clone, Default, PartialEq, Eq)]
422pub struct StepAssemblyReport {
423    /// Parts-library entries this import added or reused — the entries of the
424    /// USER'S document. On a nested import that is the top level only: a
425    /// sub-assembly's own entries live in ITS document's library, which the
426    /// parent never sees.
427    pub parts: usize,
428    /// ACOMP instance features appended to the user's document. Nested: one per
429    /// ROOT-level occurrence (a sub-assembly is one component, per build-spec
430    /// §2.2), not one per leaf body.
431    pub instances: usize,
432    /// Occurrences whose non-rigid factor was baked into a distinct part
433    /// (§3.4), summed over every level a nested import built.
434    pub baked_nonrigid: usize,
435    /// Products (or baked non-rigid variants of one) that did not encode to a
436    /// payload — skipped and counted, never fatal: the importer's
437    /// graceful-degradation contract, carried up to this altitude. Summed over
438    /// every level a nested import built.
439    pub failed_products: usize,
440    /// The first thing that went wrong, from the kernel's body-build errors or
441    /// this lane's own encode failures.
442    pub first_error: Option<String>,
443    /// The structured lane did not run: the file carries no usable structure, or
444    /// nothing in it encoded, so the bodies were imported through the flat lane
445    /// exactly as before. Only ever `true` from [`EngineState::import_step_assembly`],
446    /// which holds the text; the dialog-driven entry point returns `Err` instead
447    /// and lets its caller re-run the flat import it already has the text for.
448    pub flat_fallback: bool,
449}
450
451/// The outcome of consuming a parsed assembly, before it is shaped into either
452/// an `Err` (dialog lane) or a flat fallback (text lane) — so neither has to
453/// recognise "nothing imported" by matching an error string.
454enum Consumed {
455    Imported(StepAssemblyReport),
456    /// Every geometry-bearing product failed to encode: no components, so this
457    /// is not an import.
458    NoComponents {
459        failed_products: usize,
460        first_error: Option<String>,
461    },
462}
463
464/// A row-major 4×4 affine, the shape `StepOccurrence::placement` and
465/// `AffineTransform` both use.
466type Mat4 = [f64; 16];
467
468/// One node of the composed occurrence tree — a product at a world pose.
469struct PlacedProduct {
470    /// Index into `StepAssembly::products`.
471    product: usize,
472    /// Composed child-local → world transform.
473    world: Mat4,
474    /// Occurrence edges between a root and this node (`0` at a root).
475    depth: usize,
476    /// Every edge on the path here was rigid, so `world` IS a component pose.
477    /// False means the non-rigid factor must be baked into the part (§3.4).
478    rigid_path: bool,
479}
480
481/// A parts-library entry this import needs: a product, plus the bits of the
482/// non-rigid factor baked into it (all-zero linear block ⇒ none). Two
483/// occurrences of one product under DIFFERENT non-rigid factors are different
484/// parts — never a wrong-handed reuse.
485type PartKey = (usize, [u64; 9]);
486
487/// The `PartKey` factor slot for a plain rigid instance.
488const NO_FACTOR: [u64; 9] = [0; 9];
489
490impl EngineState {
491    /// Read a STEP file's product structure — THE parse of a structured import.
492    /// Stashes the parsed assembly (with every product's solids) for
493    /// [`Self::import_probed_step_assembly`] and returns the dialog's counts.
494    ///
495    /// `Ok(None)` = no usable structure (no NAUO edges, or none reaching built
496    /// geometry): the caller imports through the flat
497    /// [`Self::import_step_feature`] lane with the text it already holds, which
498    /// is byte-for-byte today's behaviour. `Err` only for text that is not a
499    /// Part 21 file at all — a BROKEN assembly degrades, it does not fail.
500    ///
501    /// Replaces any previously stashed assembly on EVERY outcome, `Ok(None)`
502    /// included: a stale stash surviving a probe of a different file is how a
503    /// consume silently imports the wrong one.
504    pub fn probe_step_assembly(
505        &mut self,
506        step_text: &str,
507    ) -> Result<Option<StepAssemblyProbe>, String> {
508        let id = self.submit_step_probe(step_text);
509        // Inline answers inside `submit`'s own pump; a background runner has
510        // not answered yet and this call must not pretend it has.
511        match self.take_step_probe() {
512            Some((answered, outcome)) if answered == id => match outcome {
513                super::StepProbeOutcome::Structure(probe) => Ok(Some(probe)),
514                super::StepProbeOutcome::Flat => Ok(None),
515                super::StepProbeOutcome::Failed(error) => Err(error),
516            },
517            _ => Err(
518                "the STEP probe is still running on the background runner — use \
519                 submit_step_probe / take_step_probe"
520                    .into(),
521            ),
522        }
523    }
524
525    /// SUBMIT a STEP text to be probed for product structure on the runner
526    /// (the parse builds every product's bodies: seconds for a real assembly,
527    /// which is why it leaves the UI thread). The answer arrives through
528    /// [`Self::take_step_probe`] under the returned id, after a later `pump`;
529    /// a found structure is stashed for [`Self::import_probed_step_assembly`].
530    /// Any earlier stash is dropped now — the probe REPLACES it on every
531    /// outcome, so a stale parse can never be consumed for the wrong file.
532    ///
533    /// The structure test the parse would make is "any NEXT_ASSEMBLY_USAGE_
534    /// OCCURRENCE entity" (`assembly_edges`), so a text with none cannot have
535    /// structure and is answered `Flat` without a trip to the runner: a part
536    /// file — the common upload — no longer pays a full parse only to be told
537    /// to take the flat lane, where the worker parses it anyway. (The text
538    /// test is a superset of the entity test: a stray mention in a comment
539    /// merely runs the parse.)
540    pub fn submit_step_probe(&mut self, step_text: &str) -> u64 {
541        self.pending_step_assembly = None;
542        let id = self.next_step_probe_id;
543        self.next_step_probe_id = self.next_step_probe_id.wrapping_add(1);
544        if !step_text.contains("NEXT_ASSEMBLY_USAGE_OCCURRENCE") {
545            self.step_probe_results
546                .push_back((id, super::StepProbeOutcome::Flat));
547            return id;
548        }
549        self.pending_step_probes.insert(id);
550        self.runner.submit_step_probe(crate::runner::StepProbeRequest {
551            id,
552            text: step_text.to_string(),
553        });
554        self.pump();
555        id
556    }
557
558    /// The oldest answered probe, if any: its submission id and what it found.
559    pub fn take_step_probe(&mut self) -> Option<(u64, super::StepProbeOutcome)> {
560        self.step_probe_results.pop_front()
561    }
562
563    /// Whether a submitted probe has not been answered yet — the app keeps the
564    /// frame loop alive (and the panel its "reading…" status) while it is.
565    pub fn step_probes_pending(&self) -> bool {
566        !self.pending_step_probes.is_empty()
567    }
568
569    /// Import the assembly [`Self::probe_step_assembly`] stashed: one
570    /// parts-library entry per unique product, one ACOMP instance per
571    /// occurrence, ONE rebuild. TAKES the stash, so a double-import is an error
572    /// rather than a double-insert.
573    ///
574    /// `doc_name` names products the file left unnamed (`{doc_name}-part-{id}`).
575    /// `opts.nested` chooses between the flat and nested shapes — see
576    /// [`StepAssemblyImport::nested`]. Errs when nothing is stashed, and when
577    /// every product failed to encode — the latter being the caller's cue to
578    /// re-run the flat import with the file text it holds.
579    /// `sink` receives every unique part document so the app can write it to
580    /// the model store and hand back a real `sourceKey`; pass [`EmbeddedOnly`]
581    /// to keep the parts embedded (what a caller with no store does).
582    pub fn import_probed_step_assembly(
583        &mut self,
584        doc_name: &str,
585        opts: StepAssemblyImport,
586        sink: &mut dyn PartSink,
587    ) -> Result<StepAssemblyReport, String> {
588        let assembly = self.pending_step_assembly.take().ok_or_else(|| {
589            "import STEP assembly: nothing probed (call probe_step_assembly first)".to_string()
590        })?;
591        match self.consume_step_assembly(assembly, doc_name, opts.nested, sink) {
592            Consumed::Imported(report) => Ok(report),
593            Consumed::NoComponents { first_error, .. } => Err(format!(
594                "import STEP assembly: no part of the assembly could be built{}",
595                first_error
596                    .map(|error| format!(" ({error})"))
597                    .unwrap_or_default()
598            )),
599        }
600    }
601
602    /// Drop a probed assembly and the solids it holds resident — the dialog's
603    /// Cancel. Idempotent.
604    pub fn discard_probed_step_assembly(&mut self) {
605        self.pending_step_assembly = None;
606    }
607
608    /// Probe + consume in one call, falling back to the flat lane by itself —
609    /// the HEADLESS/test entry point. The app uses the probe/consume pair
610    /// instead, because it has a dialog between the two halves.
611    ///
612    /// Still exactly one parse: this is `probe_step_assembly` followed by the
613    /// consume of what it stashed.
614    ///
615    /// Parts stay EMBEDDED here ([`EmbeddedOnly`]): this entry point has no
616    /// store handle and no way to ask for a destination. The app uses the
617    /// probe/consume pair with a real sink.
618    pub fn import_step_assembly(
619        &mut self,
620        step_text: &str,
621        doc_name: &str,
622        opts: StepAssemblyImport,
623    ) -> Result<StepAssemblyReport, String> {
624        let structured = self.probe_step_assembly(step_text)?.is_some();
625        let outcome = structured.then(|| {
626            let assembly = self
627                .pending_step_assembly
628                .take()
629                .expect("a Some probe stashed the assembly it counted");
630            self.consume_step_assembly(assembly, doc_name, opts.nested, &mut EmbeddedOnly)
631        });
632        match outcome {
633            Some(Consumed::Imported(report)) => Ok(report),
634            // No structure, or a structure nothing built out of: import the
635            // bodies exactly as the pre-assembly lane did.
636            Some(Consumed::NoComponents {
637                failed_products,
638                first_error,
639            }) => {
640                self.import_step_feature(step_text)?;
641                Ok(StepAssemblyReport {
642                    failed_products,
643                    first_error,
644                    flat_fallback: true,
645                    ..StepAssemblyReport::default()
646                })
647            }
648            None => {
649                self.import_step_feature(step_text)?;
650                Ok(StepAssemblyReport {
651                    flat_fallback: true,
652                    ..StepAssemblyReport::default()
653                })
654            }
655        }
656    }
657
658    /// The import itself (kernel-plan §3.7 steps 2-5), shared by both entry
659    /// points so neither has to recognise "nothing imported" from an error
660    /// string.
661    fn consume_step_assembly(
662        &mut self,
663        assembly: brep_kernel::StepAssembly,
664        doc_name: &str,
665        nested: bool,
666        sink: &mut dyn PartSink,
667    ) -> Consumed {
668        let mut first_error = assembly.first_error.clone();
669        // ONE writer for the whole import, so identical content is written to
670        // the store exactly once however many products or LEVELS share it.
671        let mut writer = PartWriter::new(sink);
672
673        // --- what to build ------------------------------------------------
674        // One row per component the USER'S document gets, each naming the
675        // library entry it needs. Flat walks the whole tree to its leaves;
676        // nested stops at the root's own children and folds everything below
677        // each of them into that child's part document.
678        let plan = if nested {
679            plan_nested(&assembly, doc_name, &mut first_error, &mut writer)
680        } else {
681            plan_flat(&assembly, &mut first_error)
682        };
683        let Plan {
684            wanted,
685            factors,
686            documents,
687            mut failed_products,
688            baked_below_root,
689        } = plan;
690
691        // --- build the library entries -------------------------------------
692        // In (pd_ref, factor) order so an import is deterministic regardless of
693        // the tree's emit order, and ONCE per key however many instances use it.
694        let mut keys: Vec<PartKey> = wanted.iter().map(|(key, _)| *key).collect();
695        keys.sort_unstable();
696        keys.dedup();
697        let mut entry_names: std::collections::HashMap<PartKey, String> =
698            std::collections::HashMap::new();
699        {
700            // THE metadata bracket. `native_import_payload` seals whatever record
701            // this thread's scene-metadata store holds for each name it stamps —
702            // right for a snapshot of the live scene, catastrophic here: a new
703            // part whose stamped face names collide with names already in THIS
704            // document would silently carry the current document's metadata.
705            // Scoped to the encode alone; the rebuild below stamps records the
706            // document must keep, and this guard's drop would discard them.
707            //
708            // The nested lane's payloads are encoded inside `plan_nested`,
709            // which holds a bracket of its own for exactly the same reason.
710            let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
711            for key in &keys {
712                // Nested pre-built the whole document (a sub-assembly's is a
713                // recursive `{partsLibrary, features}`); flat builds the §3.2
714                // native part document right here.
715                let built = match documents.get(key) {
716                    Some((name, document)) => install_part(name, document, &mut writer),
717                    None => {
718                        let product = assembly
719                            .products
720                            .iter()
721                            .find(|product| product.pd_ref == key.0)
722                            .expect("every key names a product of this assembly");
723                        build_library_entry(product, factors.get(key), doc_name, &mut writer)
724                    }
725                };
726                match built {
727                    Ok(name) => {
728                        entry_names.insert(*key, name);
729                    }
730                    Err(error) => {
731                        failed_products += 1;
732                        note(&mut first_error, error);
733                    }
734                }
735            }
736        }
737        if entry_names.is_empty() {
738            return Consumed::NoComponents {
739                failed_products,
740                first_error,
741            };
742        }
743
744        // --- append every instance in ONE history mutation -----------------
745        // `insert_component`'s rule, verbatim: ground the FIRST component only
746        // when the document has none yet. Grounding a second one over-constrains
747        // the next solve.
748        let mut ground_next = !(0..self.history.len()).any(|index| {
749            matches!(
750                self.history.feature_type(index).as_deref(),
751                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
752            )
753        });
754        let mut features: Vec<serde_json::Value> = Vec::with_capacity(wanted.len());
755        let mut baked_nonrigid = 0usize;
756        for (key, pose) in &wanted {
757            let Some(part_name) = entry_names.get(key) else {
758                continue; // this product failed to encode; counted above
759            };
760            let transform = match brep_kernel::AffineTransform::new(*pose) {
761                Ok(transform) => transform,
762                Err(error) => {
763                    note(&mut first_error, format!("occurrence pose: {error}"));
764                    continue;
765                }
766            };
767            if key.1 != NO_FACTOR {
768                baked_nonrigid += 1;
769            }
770            features.push(serde_json::json!({
771                "type": "ACOMP",
772                "inputParams": {
773                    "id": self.history.next_feature_id("ACOMP"),
774                    "partName": part_name,
775                    "transform": brep_kernel::transform_to_pose_params(&transform),
776                    "isFixed": ground_next,
777                },
778                "persistentData": {}
779            }));
780            ground_next = false;
781        }
782        if features.is_empty() {
783            return Consumed::NoComponents {
784                failed_products,
785                first_error,
786            };
787        }
788
789        // The library block must ride the request so the display runner ingests
790        // the new entries on the very next run (as `insert_component` does).
791        // Written only now that there are components to reference them, so an
792        // import that produced nothing leaves the document untouched.
793        if let Ok(library) =
794            serde_json::from_str::<serde_json::Value>(&brep_kernel::parts_library_json())
795        {
796            self.history.set_parts_library(library);
797        }
798        // Frame the assembly once the (possibly async) run lands — see
799        // [`EngineState::pending_fit`], same reasoning as `import_step_feature`.
800        self.pending_fit = true;
801        let instances = features.len();
802        let baked_nonrigid = baked_nonrigid + baked_below_root;
803        self.add_features(&features);
804        Consumed::Imported(StepAssemblyReport {
805            // DISTINCT entries, not distinct keys: `add_part_to_library` reuses
806            // an entry whose content already matches, so two products that are
807            // the same geometry collapse to one part (§3.5's free content dedup).
808            parts: entry_names
809                .values()
810                .collect::<std::collections::HashSet<_>>()
811                .len(),
812            instances,
813            baked_nonrigid,
814            failed_products,
815            first_error,
816            flat_fallback: false,
817        })
818    }
819}
820
821/// What one import decided to build, before any of it is installed: the rows
822/// the user's document gets, and whatever each lane needed to work out on the
823/// way there.
824#[derive(Default)]
825struct Plan {
826    /// One row per component of the USER'S document, in emit order.
827    wanted: Vec<(PartKey, Mat4)>,
828    /// FLAT only: the non-rigid factor a key's part must bake (§3.4). The
829    /// nested lane bakes inside its own builder and hands the finished document
830    /// over in `documents` instead.
831    factors: std::collections::HashMap<PartKey, Mat4>,
832    /// NESTED only: `(entry name, part document)` per key, already built — a
833    /// leaf's §3.2 native document, or a sub-assembly's recursive
834    /// `{partsLibrary, features}`.
835    documents: std::collections::HashMap<PartKey, (String, serde_json::Value)>,
836    /// Products that did not encode while planning (nested builds payloads
837    /// during the plan; flat builds them during the install).
838    failed_products: usize,
839    /// Non-rigid occurrences baked BELOW the root — nested only, since the flat
840    /// lane has no below-the-root and counts its bakes at install time.
841    baked_below_root: usize,
842}
843
844/// **FLAT** (kernel-plan §3.3 Phase 1): flatten the occurrence tree to its
845/// geometry-bearing nodes, each carrying the COMPOSED world pose. Byte-for-byte
846/// the lane A6 shipped.
847fn plan_flat(assembly: &brep_kernel::StepAssembly, first_error: &mut Option<String>) -> Plan {
848    let mut plan = Plan::default();
849    for placed in &compose_world_occurrences(assembly) {
850        let product = &assembly.products[placed.product];
851        if product.bodies.is_empty() {
852            continue; // a pure assembly node contributes structure, not a component
853        }
854        let (key, pose) = if placed.rigid_path {
855            ((product.pd_ref, NO_FACTOR), placed.world)
856        } else {
857            // §3.4: world = rigid · factor. Bake `factor` into a distinct part
858            // and give the instance the rigid residue, so a mirrored instance
859            // never lands on its unmirrored twin.
860            match split_rigid(&placed.world) {
861                // Non-rigid edges that cancel out along the path leave an
862                // identity factor: that is an ordinary instance of the ordinary
863                // part, not a bake.
864                Ok((rigid, factor)) if is_identity(&factor) => {
865                    ((product.pd_ref, NO_FACTOR), rigid)
866                }
867                Ok((rigid, factor)) => {
868                    let key = (product.pd_ref, factor_key(&factor));
869                    plan.factors.insert(key, factor);
870                    (key, rigid)
871                }
872                Err(error) => {
873                    note(first_error, error);
874                    continue;
875                }
876            }
877        };
878        plan.wanted.push((key, pose));
879    }
880    plan
881}
882
883/// **NESTED** (kernel-plan §3.3 Phase 2): the live document plays the ROOT, so
884/// it gets one component per root-level row and nothing deeper —
885///
886/// - a root's OWN bodies become a leaf part at identity (exactly the flat
887///   lane's treatment of interior geometry at the root), and
888/// - each root-child occurrence becomes ONE component: a leaf part when the
889///   child has no children of its own, else a rigid sub-assembly whose part
890///   document carries its own `partsLibrary` and its own ACOMPs.
891///
892/// Emit order matches [`plan_flat`]'s DFS pre-order — root before its children,
893/// children by ascending `nauo_ref` — which is what makes the two lanes produce
894/// the SAME document for a depth-1 tree.
895fn plan_nested(
896    assembly: &brep_kernel::StepAssembly,
897    doc_name: &str,
898    first_error: &mut Option<String>,
899    writer: &mut PartWriter<'_>,
900) -> Plan {
901    // The same bracket the install loop holds, for the same reason: every
902    // payload this builder encodes (at every level) must see an empty ambient
903    // scene-metadata store, or a nested leaf whose stamped face names collide
904    // with the live document's silently inherits the live document's records.
905    let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
906    let mut build = NestedBuild {
907        assembly,
908        doc_name,
909        writer,
910        memo: std::collections::HashMap::new(),
911        factors: std::collections::HashMap::new(),
912        entries: 0,
913        bytes: 0,
914        failed_products: 0,
915        baked_nonrigid: 0,
916        first_error: None,
917    };
918    let mut plan = Plan::default();
919    for &root in &assembly.roots {
920        let mut rows: Vec<(DocKey, Mat4)> = Vec::new();
921        // The root's OWN bodies become a leaf part at identity — exactly the
922        // flat lane's treatment, and the reason a depth-1 tree comes out the
923        // same either way.
924        if !assembly.products[root].bodies.is_empty() {
925            rows.push((
926                DocKey::Leaf((assembly.products[root].pd_ref, NO_FACTOR)),
927                MAT4_IDENTITY,
928            ));
929        }
930        // Root-level bakes are counted by the install loop's own pass over
931        // `wanted` (they are ordinary top-level rows); only bakes BELOW the root
932        // — which never become rows of the user's document — are counted here.
933        let mut root_level_bakes = 0usize;
934        build.place_children(root, &[root], &mut rows, &mut root_level_bakes);
935        for (key, pose) in rows {
936            let part = match build.document(key, &mut vec![root]) {
937                Ok(Some(document)) => document,
938                // A subtree with no geometry anywhere places nothing — the flat
939                // lane says the same thing by emitting no component for it.
940                Ok(None) => continue,
941                Err(error) => {
942                    build.failed_products += 1;
943                    note(&mut build.first_error, error);
944                    continue;
945                }
946            };
947            let part_key = key.part_key(assembly);
948            plan.documents.insert(part_key, part);
949            plan.wanted.push((part_key, pose));
950        }
951    }
952    plan.failed_products = build.failed_products;
953    plan.baked_below_root = build.baked_nonrigid;
954    if let Some(error) = build.first_error {
955        note(first_error, error);
956    }
957    plan
958}
959
960/// How deep the recursive builder will go before it refuses. `read_step_assembly`
961/// guards cycles inside its own walk and [`NestedBuild::document`] guards them
962/// again along the recursion path, so this is the SECOND line: a malformed file
963/// that is merely pathologically deep (rather than cyclic) must not run the
964/// native stack out. Sixty-four levels of embedded documents is already far past
965/// anything a real CAD assembly carries — and each level embeds the whole
966/// subtree below it, so the document would be unusable long before then.
967const MAX_NESTED_DEPTH: usize = 64;
968
969/// How many DISTINCT part documents a nested import may build. Bounds the
970/// builder's work; it does NOT bound the result's size — see
971/// [`MAX_NESTED_BYTES`], which is the guard that matters.
972const MAX_NESTED_ENTRIES: usize = 10_000;
973
974/// How many bytes of part document a nested import may EMBED, summed over every
975/// `partsLibrary` entry it writes at every level.
976///
977/// This is the guard neither the depth cap nor the entry count provides. A
978/// product reachable at many different depths is stored once PER LEVEL
979/// (build-spec §2.2) — the memo builds its document once, but each parent
980/// embeds a COPY, so a diamond-shaped structure well inside the depth cap can
981/// still multiply out geometrically. Charging the embedded bytes is the only
982/// place that multiplication is visible, so it is charged where it happens.
983const MAX_NESTED_BYTES: usize = 256 * 1024 * 1024;
984
985/// What a nested part document is memoised under. A product is either a leaf
986/// (no occurrence children) or an assembly node, never both, so the two
987/// variants can never name the same product — except at a ROOT, whose own
988/// bodies become a leaf part while the root itself is an assembly node. That
989/// case is exactly why this is an enum and not a bare [`PartKey`].
990#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
991enum DocKey {
992    /// A geometry-bearing product placed as a part: `(pd_ref, baked factor)`.
993    Leaf(PartKey),
994    /// A product placed as a rigid sub-assembly, by index into `products`.
995    Assembly(usize),
996}
997
998impl DocKey {
999    /// The parts-library identity this document is stored under. Always keyed on
1000    /// the `pd_ref` (never the product INDEX, which lives in a different number
1001    /// space and would collide with some other product's `pd_ref`). An assembly
1002    /// node never carries a baked factor — a non-rigid edge into one is skipped,
1003    /// see [`NestedBuild::place_children`] — so `NO_FACTOR` is exact.
1004    fn part_key(self, assembly: &brep_kernel::StepAssembly) -> PartKey {
1005        match self {
1006            DocKey::Leaf(key) => key,
1007            DocKey::Assembly(product) => (assembly.products[product].pd_ref, NO_FACTOR),
1008        }
1009    }
1010}
1011
1012/// The recursive builder behind [`plan_nested`]: turns one product into the part
1013/// document that represents it, bottom-up, memoised so a product reached from
1014/// several parents is built ONCE however many places embed it.
1015struct NestedBuild<'a, 'w> {
1016    assembly: &'a brep_kernel::StepAssembly,
1017    doc_name: &'a str,
1018    /// Where a CHILD library entry's document is written, shared with the
1019    /// top-level install loop so one part is one file at every level.
1020    writer: &'a mut PartWriter<'w>,
1021    /// `None` = this subtree carries no geometry at all, so nothing places it.
1022    memo: std::collections::HashMap<DocKey, Option<(String, serde_json::Value)>>,
1023    /// The non-rigid factor behind every baked [`DocKey::Leaf`] key, so the
1024    /// builder never has to reconstruct a matrix out of its own hash key.
1025    factors: std::collections::HashMap<PartKey, Mat4>,
1026    entries: usize,
1027    /// Bytes of part document embedded so far — the [`MAX_NESTED_BYTES`] charge.
1028    bytes: usize,
1029    failed_products: usize,
1030    baked_nonrigid: usize,
1031    first_error: Option<String>,
1032}
1033
1034impl NestedBuild<'_, '_> {
1035    /// The part document for `key`, built once and reused. `ancestors` is the
1036    /// recursion path — the cycle guard, and the depth the cap is measured on.
1037    ///
1038    /// A cyclic file gets ONE deterministic truncation: the memo keeps whichever
1039    /// path reached a node first, and that path's skipped back-edge is the one
1040    /// every embedding sees. Deterministic and finite is the whole contract for
1041    /// input that is malformed by construction.
1042    fn document(
1043        &mut self,
1044        key: DocKey,
1045        ancestors: &mut Vec<usize>,
1046    ) -> Result<Option<(String, serde_json::Value)>, String> {
1047        if let Some(hit) = self.memo.get(&key) {
1048            return Ok(hit.clone());
1049        }
1050        if ancestors.len() >= MAX_NESTED_DEPTH {
1051            return Err(format!(
1052                "nested import: sub-assembly nesting deeper than {MAX_NESTED_DEPTH} levels \
1053                 (import as bodies, or import flat)"
1054            ));
1055        }
1056        let built = match key {
1057            DocKey::Leaf(part) => self.leaf_document(part),
1058            DocKey::Assembly(product) => {
1059                ancestors.push(product);
1060                let built = self.assembly_document(product, ancestors);
1061                ancestors.pop();
1062                built
1063            }
1064        }?;
1065        self.memo.insert(key, built.clone());
1066        Ok(built)
1067    }
1068
1069    /// A geometry-bearing product as the §3.2 part document — the same one the
1070    /// flat lane installs, built by the same helper, so a depth-1 nested import
1071    /// and a flat one store byte-identical entries.
1072    fn leaf_document(
1073        &mut self,
1074        key: PartKey,
1075    ) -> Result<Option<(String, serde_json::Value)>, String> {
1076        let product = self
1077            .assembly
1078            .products
1079            .iter()
1080            .find(|product| product.pd_ref == key.0)
1081            .expect("every key names a product of this assembly");
1082        if product.bodies.is_empty() {
1083            return Ok(None);
1084        }
1085        let factor = self.factors.get(&key).copied();
1086        self.spend_entry()?;
1087        native_part_document(product, factor.as_ref(), self.doc_name).map(Some)
1088    }
1089
1090    /// An assembly-node product as a rigid sub-assembly document: its OWN bodies
1091    /// as plain native IMPORT3D features (the interior-node geometry Phase 1
1092    /// could only make a SIBLING of its own children), one ACOMP per child
1093    /// occurrence, and the children's documents in this level's own
1094    /// `partsLibrary`.
1095    ///
1096    /// The entries carry NO snapshot. An entry with an unreadable snapshot heals
1097    /// from its embedded document (`assembly_component.rs`'s SELF-HEAL lane),
1098    /// and for a native part that heal is a decode + re-encode — so the level
1099    /// above bakes this whole subtree into ITS snapshot on insert, and these
1100    /// inner caches would only ever be rebuilt to be thrown away. Kernel-plan §6
1101    /// names this exact economy ("omit the persisted snapshot for an entry whose
1102    /// document is a single native IMPORT3D"); nesting is where it pays, because
1103    /// otherwise every level stores the level below it twice.
1104    fn assembly_document(
1105        &mut self,
1106        product: usize,
1107        ancestors: &mut Vec<usize>,
1108    ) -> Result<Option<(String, serde_json::Value)>, String> {
1109        let node = &self.assembly.products[product];
1110        let mut library = serde_json::Map::new();
1111        let mut features: Vec<serde_json::Value> = Vec::new();
1112
1113        // The node's own bodies first, matching the flat lane's "a node before
1114        // its children" emit order.
1115        if !node.bodies.is_empty() {
1116            let payload = brep_kernel::native_import_payload_with_appearance(
1117                "IMPORT3D1",
1118                &node.bodies,
1119                &node.appearances,
1120            )
1121            .map_err(|error| format!("part '{}': {error}", part_name(node, self.doc_name)))?;
1122            features.push(serde_json::json!({
1123                "type": "IMPORT3D",
1124                "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
1125                "persistentData": {},
1126            }));
1127        }
1128
1129        // One ACOMP per child occurrence, children by ascending `nauo_ref`.
1130        let mut rows: Vec<(DocKey, Mat4)> = Vec::new();
1131        let mut bakes = 0usize;
1132        self.place_children(product, ancestors, &mut rows, &mut bakes);
1133        self.baked_nonrigid += bakes;
1134        let mut names: std::collections::HashMap<DocKey, String> =
1135            std::collections::HashMap::new();
1136        // `add_part_to_library`'s content reuse, applied to this level's block:
1137        // two products that are the SAME geometry collapse to one entry (§3.5's
1138        // free dedup), and every instance of either references it.
1139        let mut by_signature: std::collections::HashMap<String, String> =
1140            std::collections::HashMap::new();
1141        let mut components = 0usize;
1142        for (key, pose) in rows {
1143            let name = match names.get(&key) {
1144                Some(name) => name.clone(),
1145                None => {
1146                    let built = match self.document(key, ancestors) {
1147                        Ok(Some(built)) => built,
1148                        Ok(None) => continue,
1149                        Err(error) => {
1150                            self.failed_products += 1;
1151                            note(&mut self.first_error, error);
1152                            continue;
1153                        }
1154                    };
1155                    let serialized = built.1.to_string();
1156                    let signature = document_signature(&serialized);
1157                    let name = match by_signature.get(&signature) {
1158                        Some(name) => name.clone(),
1159                        None => {
1160                            // Charged HERE, at the embedding, because that is
1161                            // where a product stored once per level multiplies.
1162                            self.spend_bytes(serialized.len())?;
1163                            // Unique WITHIN this level's library — parent and
1164                            // child libraries are independent (build-spec §2.2),
1165                            // so a name taken upstairs is free down here.
1166                            let name = unique_entry_name(&library, &built.0);
1167                            // A nested child is a part like any other: it gets
1168                            // its own store document and a REAL sourceKey, so
1169                            // Open Part and update-components work the same way
1170                            // however deep it sits.
1171                            let source_key =
1172                                self.writer.key_for(&name, &serialized, &signature);
1173                            library.insert(
1174                                name.clone(),
1175                                serde_json::json!({
1176                                    "sourceKey": source_key,
1177                                    "sourceSignature": signature.clone(),
1178                                    "document": built.1,
1179                                    "snapshot": "",
1180                                }),
1181                            );
1182                            by_signature.insert(signature, name.clone());
1183                            name
1184                        }
1185                    };
1186                    names.insert(key, name.clone());
1187                    name
1188                }
1189            };
1190            let Ok(transform) = brep_kernel::AffineTransform::new(pose) else {
1191                note(
1192                    &mut self.first_error,
1193                    format!("sub-assembly '{name}': occurrence pose is not an affine"),
1194                );
1195                continue;
1196            };
1197            components += 1;
1198            features.push(serde_json::json!({
1199                "type": "ACOMP",
1200                "inputParams": {
1201                    // Its OWN counter, so the ids read `ACOMP1..n` whether or
1202                    // not this node also owns bodies. (The id must match
1203                    // `ACOMP<digits>`: it IS the namespace prefix.)
1204                    "id": format!("ACOMP{components}"),
1205                    "partName": name,
1206                    "transform": brep_kernel::transform_to_pose_params(&transform),
1207                    // Written EXPLICITLY rather than left to the kernel's
1208                    // auto-ground rule, which keys on ABSENCE: the first
1209                    // component of an assembly is grounded, and every other one
1210                    // must not be, or the next solve is over-constrained.
1211                    "isFixed": components == 1,
1212                },
1213                "persistentData": {},
1214            }));
1215        }
1216
1217        // A node whose whole subtree failed to produce geometry places nothing.
1218        // Returning `None` rather than a feature-less document matters: an empty
1219        // document is a hard error inside `add_part_to_library`, which would turn
1220        // "there was nothing here" into "the import failed".
1221        if features.is_empty() {
1222            return Ok(None);
1223        }
1224        self.spend_entry()?;
1225        Ok(Some((
1226            part_name(node, self.doc_name),
1227            serde_json::json!({ "partsLibrary": library, "features": features }),
1228        )))
1229    }
1230
1231    /// The child occurrences of `product`, as `(document key, pose)` rows in the
1232    /// kernel walk's order — ascending `nauo_ref`, with the same ancestor cycle
1233    /// guard. The pose is the occurrence's own child→parent placement: nesting
1234    /// is precisely what stops it having to be composed.
1235    fn place_children(
1236        &mut self,
1237        product: usize,
1238        ancestors: &[usize],
1239        rows: &mut Vec<(DocKey, Mat4)>,
1240        bakes: &mut usize,
1241    ) {
1242        let mut children: Vec<&brep_kernel::StepOccurrence> = self
1243            .assembly
1244            .occurrences
1245            .iter()
1246            .filter(|occurrence| occurrence.parent == product)
1247            .collect();
1248        children.sort_by_key(|occurrence| occurrence.nauo_ref);
1249        for occurrence in children {
1250            if ancestors.contains(&occurrence.child) {
1251                note(
1252                    &mut self.first_error,
1253                    format!(
1254                        "occurrence #{} closes a cycle in the product structure and was skipped",
1255                        occurrence.nauo_ref
1256                    ),
1257                );
1258                continue;
1259            }
1260            let child = &self.assembly.products[occurrence.child];
1261            let is_assembly = self
1262                .assembly
1263                .occurrences
1264                .iter()
1265                .any(|edge| edge.parent == occurrence.child);
1266            if occurrence.rigid {
1267                let key = if is_assembly {
1268                    DocKey::Assembly(occurrence.child)
1269                } else {
1270                    DocKey::Leaf((child.pd_ref, NO_FACTOR))
1271                };
1272                rows.push((key, occurrence.placement));
1273                continue;
1274            }
1275            // §3.4 on a single edge: a leaf bakes its non-rigid factor into its
1276            // own part, exactly as the flat lane does with the composed pose.
1277            match split_rigid(&occurrence.placement) {
1278                Ok((rigid, factor)) if is_identity(&factor) => {
1279                    let key = if is_assembly {
1280                        DocKey::Assembly(occurrence.child)
1281                    } else {
1282                        DocKey::Leaf((child.pd_ref, NO_FACTOR))
1283                    };
1284                    rows.push((key, rigid));
1285                }
1286                // A mirrored/scaled SUB-ASSEMBLY would have to push its factor
1287                // down through a whole document tree, rewriting every level's
1288                // poses. Nothing in the corpus does it, and a wrong answer here
1289                // would be a silently mis-handed assembly: skip and say so, so
1290                // the user can re-import flat (which bakes it correctly).
1291                Ok(_) if is_assembly => {
1292                    note(
1293                        &mut self.first_error,
1294                        format!(
1295                            "occurrence #{} places sub-assembly '{}' with a non-rigid transform, \
1296                             which a nested import cannot represent — import flat instead",
1297                            occurrence.nauo_ref,
1298                            part_name(child, self.doc_name)
1299                        ),
1300                    );
1301                }
1302                Ok((rigid, factor)) => {
1303                    *bakes += 1;
1304                    let key = (child.pd_ref, factor_key(&factor));
1305                    self.factors.insert(key, factor);
1306                    rows.push((DocKey::Leaf(key), rigid));
1307                }
1308                Err(error) => note(&mut self.first_error, error),
1309            }
1310        }
1311    }
1312
1313    /// Charge one built part document against [`MAX_NESTED_ENTRIES`].
1314    fn spend_entry(&mut self) -> Result<(), String> {
1315        self.entries += 1;
1316        if self.entries > MAX_NESTED_ENTRIES {
1317            return Err(format!(
1318                "nested import: more than {MAX_NESTED_ENTRIES} distinct parts \
1319                 (import as bodies, or import flat)"
1320            ));
1321        }
1322        Ok(())
1323    }
1324
1325    /// Charge one embedded part document against [`MAX_NESTED_BYTES`].
1326    fn spend_bytes(&mut self, bytes: usize) -> Result<(), String> {
1327        self.bytes = self.bytes.saturating_add(bytes);
1328        if self.bytes > MAX_NESTED_BYTES {
1329            return Err(format!(
1330                "nested import: the embedded sub-assembly documents exceed \
1331                 {} MB (import as bodies, or import flat)",
1332                MAX_NESTED_BYTES / (1024 * 1024)
1333            ));
1334        }
1335        Ok(())
1336    }
1337}
1338
1339/// A part name not yet used in THIS level's library: `requested`, else
1340/// `requested-2`, `requested-3`, … — the kernel `parts_library::unique_name`
1341/// convention, applied to an embedded block the kernel never sees inserted.
1342fn unique_entry_name(library: &serde_json::Map<String, serde_json::Value>, requested: &str) -> String {
1343    if !library.contains_key(requested) {
1344        return requested.to_string();
1345    }
1346    (2..)
1347        .map(|counter| format!("{requested}-{counter}"))
1348        .find(|candidate| !library.contains_key(candidate))
1349        .expect("the counter loop is unbounded")
1350}
1351
1352/// Keep the FIRST thing that went wrong (the report carries one, and the first
1353/// is the one that explains the rest).
1354fn note(slot: &mut Option<String>, error: String) {
1355    if slot.is_none() {
1356        *slot = Some(error);
1357    }
1358}
1359
1360/// Encode one product as a parts-library entry and return the EFFECTIVE entry
1361/// name the instances must reference (`add_part_to_library` disambiguates a name
1362/// clash and REUSES an entry with identical content, which is where cross-import
1363/// dedup comes from).
1364///
1365/// `factor`, when present, is the non-rigid part of an occurrence's placement:
1366/// applied to the geometry HERE, so the instance can carry a rigid pose (§3.4).
1367fn build_library_entry(
1368    product: &brep_kernel::StepProduct,
1369    factor: Option<&Mat4>,
1370    doc_name: &str,
1371    writer: &mut PartWriter<'_>,
1372) -> Result<String, String> {
1373    let (name, document) = native_part_document(product, factor, doc_name)?;
1374    install_part(&name, &document, writer)
1375}
1376
1377/// The §3.2 part document for one product's OWN bodies: ONE IMPORT3D whose only
1378/// input is the native payload, plus the library name it wants. No STEP text is
1379/// stored anywhere — a rebuild of this part is a base64 decode, not a re-parse.
1380///
1381/// `factor`, when present, is the non-rigid part of an occurrence's placement:
1382/// applied to the geometry HERE, so the instance can carry a rigid pose (§3.4).
1383///
1384/// Split out from [`build_library_entry`] because the nested lane needs the
1385/// DOCUMENT before it installs anything — a leaf's document is embedded in its
1386/// parent's `partsLibrary`, where there is no `add_part_to_library` to call.
1387/// One producer, so a leaf part is byte-identical however deep it lands.
1388fn native_part_document(
1389    product: &brep_kernel::StepProduct,
1390    factor: Option<&Mat4>,
1391    doc_name: &str,
1392) -> Result<(String, serde_json::Value), String> {
1393    let mut name = part_name(product, doc_name);
1394    let bodies = match factor {
1395        None => product.bodies.clone(),
1396        Some(factor) => {
1397            let transform = brep_kernel::AffineTransform::new(*factor)
1398                .map_err(|error| format!("part '{name}': non-rigid factor: {error}"))?;
1399            let mirrored = transform.determinant3() < 0.0;
1400            name.push_str(if mirrored { " (mirrored)" } else { " (scaled)" });
1401            product
1402                .bodies
1403                .iter()
1404                .map(|body| {
1405                    // A mirror MUST reverse orientation or `transform_brep`
1406                    // refuses it (an unreversed reflection inverts the solid).
1407                    brep_kernel::transform_brep(body, transform, mirrored)
1408                        .map_err(|error| format!("part '{name}': {error}"))
1409                })
1410                .collect::<Result<Vec<_>, _>>()?
1411        }
1412    };
1413    // The product's STEP colours ride into the payload with the geometry (the
1414    // snapshot captures the records the stamp writes), so a coloured part keeps
1415    // its colour through the parts library and every reload.
1416    let payload = brep_kernel::native_import_payload_with_appearance(
1417        "IMPORT3D1",
1418        &bodies,
1419        &product.appearances,
1420    )
1421    .map_err(|error| format!("part '{name}': {error}"))?;
1422    let document = serde_json::json!({
1423        "features": [{
1424            "type": "IMPORT3D",
1425            "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
1426            "persistentData": {},
1427        }]
1428    });
1429    Ok((name, document))
1430}
1431
1432/// Install a part document as a parts-library entry of the OPEN document and
1433/// return the EFFECTIVE entry name the instances must reference
1434/// (`add_part_to_library` disambiguates a name clash and REUSES an entry with
1435/// identical content, which is where cross-import dedup comes from).
1436///
1437/// The `sourceKey` comes from the [`PartSink`]: an imported part is written to
1438/// the store as its own document and carries a REAL key, exactly like a part
1439/// inserted from the parts library, so there is no second kind of part. A sink
1440/// that declines (no store, or a failed write) yields `""` — the embedded-only
1441/// entry this lane used to produce unconditionally, and the case
1442/// `UpdateComponents` already skips.
1443fn install_part(
1444    name: &str,
1445    document: &serde_json::Value,
1446    writer: &mut PartWriter<'_>,
1447) -> Result<String, String> {
1448    let document = document.to_string();
1449    let signature = document_signature(&document);
1450    let source_key = writer.key_for(name, &document, &signature);
1451    brep_kernel::add_part_to_library(name, &source_key, &signature, &document)
1452        .map_err(|error| format!("part '{name}': {error:?}"))
1453}
1454
1455/// Where an imported assembly's unique parts are written, so each becomes a
1456/// document in its own right rather than a payload embedded in one assembly.
1457///
1458/// A trait, and not a `&dyn ModelStore`, because the store lives in `BREP_app`
1459/// and this crate is BELOW it — `BREP_app` depends on `BREP_render`, so naming
1460/// the store here would be a dependency cycle. The import therefore asks for a
1461/// key and the app answers with one, which is also what keeps the destination
1462/// (and any prompt for it) entirely the app's business.
1463pub trait PartSink {
1464    /// Store `document_json` under a name derived from `part_name` and return
1465    /// the stable key it can be read back by. `None` declines — no store, or a
1466    /// write that failed — and the entry stays embedded-only.
1467    fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String>;
1468}
1469
1470/// The sink that stores nothing: every entry stays embedded-only. The default
1471/// for headless callers and tests, which have no store to write to.
1472pub struct EmbeddedOnly;
1473
1474impl PartSink for EmbeddedOnly {
1475    fn store_part(&mut self, _part_name: &str, _document_json: &str) -> Option<String> {
1476        None
1477    }
1478}
1479
1480/// A [`PartSink`] plus the CONTENT DEDUP that must ride with it.
1481///
1482/// `add_part_to_library` reuses an entry whose `(sourceKey, sourceSignature)`
1483/// both match, which is where §3.5's free dedup came from while every imported
1484/// part carried the same empty key. Give each part its own key and that reuse
1485/// stops: the same product under two `PRODUCT_DEFINITION`s would become two
1486/// entries AND two identical files.
1487///
1488/// So the dedup moves in front of the write, keyed on the document signature
1489/// alone. Identical content is written ONCE and every occurrence of it gets the
1490/// SAME key — which then makes `add_part_to_library`'s own `(key, signature)`
1491/// reuse fire exactly as before. Dedup ACROSS imports keeps working for the
1492/// same reason: a re-import derives the same file name, so the same key and
1493/// signature come back and the resident entry is reused.
1494struct PartWriter<'a> {
1495    sink: &'a mut dyn PartSink,
1496    by_signature: std::collections::HashMap<String, String>,
1497}
1498
1499impl<'a> PartWriter<'a> {
1500    fn new(sink: &'a mut dyn PartSink) -> Self {
1501        Self {
1502            sink,
1503            by_signature: std::collections::HashMap::new(),
1504        }
1505    }
1506
1507    /// The `sourceKey` for a part with this content — writing it exactly once
1508    /// however many products share it.
1509    fn key_for(&mut self, name: &str, document_json: &str, signature: &str) -> String {
1510        if let Some(key) = self.by_signature.get(signature) {
1511            return key.clone();
1512        }
1513        let key = self
1514            .sink
1515            .store_part(name, document_json)
1516            .unwrap_or_default();
1517        self.by_signature.insert(signature.to_string(), key.clone());
1518        key
1519    }
1520}
1521
1522/// The library name for a product: its `PRODUCT.name`, else a stem built from
1523/// the imported document's name so an unnamed product is still identifiable.
1524fn part_name(product: &brep_kernel::StepProduct, doc_name: &str) -> String {
1525    let named = product.name.trim();
1526    if !named.is_empty() {
1527        return named.to_string();
1528    }
1529    match doc_name.trim() {
1530        "" => format!("part-{}", product.pd_ref),
1531        stem => format!("{stem}-part-{}", product.pd_ref),
1532    }
1533}
1534
1535/// The dialog's counts, taken from the SAME walk the import runs, so the numbers
1536/// the user was shown are the numbers they get (bar an encode failure, and bar
1537/// the extra entry a non-rigid occurrence bakes).
1538pub(super) fn probe_counts(assembly: &brep_kernel::StepAssembly) -> StepAssemblyProbe {
1539    let mut parts = std::collections::HashSet::new();
1540    let mut instances = 0usize;
1541    let mut nested_depth = 0usize;
1542    for placed in compose_world_occurrences(assembly) {
1543        let product = &assembly.products[placed.product];
1544        if product.bodies.is_empty() {
1545            continue;
1546        }
1547        parts.insert(product.pd_ref);
1548        instances += 1;
1549        nested_depth = nested_depth.max(placed.depth);
1550    }
1551    StepAssemblyProbe {
1552        parts: parts.len(),
1553        instances,
1554        nested_depth,
1555    }
1556}
1557
1558/// Depth-first from the roots, composing each occurrence's child→parent
1559/// placement into a world transform — the consumer half of `read_step_assembly`,
1560/// which deliberately transforms nothing.
1561///
1562/// Emit order, child ordering (by `nauo_ref`) and the ancestor cycle guard mirror
1563/// the kernel's own `walk_occurrences`, which is what makes the components this
1564/// lane produces the same solids, in the same order, as the flat lane's — the
1565/// kernel asserts that equivalence BIT-for-bit
1566/// (`step_import/tests/assembly_structure.rs`), and
1567/// `structured_import_matches_the_flat_lane_geometry` below re-asserts it from
1568/// this side, where a divergence would actually land.
1569fn compose_world_occurrences(assembly: &brep_kernel::StepAssembly) -> Vec<PlacedProduct> {
1570    struct Node {
1571        placed: PlacedProduct,
1572        ancestors: Vec<usize>,
1573    }
1574    let mut out = Vec::new();
1575    let mut stack: Vec<Node> = assembly
1576        .roots
1577        .iter()
1578        .rev()
1579        .map(|&product| Node {
1580            placed: PlacedProduct {
1581                product,
1582                world: MAT4_IDENTITY,
1583                depth: 0,
1584                rigid_path: true,
1585            },
1586            ancestors: vec![product],
1587        })
1588        .collect();
1589    while let Some(node) = stack.pop() {
1590        let (product, world, depth, rigid_path) = (
1591            node.placed.product,
1592            node.placed.world,
1593            node.placed.depth,
1594            node.placed.rigid_path,
1595        );
1596        out.push(node.placed);
1597        let mut children: Vec<&brep_kernel::StepOccurrence> = assembly
1598            .occurrences
1599            .iter()
1600            .filter(|occurrence| occurrence.parent == product)
1601            .collect();
1602        children.sort_by_key(|occurrence| occurrence.nauo_ref);
1603        for occurrence in children.into_iter().rev() {
1604            if node.ancestors.contains(&occurrence.child) {
1605                continue; // the cycle guard the kernel's walk applies
1606            }
1607            let mut ancestors = node.ancestors.clone();
1608            ancestors.push(occurrence.child);
1609            stack.push(Node {
1610                placed: PlacedProduct {
1611                    product: occurrence.child,
1612                    world: mat4_mul(&world, &occurrence.placement),
1613                    depth: depth + 1,
1614                    // The kernel's per-edge rigidity flag, carried down the path:
1615                    // a composed pose is a component pose only when every edge
1616                    // on the way to it was one.
1617                    rigid_path: rigid_path && occurrence.rigid,
1618                },
1619                ancestors,
1620            });
1621        }
1622    }
1623    out
1624}
1625
1626const MAT4_IDENTITY: Mat4 = [
1627    1.0, 0.0, 0.0, 0.0, //
1628    0.0, 1.0, 0.0, 0.0, //
1629    0.0, 0.0, 1.0, 0.0, //
1630    0.0, 0.0, 0.0, 1.0,
1631];
1632
1633/// Row-major 4×4 product `a · b`.
1634fn mat4_mul(a: &Mat4, b: &Mat4) -> Mat4 {
1635    let mut out = [0.0; 16];
1636    for row in 0..4 {
1637        for column in 0..4 {
1638            out[row * 4 + column] = (0..4)
1639                .map(|k| a[row * 4 + k] * b[k * 4 + column])
1640                .sum();
1641        }
1642    }
1643    out
1644}
1645
1646/// Split a non-rigid world placement into `world = rigid · factor`, where
1647/// `rigid` is a component pose (rotation + translation, det +1) and `factor` is
1648/// a purely linear residue carrying the mirror/scale/shear.
1649///
1650/// Gram-Schmidt on the linear block's columns gives `A = Q·U` with `U` upper
1651/// triangular and positively-diagonalled; when `Q` came out left-handed the pair
1652/// is re-signed through `D = diag(-1, 1, 1)` (`Q' = Q·D`, `U' = D·U`, still
1653/// `Q'U' = A`) so the ROTATION is a rotation and the reflection rides in the
1654/// factor. A mirror composed with a rotation therefore yields the same factor
1655/// whatever the rotation, which keeps every such instance on ONE baked part.
1656fn split_rigid(world: &Mat4) -> Result<(Mat4, Mat4), String> {
1657    let column = |index: usize| [world[index], world[4 + index], world[8 + index]];
1658    let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1659    let axpy = |a: [f64; 3], scale: f64, b: [f64; 3]| {
1660        [a[0] - scale * b[0], a[1] - scale * b[1], a[2] - scale * b[2]]
1661    };
1662    let (a1, a2, a3) = (column(0), column(1), column(2));
1663
1664    let r11 = dot(a1, a1).sqrt();
1665    let mut q1 = normalize(a1, r11)?;
1666    let r12 = dot(q1, a2);
1667    let v2 = axpy(a2, r12, q1);
1668    let r22 = dot(v2, v2).sqrt();
1669    let q2 = normalize(v2, r22)?;
1670    let r13 = dot(q1, a3);
1671    let r23 = dot(q2, a3);
1672    let v3 = axpy(axpy(a3, r13, q1), r23, q2);
1673    let r33 = dot(v3, v3).sqrt();
1674    let q3 = normalize(v3, r33)?;
1675
1676    // det Q = q1 · (q2 × q3); -1 means Q is a reflection, not a rotation.
1677    let cross = [
1678        q2[1] * q3[2] - q2[2] * q3[1],
1679        q2[2] * q3[0] - q2[0] * q3[2],
1680        q2[0] * q3[1] - q2[1] * q3[0],
1681    ];
1682    let (mut r11, mut r12, mut r13) = (r11, r12, r13);
1683    if dot(q1, cross) < 0.0 {
1684        q1 = [-q1[0], -q1[1], -q1[2]];
1685        r11 = -r11;
1686        r12 = -r12;
1687        r13 = -r13;
1688    }
1689    let rigid = [
1690        q1[0], q2[0], q3[0], world[3], //
1691        q1[1], q2[1], q3[1], world[7], //
1692        q1[2], q2[2], q3[2], world[11], //
1693        0.0, 0.0, 0.0, 1.0,
1694    ];
1695    let factor = [
1696        r11, r12, r13, 0.0, //
1697        0.0, r22, r23, 0.0, //
1698        0.0, 0.0, r33, 0.0, //
1699        0.0, 0.0, 0.0, 1.0,
1700    ];
1701    Ok((rigid, factor))
1702}
1703
1704/// Unit vector, or a clear error for the degenerate column a near-singular
1705/// placement produces (skipped and counted, never fatal).
1706fn normalize(vector: [f64; 3], length: f64) -> Result<[f64; 3], String> {
1707    if !(length > 1e-12) || !length.is_finite() {
1708        return Err("occurrence placement is singular (a degenerate axis)".into());
1709    }
1710    Ok([vector[0] / length, vector[1] / length, vector[2] / length])
1711}
1712
1713/// Is this affine the identity to 1e-9 — the tolerance the kernel's own
1714/// rigidity gate uses?
1715fn is_identity(matrix: &Mat4) -> bool {
1716    matrix
1717        .iter()
1718        .zip(MAT4_IDENTITY.iter())
1719        .all(|(value, want)| (value - want).abs() <= 1e-9)
1720}
1721
1722/// The linear block of a baked factor as an exact bit key — two occurrences
1723/// share a baked part only when their factor is bit-identical, so a wrong-handed
1724/// reuse is not reachable through rounding.
1725fn factor_key(factor: &Mat4) -> [u64; 9] {
1726    let mut key = [0u64; 9];
1727    for (slot, index) in key.iter_mut().zip([0, 1, 2, 4, 5, 6, 8, 9, 10]) {
1728        *slot = factor[index].to_bits();
1729    }
1730    key
1731}
1732
1733// BREP private tests: 442bc619b216060d