Skip to main content

brep_kernel/feature_pipeline/features/
import3d.rs

1//! IMPORT3D — Import 3D Model (STEP). Ported from the retired `Import3dModelFeature`.
2//!
3//! Three headless sources, tried in order:
4//! 0. **`inputParams.nativeBrep`** — a native [`crate::snapshot_solids`] payload
5//!    (`io/snapshot.rs`): the exact solids of an earlier import, already carrying
6//!    their final IMPORT3D names. Tried FIRST and EXCLUSIVE — a feature carrying
7//!    it must not also carry `stepText`/`igesText` (an error, not a precedence
8//!    puzzle). The solids register VERBATIM under their stored names: re-deriving
9//!    them would break every scene-metadata record and every constraint keyed to a
10//!    face name. This is the lane a STEP-assembly part document rides on (see
11//!    `docs/developer/kernel-plans/step-assembly-import.md` §3.2), and the reason
12//!    the snapshot container is a DURABLE format, not a cache (§6 there, and the
13//!    `io/snapshot.rs` module doc).
14//! 1. **`inputParams.stepText`** — the raw ISO-10303-21 document, baked in by the
15//!    toolbar Import lane ([`crate`]'s host: `EngineState::import_step_feature`).
16//!    Runs the exact Rust STEP importer
17//!    ([`crate::import_step_with_appearance`] — the colour-carrying form).
18//! 2. **`persistentData.importCache`** (`kind == "step-brep"`) — SUPERSEDED by
19//!    `nativeBrep`. The JS-era persisted EXACT BREP records of a previous import
20//!    (`serializeBrepSolid` was the serde `BrepSolid` JSON, so records deserialize
21//!    directly) — verbose and unversioned. NOTHING in the Rust tree writes it; the
22//!    reader stays only so a JS-era saved model still opens. New payloads go in
23//!    `nativeBrep`.
24//!
25//! Name fidelity (contract rule 2, byte-matching `Import3dModelFeature`):
26//! - Body names (`importedSolidNames`): a single body keeps the feature name;
27//!   several get `{feature}_SOLID_{N}` with N 1-based, zero-padded to
28//!   `max(2, digits(count))`.
29//! - Faces (`importedBodyFaceNames` / the single-body fallback): a MULTI-body
30//!   import names EVERY face `{bodyName}_Face_{f}` positionally (shell/face
31//!   order) so faces stay unique across bodies; a SINGLE body prepends the
32//!   (unique) feature name — `{feature}_{stepName}`, or `{feature}_Face_{f}` where
33//!   the STEP file left the face unnamed — so two separate imports never share a
34//!   face name (the cross-solid name-uniqueness guard's collision class). Edge
35//!   names re-derive from the face names, so the derived edges are namespaced too.
36//!
37//! COLOUR rides the same names. What a STEP file's presentation entities assign
38//! (`io/step_import/styles.rs`) is stamped here as a `{"color": "#RRGGBB"}`
39//! scene-metadata record on the FINAL body/face names — see `io/appearance.rs`
40//! for the convention, [`stamp_appearance`] for the write, and
41//! [`native_import_payload_with_appearance`] for the lane that seals it into a
42//! part payload. The stamp is a NON-overwriting merge, so a colour edited in the
43//! caller's info panel survives a history replay.
44//!
45//! ONE convention, ONE implementation: [`stamp_imported_names`] holds it, and
46//! every lane that names imported bodies goes through it — the live import lanes
47//! (via [`add_named_bodies`]) and the payload encoder [`native_import_payload`].
48//! Names are stamped ONCE, at import, and then frozen in the payload; a later
49//! change here does NOT retro-rename already-imported parts, which is the point
50//! (a constraint keyed to a face name survives reload).
51//!
52//! The failed-import base64 retry lane (`persistentData.failedImportFile`) is
53//! not yet migrated — it errors loudly rather than silently skipping.
54
55use crate::feature_pipeline::features::common;
56use crate::feature_pipeline::{scene_metadata, AddedSolid, FeatureContext, FeatureResult};
57use crate::{
58    import_iges, import_step_with_appearance, restore_solids, BodyAppearance, BrepSolid,
59    ImportedColor, COLOR_METADATA_KEY,
60};
61
62pub fn execute(ctx: &FeatureContext) -> FeatureResult {
63    match build(ctx) {
64        Ok(result) => result,
65        Err(error) => ctx.fail(error),
66    }
67}
68
69fn build(ctx: &FeatureContext) -> Result<FeatureResult, String> {
70    let feature_name = if ctx.id.is_empty() {
71        "IMPORT3D".to_string()
72    } else {
73        ctx.id.clone()
74    };
75
76    // 0. Native payload — the exact solids of an earlier import, names frozen in.
77    //    EXCLUSIVE by design (see the module doc): a document carrying both this
78    //    and a text source is malformed, and answering it with a precedence rule
79    //    would silently drop one of the two. Keyed on PRESENCE, not on a
80    //    successful `as_str`, so a non-string `nativeBrep` errors here instead of
81    //    falling through to a different source.
82    if let Some(native) = ctx.param("nativeBrep").filter(|value| !value.is_null()) {
83        if ctx.param("stepText").is_some() || ctx.param("igesText").is_some() {
84            return Err(
85                "import3d: `nativeBrep` is exclusive — a feature carrying it must not also carry `stepText`/`igesText`"
86                    .into(),
87            );
88        }
89        let payload = native
90            .as_str()
91            .ok_or("import3d: param `nativeBrep` must be a base64 snapshot string")?;
92        return restore_native_bodies(ctx, payload);
93    }
94
95    // 1. Fresh STEP text (baked into `stepText` by the toolbar Import lane).
96    if let Some(step_text) = ctx.param("stepText").and_then(|v| v.as_str()) {
97        if step_text.contains("ISO-10303-21") {
98            let (solids, appearances) = import_step_with_appearance(step_text)
99                .map_err(|error| format!("import3d: STEP import failed: {error}"))?;
100            if solids.is_empty() {
101                return Err("import3d: STEP file contained no importable solids".into());
102            }
103            return Ok(add_named_bodies(ctx, solids, &appearances, &feature_name));
104        }
105        return Err(
106            "import3d: only STEP (ISO-10303-21) files are supported (STL/3MF mesh import was removed)"
107                .into(),
108        );
109    }
110
111    // 1b. Fresh IGES text (baked into `igesText` by the toolbar Import lane).
112    if let Some(iges_text) = ctx.param("igesText").and_then(|v| v.as_str()) {
113        let solids = import_iges(iges_text)
114            .map_err(|error| format!("import3d: IGES import failed: {error}"))?;
115        if solids.is_empty() {
116            return Err("import3d: IGES file contained no importable solids".into());
117        }
118        return Ok(add_named_bodies(ctx, solids, &[], &feature_name));
119    }
120
121    // 2. Persisted exact-BREP records from a previous import.
122    let cache = ctx.persistent.get("importCache");
123    if let Some(cache) = cache {
124        let kind = cache.get("kind").and_then(|v| v.as_str()).unwrap_or("");
125        if kind == "step-brep" {
126            let records = cache
127                .get("kernelSolidRecords")
128                .and_then(|v| v.as_array())
129                .ok_or("import3d: importCache has no kernelSolidRecords")?;
130            let mut solids = Vec::with_capacity(records.len());
131            for (index, record) in records.iter().enumerate() {
132                let solid: BrepSolid = serde_json::from_value(record.clone()).map_err(|error| {
133                    format!("import3d: kernel record {index} failed to deserialize: {error}")
134                })?;
135                solids.push(solid);
136            }
137            if solids.is_empty() {
138                return Err("import3d: importCache is empty".into());
139            }
140            return Ok(add_named_bodies(ctx, solids, &[], &feature_name));
141        }
142        return Err(format!(
143            "import3d: unsupported importCache kind '{kind}' (only 'step-brep')"
144        ));
145    }
146
147    // A failed-import retry payload without a cache is a not-yet-migrated lane.
148    if ctx.persistent.get("failedImportFile").is_some() {
149        return Err(
150            "import3d: the failed-import base64 retry lane is not yet migrated to the Rust pipeline"
151                .into(),
152        );
153    }
154
155    Err(
156        "import3d: no model data (no `nativeBrep`/`stepText` param and no `importCache`)".into(),
157    )
158}
159
160/// Restore a native payload and register its solids VERBATIM — the `nativeBrep`
161/// lane. Nothing about the names is re-derived: they were stamped once, when the
162/// geometry was first imported, and every scene-metadata record and every
163/// constraint attached to a face is keyed to exactly those strings. (This is
164/// `component::create_component`'s phase 2 for the same reason — see its module
165/// doc: "do not `fix` this by routing members through `register_added`".)
166///
167/// The payload's captured metadata records are merged back into the scene store
168/// UN-namespaced, exactly as they were captured: an IMPORT3D feature is not a
169/// component boundary, so there is no instance prefix to apply. The ACOMP lane
170/// namespaces on insert if this document is later used as a part.
171fn restore_native_bodies(ctx: &FeatureContext, payload: &str) -> Result<FeatureResult, String> {
172    let restored = restore_solids(payload)
173        .map_err(|error| format!("import3d: native payload did not decode: {error}"))?;
174    if restored.solids.is_empty() {
175        return Err("import3d: native payload contained no solids".into());
176    }
177    let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
178    for solid in restored.solids {
179        result.added.push(register_verbatim(solid.solid, &solid.name));
180    }
181    // Overwriting: the payload's records ARE the part's metadata. The pipeline's
182    // own `sourceFeatureId` seed runs after this feature returns and is
183    // NON-overwriting, so a face keeps the producing-feature id it was imported
184    // with rather than picking up this feature's.
185    for (name, record) in restored.metadata {
186        scene_metadata::merge_record(&name, &record, true);
187    }
188    Ok(result)
189}
190
191/// [`common::register_added`] WITHOUT its two name-deriving passes: collect the
192/// names the solid already carries and make it scene-resident.
193///
194/// The container groups are EMPTY, and that is the right answer rather than a
195/// stub: a group is the un-keyed alias a PROFILE-SWEPT feature publishes for its
196/// per-loop face families (`{cap_base}_START` standing for every
197/// `{cap_base}:L{id}_START` — see [`common::register_added_grouped`]). Imported
198/// geometry has no sketch loops behind it, so there is no family to alias; its
199/// names arrived stamped from outside and are already the final, unique ones.
200fn register_verbatim(solid: BrepSolid, name: &str) -> AddedSolid {
201    let face_names = common::collect_face_names(&solid);
202    let edge_names = common::collect_edge_names(&solid);
203    let handle = crate::register_solid_value(solid);
204    AddedSolid {
205        handle,
206        name: name.to_string(),
207        face_names,
208        edge_names,
209        face_groups: Vec::new(),
210        edge_groups: Vec::new(),
211    }
212}
213
214/// Stamp the established naming convention onto the imported bodies, register
215/// them, and stamp whatever COLOUR the file carried onto those same names.
216///
217/// `appearances` is parallel to `solids` (see [`crate::BodyAppearance`]); pass
218/// `&[]` from a lane whose format has no colour, which stamps nothing.
219fn add_named_bodies(
220    ctx: &FeatureContext,
221    mut solids: Vec<BrepSolid>,
222    appearances: &[BodyAppearance],
223    feature_name: &str,
224) -> FeatureResult {
225    let body_names = stamp_imported_names(&mut solids, feature_name);
226    let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
227    for (index, (solid, body_name)) in solids.into_iter().zip(body_names).enumerate() {
228        // Colour is stamped AFTER `register_added`, which is where the names
229        // become final (it dedupes face names before deriving the edge names).
230        let added = common::register_added(solid, &body_name);
231        if let Some(appearance) = appearances.get(index) {
232            stamp_appearance(&added.name, &added.face_names, appearance);
233        }
234        result.added.push(added);
235    }
236    result
237}
238
239/// Write an imported body's colours into the scene-metadata store, keyed by the
240/// FINAL names — the one place the `{"color": "#RRGGBB"}` convention of
241/// `io/appearance.rs` is written.
242///
243/// `face_names` is `(face_id, name)` in shell/face order
244/// ([`common::collect_face_names`]), the same order `appearance.faces` is
245/// indexed by. A length disagreement means the two walks have drifted, so
246/// nothing per-face is stamped rather than colouring the wrong faces — the body
247/// colour, which is not positional, still lands.
248///
249/// Merged NON-overwriting: a history replay re-runs this feature, and a colour
250/// the user has since edited in the info panel must survive it.
251fn stamp_appearance(body_name: &str, face_names: &[(u64, String)], appearance: &BodyAppearance) {
252    if let Some(color) = appearance.body {
253        stamp_color(body_name, color);
254    }
255    if appearance.faces.is_empty() || appearance.faces.len() != face_names.len() {
256        return;
257    }
258    for ((_, face_name), color) in face_names.iter().zip(&appearance.faces) {
259        if let Some(color) = color {
260            stamp_color(face_name, *color);
261        }
262    }
263}
264
265fn stamp_color(name: &str, color: ImportedColor) {
266    let mut record = serde_json::Map::new();
267    record.insert(
268        COLOR_METADATA_KEY.to_string(),
269        serde_json::Value::String(color.to_hex()),
270    );
271    scene_metadata::merge_record(name, &record, false);
272}
273
274/// The naming half of [`add_named_bodies`]: stamp body + face names onto
275/// `solids` IN PLACE and return the body name chosen for each, WITHOUT
276/// registering anything. Shared by the live import lanes and the payload encoder
277/// [`native_import_payload`], so an imported part is named identically however it
278/// arrived (kernel-plan `step-assembly-import.md` §3.1).
279fn stamp_imported_names(solids: &mut [BrepSolid], feature_name: &str) -> Vec<String> {
280    let body_names = imported_solid_names(solids.len(), feature_name);
281    let multi_body = solids.len() > 1;
282    let mut chosen = Vec::with_capacity(solids.len());
283    for (index, solid) in solids.iter_mut().enumerate() {
284        let body_name = body_names
285            .get(index)
286            .cloned()
287            .unwrap_or_else(|| feature_name.to_string());
288        let mut face_index = 0usize;
289        for shell in &mut solid.shells {
290            for face in &mut shell.faces {
291                if multi_body {
292                    // Multi-body: EVERY face namespaced under its body.
293                    face.name = Some(format!("{body_name}_Face_{face_index}"));
294                } else {
295                    // Single body: prepend the (unique) feature name so faces never
296                    // collide with ANOTHER import's faces — two single-body imports
297                    // both stamped bare `Face_N` before, which the cross-solid
298                    // name-uniqueness guard flags. Keep any STEP-authored name as the
299                    // stem, else the positional `Face_N` fallback. (`body_name` IS the
300                    // feature name here — see `imported_solid_names`.) Edge names are
301                    // re-derived from these face names downstream, so namespacing the
302                    // faces namespaces the derived edges too.
303                    let stem = face
304                        .name
305                        .as_deref()
306                        .map(str::trim)
307                        .filter(|name| !name.is_empty())
308                        .map(str::to_string)
309                        .unwrap_or_else(|| format!("Face_{face_index}"));
310                    face.name = Some(format!("{body_name}_{stem}"));
311                }
312                face_index += 1;
313            }
314        }
315        chosen.push(body_name);
316    }
317    chosen
318}
319
320/// `importedSolidNames` port: 1 body keeps the feature name; several get a
321/// stable, zero-padded `{feature}_SOLID_0N` suffix.
322fn imported_solid_names(count: usize, feature_name: &str) -> Vec<String> {
323    if count == 0 {
324        return Vec::new();
325    }
326    if count == 1 {
327        return vec![feature_name.to_string()];
328    }
329    let digits = count.to_string().len().max(2);
330    (1..=count)
331        .map(|index| format!("{feature_name}_SOLID_{index:0width$}", width = digits))
332        .collect()
333}
334
335/// Encode `solids` as a native IMPORT3D payload: stamp the names this feature
336/// would stamp under `feature_name`, then seal them into the `io/snapshot`
337/// container. The result is exactly what the `nativeBrep` source above reads
338/// back, so a part is named identically however it arrived — imported live from
339/// a text source, or restored from a payload built here.
340///
341/// GENERIC, not STEP-specific: any producer of finished `BrepSolid`s that wants
342/// them to become an IMPORT3D part document uses this (the STEP-assembly import
343/// lane is the first caller — kernel-plan `step-assembly-import.md` §3.1/§3.2).
344///
345/// The two passes after the stamping are `register_added`'s name-FINALIZING half
346/// ([`common::ensure_unique_face_names`] + [`common::stamp_derived_edge_names`]),
347/// applied HERE rather than at restore time: the `nativeBrep` lane registers
348/// verbatim, so whatever the payload carries IS the final name set. Without them
349/// a payload's edge names would be the importer's raw ones while the `stepText`
350/// lane's are the derived `{faceA}|{faceB}[n]` — the same geometry under two
351/// different name sets, which is exactly what this helper exists to prevent.
352///
353/// The payload is byte-deterministic for identical input (`snapshot_solids`), so
354/// two encodings of the same part collapse to ONE parts-library entry.
355pub fn native_import_payload(feature_name: &str, solids: &[BrepSolid]) -> Result<String, String> {
356    native_import_payload_with_appearance(feature_name, solids, &[])
357}
358
359/// [`native_import_payload`] carrying the import's COLOURS into the payload.
360///
361/// `appearances` is parallel to `solids` (see [`crate::BodyAppearance`]); `&[]`
362/// is the colourless case and makes this identical to [`native_import_payload`].
363///
364/// The colours are stamped into the AMBIENT scene-metadata store just before
365/// `snapshot_solids`, which is exactly how they get INTO the payload — the
366/// snapshot captures each stamped name's own record alongside the geometry. So a
367/// caller that runs this inside a `crate::IsolatedSceneMetadata` bracket (the
368/// STEP-assembly part-document lane does, and must) gets the colours sealed into
369/// the part and discarded from the live document, which is the whole point of
370/// that bracket: the part carries its own metadata, the open document is not
371/// touched.
372pub fn native_import_payload_with_appearance(
373    feature_name: &str,
374    solids: &[BrepSolid],
375    appearances: &[BodyAppearance],
376) -> Result<String, String> {
377    if solids.is_empty() {
378        return Err("import3d: native payload needs at least one solid".into());
379    }
380    let mut bodies = solids.to_vec();
381    let body_names = stamp_imported_names(&mut bodies, feature_name);
382    for solid in &mut bodies {
383        common::ensure_unique_face_names(solid);
384        common::stamp_derived_edge_names(solid);
385    }
386    // Names are FINAL from here, so this is where colour can be keyed to them.
387    for (index, (solid, body_name)) in bodies.iter().zip(&body_names).enumerate() {
388        if let Some(appearance) = appearances.get(index) {
389            stamp_appearance(body_name, &common::collect_face_names(solid), appearance);
390        }
391    }
392    let named: Vec<(&str, &BrepSolid)> = body_names
393        .iter()
394        .map(String::as_str)
395        .zip(bodies.iter())
396        .collect();
397    crate::snapshot_solids(&named)
398}
399
400
401/// Context-bar applicability ([`crate::feature_pipeline::context_offer`]):
402/// never offered from a selection (no reference inputs).
403pub fn context_applicable(_probe: &crate::feature_pipeline::SelectionProbe) -> bool {
404    false
405}
406
407/// This feature's kernel-owned definition (name + parameter schema), kept
408/// WITH the feature code — the caller's feature editor requests it via `feature_schemas_json`
409/// (see `feature_pipeline/schema.rs`, which aggregates every feature's
410/// `schema()` into the catalogue).
411///
412/// Only `id` is exposed: an IMPORT3D feature is created by an import lane that
413/// bakes its payload in — the toolbar Import lane
414/// ([`EngineState::import_step_feature`] / `import_iges_feature`) writes the raw
415/// document text into `inputParams.stepText` / `igesText`, and the native lane
416/// ([`native_import_payload`], the STEP-assembly part document) writes
417/// `inputParams.nativeBrep`. None of the three is form-editable, so none is
418/// exposed. (The former `fileToImport` "file" param was a JS-era vestige: nothing
419/// produced or consumed it and no form builder rendered it.)
420pub fn schema() -> serde_json::Value {
421    serde_json::json!({
422    "type": "IMPORT3D",
423    "shortName": "IMPORT3D",
424    "longName": "Import 3D Model",
425    "displayBuilder": false,
426    "inputParamsSchema": {
427        "id": {
428            "type": "string",
429            "default_value": null,
430            "hint": "unique identifier for the import feature"
431        }
432    }
433})
434}
435
436#[cfg(test)]
437mod tests {
438    use crate::feature_pipeline::scene_metadata::{
439        scene_metadata_get_own_json, scene_metadata_load_json, scene_metadata_set_json,
440    };
441    use crate::feature_pipeline::{execute_history, HistoryRequest, HistoryResult};
442    use crate::{encode_solid, native_import_payload, restore_solids, BrepSolid, Vec3};
443
444    fn run(request: serde_json::Value) -> HistoryResult {
445        let request: HistoryRequest =
446            serde_json::from_value(request).expect("request deserializes");
447        {
448            crate::feature_pipeline::clear_history_cache();
449            execute_history(&request)
450        }
451    }
452
453    fn volume(handle: u32) -> f64 {
454        crate::with_registered_solid_str(handle, |solid| {
455            crate::solid_mass_properties(solid).map(|p| p.volume)
456        })
457        .expect("mass properties")
458    }
459
460    #[test]
461    fn step_text_round_trip_imports_named_solid() {
462        // export_step(box) → the pipeline imports it back: exact round-trip.
463        let solid = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 4.0, 3.0, 2.0).unwrap();
464        let step_text =
465            crate::export_step(&[solid], "part", "MM", "2026-01-01T00:00:00Z").unwrap();
466
467        let history = run(serde_json::json!({
468            "expressions": "", "configurator": {},
469            "features": [{ "type": "IMPORT3D",
470                "inputParams": { "id": "Imp", "stepText": step_text },
471                "persistentData": {}, "timestamp": null }]
472        }));
473        let result = &history.results[0];
474        assert!(result.error.is_none(), "import error: {:?}", result.error);
475        let added = &result.added[0];
476        assert_eq!(added.name, "Imp", "single body keeps the feature name");
477        let got = volume(added.handle);
478        assert!((got - 24.0).abs() < 1e-6, "imported volume {got}, expected 24");
479        // Single-body face names are prepended with the feature name so a second
480        // import never collides; the fallback stem stays `Face_N`.
481        assert!(
482            added.face_names.iter().any(|(_, n)| n == "Imp_Face_0"),
483            "feature-prefixed face names: {:?}",
484            added.face_names
485        );
486        crate::feature_pipeline::clear_history_cache();
487    }
488
489    #[test]
490    fn two_single_body_imports_do_not_collide_on_face_names() {
491        // Regression: importing the SAME box twice used to leave both resident
492        // solids with bare `Face_0..` (and the edges derived from them), which the
493        // cross-solid name-uniqueness guard flags. Feature-name prefixing keeps
494        // each import's faces (and derived edges) unique — no collision error.
495        let solid = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 4.0, 3.0, 2.0).unwrap();
496        let step_text =
497            crate::export_step(&[solid], "part", "MM", "2026-01-01T00:00:00Z").unwrap();
498
499        let history = run(serde_json::json!({
500            "expressions": "", "configurator": {},
501            "features": [
502                { "type": "IMPORT3D",
503                  "inputParams": { "id": "ImpA", "stepText": step_text },
504                  "persistentData": {}, "timestamp": null },
505                { "type": "IMPORT3D",
506                  "inputParams": { "id": "ImpB", "stepText": step_text },
507                  "persistentData": {}, "timestamp": null }
508            ]
509        }));
510        for result in &history.results {
511            assert!(
512                result.error.is_none(),
513                "import `{}` errored (expected no name collision): {:?}",
514                result.id,
515                result.error
516            );
517        }
518        let names_a: std::collections::BTreeSet<_> =
519            history.results[0].added[0].face_names.iter().map(|(_, n)| n.clone()).collect();
520        let names_b: std::collections::BTreeSet<_> =
521            history.results[1].added[0].face_names.iter().map(|(_, n)| n.clone()).collect();
522        assert!(
523            names_a.is_disjoint(&names_b),
524            "the two imports must not share any face name: A={names_a:?} B={names_b:?}"
525        );
526        crate::feature_pipeline::clear_history_cache();
527    }
528
529    #[test]
530    fn import_cache_records_replay_with_multi_body_names() {
531        // Two serde BrepSolid records in importCache → {id}_SOLID_01/_02 with
532        // body-prefixed face names.
533        let a = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 1.0, 1.0).unwrap();
534        let b = crate::make_box_brep(Vec3::new(5.0, 0.0, 0.0), 2.0, 2.0, 2.0).unwrap();
535        let records = serde_json::json!([
536            serde_json::to_value(&a).unwrap(),
537            serde_json::to_value(&b).unwrap(),
538        ]);
539
540        let history = run(serde_json::json!({
541            "expressions": "", "configurator": {},
542            "features": [{ "type": "IMPORT3D",
543                "inputParams": { "id": "Imp" },
544                "persistentData": { "importCache": {
545                    "version": 3, "kind": "step-brep", "kernelSolidRecords": records,
546                    "updatedAt": "2026-01-01T00:00:00.000Z" } },
547                "timestamp": null }]
548        }));
549        let result = &history.results[0];
550        assert!(result.error.is_none(), "cache replay error: {:?}", result.error);
551        assert_eq!(result.added.len(), 2);
552        assert_eq!(result.added[0].name, "Imp_SOLID_01");
553        assert_eq!(result.added[1].name, "Imp_SOLID_02");
554        assert!(
555            result.added[1]
556                .face_names
557                .iter()
558                .all(|(_, n)| n.starts_with("Imp_SOLID_02_Face_")),
559            "multi-body faces namespaced: {:?}",
560            result.added[1].face_names
561        );
562        let got = volume(result.added[1].handle);
563        assert!((got - 8.0).abs() < 1e-6, "second body volume {got}, expected 8");
564        crate::feature_pipeline::clear_history_cache();
565    }
566
567    // -----------------------------------------------------------------------
568    // The native payload lane: `native_import_payload` (A1) + the `nativeBrep`
569    // source (A2) — kernel-plan `step-assembly-import.md` §3.1/§3.2.
570    // -----------------------------------------------------------------------
571
572    fn boxes(count: usize) -> Vec<BrepSolid> {
573        (0..count)
574            .map(|index| {
575                let offset = index as f64 * 10.0;
576                crate::make_box_brep(
577                    Vec3::new(offset, 0.0, 0.0),
578                    1.0 + index as f64,
579                    2.0,
580                    3.0,
581                )
582                .unwrap()
583            })
584            .collect()
585    }
586
587    fn face_names(solid: &BrepSolid) -> Vec<String> {
588        super::common::collect_face_names(solid)
589            .into_iter()
590            .map(|(_, name)| name)
591            .collect()
592    }
593
594    fn edge_names(solid: &BrepSolid) -> Vec<String> {
595        super::common::collect_edge_names(solid)
596            .into_iter()
597            .map(|(_, name)| name)
598            .collect()
599    }
600
601    /// The naming ORACLE: the SAME solids through a live import lane. The
602    /// `importCache` source is `add_named_bodies` with no importer in front of
603    /// it, so what it registers is exactly what a `stepText` import registers
604    /// for those solids — and therefore exactly what the payload must carry.
605    fn names_through_the_live_lane(
606        feature: &str,
607        solids: &[BrepSolid],
608    ) -> Vec<(String, Vec<String>, Vec<String>)> {
609        let records: Vec<serde_json::Value> = solids
610            .iter()
611            .map(|solid| serde_json::to_value(solid).unwrap())
612            .collect();
613        let history = run(serde_json::json!({
614            "expressions": "", "configurator": {},
615            "features": [{ "type": "IMPORT3D",
616                "inputParams": { "id": feature },
617                "persistentData": { "importCache": {
618                    "version": 3, "kind": "step-brep", "kernelSolidRecords": records,
619                    "updatedAt": "2026-01-01T00:00:00.000Z" } },
620                "timestamp": null }]
621        }));
622        let result = &history.results[0];
623        assert!(result.error.is_none(), "oracle lane error: {:?}", result.error);
624        let names = result
625            .added
626            .iter()
627            .map(|added| {
628                (
629                    added.name.clone(),
630                    added.face_names.iter().map(|(_, n)| n.clone()).collect(),
631                    added.edge_names.iter().map(|(_, n)| n.clone()).collect(),
632                )
633            })
634            .collect();
635        crate::feature_pipeline::clear_history_cache();
636        names
637    }
638
639    /// A payload's solids must restore to the INPUT geometry, bit for bit,
640    /// under the names the live lane would have stamped. Runs both naming
641    /// branches: one body (feature name + `{feature}_{stem}` faces) and several
642    /// (`{feature}_SOLID_0N` + `{body}_Face_{f}`).
643    #[test]
644    fn native_payload_restores_the_input_solids_with_the_import3d_names() {
645        for count in [1usize, 2] {
646            // Deterministic store state: `snapshot_solids` captures whatever
647            // records the thread's store holds for the stamped names.
648            scene_metadata_load_json("{}").unwrap();
649            let solids = boxes(count);
650            let payload = native_import_payload("Imp", &solids).expect("payload encodes");
651            let restored = restore_solids(&payload).expect("payload restores");
652            assert_eq!(restored.solids.len(), count, "{count}-body payload");
653
654            let oracle = names_through_the_live_lane("Imp", &solids);
655            assert_eq!(oracle.len(), count);
656            for (index, entry) in restored.solids.iter().enumerate() {
657                // Geometry: the payload transports the INPUT solid unchanged
658                // (the flat `f64` codec round-trips every coordinate bit-exact).
659                let (want, _) = encode_solid(&solids[index]).expect("encode input");
660                let (got, _) = encode_solid(&entry.solid).expect("encode restored");
661                assert_eq!(got, want, "body {index} geometry must be bit-identical");
662                // Names: identical to the live lane's, body/face/edge alike.
663                let (body, faces, edges) = &oracle[index];
664                assert_eq!(&entry.name, body, "body name");
665                assert_eq!(&face_names(&entry.solid), faces, "face names");
666                assert_eq!(&edge_names(&entry.solid), edges, "edge names");
667            }
668            // Spot-check the convention itself, so a change to BOTH lanes at
669            // once still trips a test.
670            if count == 1 {
671                assert_eq!(restored.solids[0].name, "Imp");
672                assert!(face_names(&restored.solids[0].solid).iter().all(|n| n.starts_with("Imp_")));
673            } else {
674                assert_eq!(restored.solids[0].name, "Imp_SOLID_01");
675                assert_eq!(restored.solids[1].name, "Imp_SOLID_02");
676                assert!(face_names(&restored.solids[1].solid)
677                    .iter()
678                    .all(|n| n.starts_with("Imp_SOLID_02_Face_")));
679            }
680            scene_metadata_load_json("{}").unwrap();
681        }
682    }
683
684    /// The part-document shape of kernel-plan §3.2, executed end to end: one
685    /// IMPORT3D feature whose only input is `nativeBrep`.
686    #[test]
687    fn native_brep_document_round_trips_through_the_pipeline() {
688        scene_metadata_load_json("{}").unwrap();
689        let solids = boxes(2);
690        let payload = native_import_payload("IMPORT3D1", &solids).expect("payload encodes");
691        // A metadata record stamped on the part's face must ride along in the
692        // payload and land back in the store when the document executes.
693        let restored = restore_solids(&payload).expect("payload restores");
694        let face = face_names(&restored.solids[0].solid)[0].clone();
695        scene_metadata_set_json(&face, r#"{"material":"steel"}"#).unwrap();
696        let payload = native_import_payload("IMPORT3D1", &solids).expect("payload re-encodes");
697        scene_metadata_load_json("{}").unwrap();
698
699        let history = run(serde_json::json!({
700            "expressions": "", "configurator": {},
701            "features": [{ "type": "IMPORT3D",
702                "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
703                "persistentData": {}, "timestamp": null }]
704        }));
705        let result = &history.results[0];
706        assert!(result.error.is_none(), "native import error: {:?}", result.error);
707        assert_eq!(result.added.len(), 2);
708        assert_eq!(result.added[0].name, "IMPORT3D1_SOLID_01");
709        assert_eq!(result.added[1].name, "IMPORT3D1_SOLID_02");
710        let got = volume(result.added[1].handle);
711        assert!((got - 12.0).abs() < 1e-9, "second body volume {got}, expected 12");
712        let record: serde_json::Value =
713            serde_json::from_str(&scene_metadata_get_own_json(&face)).unwrap();
714        assert_eq!(record["material"], "steel", "captured record must be merged back");
715        crate::feature_pipeline::clear_history_cache();
716        scene_metadata_load_json("{}").unwrap();
717    }
718
719    /// Names come out of the PAYLOAD, never re-derived from the feature id: a
720    /// part keeps the names it was imported with wherever its document is
721    /// executed (a library entry's heal runs it under a different id).
722    #[test]
723    fn native_brep_registers_the_stored_names_verbatim() {
724        scene_metadata_load_json("{}").unwrap();
725        let solids = boxes(1);
726        let payload = native_import_payload("Bracket", &solids).expect("payload encodes");
727        let history = run(serde_json::json!({
728            "expressions": "", "configurator": {},
729            "features": [{ "type": "IMPORT3D",
730                "inputParams": { "id": "IMPORT3D7", "nativeBrep": payload },
731                "persistentData": {}, "timestamp": null }]
732        }));
733        let result = &history.results[0];
734        assert!(result.error.is_none(), "native import error: {:?}", result.error);
735        assert_eq!(result.added[0].name, "Bracket", "stored body name wins");
736        assert!(
737            result.added[0].face_names.iter().all(|(_, n)| n.starts_with("Bracket_")),
738            "stored face names must survive verbatim: {:?}",
739            result.added[0].face_names
740        );
741        crate::feature_pipeline::clear_history_cache();
742        scene_metadata_load_json("{}").unwrap();
743    }
744
745    /// A payload that does not decode surfaces as this feature's OWN result
746    /// error — a reported, inspectable outcome, never a panic and never damage
747    /// to the features that already ran. (The history still stops at the first
748    /// erroring feature, as it does for any feature error; it is the IMPORT
749    /// lane above the kernel that counts a failed part and carries on.)
750    #[test]
751    fn corrupt_native_payload_is_a_clean_feature_error() {
752        scene_metadata_load_json("{}").unwrap();
753        let payload = native_import_payload("Imp", &boxes(1)).expect("payload encodes");
754        // Flip one base64 character: the container checksum must catch it.
755        let mut corrupt: Vec<char> = payload.chars().collect();
756        corrupt[20] = if corrupt[20] == 'A' { 'B' } else { 'A' };
757        let corrupt: String = corrupt.into_iter().collect();
758        assert_ne!(corrupt, payload, "the flip must actually change the payload");
759
760        let history = run(serde_json::json!({
761            "expressions": "", "configurator": {},
762            "features": [
763                { "type": "IMPORT3D",
764                  "inputParams": { "id": "Good", "nativeBrep": payload },
765                  "persistentData": {}, "timestamp": null },
766                { "type": "IMPORT3D",
767                  "inputParams": { "id": "Bad", "nativeBrep": corrupt },
768                  "persistentData": {}, "timestamp": null }
769            ]
770        }));
771        assert_eq!(history.results.len(), 2, "both features produced a result");
772        assert!(
773            history.results[0].error.is_none(),
774            "the healthy import must still stand: {:?}",
775            history.results[0].error
776        );
777        assert_eq!(history.results[0].added.len(), 1);
778        let error = history.results[1].error.as_deref().unwrap_or_default();
779        assert!(
780            error.contains("native payload did not decode"),
781            "expected the clean decode error, got {error:?}"
782        );
783        assert!(history.results[1].added.is_empty(), "a failed import adds nothing");
784        crate::feature_pipeline::clear_history_cache();
785
786        // Garbage that is not a container at all is the same clean error.
787        let history = run(serde_json::json!({
788            "expressions": "", "configurator": {},
789            "features": [{ "type": "IMPORT3D",
790                "inputParams": { "id": "Bad", "nativeBrep": "not-a-payload" },
791                "persistentData": {}, "timestamp": null }]
792        }));
793        assert!(
794            history.results[0]
795                .error
796                .as_deref()
797                .unwrap_or_default()
798                .contains("native payload did not decode"),
799            "expected the clean decode error, got {:?}",
800            history.results[0].error
801        );
802        crate::feature_pipeline::clear_history_cache();
803        scene_metadata_load_json("{}").unwrap();
804    }
805
806    /// EXACTLY ONE source: a feature carrying both a payload and a text source
807    /// is malformed, and silently preferring one would drop the other.
808    #[test]
809    fn native_brep_with_a_text_source_is_an_error() {
810        scene_metadata_load_json("{}").unwrap();
811        let solids = boxes(1);
812        let payload = native_import_payload("Imp", &solids).expect("payload encodes");
813        let step_text =
814            crate::export_step(&solids, "part", "MM", "2026-01-01T00:00:00Z").unwrap();
815
816        let history = run(serde_json::json!({
817            "expressions": "", "configurator": {},
818            "features": [{ "type": "IMPORT3D",
819                "inputParams": { "id": "Imp", "nativeBrep": payload, "stepText": step_text },
820                "persistentData": {}, "timestamp": null }]
821        }));
822        let error = history.results[0].error.as_deref().unwrap_or_default();
823        assert!(
824            error.contains("exclusive"),
825            "expected the one-source error, got {error:?}"
826        );
827        assert!(history.results[0].added.is_empty(), "nothing imports on a conflict");
828        crate::feature_pipeline::clear_history_cache();
829
830        // A present-but-non-string payload errors HERE rather than falling
831        // through to a different source.
832        let history = run(serde_json::json!({
833            "expressions": "", "configurator": {},
834            "features": [{ "type": "IMPORT3D",
835                "inputParams": { "id": "Imp", "nativeBrep": 42 },
836                "persistentData": {}, "timestamp": null }]
837        }));
838        assert!(
839            history.results[0]
840                .error
841                .as_deref()
842                .unwrap_or_default()
843                .contains("must be a base64 snapshot string"),
844            "expected the type error, got {:?}",
845            history.results[0].error
846        );
847        crate::feature_pipeline::clear_history_cache();
848        scene_metadata_load_json("{}").unwrap();
849    }
850
851    /// A1O — imported STEP COLOUR reaches the name-keyed metadata store, both
852    /// per body and per face, and rides the native payload back.
853    ///
854    /// Two REAL corpus fixtures, one per styling target:
855    /// * `freecad_partdesign_body.step` styles its MANIFOLD_SOLID_BREP grey
856    ///   (`COLOUR_RGB 0.8`) AND pairs it with a near-black CURVE_STYLE — so the
857    ///   body must come out `#CCCCCC`, never the curve's `#191919`.
858    /// * `io1-ug-214.stp` over-rides ONE ADVANCED_FACE with a
859    ///   `DRAUGHTING_PRE_DEFINED_COLOUR('red')` fill while its solid carries a
860    ///   boundary-only style — so exactly one face is `#FF0000` and the body has
861    ///   no colour at all.
862    #[test]
863    fn step_colours_land_on_the_metadata_records_and_survive_the_payload() {
864        const BODY_COLOURED: &str = include_str!(concat!(
865            env!("CARGO_MANIFEST_DIR"),
866            "/tests/fixtures/step-import/freecad_partdesign_body.step"
867        ));
868        const FACE_COLOURED: &str = include_str!(concat!(
869            env!("CARGO_MANIFEST_DIR"),
870            "/tests/fixtures/step-import/io1-ug-214.stp"
871        ));
872        let color_of = |name: &str| -> Option<String> {
873            serde_json::from_str::<serde_json::Value>(&scene_metadata_get_own_json(name))
874                .unwrap()
875                .get("color")
876                .and_then(|value| value.as_str())
877                .map(str::to_string)
878        };
879
880        // --- per-body, through the live `stepText` lane ---------------------
881        scene_metadata_load_json("{}").unwrap();
882        let history = run(serde_json::json!({
883            "expressions": "", "configurator": {},
884            "features": [{ "type": "IMPORT3D",
885                "inputParams": { "id": "Imp", "stepText": BODY_COLOURED },
886                "persistentData": {}, "timestamp": null }]
887        }));
888        let added = &history.results[0].added[0];
889        assert_eq!(
890            color_of(&added.name).as_deref(),
891            Some("#CCCCCC"),
892            "the solid's FILL_AREA colour, not its CURVE_STYLE colour"
893        );
894        assert!(
895            added.face_names.iter().all(|(_, name)| color_of(name).is_none()),
896            "a per-body colour must not be fanned out onto the faces"
897        );
898        crate::feature_pipeline::clear_history_cache();
899
900        // --- per-face, and the payload round trip ---------------------------
901        scene_metadata_load_json("{}").unwrap();
902        let history = run(serde_json::json!({
903            "expressions": "", "configurator": {},
904            "features": [{ "type": "IMPORT3D",
905                "inputParams": { "id": "Imp", "stepText": FACE_COLOURED },
906                "persistentData": {}, "timestamp": null }]
907        }));
908        let added = &history.results[0].added[0];
909        assert_eq!(color_of(&added.name), None, "boundary style is not a body colour");
910        let red: Vec<&String> = added
911            .face_names
912            .iter()
913            .map(|(_, name)| name)
914            .filter(|name| color_of(name).as_deref() == Some("#FF0000"))
915            .collect();
916        assert_eq!(red.len(), 1, "exactly one over-ridden face is red: {red:?}");
917        let red_face = red[0].clone();
918        crate::feature_pipeline::clear_history_cache();
919
920        // The native payload lane must carry the SAME colour: encode the same
921        // import into a part document, wipe the store, execute it, and the
922        // record must be back on the same face name.
923        let (solids, appearances) =
924            crate::import_step_with_appearance(FACE_COLOURED).expect("fixture imports");
925        scene_metadata_load_json("{}").unwrap();
926        let payload = crate::native_import_payload_with_appearance("Imp", &solids, &appearances)
927            .expect("payload encodes");
928        scene_metadata_load_json("{}").unwrap();
929        let history = run(serde_json::json!({
930            "expressions": "", "configurator": {},
931            "features": [{ "type": "IMPORT3D",
932                "inputParams": { "id": "Imp", "nativeBrep": payload },
933                "persistentData": {}, "timestamp": null }]
934        }));
935        assert!(history.results[0].error.is_none(), "{:?}", history.results[0].error);
936        assert_eq!(
937            color_of(&red_face).as_deref(),
938            Some("#FF0000"),
939            "the colour must ride the snapshot into the part document"
940        );
941        crate::feature_pipeline::clear_history_cache();
942        scene_metadata_load_json("{}").unwrap();
943    }
944
945    /// A colour the USER edited in the info panel outlives a history replay:
946    /// the import stamp is a non-overwriting merge, not an assignment.
947    #[test]
948    fn a_user_edited_colour_survives_a_re_execution() {
949        const BODY_COLOURED: &str = include_str!(concat!(
950            env!("CARGO_MANIFEST_DIR"),
951            "/tests/fixtures/step-import/freecad_partdesign_body.step"
952        ));
953        scene_metadata_load_json("{}").unwrap();
954        let request = serde_json::json!({
955            "expressions": "", "configurator": {},
956            "features": [{ "type": "IMPORT3D",
957                "inputParams": { "id": "Imp", "stepText": BODY_COLOURED },
958                "persistentData": {}, "timestamp": null }]
959        });
960        let history = run(request.clone());
961        let name = history.results[0].added[0].name.clone();
962        // `r##` — a `"#` inside a plain `r#"…"#` would close the literal early.
963        scene_metadata_set_json(&name, r##"{"color":"#123456"}"##).unwrap();
964        run(request);
965        let record: serde_json::Value =
966            serde_json::from_str(&scene_metadata_get_own_json(&name)).unwrap();
967        assert_eq!(record["color"], "#123456", "the user's edit must win");
968        crate::feature_pipeline::clear_history_cache();
969        scene_metadata_load_json("{}").unwrap();
970    }
971
972    #[test]
973    fn import_without_source_errors_loudly() {
974        let history = run(serde_json::json!({
975            "expressions": "", "configurator": {},
976            "features": [{ "type": "IMPORT3D", "inputParams": { "id": "Imp" },
977                "persistentData": {}, "timestamp": null }]
978        }));
979        assert!(
980            history.results[0].error.as_deref().unwrap_or("").contains("no model data"),
981            "expected the no-source error, got {:?}",
982            history.results[0].error
983        );
984    }
985}