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.
322pub(crate) fn 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///
408/// Only `id` is exposed: an IMPORT3D feature is created by an import lane that
409/// bakes its payload in — the toolbar Import lane
410/// ([`EngineState::import_step_feature`] / `import_iges_feature`) writes the raw
411/// document text into `inputParams.stepText` / `igesText`, and the native lane
412/// ([`native_import_payload`], the STEP-assembly part document) writes
413/// `inputParams.nativeBrep`. None of the three is form-editable, so none is
414/// exposed. (The former `fileToImport` "file" param was a JS-era vestige: nothing
415/// produced or consumed it and no form builder rendered it.)
416pub fn schema() -> serde_json::Value {
417 serde_json::json!({
418 "type": "IMPORT3D",
419 "shortName": "IMPORT3D",
420 "longName": "Import 3D Model",
421 "displayBuilder": false,
422 "inputParamsSchema": {
423 "id": {
424 "type": "string",
425 "default_value": null,
426 "hint": "unique identifier for the import feature"
427 }
428 }
429})
430}
431
432// BREP private tests: fdb8c7a1acf7c80a