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