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, and the
11//! reason the snapshot container is a DURABLE format, not a cache (§6 there,
12//! and the `io/snapshot.rs` module doc).
13//! 1. **`inputParams.stepText`** — the raw ISO-10303-21 document, baked in by the
14//! toolbar Import lane ([`crate`]'s host: `EngineState::import_step_feature`).
15//! Runs the exact Rust STEP importer
16//! ([`crate::import_step_with_appearance`] — the colour-carrying form).
17//! 2. **`persistentData.importCache`** (`kind == "step-brep"`) — SUPERSEDED by
18//! `nativeBrep`. The JS-era persisted EXACT BREP records of a previous import
19//! (`serializeBrepSolid` was the serde `BrepSolid` JSON, so records deserialize
20//! directly) — verbose and unversioned. NOTHING in the Rust tree writes it; the
21//! reader stays only so a JS-era saved model still opens. New payloads go in
22//! `nativeBrep`.
23//!
24//! Name fidelity (contract rule 2, byte-matching `Import3dModelFeature`):
25//! - Body names (`importedSolidNames`): a single body keeps the feature name;
26//! several get `{feature}_SOLID_{N}` with N 1-based, zero-padded to
27//! `max(2, digits(count))`.
28//! - Faces (`importedBodyFaceNames` / the single-body fallback): a MULTI-body
29//! import names EVERY face `{bodyName}_Face_{f}` positionally (shell/face
30//! order) so faces stay unique across bodies; a SINGLE body prepends the
31//! (unique) feature name — `{feature}_{stepName}`, or `{feature}_Face_{f}` where
32//! the STEP file left the face unnamed — so two separate imports never share a
33//! face name (the cross-solid name-uniqueness guard's collision class). Edge
34//! names re-derive from the face names, so the derived edges are namespaced too.
35//!
36//! COLOUR rides the same names. What a STEP file's presentation entities assign
37//! (`io/step_import/styles.rs`) is stamped here as a `{"color": "#RRGGBB"}`
38//! scene-metadata record on the FINAL body/face names — see `io/appearance.rs`
39//! for the convention, [`stamp_appearance`] for the write, and
40//! [`native_import_payload_with_appearance`] for the lane that seals it into a
41//! part payload. The stamp is a NON-overwriting merge, so a colour edited in the
42//! caller's info panel survives a history replay.
43//!
44//! ONE convention, ONE implementation: [`stamp_imported_names`] holds it, and
45//! every lane that names imported bodies goes through it — the live import lanes
46//! (via [`add_named_bodies`]) and the payload encoder [`native_import_payload`].
47//! Names are stamped ONCE, at import, and then frozen in the payload; a later
48//! change here does NOT retro-rename already-imported parts, which is the point
49//! (a constraint keyed to a face name survives reload).
50//!
51//! The failed-import base64 retry lane (`persistentData.failedImportFile`) is
52//! not yet migrated — it errors loudly rather than silently skipping.
53
54use crate::feature_pipeline::features::common;
55use crate::feature_pipeline::{
56 scene_metadata, AddedSolid, FeatureContext, FeatureRefusal, FeatureResult,
57};
58use crate::{
59 import_iges_report, import_step_bodies, restore_solids, BodyAppearance, BrepSolid, ImportedColor,
60 StepBodyError, COLOR_METADATA_KEY,
61};
62
63pub fn execute(ctx: &FeatureContext) -> FeatureResult {
64 match build(ctx) {
65 Ok(result) => result,
66 Err(refusal) => ctx.fail(refusal),
67 }
68}
69
70/// `FeatureRefusal`, not `String`: the STEP lane's shell-closure gate refuses a
71/// body TYPED (`RefusalClass::UnsoundResult { defect: VectorArea }`,
72/// `io/step_import/builder/closure.rs`), and the class has to reach
73/// `FeatureResult::refusal` for the app to show it as more than text. Every
74/// other failure here is still text, and `?` accepts both.
75fn build(ctx: &FeatureContext) -> Result<FeatureResult, FeatureRefusal> {
76 let feature_name = if ctx.id.is_empty() {
77 "IMPORT3D".to_string()
78 } else {
79 ctx.id.clone()
80 };
81
82 // 0. Native payload — the exact solids of an earlier import, names frozen in.
83 // EXCLUSIVE by design (see the module doc): a document carrying both this
84 // and a text source is malformed, and answering it with a precedence rule
85 // would silently drop one of the two. Keyed on PRESENCE, not on a
86 // successful `as_str`, so a non-string `nativeBrep` errors here instead of
87 // falling through to a different source.
88 if let Some(native) = ctx.param("nativeBrep").filter(|value| !value.is_null()) {
89 if ctx.param("stepText").is_some() || ctx.param("igesText").is_some() {
90 return Err(
91 "import3d: `nativeBrep` is exclusive — a feature carrying it must not also carry `stepText`/`igesText`"
92 .into(),
93 );
94 }
95 let payload = native
96 .as_str()
97 .ok_or("import3d: param `nativeBrep` must be a base64 snapshot string")?;
98 return Ok(restore_native_bodies(ctx, payload)?);
99 }
100
101 // 1. Fresh STEP text (baked into `stepText` by the toolbar Import lane).
102 if let Some(step_text) = ctx.param("stepText").and_then(|v| v.as_str()) {
103 if step_text.contains("ISO-10303-21") {
104 let imported = import_step_bodies(step_text).map_err(|error| match error {
105 StepBodyError::Text(text) => {
106 FeatureRefusal::Text(format!("import3d: STEP import failed: {text}"))
107 }
108 StepBodyError::Refused(refusal) => FeatureRefusal::Typed(
109 refusal.with_message(|message| format!("import3d: STEP import refused: {message}")),
110 ),
111 })?;
112 if imported.solids.is_empty() {
113 return Err("import3d: STEP file contained no importable solids".into());
114 }
115 let mut result = add_named_bodies(ctx, imported.solids, &imported.appearances, &feature_name);
116 // The closure gate's approximations ride on the feature TYPED,
117 // re-keyed from the file index to the scene name the body was
118 // just given, so the app and the case runner can name the body.
119 for (index, mut approximation) in imported.approximations {
120 if let Some(added) = result.added.get(index) {
121 approximation.body = added.name.clone();
122 approximation.message =
123 format!("{}: {}", added.name, approximation.message);
124 }
125 result.approximations.push(approximation);
126 }
127 return Ok(result);
128 }
129 return Err(
130 "import3d: only STEP (ISO-10303-21) files are supported (STL/3MF mesh import was removed)"
131 .into(),
132 );
133 }
134
135 // 1b. Fresh IGES text (baked into `igesText` by the toolbar Import lane).
136 if let Some(iges_text) = ctx.param("igesText").and_then(|v| v.as_str()) {
137 // The file's colours (314 or the directory colour number) are stamped
138 // as the STEP branch stamps its styled colours.
139 let report = import_iges_report(iges_text)
140 .map_err(|error| format!("import3d: IGES import failed: {error}"))?;
141 if report.solids.is_empty() {
142 return Err("import3d: IGES file contained no importable solids".into());
143 }
144 return Ok(add_named_bodies(ctx, report.solids, &report.appearances, &feature_name));
145 }
146
147 // 2. Persisted exact-BREP records from a previous import.
148 let cache = ctx.persistent.get("importCache");
149 if let Some(cache) = cache {
150 let kind = cache.get("kind").and_then(|v| v.as_str()).unwrap_or("");
151 if kind == "step-brep" {
152 let records = cache
153 .get("kernelSolidRecords")
154 .and_then(|v| v.as_array())
155 .ok_or("import3d: importCache has no kernelSolidRecords")?;
156 let mut solids = Vec::with_capacity(records.len());
157 for (index, record) in records.iter().enumerate() {
158 let solid: BrepSolid = serde_json::from_value(record.clone()).map_err(|error| {
159 format!("import3d: kernel record {index} failed to deserialize: {error}")
160 })?;
161 solids.push(solid);
162 }
163 if solids.is_empty() {
164 return Err("import3d: importCache is empty".into());
165 }
166 return Ok(add_named_bodies(ctx, solids, &[], &feature_name));
167 }
168 return Err(format!(
169 "import3d: unsupported importCache kind '{kind}' (only 'step-brep')"
170 )
171 .into());
172 }
173
174 // A failed-import retry payload without a cache is a not-yet-migrated lane.
175 if ctx.persistent.get("failedImportFile").is_some() {
176 return Err(
177 "import3d: the failed-import base64 retry lane is not yet migrated to the Rust pipeline"
178 .into(),
179 );
180 }
181
182 Err(
183 "import3d: no model data (no `nativeBrep`/`stepText` param and no `importCache`)".into(),
184 )
185}
186
187/// Restore a native payload and register its solids VERBATIM — the `nativeBrep`
188/// lane. Nothing about the names is re-derived: they were stamped once, when the
189/// geometry was first imported, and every scene-metadata record and every
190/// constraint attached to a face is keyed to exactly those strings. (This is
191/// `component::create_component`'s phase 2 for the same reason — see its module
192/// doc: "do not `fix` this by routing members through `register_added`".)
193///
194/// The payload's captured metadata records are merged back into the scene store
195/// UN-namespaced, exactly as they were captured: an IMPORT3D feature is not a
196/// component boundary, so there is no instance prefix to apply. The ACOMP lane
197/// namespaces on insert if this document is later used as a part.
198fn restore_native_bodies(ctx: &FeatureContext, payload: &str) -> Result<FeatureResult, String> {
199 let restored = restore_solids(payload)
200 .map_err(|error| format!("import3d: native payload did not decode: {error}"))?;
201 if restored.solids.is_empty() {
202 return Err("import3d: native payload contained no solids".into());
203 }
204 let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
205 for solid in restored.solids {
206 let mut body = solid.solid;
207 // Exactly analytic faces in a foreign parametrisation take the
208 // kernel's canonical one (`analytic_surface/canonical.rs`).
209 if crate::canonical_carriers_enabled() {
210 let report = crate::canonicalise_carriers(&mut body);
211 if crate::profile::profile_enabled() && !report.rewritten.is_empty() {
212 profile_line!("import3d.canonical_carriers solid={} rewritten={:?}", solid.name, report.rewritten);
213 }
214 }
215 // A payload encoded straight from `snapshot_solids` (not through
216 // `native_import_payload`) can carry faces the import never named;
217 // those get the import's positional convention under the stored body
218 // name, deterministically from the frozen payload, so picks and CAM
219 // targets resolve and stay the same across save and reopen. Every
220 // name the payload does carry stays verbatim.
221 name_unnamed_native_faces(&mut body, &solid.name);
222 result.added.push(register_verbatim(body, &solid.name));
223 }
224 // Overwriting: the payload's records ARE the part's metadata. The pipeline's
225 // own `sourceFeatureId` seed runs after this feature returns and is
226 // NON-overwriting, so a face keeps the producing-feature id it was imported
227 // with rather than picking up this feature's.
228 for (name, record) in restored.metadata {
229 scene_metadata::merge_record(&name, &record, true);
230 }
231 Ok(result)
232}
233
234/// Name every face of a restored native body that arrived unnamed
235/// `{body}_Face_{f}` (shell / face order, the import's positional fallback),
236/// then re-derive the TOPOLOGY edge names (`{faceA}|{faceB}[n]`, or none) so
237/// the edges along them are named too; an edge the payload named otherwise is
238/// kept. A body whose faces and edges are all named is untouched. Returns
239/// whether anything was named.
240fn name_unnamed_native_faces(solid: &mut BrepSolid, body_name: &str) -> bool {
241 let unnamed = |name: &Option<String>| name.as_deref().is_none_or(|n| n.trim().is_empty());
242 let mut named_any = false;
243 let mut face_index = 0usize;
244 for shell in &mut solid.shells {
245 for face in &mut shell.faces {
246 if unnamed(&face.name) {
247 face.name = Some(format!("{body_name}_Face_{face_index}"));
248 named_any = true;
249 }
250 face_index += 1;
251 }
252 }
253 let edges_unnamed = solid.edges.iter().any(|e| !e.degenerate && unnamed(&e.name));
254 if named_any || edges_unnamed {
255 common::stamp_derived_edge_names(solid);
256 }
257 named_any || edges_unnamed
258}
259
260/// [`common::register_added`] WITHOUT its two name-deriving passes: collect the
261/// names the solid already carries and make it scene-resident.
262///
263/// The container groups are EMPTY, and that is the right answer rather than a
264/// stub: a group is the un-keyed alias a PROFILE-SWEPT feature publishes for its
265/// per-loop face families (`{cap_base}_START` standing for every
266/// `{cap_base}:L{id}_START` — see [`common::register_added_grouped`]). Imported
267/// geometry has no sketch loops behind it, so there is no family to alias; its
268/// names arrived stamped from outside and are already the final, unique ones.
269fn register_verbatim(solid: BrepSolid, name: &str) -> AddedSolid {
270 let face_names = common::collect_face_names(&solid);
271 let edge_names = common::collect_edge_names(&solid);
272 let handle = crate::register_solid_value(solid);
273 AddedSolid {
274 handle,
275 name: name.to_string(),
276 face_names,
277 edge_names,
278 face_groups: Vec::new(),
279 edge_groups: Vec::new(),
280 }
281}
282
283/// Stamp the established naming convention onto the imported bodies, register
284/// them, and stamp whatever COLOUR the file carried onto those same names.
285///
286/// `appearances` is parallel to `solids` (see [`crate::BodyAppearance`]); pass
287/// `&[]` from a lane whose format has no colour, which stamps nothing.
288fn add_named_bodies(
289 ctx: &FeatureContext,
290 mut solids: Vec<BrepSolid>,
291 appearances: &[BodyAppearance],
292 feature_name: &str,
293) -> FeatureResult {
294 let body_names = stamp_imported_names(&mut solids, feature_name);
295 let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
296 for (index, (solid, body_name)) in solids.into_iter().zip(body_names).enumerate() {
297 // Colour is stamped AFTER `register_added`, which is where the names
298 // become final (it dedupes face names before deriving the edge names).
299 let added = common::register_added(solid, &body_name);
300 if let Some(appearance) = appearances.get(index) {
301 stamp_appearance(&added.name, &added.face_names, appearance);
302 }
303 result.added.push(added);
304 }
305 result
306}
307
308/// Write an imported body's colours into the scene-metadata store, keyed by the
309/// FINAL names — the one place the `{"color": "#RRGGBB"}` convention of
310/// `io/appearance.rs` is written.
311///
312/// `face_names` is `(face_id, name)` in shell/face order
313/// ([`common::collect_face_names`]), the same order `appearance.faces` is
314/// indexed by. A length disagreement means the two walks have drifted, so
315/// nothing per-face is stamped rather than colouring the wrong faces — the body
316/// colour, which is not positional, still lands.
317///
318/// Merged NON-overwriting: a history replay re-runs this feature, and a colour
319/// the user has since edited in the info panel must survive it.
320fn stamp_appearance(body_name: &str, face_names: &[(u64, String)], appearance: &BodyAppearance) {
321 if let Some(color) = appearance.body {
322 stamp_color(body_name, color);
323 }
324 if appearance.faces.is_empty() || appearance.faces.len() != face_names.len() {
325 return;
326 }
327 for ((_, face_name), color) in face_names.iter().zip(&appearance.faces) {
328 if let Some(color) = color {
329 stamp_color(face_name, *color);
330 }
331 }
332}
333
334fn stamp_color(name: &str, color: ImportedColor) {
335 let mut record = serde_json::Map::new();
336 record.insert(
337 COLOR_METADATA_KEY.to_string(),
338 serde_json::Value::String(color.to_hex()),
339 );
340 scene_metadata::merge_record(name, &record, false);
341}
342
343/// The naming half of [`add_named_bodies`]: stamp body + face names onto `solids`
344/// IN PLACE and return the body name chosen for each, WITHOUT registering
345/// anything. Shared by the live import lanes and the payload encoder
346/// [`native_import_payload`], so an imported part is named identically however it
347/// arrived.
348fn stamp_imported_names(solids: &mut [BrepSolid], feature_name: &str) -> Vec<String> {
349 let body_names = imported_solid_names(solids.len(), feature_name);
350 let multi_body = solids.len() > 1;
351 let mut chosen = Vec::with_capacity(solids.len());
352 for (index, solid) in solids.iter_mut().enumerate() {
353 let body_name = body_names
354 .get(index)
355 .cloned()
356 .unwrap_or_else(|| feature_name.to_string());
357 let mut face_index = 0usize;
358 for shell in &mut solid.shells {
359 for face in &mut shell.faces {
360 if multi_body {
361 // Multi-body: EVERY face namespaced under its body.
362 face.name = Some(format!("{body_name}_Face_{face_index}"));
363 } else {
364 // Single body: prepend the (unique) feature name so faces never
365 // collide with ANOTHER import's faces — two single-body imports
366 // both stamped bare `Face_N` before, which the cross-solid
367 // name-uniqueness guard flags. Keep any STEP-authored name as the
368 // stem, else the positional `Face_N` fallback. (`body_name` IS the
369 // feature name here — see `imported_solid_names`.) Edge names are
370 // re-derived from these face names downstream, so namespacing the
371 // faces namespaces the derived edges too.
372 let stem = face
373 .name
374 .as_deref()
375 .map(str::trim)
376 .filter(|name| !name.is_empty())
377 .map(str::to_string)
378 .unwrap_or_else(|| format!("Face_{face_index}"));
379 face.name = Some(format!("{body_name}_{stem}"));
380 }
381 face_index += 1;
382 }
383 }
384 chosen.push(body_name);
385 }
386 chosen
387}
388
389/// `importedSolidNames` port: 1 body keeps the feature name; several get a
390/// stable, zero-padded `{feature}_SOLID_0N` suffix.
391pub(crate) fn imported_solid_names(count: usize, feature_name: &str) -> Vec<String> {
392 if count == 0 {
393 return Vec::new();
394 }
395 if count == 1 {
396 return vec![feature_name.to_string()];
397 }
398 let digits = count.to_string().len().max(2);
399 (1..=count)
400 .map(|index| format!("{feature_name}_SOLID_{index:0width$}", width = digits))
401 .collect()
402}
403
404/// Encode `solids` as a native IMPORT3D payload: stamp the names this feature
405/// would stamp under `feature_name`, then seal them into the `io/snapshot`
406/// container. The result is exactly what the `nativeBrep` source above reads
407/// back, so a part is named identically however it arrived — imported live from
408/// a text source, or restored from a payload built here.
409///
410/// GENERIC, not STEP-specific: any producer of finished `BrepSolid`s that wants
411/// them to become an IMPORT3D part document uses this (the STEP-assembly import
412/// lane is the first caller).
413///
414/// The two passes after the stamping are `register_added`'s name-FINALIZING half
415/// (`common::ensure_unique_face_names` + `common::stamp_derived_edge_names`),
416/// applied HERE rather than at restore time: the `nativeBrep` lane registers
417/// verbatim, so whatever the payload carries IS the final name set. Without them
418/// a payload's edge names would be the importer's raw ones while the `stepText`
419/// lane's are the derived `{faceA}|{faceB}[n]` — the same geometry under two
420/// different name sets, which is exactly what this helper exists to prevent.
421///
422/// The payload is byte-deterministic for identical input (`snapshot_solids`), so
423/// two encodings of the same part collapse to ONE parts-library entry.
424pub fn native_import_payload(feature_name: &str, solids: &[BrepSolid]) -> Result<String, String> {
425 native_import_payload_with_appearance(feature_name, solids, &[])
426}
427
428/// [`native_import_payload`] carrying the import's COLOURS into the payload.
429///
430/// `appearances` is parallel to `solids` (see [`crate::BodyAppearance`]); `&[]`
431/// is the colourless case and makes this identical to [`native_import_payload`].
432///
433/// The colours are stamped into the AMBIENT scene-metadata store just before
434/// `snapshot_solids`, which is exactly how they get INTO the payload — the
435/// snapshot captures each stamped name's own record alongside the geometry. So a
436/// caller that runs this inside a `crate::IsolatedSceneMetadata` bracket (the
437/// STEP-assembly part-document lane does, and must) gets the colours sealed into
438/// the part and discarded from the live document, which is the whole point of
439/// that bracket: the part carries its own metadata, the open document is not
440/// touched.
441pub fn native_import_payload_with_appearance(
442 feature_name: &str,
443 solids: &[BrepSolid],
444 appearances: &[BodyAppearance],
445) -> Result<String, String> {
446 if solids.is_empty() {
447 return Err("import3d: native payload needs at least one solid".into());
448 }
449 let mut bodies = solids.to_vec();
450 let body_names = stamp_imported_names(&mut bodies, feature_name);
451 for solid in &mut bodies {
452 common::ensure_unique_face_names(solid);
453 common::stamp_derived_edge_names(solid);
454 }
455 // Names are FINAL from here, so this is where colour can be keyed to them.
456 for (index, (solid, body_name)) in bodies.iter().zip(&body_names).enumerate() {
457 if let Some(appearance) = appearances.get(index) {
458 stamp_appearance(body_name, &common::collect_face_names(solid), appearance);
459 }
460 }
461 let named: Vec<(&str, &BrepSolid)> = body_names
462 .iter()
463 .map(String::as_str)
464 .zip(bodies.iter())
465 .collect();
466 crate::snapshot_solids(&named)
467}
468
469
470/// Context-bar applicability ([`crate::feature_pipeline::context_offer`]):
471/// never offered from a selection (no reference inputs).
472pub fn context_applicable(_probe: &crate::feature_pipeline::SelectionProbe) -> bool {
473 false
474}
475
476///
477/// Only `id` is exposed: an IMPORT3D feature is created by an import lane that
478/// bakes its payload in — the toolbar Import lane
479/// ([`EngineState::import_step_feature`] / `import_iges_feature`) writes the raw
480/// document text into `inputParams.stepText` / `igesText`, and the native lane
481/// ([`native_import_payload`], the STEP-assembly part document) writes
482/// `inputParams.nativeBrep`. None of the three is form-editable, so none is
483/// exposed. (The former `fileToImport` "file" param was a JS-era vestige: nothing
484/// produced or consumed it and no form builder rendered it.)
485pub fn schema() -> serde_json::Value {
486 serde_json::json!({
487 "type": "IMPORT3D",
488 "shortName": "IMPORT3D",
489 "longName": "Import 3D Model",
490 "ribbonPath": "Home/Modeling/Import 3D Model",
491 "commandSize": "Compact",
492 "displayBuilder": false,
493 "inputParamsSchema": {
494 "id": {
495 "type": "string",
496 "default_value": null,
497 "hint": "unique identifier for the import feature"
498 }
499 }
500})
501}
502