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