1use 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 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 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 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 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 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
160fn 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 for (name, record) in restored.metadata {
186 scene_metadata::merge_record(&name, &record, true);
187 }
188 Ok(result)
189}
190
191fn 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
214fn 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 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
239fn 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
274fn 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 face.name = Some(format!("{body_name}_Face_{face_index}"));
294 } else {
295 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
320fn 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
335pub fn native_import_payload(feature_name: &str, solids: &[BrepSolid]) -> Result<String, String> {
356 native_import_payload_with_appearance(feature_name, solids, &[])
357}
358
359pub 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 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
401pub fn context_applicable(_probe: &crate::feature_pipeline::SelectionProbe) -> bool {
404 false
405}
406
407pub 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 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 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 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 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 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 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 #[test]
644 fn native_payload_restores_the_input_solids_with_the_import3d_names() {
645 for count in [1usize, 2] {
646 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 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 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 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 #[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 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 #[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 #[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 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 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 #[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 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 #[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 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 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 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 #[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 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}