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/3MF 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    /// Import a 3MF package (core specification) through the same chain. The
29    /// package is unzipped and its build flattened on the runner, so a large
30    /// model never decompresses on the UI thread.
31    pub fn import_3mf_bytes_feature(&mut self, bytes: &[u8]) -> Result<String, String> {
32        self.submit_mesh_import(crate::runner::MeshImportFormat::ThreeMf, bytes.to_vec())
33    }
34
35    fn submit_mesh_import(
36        &mut self,
37        format: crate::runner::MeshImportFormat,
38        bytes: Vec<u8>,
39    ) -> Result<String, String> {
40        let id = self.submit_mesh_reconstruction(
41            format, bytes, Default::default(), MeshImportDestination::Document,
42        )?;
43        Ok(serde_json::json!({ "meshImport": "submitted", "id": id }).to_string())
44    }
45
46    /// Reconstruct without editing history. The caller owns confirmation and
47    /// can inspect the exact STEP and diagnostics returned by `take_mesh_preview`.
48    pub fn reconstruct_mesh_preview(
49        &mut self,
50        format: crate::runner::MeshImportFormat,
51        bytes: Vec<u8>,
52        options: crate::runner::StlConversionOptions,
53    ) -> Result<u64, String> {
54        self.submit_mesh_reconstruction(format, bytes, options, MeshImportDestination::Preview)
55    }
56
57    pub fn take_mesh_preview(&mut self) -> Option<crate::runner::MeshImportReply> {
58        self.mesh_preview_results.pop_front()
59    }
60
61    fn submit_mesh_reconstruction(
62        &mut self,
63        format: crate::runner::MeshImportFormat,
64        bytes: Vec<u8>,
65        options: crate::runner::StlConversionOptions,
66        destination: MeshImportDestination,
67    ) -> Result<u64, String> {
68        if bytes.is_empty() {
69            return Err("mesh import failed: file is empty".into());
70        }
71        let id = self.next_mesh_import_id;
72        self.next_mesh_import_id = self.next_mesh_import_id.wrapping_add(1);
73        self.pending_mesh_imports.insert(id, destination);
74        self.runner.submit_mesh_import(crate::runner::MeshImportRequest {
75            id, format, bytes, options,
76        });
77        self.pump();
78        Ok(id)
79    }
80
81    /// Import a STEP document into the model: append an `IMPORT3D` feature whose
82    /// `inputParams.stepText` is the raw ISO-10303-21 text (the exact headless
83    /// source the kernel importer reads — no `fileToImport` data-URL marshaling
84    /// needed), mint it a persistent-counter id, roll to it, and rebuild. Returns the
85    /// build report JSON (imported bodies + any per-feature error). A non-STEP
86    /// payload is refused up front so a bad upload never leaves a dead feature.
87    pub fn import_step_feature(&mut self, step_text: &str) -> Result<String, String> {
88        if !step_text.contains("ISO-10303-21") {
89            return Err("not a STEP file (missing the ISO-10303-21 header)".into());
90        }
91        let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
92        let feature = serde_json::json!({
93            "type": "IMPORT3D",
94            "inputParams": { "id": id, "stepText": step_text },
95            "persistentData": {},
96        });
97        // The file's AP242 PMI, lifted ONCE into the document's pmi block with
98        // references naming the faces / edges / vertices the feature will stamp
99        // (the STEP text is never re-read for it). Read BEFORE the add so the
100        // add's undo checkpoint precedes both writes: one undo removes the
101        // feature and its PMI together.
102        let lifted = brep_kernel::read_step_pmi(step_text, &id).unwrap_or(None);
103        // Frame the imported body once the (possibly async) run lands — see
104        // [`EngineState::pending_fit`]. An immediate fit here would frame the still
105        // empty scene under a background runner (native thread / wasm worker).
106        self.pending_fit = true;
107        let report = self.add_feature(&feature.to_string());
108        if let Some(lifted) = lifted {
109            self.pmi_merge_imported(lifted);
110        }
111        report
112    }
113
114    /// Export the CURRENT model's resident solids to an ISO-10303-21 STEP
115    /// document. Collects the resident handles of the rolled-to model (a warm
116    /// re-run of the same prefix the display scene was built from — see
117    /// [`crate::pipeline::resident_solid_handles`]) and hands them to the kernel's
118    /// [`brep_kernel::export_step_handles`], so the exact NURBS topology is
119    /// serialized (never the display mesh). Errs clearly when the model is empty.
120    pub fn export_step_text(&mut self) -> Result<String, String> {
121        self.export_step_text_named("Part")
122    }
123
124    /// [`Self::export_step_text`] with the document's own name, which becomes
125    /// the root `PRODUCT`'s name (and the file's `FILE_NAME`).
126    ///
127    /// A document with ASSEMBLY COMPONENTS takes the STRUCTURED lane: each
128    /// parts-library entry is written once as its own product, in its own local
129    /// frame, and every instance becomes a `NEXT_ASSEMBLY_USAGE_OCCURRENCE`
130    /// carrying its pose — nested sub-assemblies included. Without components
131    /// the flat single-product writer is used, exactly as before.
132    pub fn export_step_text_named(&mut self, document_name: &str) -> Result<String, String> {
133        // The component projection the structured lane places instances by is
134        // the one the last run shipped (post-solve poses), so nothing has to be
135        // synced here; the resident re-run below registers the geometry.
136        let request: HistoryRequest = serde_json::from_value(self.run_request_value())
137            .map_err(|e| format!("export STEP: history request: {e}"))?;
138        let named = crate::pipeline::resident_solid_handles(&request);
139        // A board document's BOARD — substrate, copper, via barrels — as exact
140        // solids. The viewport's board is display triangles in no registry, so
141        // these are handed to the writer as values, in the ROOT product beside
142        // whatever the history made (`step_solids`).
143        let board = self.board_step_solids().unwrap_or_default();
144        for notice in &board.notices {
145            self.push_notice_as(super::NoticeSeverity::Warning, notice.clone());
146        }
147        if let Some(first) = board.skipped.first() {
148            // The file is still written; the user is told what it lacks.
149            self.push_notice(format!(
150                "STEP export: {} board item(s) could not be made as solids and are missing from the file — first: {first}",
151                board.skipped.len()
152            ));
153        }
154        if named.is_empty() && board.bodies.is_empty() {
155            return Err("nothing to export: the model has no solids".into());
156        }
157        // The document's PMI rides along as AP242 semantic PMI + saved views,
158        // resolved against the same resident solids the file is written from
159        // (the warm re-run above left the tail's report on this thread).
160        let report = request
161            .pmi
162            .as_ref()
163            .map(|_| {
164                let _trace = crate::run_trace::span("export_pmi");
165                brep_kernel::execute_history(&request).pmi
166            })
167            .flatten();
168        let pmi = match (request.pmi.as_ref(), report.as_ref()) {
169            (Some(state), Some(report)) => Some(brep_kernel::StepPmi { state, report }),
170            _ => None,
171        };
172        let mut colors = self.step_export_colors(&named);
173        for (name, rgb) in &board.colors {
174            // A colour the user stored under a board body's name still wins.
175            if self.metadata.attribute(name, brep_kernel::COLOR_METADATA_KEY).is_none() {
176                colors.set(name, *rgb);
177            }
178        }
179        // The resident solids by value — read on THIS thread, where the re-run
180        // above registered them — whenever the handle-taking writers cannot
181        // take the whole file: there is a board to add, or a product id to fix.
182        let resident = |named: &[(String, u32)]| -> Result<Vec<(String, brep_kernel::BrepSolid)>, String> {
183            named
184                .iter()
185                .map(|(name, handle)| Ok((name.clone(), brep_kernel::registered_solid_clone(*handle)?)))
186                .collect()
187        };
188        if self.assembly_components.is_empty() && !board.bodies.is_empty() {
189            let mut solids = resident(&named)?;
190            solids.extend(board.bodies);
191            let borrowed: Vec<(String, &brep_kernel::BrepSolid)> =
192                solids.iter().map(|(name, solid)| (name.clone(), solid)).collect();
193            return brep_kernel::export_step_report_named(
194                &borrowed,
195                document_name,
196                "MM",
197                "",
198                pmi.as_ref(),
199                Some(&colors),
200            )
201            .map(|report| report.text);
202        }
203        if self.assembly_components.is_empty() {
204            return brep_kernel::export_step_named_handles(
205                &named,
206                document_name,
207                "MM",
208                "",
209                pmi.as_ref(),
210                Some(&colors),
211            )
212            .map(|report| report.text);
213        }
214        let components: Vec<(String, String, brep_kernel::Mat4)> = self
215            .assembly_components
216            .iter()
217            .map(|record| {
218                (
219                    record.id.clone(),
220                    record.part_name.clone(),
221                    record.transform,
222                )
223            })
224            .collect();
225        let mut assembly =
226            brep_kernel::assembly_export_tree(document_name, resident(&named)?, &components)?;
227        // The board is a product of its own, placed in the root at identity,
228        // so a receiving system's tree lists it by name (`PCB`) beside the
229        // parts. As the root's own geometry it read as an unnamed compound
230        // (OpenCascade: `=>[0:1:1:6]`). Its colours are keyed again under the
231        // occurrence's namespace, which is how the writer resolves them.
232        if !board.bodies.is_empty() {
233            for (name, rgb) in &board.colors {
234                if self.metadata.attribute(name, brep_kernel::COLOR_METADATA_KEY).is_none() {
235                    colors.set(&format!("{BOARD_OCCURRENCE}:{name}"), *rgb);
236                }
237            }
238            for (name, rgb) in self.persisted_export_colors() {
239                if name.starts_with(super::board_geometry::BOARD_SOLID_PREFIX) {
240                    colors.set(&format!("{BOARD_OCCURRENCE}:{name}"), rgb);
241                }
242            }
243            assembly.products.push(brep_kernel::StepExportProduct {
244                name: BOARD_OCCURRENCE.to_string(),
245                id: BOARD_OCCURRENCE.to_string(),
246                bodies: board.bodies,
247                pmi: None,
248            });
249            assembly.occurrences.push(brep_kernel::StepExportOccurrence {
250                designator: BOARD_OCCURRENCE.to_string(),
251                parent: 0,
252                child: assembly.products.len() - 1,
253                placement: [
254                    1., 0., 0., 0., //
255                    0., 1., 0., 0., //
256                    0., 0., 1., 0., //
257                    0., 0., 0., 1.,
258                ],
259            });
260        }
261        for product in &mut assembly.products {
262            product.id = portable_product_id(&product.id, &product.name);
263        }
264        if self.history.pcb_block().is_some() && pmi.is_none() {
265            self.label_occurrences_by_reference(&mut assembly, &mut colors, &named);
266        }
267        brep_kernel::export_step_assembly_report(&assembly, "MM", "", pmi.as_ref(), Some(&colors))
268            .map(|report| report.text)
269    }
270
271    /// A BOARD document's placed parts, named in the STEP tree by their
272    /// REFERENCE DESIGNATOR (`U1`, `R3`) instead of the component feature id
273    /// (`ACOMP1`) — the name the engineer on the other end knows the part by.
274    ///
275    /// The occurrence label is also the namespace the writer resolves the
276    /// document's names through (`ACOMP1:IMPORT3D1_Face_0` finds its face under
277    /// the `ACOMP1:` occurrence), so every colour keyed under a relabelled
278    /// component is keyed again under its new label, or the KiCad body colours
279    /// would drop out of the file. PMI resolves through the same namespace,
280    /// which is why the caller only relabels a document that carries none.
281    ///
282    /// A reference is used only when it is unambiguous: non-empty, free of the
283    /// `:` namespace separator, carried by exactly one component, and neither
284    /// some other component's id nor the board's own label
285    /// ([`BOARD_OCCURRENCE`]). Anything else keeps its feature id.
286    fn label_occurrences_by_reference(
287        &self,
288        assembly: &mut brep_kernel::StepAssemblyExport,
289        colors: &mut brep_kernel::StepColors,
290        named: &[(String, u32)],
291    ) {
292        let mut references: std::collections::BTreeMap<String, String> = Default::default();
293        let mut seen: std::collections::BTreeMap<String, usize> = Default::default();
294        let mut ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
295        for feature in self.history.features() {
296            let is_component = feature["type"]
297                .as_str()
298                .is_some_and(super::components::is_acomp_feature_type);
299            let params = &feature["inputParams"];
300            let Some(id) = params["id"].as_str().filter(|_| is_component) else {
301                continue;
302            };
303            ids.insert(id.to_string());
304            let reference = params["bom"]["Reference_Designator"].as_str().unwrap_or("").trim();
305            if !reference.is_empty() && !reference.contains(':') {
306                references.insert(id.to_string(), reference.to_string());
307                *seen.entry(reference.to_string()).or_default() += 1;
308            }
309        }
310        references.retain(|_, reference| {
311            seen[reference.as_str()] == 1 && !ids.contains(reference.as_str()) && reference != BOARD_OCCURRENCE
312        });
313        if references.is_empty() {
314            return;
315        }
316        for occurrence in assembly.occurrences.iter_mut().filter(|o| o.parent == 0) {
317            if let Some(reference) = references.get(&occurrence.designator) {
318                occurrence.designator = reference.clone();
319            }
320        }
321        // The same colours `step_export_colors` keyed, keyed again under the
322        // new label: the persisted ones, and the name-hash look's.
323        let hashed = self.settings.face_color_mode == crate::style::FaceColorMode::HashedBySolid;
324        let mut keyed: Vec<(String, [u8; 3])> = self.persisted_export_colors();
325        if hashed {
326            for (name, _) in named {
327                if self.metadata.attribute(name, brep_kernel::COLOR_METADATA_KEY).is_none() {
328                    keyed.push((name.clone(), srgb_bytes(crate::color::solid_color_srgb(name))));
329                }
330            }
331        }
332        for (name, rgb) in keyed {
333            let Some((id, rest)) = name.split_once(':') else {
334                continue;
335            };
336            if let Some(reference) = references.get(id) {
337                colors.set(&format!("{reference}:{rest}"), rgb);
338            }
339        }
340    }
341
342    /// The body/face colours the STEP writer should emit as presentation styles.
343    ///
344    /// The METADATA STORE is the authority, exactly as it is for the viewport
345    /// ([`EngineState::sync_colors_from_metadata`]): every `color` attribute the
346    /// document persists — a STEP import's stamped colours, a colour picked in
347    /// the Info window, a colour restored from a saved file — becomes a style on
348    /// the body or face of that name. Spellings are read through
349    /// [`crate::style::parse_css_hex`], the one parser the display uses, so a
350    /// value the viewport shows is a value the file carries.
351    ///
352    /// `override_model_colors` is deliberately NOT honoured: it is a display
353    /// switch whose own contract is that the store is never touched, so a
354    /// document exported with the box ticked still carries all of its colours.
355    ///
356    /// The NAME-DERIVED fallback is the second half, and it is narrow on
357    /// purpose: a body with no persisted colour gets the stable name-hash
358    /// colour ONLY when the viewport is itself colouring by name hash
359    /// ([`crate::style::FaceColorMode::HashedBySolid`] — the artifact look, not
360    /// the default). In the default `Uniform` mode an uncoloured body gets no
361    /// style at all, so an export from a document nobody coloured is
362    /// byte-for-byte the file this writer produced before colour existed rather
363    /// than a model painted in colours the user never saw.
364    ///
365    /// Faces are never name-derived: the renderer hashes per SOLID, so there is
366    /// no per-face colour to match.
367    fn step_export_colors(&self, named: &[(String, u32)]) -> brep_kernel::StepColors {
368        let mut colors = brep_kernel::StepColors::new();
369        for (name, rgb) in self.persisted_export_colors() {
370            colors.set(&name, rgb);
371        }
372        if self.settings.face_color_mode == crate::style::FaceColorMode::HashedBySolid {
373            for (name, _) in named {
374                if self.metadata.attribute(name, brep_kernel::COLOR_METADATA_KEY).is_none() {
375                    colors.set(name, srgb_bytes(crate::color::solid_color_srgb(name)));
376                }
377            }
378        }
379        colors
380    }
381
382    /// Resident handle of the part's target sheet-metal body for a flat-pattern
383    /// export. Enumerates the current resident solids (a warm re-run of the same
384    /// prefix the display scene was built from, like the STEP lane) and keeps the
385    /// ones carrying a sheet-metal tree; uses the SELECTED sheet-metal body if the
386    /// selection names exactly one, else the SOLE sheet-metal body (the same
387    /// auto-target SM.CUTOUT uses). Errs with the exact `"no sheet-metal body in
388    /// the part"` when there is none, and loudly when several are ambiguous.
389    fn flat_pattern_target_handle(&self) -> Result<u32, String> {
390        let request: HistoryRequest = serde_json::from_value(self.run_request_value())
391            .map_err(|e| format!("export flat pattern: history request: {e}"))?;
392        let sheet_metal: Vec<(String, u32)> = crate::pipeline::resident_solid_handles(&request)
393            .into_iter()
394            .filter(|(_, handle)| brep_kernel::is_sheet_metal_handle(*handle))
395            .collect();
396        if sheet_metal.is_empty() {
397            return Err("no sheet-metal body in the part".into());
398        }
399        // Prefer a selected sheet-metal body when the selection names exactly one.
400        let selected: Vec<u32> = sheet_metal
401            .iter()
402            .filter(|(name, _)| self.emphasis.selected_solids.contains(name))
403            .map(|(_, handle)| *handle)
404            .collect();
405        if let [handle] = selected.as_slice() {
406            return Ok(*handle);
407        }
408        match sheet_metal.as_slice() {
409            [(_, handle)] => Ok(*handle),
410            _ => Err(
411                "several sheet-metal bodies in the part — select the one to export".into(),
412            ),
413        }
414    }
415
416    /// Export the part's sheet-metal FLAT PATTERN (the unfold) as a DXF (R12
417    /// ASCII) 2D vector document. Runs the unfold TRANSIENTLY off the target
418    /// body's resident tree — no feature is added and history is not mutated. Errs
419    /// (`"no sheet-metal body in the part"`) when the part carries no sheet metal.
420    pub fn export_flat_pattern_dxf(&self) -> Result<String, String> {
421        brep_kernel::flat_pattern_dxf(self.flat_pattern_target_handle()?)
422    }
423
424    /// Export the part's sheet-metal flat pattern as an SVG — the DXF sibling of
425    /// [`Self::export_flat_pattern_dxf`].
426    pub fn export_flat_pattern_svg(&self) -> Result<String, String> {
427        brep_kernel::flat_pattern_svg(self.flat_pattern_target_handle()?)
428    }
429
430    /// Import an IGES document into the model: append an `IMPORT3D` feature whose
431    /// `inputParams.igesText` is the raw IGES text (the kernel importer reads it
432    /// via [`brep_kernel::import_iges`]), mint an id, roll to it, and rebuild.
433    /// Refuses a non-IGES payload up front so a bad upload never leaves a dead
434    /// feature.
435    pub fn import_iges_feature(&mut self, iges_text: &str) -> Result<String, String> {
436        if iges_text.contains("ISO-10303-21") {
437            return Err("not an IGES file (this looks like a STEP document)".into());
438        }
439        // IGES records carry an S/G/D/P/T section letter in column 73.
440        let looks_like_iges = iges_text.lines().any(|line| {
441            matches!(line.chars().nth(72), Some('S' | 'G' | 'D' | 'P' | 'T'))
442        });
443        if !looks_like_iges {
444            return Err("not an IGES file (no S/G/D/P/T section records found)".into());
445        }
446        let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
447        let feature = serde_json::json!({
448            "type": "IMPORT3D",
449            "inputParams": { "id": id, "igesText": iges_text },
450            "persistentData": {},
451        });
452        // Frame the imported body once the (possibly async) run lands — see
453        // [`EngineState::pending_fit`] (mirrors the STEP lane above).
454        self.pending_fit = true;
455        self.add_feature(&feature.to_string())
456    }
457
458    /// Export the CURRENT model's resident solids to an IGES 5.3 document of
459    /// trimmed NURBS surfaces — the IGES analogue of [`Self::export_step_text`],
460    /// handing the resident handles to [`brep_kernel::export_iges_handles`].
461    pub fn export_iges_text(&self) -> Result<String, String> {
462        let request: HistoryRequest = serde_json::from_value(self.run_request_value())
463            .map_err(|e| format!("export IGES: history request: {e}"))?;
464        let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
465            .into_iter()
466            .map(|(_, handle)| handle)
467            .collect();
468        if handles.is_empty() {
469            return Err("nothing to export: the model has no solids".into());
470        }
471        brep_kernel::export_iges_handles(&handles, "Part", "MM", "")
472    }
473
474    /// Export the CURRENT display scene to an ASCII STL string (one `solid` with a
475    /// per-triangle geometric normal for every mesh triangle of every displayed
476    /// solid). STL is a triangle-soup format with no multi-body concept, so all
477    /// solids fold into a single `solid brep … endsolid brep`. String-shaped so it
478    /// crosses the same string `ModelStore` seam the STEP lane uses. Errs when the
479    /// scene has no triangles.
480    pub fn export_stl_text(&self) -> Result<String, String> {
481        let mut out = String::from("solid brep\n");
482        let mut triangles = 0usize;
483        for solid in self.scene.solids() {
484            let positions = &solid.mesh.positions;
485            for tri in solid.mesh.indices.chunks_exact(3) {
486                let a = positions[tri[0] as usize];
487                let b = positions[tri[1] as usize];
488                let c = positions[tri[2] as usize];
489                let normal = triangle_normal(a, b, c);
490                out.push_str(&format!(
491                    "  facet normal {} {} {}\n    outer loop\n",
492                    normal[0], normal[1], normal[2]
493                ));
494                for v in [a, b, c] {
495                    out.push_str(&format!("      vertex {} {} {}\n", v[0], v[1], v[2]));
496                }
497                out.push_str("    endloop\n  endfacet\n");
498                triangles += 1;
499            }
500        }
501        out.push_str("endsolid brep\n");
502        if triangles == 0 {
503            return Err("nothing to export: the scene has no triangles".into());
504        }
505        Ok(out)
506    }
507
508    /// Export the CURRENT display scene as Wavefront OBJ text — the same
509    /// triangles [`Self::export_stl_text`] writes, folded into ONE object `brep`
510    /// with the display normals kept per vertex, through the kernel's
511    /// [`brep_kernel::write_obj`]. Millimetres, like every other export lane.
512    /// Errs when the scene has no triangles.
513    pub fn export_obj_text(&self) -> Result<String, String> {
514        let mut mesh = brep_kernel::Mesh::default();
515        for solid in self.scene.solids() {
516            let base = (mesh.positions.len() / 3) as u32;
517            for (position, normal) in solid.mesh.positions.iter().zip(&solid.mesh.normals) {
518                mesh.positions.extend(position.iter().map(|value| f64::from(*value)));
519                mesh.normals.extend(normal.iter().map(|value| f64::from(*value)));
520            }
521            mesh.indices.extend(solid.mesh.indices.iter().map(|index| base + index));
522        }
523        if mesh.indices.is_empty() {
524            return Err("nothing to export: the scene has no triangles".into());
525        }
526        brep_kernel::write_obj(&mesh, "brep")
527    }
528
529    /// Every `color` attribute the document persists, as 8-bit sRGB keyed by
530    /// scene name. The METADATA STORE is the authority for BOTH export lanes —
531    /// the STEP writer's styles ([`Self::step_export_colors`]) and the GLB
532    /// writer's materials ([`Self::export_glb_bytes`]) — so a colour the Info
533    /// window's Metadata tab shows is the colour either file carries. Spellings
534    /// are read through [`crate::style::parse_css_hex`], the one parser the
535    /// display itself uses.
536    fn persisted_export_colors(&self) -> Vec<(String, [u8; 3])> {
537        self.metadata
538            .all()
539            .iter()
540            .filter_map(|(name, record)| {
541                let value = record.get(brep_kernel::COLOR_METADATA_KEY)?;
542                let rgb = crate::style::parse_css_hex(value)?;
543                Some((name.clone(), srgb_bytes(rgb.map(f64::from))))
544            })
545            .collect()
546    }
547
548    /// The colour the VIEWPORT is showing a body with no persisted colour of
549    /// its own: the uniform `face_color` setting, or the stable name-hash in the
550    /// artifact look. This is the one place the GLB lane parts company with the
551    /// STEP lane, which leaves such a body unstyled — glTF has no "no style"
552    /// state, so a primitive with no material renders in the format's own
553    /// default (white, fully metallic), a colour nobody chose.
554    fn display_body_color(&self, name: &str) -> [u8; 3] {
555        match self.settings.face_color_mode {
556            crate::style::FaceColorMode::HashedBySolid => {
557                srgb_bytes(crate::color::solid_color_srgb(name))
558            }
559            crate::style::FaceColorMode::Uniform => {
560                let face = self.settings.face_color;
561                srgb_bytes([f64::from(face[0]), f64::from(face[1]), f64::from(face[2])])
562            }
563        }
564    }
565
566    /// Export the CURRENT display scene as a binary glTF 2.0 (`.glb`) document —
567    /// the same triangles [`Self::export_stl_text`] and [`Self::export_obj_text`]
568    /// write, through the kernel's [`brep_kernel::write_glb`]. Unlike those two,
569    /// glTF HAS a multi-body concept, so each scene solid becomes its own named
570    /// mesh instead of folding into one object.
571    ///
572    /// BINARY, not text: the bytes cross the store's byte-shaped interchange
573    /// (`export_file_named_bytes`), because base64 through the text lane would
574    /// inflate the file by a third for nothing.
575    ///
576    /// The axis convention (+Y up, which is both glTF's and this application's,
577    /// so the identity) and the millimetre-to-metre unit both ride on the root
578    /// NODE's transform — see the kernel module's doc. COLOURS are the metadata
579    /// store's, the same authority the STEP lane reads, with a face's beating its
580    /// body's; a body the store says nothing about takes the colour the viewport
581    /// shows it in ([`Self::display_body_color`]). `override_model_colors` is not
582    /// honoured, exactly as it is not by the STEP lane: it is a display switch
583    /// whose contract is that the store is never touched.
584    ///
585    /// Errs when the scene has no triangles.
586    pub fn export_glb_bytes(&self, document_name: &str) -> Result<Vec<u8>, String> {
587        let colors: std::collections::BTreeMap<String, [u8; 3]> =
588            self.persisted_export_colors().into_iter().collect();
589        let solids: Vec<brep_kernel::GlbSolid> = self
590            .scene
591            .solids()
592            .iter()
593            .map(|solid| {
594                let mut mesh = brep_kernel::Mesh::default();
595                for (position, normal) in solid.mesh.positions.iter().zip(&solid.mesh.normals) {
596                    mesh.positions.extend(position.iter().map(|value| f64::from(*value)));
597                    mesh.normals.extend(normal.iter().map(|value| f64::from(*value)));
598                }
599                mesh.indices = solid.mesh.indices.clone();
600                // The kernel writer groups by the per-TRIANGLE face id, which is
601                // an index into this solid's own `faces` — the same numbering
602                // `face_colors` is keyed by below.
603                mesh.face_ids = solid.mesh.face_ids.clone();
604                let face_colors = solid
605                    .faces
606                    .iter()
607                    .enumerate()
608                    .filter_map(|(index, face)| {
609                        colors.get(&face.name).map(|rgb| (index as u32, *rgb))
610                    })
611                    .collect();
612                brep_kernel::GlbSolid {
613                    name: solid.name.clone(),
614                    mesh,
615                    color: Some(
616                        colors
617                            .get(&solid.name)
618                            .copied()
619                            .unwrap_or_else(|| self.display_body_color(&solid.name)),
620                    ),
621                    face_colors,
622                }
623            })
624            .collect();
625        brep_kernel::write_glb(
626            &solids,
627            document_name,
628            brep_kernel::GlbUpAxis::YUp,
629            METRES_PER_MILLIMETRE,
630        )
631        .map(|(bytes, _report)| bytes)
632    }
633    /// The solids a 3D PDF shows ([`crate::sheets::pdf3d::Body`]): every
634    /// visible, non-sketch solid with triangles, UN-POSED (an explode the
635    /// active PMI view applies to the viewport is that view's, not the
636    /// model's), coloured by the GLB lane's rule — a face's persisted colour,
637    /// else its body's, else the colour the viewport shows the body in — with
638    /// its real edges (not the auxiliary or centre-line display edges).
639    pub fn pdf3d_bodies(&self) -> Vec<crate::sheets::pdf3d::Body> {
640        let colors: std::collections::BTreeMap<String, [u8; 3]> =
641            self.persisted_export_colors().into_iter().collect();
642        self.scene
643            .solids()
644            .iter()
645            .filter(|solid| solid.visible && !solid.is_sketch && solid.mesh.indices.len() >= 3)
646            .map(|shown| {
647                let solid = self.pmi_explode_originals.get(&shown.name).unwrap_or(shown);
648                let body = colors.get(&solid.name).copied().unwrap_or_else(|| self.display_body_color(&solid.name));
649                let face_colors: Vec<[u8; 3]> = solid
650                    .faces
651                    .iter()
652                    .map(|face| colors.get(&face.name).copied().unwrap_or(body))
653                    .collect();
654                crate::sheets::pdf3d::Body {
655                    name: solid.name.clone(),
656                    positions: solid.mesh.positions.clone(),
657                    normals: solid.mesh.normals.clone(),
658                    indices: solid.mesh.indices.clone(),
659                    triangle_colors: solid
660                        .mesh
661                        .face_ids
662                        .iter()
663                        .map(|face| face_colors.get(*face as usize).copied().unwrap_or(body))
664                        .collect(),
665                    edges: solid
666                        .edges
667                        .iter()
668                        .filter(|edge| !edge.aux && !edge.centerline && edge.polyline.len() >= 2)
669                        .map(|edge| edge.polyline.clone())
670                        .collect(),
671                }
672            })
673            .collect()
674    }
675}
676
677/// The model's unit expressed in glTF's. Every export lane writes millimetres
678/// (the STEP writer's `MM`) and glTF's own linear unit is the METRE, so the GLB
679/// root node carries this as its scale rather than the vertices being rewritten.
680const METRES_PER_MILLIMETRE: f64 = 0.001;
681
682/// Unit (or zero, for a degenerate triangle) geometric normal of triangle
683/// `(a, b, c)` — the per-facet normal an ASCII STL record carries.
684fn triangle_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
685    let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
686    let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
687    let n = [
688        u[1] * v[2] - u[2] * v[1],
689        u[2] * v[0] - u[0] * v[2],
690        u[0] * v[1] - u[1] * v[0],
691    ];
692    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
693    if len > 0.0 {
694        [n[0] / len, n[1] / len, n[2] / len]
695    } else {
696        [0.0, 0.0, 0.0]
697    }
698}
699
700// ===========================================================================
701// STRUCTURED STEP import — the assembly lane (kernel-plan
702// `step-assembly-import.md` §3.7).
703//
704// The flat lane above (`import_step_feature`) appends ONE IMPORT3D holding the
705// raw Part-21 text and lets the kernel bake every occurrence's world transform
706// into its own body: N bodies, no parts, no tree. This lane keeps the structure
707// instead — each unique geometry-bearing PRODUCT_DEFINITION becomes ONE
708// parts-library entry holding a NATIVE payload (`nativeBrep`, no STEP text
709// anywhere past this door), and each occurrence of it becomes an ACOMP instance
710// carrying the composed world pose. Six bolts are then one entry × six
711// instances, which is what makes the BOM, the structure tree, per-component
712// selection and constraints work on imported geometry.
713//
714// # FLAT or NESTED — the user's choice, both correct
715//
716// [`StepAssemblyImport::nested`] picks between two shapes of the same geometry
717// (kernel-plan §3.3):
718//
719// - **Flat** flattens the occurrence tree to its geometry-bearing leaves: one
720//   ACOMP per leaf occurrence, each carrying the COMPOSED world pose. Every
721//   part is stored once for the whole document.
722// - **Nested** keeps the tree: each assembly-node product becomes a part
723//   document that itself carries `{partsLibrary, features: [ACOMP…,
724//   IMPORT3D…]}`, built bottom-up by the same recursive builder, and the
725//   parent gets ONE ACOMP per sub-assembly occurrence. Build-spec §2.2's
726//   rigid nesting — the sub-assembly arrives already-solved and moves as one
727//   component, the live `ComponentMap` stays flat, and the structure tree
728//   expands it read-only from the namespace chain (`ACOMP2:ACOMP1:…`).
729//
730// Neither is the deprecated one. Nested shows the real tree; flat is the right
731// answer for a deep or pathological file, and it stores a part reused at two
732// levels ONCE, where nesting stores it once PER LEVEL (build-spec §2.2). For a
733// depth-1 tree the two lanes produce byte-identical documents — the cheapest
734// correctness check there is, and `nested_matches_flat_for_a_depth_one_tree`
735// asserts exactly it.
736//
737// # PROBE then CONSUME — because the parse is the expensive half
738//
739// The app must know the counts BEFORE it can offer the choice ("7 parts, 23
740// instances — import as assembly or as bodies?"), and re-reading multi-MB
741// Part-21 text after the user clicks would pay the file's single most expensive
742// cost twice. So [`EngineState::probe_step_assembly`] performs the ONE parse and
743// stashes the [`brep_kernel::StepAssembly`] in
744// [`EngineState::pending_step_assembly`];
745// [`EngineState::import_probed_step_assembly`] TAKES it. Cancel
746// ([`EngineState::discard_probed_step_assembly`]), a second probe, and a
747// document switch all drop it, so a user who cancels three imports is holding
748// zero parsed assemblies — a real consideration, since the stash keeps every
749// product's solids resident for as long as the dialog is open.
750//
751// # ONE rebuild for the whole import
752//
753// `add_feature` re-runs the entire history per call, so appending N instances
754// through it is O(N²). This lane appends them all through
755// [`EngineState::add_features`] — one push batch, one rebuild, one undo step.
756// (Not `set_history_json`: that is the document-SWITCH path, which clears the
757// kernel history cache and resets the runner's delta baseline.)
758//
759// # Fallback, never a silent zero
760//
761// No structure at all, or every geometry-bearing product failing to encode, both
762// end at today's flat lane. "A successful import that produces zero components"
763// is a failure wearing a result's clothes, so the zero-component case is an
764// `Err` for the dialog-driven entry point (the app owns the file text and re-runs
765// the flat import) and an automatic fall-back for the text-taking convenience.
766// ===========================================================================
767
768/// What the import dialog needs to describe a STEP file's structure — counts
769/// only, so the probe can answer without building anything.
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
771pub struct StepAssemblyProbe {
772    /// Unique geometry-bearing products → parts-library entries. The floor, not
773    /// the final count: a non-rigid occurrence bakes its own extra entry (§3.4).
774    pub parts: usize,
775    /// Geometry-bearing occurrences → ACOMP instance features.
776    pub instances: usize,
777    /// Longest root→node chain of occurrences. `1` is a flat assembly; `> 1`
778    /// means sub-assemblies exist, so [`StepAssemblyImport::nested`] changes
779    /// the shape of the result and the dialog's choice is worth offering.
780    pub nested_depth: usize,
781}
782
783/// The choices the import dialog collects.
784#[derive(Debug, Clone, Copy, Default)]
785pub struct StepAssemblyImport {
786    /// Build nested rigid sub-assembly documents (kernel-plan §3.3 Phase 2)
787    /// instead of flattening the tree to its leaf occurrences.
788    ///
789    /// `false` (the `Default`) is the flat lane, byte-for-byte unchanged. On a
790    /// depth-1 tree the two produce the same document, so this flag only ever
791    /// matters for a file that really has sub-assemblies.
792    pub nested: bool,
793}
794
795/// What an import did — the numbers the status line and notice report.
796#[derive(Debug, Clone, Default, PartialEq, Eq)]
797pub struct StepAssemblyReport {
798    /// Parts-library entries this import added or reused — the entries of the
799    /// USER'S document. On a nested import that is the top level only: a
800    /// sub-assembly's own entries live in ITS document's library, which the
801    /// parent never sees.
802    pub parts: usize,
803    /// ACOMP instance features appended to the user's document. Nested: one per
804    /// ROOT-level occurrence (a sub-assembly is one component, per build-spec
805    /// §2.2), not one per leaf body.
806    pub instances: usize,
807    /// Occurrences whose non-rigid factor was baked into a distinct part
808    /// (§3.4), summed over every level a nested import built.
809    pub baked_nonrigid: usize,
810    /// Products (or baked non-rigid variants of one) that did not encode to a
811    /// payload — skipped and counted, never fatal: the importer's
812    /// graceful-degradation contract, carried up to this altitude. Summed over
813    /// every level a nested import built.
814    pub failed_products: usize,
815    /// The first thing that went wrong, from the kernel's body-build errors or
816    /// this lane's own encode failures.
817    pub first_error: Option<String>,
818    /// The structured lane did not run: the file carries no usable structure, or
819    /// nothing in it encoded, so the bodies were imported through the flat lane
820    /// exactly as before. Only ever `true` from [`EngineState::import_step_assembly`],
821    /// which holds the text; the dialog-driven entry point returns `Err` instead
822    /// and lets its caller re-run the flat import it already has the text for.
823    pub flat_fallback: bool,
824}
825
826/// The outcome of consuming a parsed assembly, before it is shaped into either
827/// an `Err` (dialog lane) or a flat fallback (text lane) — so neither has to
828/// recognise "nothing imported" by matching an error string.
829enum Consumed {
830    Imported(StepAssemblyReport),
831    /// Every geometry-bearing product failed to encode: no components, so this
832    /// is not an import.
833    NoComponents {
834        failed_products: usize,
835        first_error: Option<String>,
836    },
837}
838
839/// A row-major 4×4 affine, the shape `StepOccurrence::placement` and
840/// `AffineTransform` both use.
841type Mat4 = [f64; 16];
842
843/// One node of the composed occurrence tree — a product at a world pose.
844struct PlacedProduct {
845    /// Index into `StepAssembly::products`.
846    product: usize,
847    /// Composed child-local → world transform.
848    world: Mat4,
849    /// Occurrence edges between a root and this node (`0` at a root).
850    depth: usize,
851    /// Every edge on the path here was rigid, so `world` IS a component pose.
852    /// False means the non-rigid factor must be baked into the part (§3.4).
853    rigid_path: bool,
854    /// The STEP occurrence edges walked to get here, root first — the chain a
855    /// lifted OCCURRENCE-scoped annotation names its geometry through
856    /// (`brep_kernel::rewrite_occurrence_refs`). Empty at a root.
857    chain: Vec<usize>,
858}
859
860/// A parts-library entry this import needs: a product, plus the bits of the
861/// non-rigid factor baked into it (all-zero linear block ⇒ none). Two
862/// occurrences of one product under DIFFERENT non-rigid factors are different
863/// parts — never a wrong-handed reuse.
864type PartKey = (usize, [u64; 9]);
865
866/// The `PartKey` factor slot for a plain rigid instance.
867const NO_FACTOR: [u64; 9] = [0; 9];
868
869impl EngineState {
870    /// Read a STEP file's product structure — THE parse of a structured import.
871    /// Stashes the parsed assembly (with every product's solids) for
872    /// [`Self::import_probed_step_assembly`] and returns the dialog's counts.
873    ///
874    /// `Ok(None)` = no usable structure (no NAUO edges, or none reaching built
875    /// geometry): the caller imports through the flat
876    /// [`Self::import_step_feature`] lane with the text it already holds, which
877    /// is byte-for-byte today's behaviour. `Err` only for text that is not a
878    /// Part 21 file at all — a BROKEN assembly degrades, it does not fail.
879    ///
880    /// Replaces any previously stashed assembly on EVERY outcome, `Ok(None)`
881    /// included: a stale stash surviving a probe of a different file is how a
882    /// consume silently imports the wrong one.
883    pub fn probe_step_assembly(
884        &mut self,
885        step_text: &str,
886    ) -> Result<Option<StepAssemblyProbe>, String> {
887        let id = self.submit_step_probe(step_text);
888        // Inline answers inside `submit`'s own pump; a background runner has
889        // not answered yet and this call must not pretend it has.
890        match self.take_step_probe() {
891            Some((answered, outcome)) if answered == id => match outcome {
892                super::StepProbeOutcome::Structure(probe) => Ok(Some(probe)),
893                super::StepProbeOutcome::Flat => Ok(None),
894                super::StepProbeOutcome::Failed(error) => Err(error),
895            },
896            _ => Err(
897                "the STEP probe is still running on the background runner — use \
898                 submit_step_probe / take_step_probe"
899                    .into(),
900            ),
901        }
902    }
903
904    /// SUBMIT a STEP text to be probed for product structure on the runner
905    /// (the parse builds every product's bodies: seconds for a real assembly,
906    /// which is why it leaves the UI thread). The answer arrives through
907    /// [`Self::take_step_probe`] under the returned id, after a later `pump`;
908    /// a found structure is stashed for [`Self::import_probed_step_assembly`].
909    /// Any earlier stash is dropped now — the probe REPLACES it on every
910    /// outcome, so a stale parse can never be consumed for the wrong file.
911    ///
912    /// The structure test the parse would make is "any NEXT_ASSEMBLY_USAGE_
913    /// OCCURRENCE entity" (`assembly_edges`), so a text with none cannot have
914    /// structure and is answered `Flat` without a trip to the runner: a part
915    /// file — the common upload — no longer pays a full parse only to be told
916    /// to take the flat lane, where the worker parses it anyway. (The text
917    /// test is a superset of the entity test: a stray mention in a comment
918    /// merely runs the parse.)
919    pub fn submit_step_probe(&mut self, step_text: &str) -> u64 {
920        self.pending_step_assembly = None;
921        let id = self.next_step_probe_id;
922        self.next_step_probe_id = self.next_step_probe_id.wrapping_add(1);
923        if !step_text.contains("NEXT_ASSEMBLY_USAGE_OCCURRENCE") {
924            self.step_probe_results
925                .push_back((id, super::StepProbeOutcome::Flat));
926            return id;
927        }
928        self.pending_step_probes.insert(id);
929        self.runner.submit_step_probe(crate::runner::StepProbeRequest {
930            id,
931            text: step_text.to_string(),
932        });
933        self.pump();
934        id
935    }
936
937    /// The oldest answered probe, if any: its submission id and what it found.
938    pub fn take_step_probe(&mut self) -> Option<(u64, super::StepProbeOutcome)> {
939        self.step_probe_results.pop_front()
940    }
941
942    /// Whether a submitted probe has not been answered yet — the app keeps the
943    /// frame loop alive (and the panel its "reading…" status) while it is.
944    pub fn step_probes_pending(&self) -> bool {
945        !self.pending_step_probes.is_empty()
946    }
947
948    /// Import the assembly [`Self::probe_step_assembly`] stashed: one
949    /// parts-library entry per unique product, one ACOMP instance per
950    /// occurrence, ONE rebuild. TAKES the stash, so a double-import is an error
951    /// rather than a double-insert.
952    ///
953    /// `doc_name` names products the file left unnamed (`{doc_name}-part-{id}`).
954    /// `opts.nested` chooses between the flat and nested shapes — see
955    /// [`StepAssemblyImport::nested`]. Errs when nothing is stashed, and when
956    /// every product failed to encode — the latter being the caller's cue to
957    /// re-run the flat import with the file text it holds.
958    /// `sink` receives every unique part document so the app can write it to
959    /// the model store and hand back a real `sourceKey`; pass [`EmbeddedOnly`]
960    /// to keep the parts embedded (what a caller with no store does).
961    pub fn import_probed_step_assembly(
962        &mut self,
963        doc_name: &str,
964        opts: StepAssemblyImport,
965        sink: &mut dyn PartSink,
966    ) -> Result<StepAssemblyReport, String> {
967        let assembly = self.pending_step_assembly.take().ok_or_else(|| {
968            "import STEP assembly: nothing probed (call probe_step_assembly first)".to_string()
969        })?;
970        match self.consume_step_assembly(assembly, doc_name, opts.nested, sink) {
971            Consumed::Imported(report) => Ok(report),
972            Consumed::NoComponents { first_error, .. } => Err(format!(
973                "import STEP assembly: no part of the assembly could be built{}",
974                first_error
975                    .map(|error| format!(" ({error})"))
976                    .unwrap_or_default()
977            )),
978        }
979    }
980
981    /// Drop a probed assembly and the solids it holds resident — the dialog's
982    /// Cancel. Idempotent.
983    pub fn discard_probed_step_assembly(&mut self) {
984        self.pending_step_assembly = None;
985    }
986
987    /// Probe + consume in one call, falling back to the flat lane by itself —
988    /// the HEADLESS/test entry point. The app uses the probe/consume pair
989    /// instead, because it has a dialog between the two halves.
990    ///
991    /// Still exactly one parse: this is `probe_step_assembly` followed by the
992    /// consume of what it stashed.
993    ///
994    /// Parts stay EMBEDDED here ([`EmbeddedOnly`]): this entry point has no
995    /// store handle and no way to ask for a destination. The app uses the
996    /// probe/consume pair with a real sink.
997    pub fn import_step_assembly(
998        &mut self,
999        step_text: &str,
1000        doc_name: &str,
1001        opts: StepAssemblyImport,
1002    ) -> Result<StepAssemblyReport, String> {
1003        let structured = self.probe_step_assembly(step_text)?.is_some();
1004        let outcome = structured.then(|| {
1005            let assembly = self
1006                .pending_step_assembly
1007                .take()
1008                .expect("a Some probe stashed the assembly it counted");
1009            self.consume_step_assembly(assembly, doc_name, opts.nested, &mut EmbeddedOnly)
1010        });
1011        match outcome {
1012            Some(Consumed::Imported(report)) => Ok(report),
1013            // No structure, or a structure nothing built out of: import the
1014            // bodies exactly as the pre-assembly lane did.
1015            Some(Consumed::NoComponents {
1016                failed_products,
1017                first_error,
1018            }) => {
1019                self.import_step_feature(step_text)?;
1020                Ok(StepAssemblyReport {
1021                    failed_products,
1022                    first_error,
1023                    flat_fallback: true,
1024                    ..StepAssemblyReport::default()
1025                })
1026            }
1027            None => {
1028                self.import_step_feature(step_text)?;
1029                Ok(StepAssemblyReport {
1030                    flat_fallback: true,
1031                    ..StepAssemblyReport::default()
1032                })
1033            }
1034        }
1035    }
1036
1037    /// The import itself (kernel-plan §3.7 steps 2-5), shared by both entry
1038    /// points so neither has to recognise "nothing imported" from an error
1039    /// string.
1040    fn consume_step_assembly(
1041        &mut self,
1042        assembly: brep_kernel::StepAssembly,
1043        doc_name: &str,
1044        nested: bool,
1045        sink: &mut dyn PartSink,
1046    ) -> Consumed {
1047        let mut first_error = assembly.first_error.clone();
1048        // ONE writer for the whole import, so identical content is written to
1049        // the store exactly once however many products or LEVELS share it.
1050        let mut writer = PartWriter::new(sink);
1051
1052        // --- what to build ------------------------------------------------
1053        // One row per component the USER'S document gets, each naming the
1054        // library entry it needs. Flat walks the whole tree to its leaves;
1055        // nested stops at the root's own children and folds everything below
1056        // each of them into that child's part document.
1057        let plan = if nested {
1058            plan_nested(&assembly, doc_name, &mut first_error, &mut writer)
1059        } else {
1060            plan_flat(&assembly, &mut first_error)
1061        };
1062        let Plan {
1063            wanted,
1064            factors,
1065            documents,
1066            mut failed_products,
1067            baked_below_root,
1068        } = plan;
1069
1070        // --- build the library entries -------------------------------------
1071        // In (pd_ref, factor) order so an import is deterministic regardless of
1072        // the tree's emit order, and ONCE per key however many instances use it.
1073        let mut keys: Vec<PartKey> = wanted.iter().map(|(key, _, _)| *key).collect();
1074        keys.sort_unstable();
1075        keys.dedup();
1076        let mut entry_names: std::collections::HashMap<PartKey, String> =
1077            std::collections::HashMap::new();
1078        {
1079            // THE metadata bracket. `native_import_payload` seals whatever record
1080            // this thread's scene-metadata store holds for each name it stamps —
1081            // right for a snapshot of the live scene, catastrophic here: a new
1082            // part whose stamped face names collide with names already in THIS
1083            // document would silently carry the current document's metadata.
1084            // Scoped to the encode alone; the rebuild below stamps records the
1085            // document must keep, and this guard's drop would discard them.
1086            //
1087            // The nested lane's payloads are encoded inside `plan_nested`,
1088            // which holds a bracket of its own for exactly the same reason.
1089            let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
1090            for key in &keys {
1091                // Nested pre-built the whole document (a sub-assembly's is a
1092                // recursive `{partsLibrary, features}`); flat builds the §3.2
1093                // native part document right here.
1094                let built = match documents.get(key) {
1095                    Some((name, document)) => install_part(name, document, &mut writer),
1096                    None => {
1097                        let product = assembly
1098                            .products
1099                            .iter()
1100                            .find(|product| product.pd_ref == key.0)
1101                            .expect("every key names a product of this assembly");
1102                        build_library_entry(product, factors.get(key), doc_name, &mut writer)
1103                    }
1104                };
1105                match built {
1106                    Ok(name) => {
1107                        entry_names.insert(*key, name);
1108                    }
1109                    Err(error) => {
1110                        failed_products += 1;
1111                        note(&mut first_error, error);
1112                    }
1113                }
1114            }
1115        }
1116        if entry_names.is_empty() {
1117            return Consumed::NoComponents {
1118                failed_products,
1119                first_error,
1120            };
1121        }
1122
1123        // --- append every instance in ONE history mutation -----------------
1124        // `insert_component`'s rule, verbatim: ground the FIRST component only
1125        // when the document has none yet. Grounding a second one over-constrains
1126        // the next solve.
1127        let mut ground_next = !(0..self.history.len()).any(|index| {
1128            matches!(
1129                self.history.feature_type(index).as_deref(),
1130                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
1131            )
1132        });
1133        let mut features: Vec<serde_json::Value> = Vec::with_capacity(wanted.len());
1134        let mut baked_nonrigid = 0usize;
1135        // Which component carries the geometry a STEP occurrence chain names —
1136        // the map the file's occurrence-scoped PMI is rewritten through, built
1137        // while the ids are minted because that is the only moment both halves
1138        // exist.
1139        let mut namespaces: std::collections::HashMap<Vec<usize>, String> =
1140            std::collections::HashMap::new();
1141        for (key, pose, chain) in &wanted {
1142            let Some(part_name) = entry_names.get(key) else {
1143                continue; // this product failed to encode; counted above
1144            };
1145            let transform = match brep_kernel::AffineTransform::new(*pose) {
1146                Ok(transform) => transform,
1147                Err(error) => {
1148                    note(&mut first_error, format!("occurrence pose: {error}"));
1149                    continue;
1150                }
1151            };
1152            if key.1 != NO_FACTOR {
1153                baked_nonrigid += 1;
1154            }
1155            let id = self.history.next_feature_id("ACOMP");
1156            namespaces
1157                .entry(chain.clone())
1158                .or_insert_with(|| format!("{id}:"));
1159            features.push(serde_json::json!({
1160                "type": "ACOMP",
1161                "inputParams": {
1162                    "id": id,
1163                    "partName": part_name,
1164                    "transform": brep_kernel::transform_to_pose_params(&transform),
1165                    "isFixed": ground_next,
1166                },
1167                "persistentData": {}
1168            }));
1169            ground_next = false;
1170        }
1171        if features.is_empty() {
1172            return Consumed::NoComponents {
1173                failed_products,
1174                first_error,
1175            };
1176        }
1177
1178        // The library block must ride the request so the display runner ingests
1179        // the new entries on the very next run (as `insert_component` does).
1180        // Written only now that there are components to reference them, so an
1181        // import that produced nothing leaves the document untouched.
1182        if let Ok(library) =
1183            serde_json::from_str::<serde_json::Value>(&brep_kernel::parts_library_json())
1184        {
1185            self.history.set_parts_library(library);
1186        }
1187        // Frame the assembly once the (possibly async) run lands — see
1188        // [`EngineState::pending_fit`], same reasoning as `import_step_feature`.
1189        self.pending_fit = true;
1190        let instances = features.len();
1191        let baked_nonrigid = baked_nonrigid + baked_below_root;
1192        self.add_features(&features);
1193
1194        // The file's ASSEMBLY-level PMI — annotations it pinned to an
1195        // occurrence, annotations spanning two parts, free notes — merged into
1196        // the document's own block with its references rewritten into THIS
1197        // import's component namespaces.
1198        //
1199        // AFTER the features, and with no checkpoint of its own, for the
1200        // reason `import_step_feature` reads its PMI BEFORE its `add_feature`:
1201        // `push_features` checkpoints at its START, so a block written first
1202        // is inside the snapshot undo restores — one undo would take the
1203        // components away and leave the annotations behind, every one of them
1204        // naming a component that no longer exists. Both writes have to fall
1205        // after the one checkpoint.
1206        //
1207        // It costs a second rebuild, which is what the flat lane already pays,
1208        // and only on a file that carries assembly-level PMI at all.
1209        //
1210        // A part's OWN PMI is not here: it rode into that part's document in
1211        // `native_part_document`, once for the part however many instances
1212        // place it — the exporter's rule, which is what makes the round trip
1213        // land the same annotations on the same parts.
1214        let pmi_unplaced = self.merge_assembly_pmi(&assembly, &namespaces);
1215        for line in &assembly.pmi_diagnostics {
1216            note(&mut first_error, line.clone());
1217        }
1218        if pmi_unplaced > 0 {
1219            note(
1220                &mut first_error,
1221                format!(
1222                    "{pmi_unplaced} PMI reference(s) name an occurrence this import placed no \
1223                     component for; the annotations are kept unresolved"
1224                ),
1225            );
1226        }
1227        Consumed::Imported(StepAssemblyReport {
1228            // DISTINCT entries, not distinct keys: `add_part_to_library` reuses
1229            // an entry whose content already matches, so two products that are
1230            // the same geometry collapse to one part (§3.5's free content dedup).
1231            parts: entry_names
1232                .values()
1233                .collect::<std::collections::HashSet<_>>()
1234                .len(),
1235            instances,
1236            baked_nonrigid,
1237            failed_products,
1238            first_error,
1239            flat_fallback: false,
1240        })
1241    }
1242}
1243
1244impl EngineState {
1245    /// Merge a structured import's ASSEMBLY-level PMI into the open document's
1246    /// block, rewriting every occurrence-scoped reference into the component
1247    /// namespace this import minted for that STEP occurrence chain.
1248    ///
1249    /// Returns how many references named a chain with no component — a NESTED
1250    /// import folds everything below a root child into that child's own
1251    /// document, so an annotation the file pinned three levels down has no
1252    /// top-level component to name. Those references keep the chain they came
1253    /// with, so the row reports itself in the panel instead of silently
1254    /// resolving to some other instance.
1255    ///
1256    /// No rerun and no undo checkpoint of its own: the caller writes this
1257    /// before `add_features`, whose single rebuild resolves it.
1258    fn merge_assembly_pmi(
1259        &mut self,
1260        assembly: &brep_kernel::StepAssembly,
1261        namespaces: &std::collections::HashMap<Vec<usize>, String>,
1262    ) -> usize {
1263        let Some(lifted) = assembly.pmi.clone() else {
1264            return 0;
1265        };
1266        let mut unplaced = 0usize;
1267        let mut state = self.pmi_state();
1268        for view in lifted.views {
1269            let id = state.next_id("VIEW");
1270            let mut annotations = Vec::with_capacity(view.annotations.len());
1271            for mut annotation in view.annotations {
1272                unplaced += brep_kernel::rewrite_occurrence_refs(&mut annotation.params, |chain| {
1273                    namespaces.get(chain).cloned()
1274                });
1275                let prefix = brep_kernel::pmi_type(&annotation.kind)
1276                    .map(|def| def.short_name)
1277                    .unwrap_or("PMI");
1278                let fresh = state.next_id(prefix);
1279                if let Some(object) = annotation.params.as_object_mut() {
1280                    object.insert("id".into(), serde_json::Value::String(fresh));
1281                }
1282                annotations.push(annotation);
1283            }
1284            state.views.push(brep_kernel::PmiView {
1285                id,
1286                name: view.name,
1287                camera: view.camera,
1288                display: view.display,
1289                annotations,
1290            });
1291        }
1292        let block = serde_json::to_value(&state).ok();
1293        self.history.set_pmi_block_no_undo(block);
1294        // The tail has to re-resolve the merged block against the components
1295        // the run above created; nothing else in this lane reruns after them.
1296        self.rerun_history();
1297        unplaced
1298    }
1299}
1300
1301/// What one import decided to build, before any of it is installed: the rows
1302/// the user's document gets, and whatever each lane needed to work out on the
1303/// way there.
1304#[derive(Default)]
1305struct Plan {
1306    /// One row per component of the USER'S document, in emit order: the
1307    /// library entry it needs, its pose, and the STEP occurrence chain it
1308    /// stands for — which is how a lifted occurrence-scoped annotation finds
1309    /// the component that carries its geometry.
1310    wanted: Vec<(PartKey, Mat4, Vec<usize>)>,
1311    /// FLAT only: the non-rigid factor a key's part must bake (§3.4). The
1312    /// nested lane bakes inside its own builder and hands the finished document
1313    /// over in `documents` instead.
1314    factors: std::collections::HashMap<PartKey, Mat4>,
1315    /// NESTED only: `(entry name, part document)` per key, already built — a
1316    /// leaf's §3.2 native document, or a sub-assembly's recursive
1317    /// `{partsLibrary, features}`.
1318    documents: std::collections::HashMap<PartKey, (String, serde_json::Value)>,
1319    /// Products that did not encode while planning (nested builds payloads
1320    /// during the plan; flat builds them during the install).
1321    failed_products: usize,
1322    /// Non-rigid occurrences baked BELOW the root — nested only, since the flat
1323    /// lane has no below-the-root and counts its bakes at install time.
1324    baked_below_root: usize,
1325}
1326
1327/// **FLAT** (kernel-plan §3.3 Phase 1): flatten the occurrence tree to its
1328/// geometry-bearing nodes, each carrying the COMPOSED world pose. Byte-for-byte
1329/// the lane A6 shipped.
1330fn plan_flat(assembly: &brep_kernel::StepAssembly, first_error: &mut Option<String>) -> Plan {
1331    let mut plan = Plan::default();
1332    for placed in &compose_world_occurrences(assembly) {
1333        let product = &assembly.products[placed.product];
1334        if product.bodies.is_empty() {
1335            continue; // a pure assembly node contributes structure, not a component
1336        }
1337        let (key, pose) = if placed.rigid_path {
1338            ((product.pd_ref, NO_FACTOR), placed.world)
1339        } else {
1340            // §3.4: world = rigid · factor. Bake `factor` into a distinct part
1341            // and give the instance the rigid residue, so a mirrored instance
1342            // never lands on its unmirrored twin.
1343            match split_rigid(&placed.world) {
1344                // Non-rigid edges that cancel out along the path leave an
1345                // identity factor: that is an ordinary instance of the ordinary
1346                // part, not a bake.
1347                Ok((rigid, factor)) if is_identity(&factor) => {
1348                    ((product.pd_ref, NO_FACTOR), rigid)
1349                }
1350                Ok((rigid, factor)) => {
1351                    let key = (product.pd_ref, factor_key(&factor));
1352                    plan.factors.insert(key, factor);
1353                    (key, rigid)
1354                }
1355                Err(error) => {
1356                    note(first_error, error);
1357                    continue;
1358                }
1359            }
1360        };
1361        plan.wanted.push((key, pose, placed.chain.clone()));
1362    }
1363    plan
1364}
1365
1366/// **NESTED** (kernel-plan §3.3 Phase 2): the live document plays the ROOT, so
1367/// it gets one component per root-level row and nothing deeper —
1368///
1369/// - a root's OWN bodies become a leaf part at identity (exactly the flat
1370///   lane's treatment of interior geometry at the root), and
1371/// - each root-child occurrence becomes ONE component: a leaf part when the
1372///   child has no children of its own, else a rigid sub-assembly whose part
1373///   document carries its own `partsLibrary` and its own ACOMPs.
1374///
1375/// Emit order matches [`plan_flat`]'s DFS pre-order — root before its children,
1376/// children by ascending `edge_ref` — which is what makes the two lanes produce
1377/// the SAME document for a depth-1 tree.
1378fn plan_nested(
1379    assembly: &brep_kernel::StepAssembly,
1380    doc_name: &str,
1381    first_error: &mut Option<String>,
1382    writer: &mut PartWriter<'_>,
1383) -> Plan {
1384    // The same bracket the install loop holds, for the same reason: every
1385    // payload this builder encodes (at every level) must see an empty ambient
1386    // scene-metadata store, or a nested leaf whose stamped face names collide
1387    // with the live document's silently inherits the live document's records.
1388    let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
1389    let mut build = NestedBuild {
1390        assembly,
1391        doc_name,
1392        writer,
1393        memo: std::collections::HashMap::new(),
1394        factors: std::collections::HashMap::new(),
1395        entries: 0,
1396        bytes: 0,
1397        failed_products: 0,
1398        baked_nonrigid: 0,
1399        first_error: None,
1400    };
1401    let mut plan = Plan::default();
1402    for &root in &assembly.roots {
1403        let mut rows: Vec<(DocKey, Mat4, Vec<usize>)> = Vec::new();
1404        // The root's OWN bodies become a leaf part at identity — exactly the
1405        // flat lane's treatment, and the reason a depth-1 tree comes out the
1406        // same either way.
1407        if !assembly.products[root].bodies.is_empty() {
1408            rows.push((
1409                DocKey::Leaf((assembly.products[root].pd_ref, NO_FACTOR)),
1410                MAT4_IDENTITY,
1411                Vec::new(),
1412            ));
1413        }
1414        // Root-level bakes are counted by the install loop's own pass over
1415        // `wanted` (they are ordinary top-level rows); only bakes BELOW the root
1416        // — which never become rows of the user's document — are counted here.
1417        let mut root_level_bakes = 0usize;
1418        build.place_children(root, &[root], &mut rows, &mut root_level_bakes);
1419        for (key, pose, chain) in rows {
1420            let part = match build.document(key, &mut vec![root]) {
1421                Ok(Some(document)) => document,
1422                // A subtree with no geometry anywhere places nothing — the flat
1423                // lane says the same thing by emitting no component for it.
1424                Ok(None) => continue,
1425                Err(error) => {
1426                    build.failed_products += 1;
1427                    note(&mut build.first_error, error);
1428                    continue;
1429                }
1430            };
1431            let part_key = key.part_key(assembly);
1432            plan.documents.insert(part_key, part);
1433            plan.wanted.push((part_key, pose, chain));
1434        }
1435    }
1436    plan.failed_products = build.failed_products;
1437    plan.baked_below_root = build.baked_nonrigid;
1438    if let Some(error) = build.first_error {
1439        note(first_error, error);
1440    }
1441    plan
1442}
1443
1444/// How deep the recursive builder will go before it refuses. `read_step_assembly`
1445/// guards cycles inside its own walk and [`NestedBuild::document`] guards them
1446/// again along the recursion path, so this is the SECOND line: a malformed file
1447/// that is merely pathologically deep (rather than cyclic) must not run the
1448/// native stack out. Sixty-four levels of embedded documents is already far past
1449/// anything a real CAD assembly carries — and each level embeds the whole
1450/// subtree below it, so the document would be unusable long before then.
1451const MAX_NESTED_DEPTH: usize = 64;
1452
1453/// How many DISTINCT part documents a nested import may build. Bounds the
1454/// builder's work; it does NOT bound the result's size — see
1455/// [`MAX_NESTED_BYTES`], which is the guard that matters.
1456const MAX_NESTED_ENTRIES: usize = 10_000;
1457
1458/// How many bytes of part document a nested import may EMBED, summed over every
1459/// `partsLibrary` entry it writes at every level.
1460///
1461/// This is the guard neither the depth cap nor the entry count provides. A
1462/// product reachable at many different depths is stored once PER LEVEL
1463/// (build-spec §2.2) — the memo builds its document once, but each parent
1464/// embeds a COPY, so a diamond-shaped structure well inside the depth cap can
1465/// still multiply out geometrically. Charging the embedded bytes is the only
1466/// place that multiplication is visible, so it is charged where it happens.
1467const MAX_NESTED_BYTES: usize = 256 * 1024 * 1024;
1468
1469/// What a nested part document is memoised under. A product is either a leaf
1470/// (no occurrence children) or an assembly node, never both, so the two
1471/// variants can never name the same product — except at a ROOT, whose own
1472/// bodies become a leaf part while the root itself is an assembly node. That
1473/// case is exactly why this is an enum and not a bare [`PartKey`].
1474#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
1475enum DocKey {
1476    /// A geometry-bearing product placed as a part: `(pd_ref, baked factor)`.
1477    Leaf(PartKey),
1478    /// A product placed as a rigid sub-assembly, by index into `products`.
1479    Assembly(usize),
1480}
1481
1482impl DocKey {
1483    /// The parts-library identity this document is stored under. Always keyed on
1484    /// the `pd_ref` (never the product INDEX, which lives in a different number
1485    /// space and would collide with some other product's `pd_ref`). An assembly
1486    /// node never carries a baked factor — a non-rigid edge into one is skipped,
1487    /// see [`NestedBuild::place_children`] — so `NO_FACTOR` is exact.
1488    fn part_key(self, assembly: &brep_kernel::StepAssembly) -> PartKey {
1489        match self {
1490            DocKey::Leaf(key) => key,
1491            DocKey::Assembly(product) => (assembly.products[product].pd_ref, NO_FACTOR),
1492        }
1493    }
1494}
1495
1496/// The recursive builder behind [`plan_nested`]: turns one product into the part
1497/// document that represents it, bottom-up, memoised so a product reached from
1498/// several parents is built ONCE however many places embed it.
1499struct NestedBuild<'a, 'w> {
1500    assembly: &'a brep_kernel::StepAssembly,
1501    doc_name: &'a str,
1502    /// Where a CHILD library entry's document is written, shared with the
1503    /// top-level install loop so one part is one file at every level.
1504    writer: &'a mut PartWriter<'w>,
1505    /// `None` = this subtree carries no geometry at all, so nothing places it.
1506    memo: std::collections::HashMap<DocKey, Option<(String, serde_json::Value)>>,
1507    /// The non-rigid factor behind every baked [`DocKey::Leaf`] key, so the
1508    /// builder never has to reconstruct a matrix out of its own hash key.
1509    factors: std::collections::HashMap<PartKey, Mat4>,
1510    entries: usize,
1511    /// Bytes of part document embedded so far — the [`MAX_NESTED_BYTES`] charge.
1512    bytes: usize,
1513    failed_products: usize,
1514    baked_nonrigid: usize,
1515    first_error: Option<String>,
1516}
1517
1518impl NestedBuild<'_, '_> {
1519    /// The part document for `key`, built once and reused. `ancestors` is the
1520    /// recursion path — the cycle guard, and the depth the cap is measured on.
1521    ///
1522    /// A cyclic file gets ONE deterministic truncation: the memo keeps whichever
1523    /// path reached a node first, and that path's skipped back-edge is the one
1524    /// every embedding sees. Deterministic and finite is the whole contract for
1525    /// input that is malformed by construction.
1526    fn document(
1527        &mut self,
1528        key: DocKey,
1529        ancestors: &mut Vec<usize>,
1530    ) -> Result<Option<(String, serde_json::Value)>, String> {
1531        if let Some(hit) = self.memo.get(&key) {
1532            return Ok(hit.clone());
1533        }
1534        if ancestors.len() >= MAX_NESTED_DEPTH {
1535            return Err(format!(
1536                "nested import: sub-assembly nesting deeper than {MAX_NESTED_DEPTH} levels \
1537                 (import as bodies, or import flat)"
1538            ));
1539        }
1540        let built = match key {
1541            DocKey::Leaf(part) => self.leaf_document(part),
1542            DocKey::Assembly(product) => {
1543                ancestors.push(product);
1544                let built = self.assembly_document(product, ancestors);
1545                ancestors.pop();
1546                built
1547            }
1548        }?;
1549        self.memo.insert(key, built.clone());
1550        Ok(built)
1551    }
1552
1553    /// A geometry-bearing product as the §3.2 part document — the same one the
1554    /// flat lane installs, built by the same helper, so a depth-1 nested import
1555    /// and a flat one store byte-identical entries.
1556    fn leaf_document(
1557        &mut self,
1558        key: PartKey,
1559    ) -> Result<Option<(String, serde_json::Value)>, String> {
1560        let product = self
1561            .assembly
1562            .products
1563            .iter()
1564            .find(|product| product.pd_ref == key.0)
1565            .expect("every key names a product of this assembly");
1566        if product.bodies.is_empty() {
1567            return Ok(None);
1568        }
1569        let factor = self.factors.get(&key).copied();
1570        self.spend_entry()?;
1571        native_part_document(product, factor.as_ref(), self.doc_name).map(Some)
1572    }
1573
1574    /// An assembly-node product as a rigid sub-assembly document: its OWN bodies
1575    /// as plain native IMPORT3D features (the interior-node geometry Phase 1
1576    /// could only make a SIBLING of its own children), one ACOMP per child
1577    /// occurrence, and the children's documents in this level's own
1578    /// `partsLibrary`.
1579    ///
1580    /// The entries carry NO snapshot. An entry with an unreadable snapshot heals
1581    /// from its embedded document (`assembly_component.rs`'s SELF-HEAL lane),
1582    /// and for a native part that heal is a decode + re-encode — so the level
1583    /// above bakes this whole subtree into ITS snapshot on insert, and these
1584    /// inner caches would only ever be rebuilt to be thrown away. Kernel-plan §6
1585    /// names this exact economy ("omit the persisted snapshot for an entry whose
1586    /// document is a single native IMPORT3D"); nesting is where it pays, because
1587    /// otherwise every level stores the level below it twice.
1588    fn assembly_document(
1589        &mut self,
1590        product: usize,
1591        ancestors: &mut Vec<usize>,
1592    ) -> Result<Option<(String, serde_json::Value)>, String> {
1593        let node = &self.assembly.products[product];
1594        let mut library = serde_json::Map::new();
1595        let mut features: Vec<serde_json::Value> = Vec::new();
1596
1597        // The node's own bodies first, matching the flat lane's "a node before
1598        // its children" emit order.
1599        if !node.bodies.is_empty() {
1600            let payload = brep_kernel::native_import_payload_with_appearance(
1601                "IMPORT3D1",
1602                &node.bodies,
1603                &node.appearances,
1604            )
1605            .map_err(|error| format!("part '{}': {error}", part_name(node, self.doc_name)))?;
1606            features.push(serde_json::json!({
1607                "type": "IMPORT3D",
1608                "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
1609                "persistentData": {},
1610            }));
1611        }
1612
1613        // One ACOMP per child occurrence, children by ascending `edge_ref`.
1614        let mut rows: Vec<(DocKey, Mat4, Vec<usize>)> = Vec::new();
1615        let mut bakes = 0usize;
1616        self.place_children(product, ancestors, &mut rows, &mut bakes);
1617        self.baked_nonrigid += bakes;
1618        let mut names: std::collections::HashMap<DocKey, String> =
1619            std::collections::HashMap::new();
1620        // `add_part_to_library`'s content reuse, applied to this level's block:
1621        // two products that are the SAME geometry collapse to one entry (§3.5's
1622        // free dedup), and every instance of either references it.
1623        let mut by_signature: std::collections::HashMap<String, String> =
1624            std::collections::HashMap::new();
1625        let mut components = 0usize;
1626        // The occurrence chain is not needed at this level: a sub-assembly's
1627        // own ACOMPs live in ITS document, which the open document's PMI never
1628        // names (an annotation pinned below a root child has no top-level
1629        // component — `merge_assembly_pmi` reports it).
1630        for (key, pose, _chain) in rows {
1631            let name = match names.get(&key) {
1632                Some(name) => name.clone(),
1633                None => {
1634                    let built = match self.document(key, ancestors) {
1635                        Ok(Some(built)) => built,
1636                        Ok(None) => continue,
1637                        Err(error) => {
1638                            self.failed_products += 1;
1639                            note(&mut self.first_error, error);
1640                            continue;
1641                        }
1642                    };
1643                    let serialized = built.1.to_string();
1644                    let signature = document_signature(&serialized);
1645                    let name = match by_signature.get(&signature) {
1646                        Some(name) => name.clone(),
1647                        None => {
1648                            // Charged HERE, at the embedding, because that is
1649                            // where a product stored once per level multiplies.
1650                            self.spend_bytes(serialized.len())?;
1651                            // Unique WITHIN this level's library — parent and
1652                            // child libraries are independent (build-spec §2.2),
1653                            // so a name taken upstairs is free down here.
1654                            let name = unique_entry_name(&library, &built.0);
1655                            // A nested child is a part like any other: it gets
1656                            // its own store document and a REAL sourceKey, so
1657                            // Open Part and update-components work the same way
1658                            // however deep it sits.
1659                            let source_key =
1660                                self.writer.key_for(&name, &serialized, &signature);
1661                            library.insert(
1662                                name.clone(),
1663                                serde_json::json!({
1664                                    "sourceKey": source_key,
1665                                    "sourceSignature": signature.clone(),
1666                                    "document": built.1,
1667                                    "snapshot": "",
1668                                }),
1669                            );
1670                            by_signature.insert(signature, name.clone());
1671                            name
1672                        }
1673                    };
1674                    names.insert(key, name.clone());
1675                    name
1676                }
1677            };
1678            let Ok(transform) = brep_kernel::AffineTransform::new(pose) else {
1679                note(
1680                    &mut self.first_error,
1681                    format!("sub-assembly '{name}': occurrence pose is not an affine"),
1682                );
1683                continue;
1684            };
1685            components += 1;
1686            features.push(serde_json::json!({
1687                "type": "ACOMP",
1688                "inputParams": {
1689                    // Its OWN counter, so the ids read `ACOMP1..n` whether or
1690                    // not this node also owns bodies. (The id must match
1691                    // `ACOMP<digits>`: it IS the namespace prefix.)
1692                    "id": format!("ACOMP{components}"),
1693                    "partName": name,
1694                    "transform": brep_kernel::transform_to_pose_params(&transform),
1695                    // Written EXPLICITLY rather than left to the kernel's
1696                    // auto-ground rule, which keys on ABSENCE: the first
1697                    // component of an assembly is grounded, and every other one
1698                    // must not be, or the next solve is over-constrained.
1699                    "isFixed": components == 1,
1700                },
1701                "persistentData": {},
1702            }));
1703        }
1704
1705        // A node whose whole subtree failed to produce geometry places nothing.
1706        // Returning `None` rather than a feature-less document matters: an empty
1707        // document is a hard error inside `add_part_to_library`, which would turn
1708        // "there was nothing here" into "the import failed".
1709        if features.is_empty() {
1710            return Ok(None);
1711        }
1712        self.spend_entry()?;
1713        let mut document =
1714            serde_json::json!({ "partsLibrary": library, "features": features });
1715        // A sub-assembly is a part too: its own `PRODUCT.id` is the part number
1716        // the level above exports it under, and its own PMI — the annotations
1717        // on the bodies IT owns, not its children's — is its document's.
1718        if let Some(attributes) = part_attributes(node) {
1719            document[PART_ATTRIBUTES] = attributes;
1720        }
1721        if let Some(pmi) = &node.pmi {
1722            if let Ok(block) = serde_json::to_value(pmi) {
1723                document["pmi"] = block;
1724            }
1725        }
1726        Ok(Some((part_name(node, self.doc_name), document)))
1727    }
1728
1729    /// The child occurrences of `product`, as `(document key, pose, occurrence
1730    /// chain)` rows in the kernel walk's order — ascending `edge_ref`, with the
1731    /// same ancestor cycle guard. The pose is the occurrence's own child→parent
1732    /// placement: nesting is precisely what stops it having to be composed.
1733    ///
1734    /// The chain is one edge long here by construction: a nested import's rows
1735    /// are the ROOT's own children, and everything below one of them is folded
1736    /// into that child's document.
1737    fn place_children(
1738        &mut self,
1739        product: usize,
1740        ancestors: &[usize],
1741        rows: &mut Vec<(DocKey, Mat4, Vec<usize>)>,
1742        bakes: &mut usize,
1743    ) {
1744        let mut children: Vec<&brep_kernel::StepOccurrence> = self
1745            .assembly
1746            .occurrences
1747            .iter()
1748            .filter(|occurrence| occurrence.parent == product)
1749            .collect();
1750        children.sort_by_key(|occurrence| occurrence.edge_ref);
1751        for occurrence in children {
1752            if ancestors.contains(&occurrence.child) {
1753                note(
1754                    &mut self.first_error,
1755                    format!(
1756                        "occurrence #{} closes a cycle in the product structure and was skipped",
1757                        occurrence.edge_ref
1758                    ),
1759                );
1760                continue;
1761            }
1762            let child = &self.assembly.products[occurrence.child];
1763            let is_assembly = self
1764                .assembly
1765                .occurrences
1766                .iter()
1767                .any(|edge| edge.parent == occurrence.child);
1768            if occurrence.rigid {
1769                let key = if is_assembly {
1770                    DocKey::Assembly(occurrence.child)
1771                } else {
1772                    DocKey::Leaf((child.pd_ref, NO_FACTOR))
1773                };
1774                rows.push((key, occurrence.placement, vec![occurrence.edge_ref]));
1775                continue;
1776            }
1777            // §3.4 on a single edge: a leaf bakes its non-rigid factor into its
1778            // own part, exactly as the flat lane does with the composed pose.
1779            match split_rigid(&occurrence.placement) {
1780                Ok((rigid, factor)) if is_identity(&factor) => {
1781                    let key = if is_assembly {
1782                        DocKey::Assembly(occurrence.child)
1783                    } else {
1784                        DocKey::Leaf((child.pd_ref, NO_FACTOR))
1785                    };
1786                    rows.push((key, rigid, vec![occurrence.edge_ref]));
1787                }
1788                // A mirrored/scaled SUB-ASSEMBLY would have to push its factor
1789                // down through a whole document tree, rewriting every level's
1790                // poses. Nothing in the corpus does it, and a wrong answer here
1791                // would be a silently mis-handed assembly: skip and say so, so
1792                // the user can re-import flat (which bakes it correctly).
1793                Ok(_) if is_assembly => {
1794                    note(
1795                        &mut self.first_error,
1796                        format!(
1797                            "occurrence #{} places sub-assembly '{}' with a non-rigid transform, \
1798                             which a nested import cannot represent — import flat instead",
1799                            occurrence.edge_ref,
1800                            part_name(child, self.doc_name)
1801                        ),
1802                    );
1803                }
1804                Ok((rigid, factor)) => {
1805                    *bakes += 1;
1806                    let key = (child.pd_ref, factor_key(&factor));
1807                    self.factors.insert(key, factor);
1808                    rows.push((DocKey::Leaf(key), rigid, vec![occurrence.edge_ref]));
1809                }
1810                Err(error) => note(&mut self.first_error, error),
1811            }
1812        }
1813    }
1814
1815    /// Charge one built part document against [`MAX_NESTED_ENTRIES`].
1816    fn spend_entry(&mut self) -> Result<(), String> {
1817        self.entries += 1;
1818        if self.entries > MAX_NESTED_ENTRIES {
1819            return Err(format!(
1820                "nested import: more than {MAX_NESTED_ENTRIES} distinct parts \
1821                 (import as bodies, or import flat)"
1822            ));
1823        }
1824        Ok(())
1825    }
1826
1827    /// Charge one embedded part document against [`MAX_NESTED_BYTES`].
1828    fn spend_bytes(&mut self, bytes: usize) -> Result<(), String> {
1829        self.bytes = self.bytes.saturating_add(bytes);
1830        if self.bytes > MAX_NESTED_BYTES {
1831            return Err(format!(
1832                "nested import: the embedded sub-assembly documents exceed \
1833                 {} MB (import as bodies, or import flat)",
1834                MAX_NESTED_BYTES / (1024 * 1024)
1835            ));
1836        }
1837        Ok(())
1838    }
1839}
1840
1841/// A part name not yet used in THIS level's library: `requested`, else
1842/// `requested-2`, `requested-3`, … — the kernel `parts_library::unique_name`
1843/// convention, applied to an embedded block the kernel never sees inserted.
1844fn unique_entry_name(library: &serde_json::Map<String, serde_json::Value>, requested: &str) -> String {
1845    if !library.contains_key(requested) {
1846        return requested.to_string();
1847    }
1848    (2..)
1849        .map(|counter| format!("{requested}-{counter}"))
1850        .find(|candidate| !library.contains_key(candidate))
1851        .expect("the counter loop is unbounded")
1852}
1853
1854/// Keep the FIRST thing that went wrong (the report carries one, and the first
1855/// is the one that explains the rest).
1856fn note(slot: &mut Option<String>, error: String) {
1857    if slot.is_none() {
1858        *slot = Some(error);
1859    }
1860}
1861
1862/// Encode one product as a parts-library entry and return the EFFECTIVE entry
1863/// name the instances must reference (`add_part_to_library` disambiguates a name
1864/// clash and REUSES an entry with identical content, which is where cross-import
1865/// dedup comes from).
1866///
1867/// `factor`, when present, is the non-rigid part of an occurrence's placement:
1868/// applied to the geometry HERE, so the instance can carry a rigid pose (§3.4).
1869fn build_library_entry(
1870    product: &brep_kernel::StepProduct,
1871    factor: Option<&Mat4>,
1872    doc_name: &str,
1873    writer: &mut PartWriter<'_>,
1874) -> Result<String, String> {
1875    let (name, document) = native_part_document(product, factor, doc_name)?;
1876    install_part(&name, &document, writer)
1877}
1878
1879/// The §3.2 part document for one product's OWN bodies: ONE IMPORT3D whose only
1880/// input is the native payload, plus the library name it wants. No STEP text is
1881/// stored anywhere — a rebuild of this part is a base64 decode, not a re-parse.
1882///
1883/// `factor`, when present, is the non-rigid part of an occurrence's placement:
1884/// applied to the geometry HERE, so the instance can carry a rigid pose (§3.4).
1885///
1886/// Split out from [`build_library_entry`] because the nested lane needs the
1887/// DOCUMENT before it installs anything — a leaf's document is embedded in its
1888/// parent's `partsLibrary`, where there is no `add_part_to_library` to call.
1889/// One producer, so a leaf part is byte-identical however deep it lands.
1890fn native_part_document(
1891    product: &brep_kernel::StepProduct,
1892    factor: Option<&Mat4>,
1893    doc_name: &str,
1894) -> Result<(String, serde_json::Value), String> {
1895    let mut name = part_name(product, doc_name);
1896    let bodies = match factor {
1897        None => product.bodies.clone(),
1898        Some(factor) => {
1899            let transform = brep_kernel::AffineTransform::new(*factor)
1900                .map_err(|error| format!("part '{name}': non-rigid factor: {error}"))?;
1901            let mirrored = transform.determinant3() < 0.0;
1902            name.push_str(if mirrored { " (mirrored)" } else { " (scaled)" });
1903            product
1904                .bodies
1905                .iter()
1906                .map(|body| {
1907                    // A mirror MUST reverse orientation or `transform_brep`
1908                    // refuses it (an unreversed reflection inverts the solid).
1909                    brep_kernel::transform_brep(body, transform, mirrored)
1910                        .map_err(|error| format!("part '{name}': {error}"))
1911                })
1912                .collect::<Result<Vec<_>, _>>()?
1913        }
1914    };
1915    // The product's STEP colours ride into the payload with the geometry (the
1916    // snapshot captures the records the stamp writes), so a coloured part keeps
1917    // its colour through the parts library and every reload.
1918    let payload = brep_kernel::native_import_payload_with_appearance(
1919        "IMPORT3D1",
1920        &bodies,
1921        &product.appearances,
1922    )
1923    .map_err(|error| format!("part '{name}': {error}"))?;
1924    let mut document = serde_json::json!({
1925        "features": [{
1926            "type": "IMPORT3D",
1927            "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
1928            "persistentData": {},
1929        }]
1930    });
1931    if let Some(attributes) = part_attributes(product) {
1932        document[PART_ATTRIBUTES] = attributes;
1933    }
1934    // The part's own PMI, lifted once for the PART (`assembly_pmi.rs`). Its
1935    // references already name what the `IMPORT3D1` feature above stamps, and a
1936    // baked factor transforms the bodies without reordering their faces, so a
1937    // mirrored variant's names hold too.
1938    if let Some(pmi) = &product.pmi {
1939        if let Ok(block) = serde_json::to_value(pmi) {
1940            document["pmi"] = block;
1941        }
1942    }
1943    Ok((name, document))
1944}
1945
1946/// The BOM attribute record an imported product's document carries: its STEP
1947/// `PRODUCT.id` as `Part_Number`, or `None` when the file gave it none.
1948///
1949/// This is the OTHER half of `io/step/export_tree.rs`, which writes a part's
1950/// `partAttributes.Part_Number` back out as `PRODUCT.id` — the identity a
1951/// downstream PDM keys on. Without it an imported id died at the import: the
1952/// document had no part number, so a re-export fell back to the library entry's
1953/// `sourceKey` and the vendor's own part numbers were replaced by our file
1954/// names on the first round trip.
1955///
1956/// It is only ever set on a document being MINTED here (a fresh part document
1957/// for one product), so it cannot overwrite a part number a user typed: a part
1958/// document that already exists is reused by
1959/// [`add_part_to_library`](brep_kernel::add_part_to_library)'s content match and
1960/// never rebuilt through this path.
1961///
1962/// Trimmed and blank-filtered to match the reader's own `part_number`, so a
1963/// `PRODUCT('  ','bolt',…)` does not mint a whitespace part number that would
1964/// then export as one.
1965fn part_attributes(product: &brep_kernel::StepProduct) -> Option<serde_json::Value> {
1966    let number = product.id.trim();
1967    if number.is_empty() {
1968        return None;
1969    }
1970    Some(serde_json::json!({ brep_kernel::PART_NUMBER: number }))
1971}
1972
1973/// Install a part document as a parts-library entry of the OPEN document and
1974/// return the EFFECTIVE entry name the instances must reference
1975/// (`add_part_to_library` disambiguates a name clash and REUSES an entry with
1976/// identical content, which is where cross-import dedup comes from).
1977///
1978/// The `sourceKey` comes from the [`PartSink`]: an imported part is written to
1979/// the store as its own document and carries a REAL key, exactly like a part
1980/// inserted from the parts library, so there is no second kind of part. A sink
1981/// that declines (no store, or a failed write) yields `""` — the embedded-only
1982/// entry this lane used to produce unconditionally, and the case
1983/// `UpdateComponents` already skips.
1984fn install_part(
1985    name: &str,
1986    document: &serde_json::Value,
1987    writer: &mut PartWriter<'_>,
1988) -> Result<String, String> {
1989    let document = document.to_string();
1990    let signature = document_signature(&document);
1991    let source_key = writer.key_for(name, &document, &signature);
1992    brep_kernel::add_part_to_library_impl(name, &source_key, &signature, &document)
1993        .map_err(|error| format!("part '{name}': {error}"))
1994}
1995
1996/// Where an imported assembly's unique parts are written, so each becomes a
1997/// document in its own right rather than a payload embedded in one assembly.
1998///
1999/// A trait, and not a `&dyn ModelStore`, because the store lives in `BREP_app`
2000/// and this crate is BELOW it — `BREP_app` depends on `BREP_render`, so naming
2001/// the store here would be a dependency cycle. The import therefore asks for a
2002/// key and the app answers with one, which is also what keeps the destination
2003/// (and any prompt for it) entirely the app's business.
2004pub trait PartSink {
2005    /// Store `document_json` under a name derived from `part_name` and return
2006    /// the stable key it can be read back by. `None` declines — no store, or a
2007    /// write that failed — and the entry stays embedded-only.
2008    fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String>;
2009}
2010
2011/// The sink that stores nothing: every entry stays embedded-only. The default
2012/// for headless callers and tests, which have no store to write to.
2013pub struct EmbeddedOnly;
2014
2015impl PartSink for EmbeddedOnly {
2016    fn store_part(&mut self, _part_name: &str, _document_json: &str) -> Option<String> {
2017        None
2018    }
2019}
2020
2021/// A [`PartSink`] plus the CONTENT DEDUP that must ride with it.
2022///
2023/// `add_part_to_library` reuses an entry whose `(sourceKey, sourceSignature)`
2024/// both match, which is where §3.5's free dedup came from while every imported
2025/// part carried the same empty key. Give each part its own key and that reuse
2026/// stops: the same product under two `PRODUCT_DEFINITION`s would become two
2027/// entries AND two identical files.
2028///
2029/// So the dedup moves in front of the write, keyed on the document signature
2030/// alone. Identical content is written ONCE and every occurrence of it gets the
2031/// SAME key — which then makes `add_part_to_library`'s own `(key, signature)`
2032/// reuse fire exactly as before. Dedup ACROSS imports keeps working for the
2033/// same reason: a re-import derives the same file name, so the same key and
2034/// signature come back and the resident entry is reused.
2035struct PartWriter<'a> {
2036    sink: &'a mut dyn PartSink,
2037    by_signature: std::collections::HashMap<String, String>,
2038}
2039
2040impl<'a> PartWriter<'a> {
2041    fn new(sink: &'a mut dyn PartSink) -> Self {
2042        Self {
2043            sink,
2044            by_signature: std::collections::HashMap::new(),
2045        }
2046    }
2047
2048    /// The `sourceKey` for a part with this content — writing it exactly once
2049    /// however many products share it.
2050    fn key_for(&mut self, name: &str, document_json: &str, signature: &str) -> String {
2051        if let Some(key) = self.by_signature.get(signature) {
2052            return key.clone();
2053        }
2054        let key = self
2055            .sink
2056            .store_part(name, document_json)
2057            .unwrap_or_default();
2058        self.by_signature.insert(signature.to_string(), key.clone());
2059        key
2060    }
2061}
2062
2063/// An sRGB triple in 0..=1 as the 8-bit channels `#RRGGBB` holds — the form
2064/// [`brep_kernel::StepColors`] takes, and the same `round(c * 255)` conversion
2065/// `brep_kernel::ImportedColor::to_hex` uses, so a colour written to a STEP file
2066/// is the colour the metadata record spells.
2067fn srgb_bytes(rgb: [f64; 3]) -> [u8; 3] {
2068    rgb.map(|channel| (channel.clamp(0.0, 1.0) * 255.0).round() as u8)
2069}
2070
2071/// The library name for a product: its `PRODUCT.name`, else a stem built from
2072/// the imported document's name so an unnamed product is still identifiable.
2073fn part_name(product: &brep_kernel::StepProduct, doc_name: &str) -> String {
2074    let named = product.name.trim();
2075    if !named.is_empty() {
2076        return named.to_string();
2077    }
2078    match doc_name.trim() {
2079        "" => format!("part-{}", product.pd_ref),
2080        stem => format!("{stem}-part-{}", product.pd_ref),
2081    }
2082}
2083
2084/// The dialog's counts, taken from the SAME walk the import runs, so the numbers
2085/// the user was shown are the numbers they get (bar an encode failure, and bar
2086/// the extra entry a non-rigid occurrence bakes).
2087pub(super) fn probe_counts(assembly: &brep_kernel::StepAssembly) -> StepAssemblyProbe {
2088    let mut parts = std::collections::HashSet::new();
2089    let mut instances = 0usize;
2090    let mut nested_depth = 0usize;
2091    for placed in compose_world_occurrences(assembly) {
2092        let product = &assembly.products[placed.product];
2093        if product.bodies.is_empty() {
2094            continue;
2095        }
2096        parts.insert(product.pd_ref);
2097        instances += 1;
2098        nested_depth = nested_depth.max(placed.depth);
2099    }
2100    StepAssemblyProbe {
2101        parts: parts.len(),
2102        instances,
2103        nested_depth,
2104    }
2105}
2106
2107/// Depth-first from the roots, composing each occurrence's child→parent
2108/// placement into a world transform — the consumer half of `read_step_assembly`,
2109/// which deliberately transforms nothing.
2110///
2111/// Emit order, child ordering (by `edge_ref`) and the ancestor cycle guard mirror
2112/// the kernel's own `walk_occurrences`, which is what makes the components this
2113/// lane produces the same solids, in the same order, as the flat lane's — the
2114/// kernel asserts that equivalence BIT-for-bit
2115/// (`step_import/tests/assembly_structure.rs`), and
2116/// `structured_import_matches_the_flat_lane_geometry` below re-asserts it from
2117/// this side, where a divergence would actually land.
2118fn compose_world_occurrences(assembly: &brep_kernel::StepAssembly) -> Vec<PlacedProduct> {
2119    struct Node {
2120        placed: PlacedProduct,
2121        ancestors: Vec<usize>,
2122    }
2123    let mut out = Vec::new();
2124    let mut stack: Vec<Node> = assembly
2125        .roots
2126        .iter()
2127        .rev()
2128        .map(|&product| Node {
2129            placed: PlacedProduct {
2130                product,
2131                world: MAT4_IDENTITY,
2132                depth: 0,
2133                rigid_path: true,
2134                chain: Vec::new(),
2135            },
2136            ancestors: vec![product],
2137        })
2138        .collect();
2139    while let Some(node) = stack.pop() {
2140        let (product, world, depth, rigid_path, chain) = (
2141            node.placed.product,
2142            node.placed.world,
2143            node.placed.depth,
2144            node.placed.rigid_path,
2145            node.placed.chain.clone(),
2146        );
2147        out.push(node.placed);
2148        let mut children: Vec<&brep_kernel::StepOccurrence> = assembly
2149            .occurrences
2150            .iter()
2151            .filter(|occurrence| occurrence.parent == product)
2152            .collect();
2153        children.sort_by_key(|occurrence| occurrence.edge_ref);
2154        for occurrence in children.into_iter().rev() {
2155            if node.ancestors.contains(&occurrence.child) {
2156                continue; // the cycle guard the kernel's walk applies
2157            }
2158            let mut ancestors = node.ancestors.clone();
2159            ancestors.push(occurrence.child);
2160            let mut child_chain = chain.clone();
2161            child_chain.push(occurrence.edge_ref);
2162            stack.push(Node {
2163                placed: PlacedProduct {
2164                    product: occurrence.child,
2165                    world: mat4_mul(&world, &occurrence.placement),
2166                    depth: depth + 1,
2167                    // The kernel's per-edge rigidity flag, carried down the path:
2168                    // a composed pose is a component pose only when every edge
2169                    // on the way to it was one.
2170                    rigid_path: rigid_path && occurrence.rigid,
2171                    chain: child_chain,
2172                },
2173                ancestors,
2174            });
2175        }
2176    }
2177    out
2178}
2179
2180const MAT4_IDENTITY: Mat4 = [
2181    1.0, 0.0, 0.0, 0.0, //
2182    0.0, 1.0, 0.0, 0.0, //
2183    0.0, 0.0, 1.0, 0.0, //
2184    0.0, 0.0, 0.0, 1.0,
2185];
2186
2187/// Row-major 4×4 product `a · b`.
2188fn mat4_mul(a: &Mat4, b: &Mat4) -> Mat4 {
2189    let mut out = [0.0; 16];
2190    for row in 0..4 {
2191        for column in 0..4 {
2192            out[row * 4 + column] = (0..4)
2193                .map(|k| a[row * 4 + k] * b[k * 4 + column])
2194                .sum();
2195        }
2196    }
2197    out
2198}
2199
2200/// Split a non-rigid world placement into `world = rigid · factor`, where
2201/// `rigid` is a component pose (rotation + translation, det +1) and `factor` is
2202/// a purely linear residue carrying the mirror/scale/shear.
2203///
2204/// Gram-Schmidt on the linear block's columns gives `A = Q·U` with `U` upper
2205/// triangular and positively-diagonalled; when `Q` came out left-handed the pair
2206/// is re-signed through `D = diag(-1, 1, 1)` (`Q' = Q·D`, `U' = D·U`, still
2207/// `Q'U' = A`) so the ROTATION is a rotation and the reflection rides in the
2208/// factor. A mirror composed with a rotation therefore yields the same factor
2209/// whatever the rotation, which keeps every such instance on ONE baked part.
2210fn split_rigid(world: &Mat4) -> Result<(Mat4, Mat4), String> {
2211    let column = |index: usize| [world[index], world[4 + index], world[8 + index]];
2212    let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
2213    let axpy = |a: [f64; 3], scale: f64, b: [f64; 3]| {
2214        [a[0] - scale * b[0], a[1] - scale * b[1], a[2] - scale * b[2]]
2215    };
2216    let (a1, a2, a3) = (column(0), column(1), column(2));
2217
2218    let r11 = dot(a1, a1).sqrt();
2219    let mut q1 = normalize(a1, r11)?;
2220    let r12 = dot(q1, a2);
2221    let v2 = axpy(a2, r12, q1);
2222    let r22 = dot(v2, v2).sqrt();
2223    let q2 = normalize(v2, r22)?;
2224    let r13 = dot(q1, a3);
2225    let r23 = dot(q2, a3);
2226    let v3 = axpy(axpy(a3, r13, q1), r23, q2);
2227    let r33 = dot(v3, v3).sqrt();
2228    let q3 = normalize(v3, r33)?;
2229
2230    // det Q = q1 · (q2 × q3); -1 means Q is a reflection, not a rotation.
2231    let cross = [
2232        q2[1] * q3[2] - q2[2] * q3[1],
2233        q2[2] * q3[0] - q2[0] * q3[2],
2234        q2[0] * q3[1] - q2[1] * q3[0],
2235    ];
2236    let (mut r11, mut r12, mut r13) = (r11, r12, r13);
2237    if dot(q1, cross) < 0.0 {
2238        q1 = [-q1[0], -q1[1], -q1[2]];
2239        r11 = -r11;
2240        r12 = -r12;
2241        r13 = -r13;
2242    }
2243    let rigid = [
2244        q1[0], q2[0], q3[0], world[3], //
2245        q1[1], q2[1], q3[1], world[7], //
2246        q1[2], q2[2], q3[2], world[11], //
2247        0.0, 0.0, 0.0, 1.0,
2248    ];
2249    let factor = [
2250        r11, r12, r13, 0.0, //
2251        0.0, r22, r23, 0.0, //
2252        0.0, 0.0, r33, 0.0, //
2253        0.0, 0.0, 0.0, 1.0,
2254    ];
2255    Ok((rigid, factor))
2256}
2257
2258/// Unit vector, or a clear error for the degenerate column a near-singular
2259/// placement produces (skipped and counted, never fatal).
2260fn normalize(vector: [f64; 3], length: f64) -> Result<[f64; 3], String> {
2261    if !(length > 1e-12) || !length.is_finite() {
2262        return Err("occurrence placement is singular (a degenerate axis)".into());
2263    }
2264    Ok([vector[0] / length, vector[1] / length, vector[2] / length])
2265}
2266
2267/// Is this affine the identity to 1e-9 — the tolerance the kernel's own
2268/// rigidity gate uses?
2269fn is_identity(matrix: &Mat4) -> bool {
2270    matrix
2271        .iter()
2272        .zip(MAT4_IDENTITY.iter())
2273        .all(|(value, want)| (value - want).abs() <= 1e-9)
2274}
2275
2276/// The linear block of a baked factor as an exact bit key — two occurrences
2277/// share a baked part only when their factor is bit-identical, so a wrong-handed
2278/// reuse is not reachable through rounding.
2279fn factor_key(factor: &Mat4) -> [u64; 9] {
2280    let mut key = [0u64; 9];
2281    for (slot, index) in key.iter_mut().zip([0, 1, 2, 4, 5, 6, 8, 9, 10]) {
2282        *slot = factor[index].to_bits();
2283    }
2284    key
2285}
2286
2287/// The product, and the placement of it, that holds a board document's board
2288/// in a STRUCTURED export. The board's bodies keep their own names inside it
2289/// (`PCB_Board`, `PCB_F.Cu/GND#3`). A component's reference designator is
2290/// never taken for an occurrence label when it is this word.
2291const BOARD_OCCURRENCE: &str = "PCB";
2292
2293/// A `PRODUCT.id` a receiving system can use. A library part with no BOM part
2294/// number is identified by the `sourceKey` it was imported from, which is an
2295/// ABSOLUTE path on the exporting machine (`/tmp/…/kicad/symbols/lm358.nbrep`):
2296/// meaningless anywhere else, and a leak of the user's directory layout into
2297/// every file they send. Such an id becomes the file's stem (`lm358`), or the
2298/// product's name when the path has no usable stem. An id with no path
2299/// separator — a real part number — is kept as it is.
2300fn portable_product_id(id: &str, name: &str) -> String {
2301    const SEPARATORS: [char; 2] = ['/', '\\'];
2302    if !id.contains(SEPARATORS) {
2303        return id.to_string();
2304    }
2305    let file = id.rsplit(SEPARATORS).next().unwrap_or_default();
2306    let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
2307    if stem.is_empty() {
2308        name.to_string()
2309    } else {
2310        stem.to_string()
2311    }
2312}
2313