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