// Exact occurrence workflows through the same commands used by the Browser. use crate::Session; use serde_json::{Value, json}; fn run(s: &mut Session, command: &str, p: Value) -> Value { s.execute(command, &p).unwrap() } fn created(s: &mut Session, name: &str) -> (u64, u64) { let c = run(s, "component.create", json!({"name": name})); (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()) } fn near(value: &Value, expected: f64) { assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}"); } #[test] fn instance_copy_uses_selected_source_transform() { let mut s = Session::default(); let (component, first) = created(&mut s, "Block"); let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [30,0,0]})); let third = run(&mut s, "occurrence.copy", json!({"occurrence": second, "translate": [5,0,0]}))["occurrence"].as_u64().unwrap(); let placement = s.doc.occurrences.iter().find(|o| o.id == third).unwrap(); assert!((placement.transform[3][0] - 55.0).abs() < 1e-6); assert_eq!(s.doc.occurrences.iter().find(|o| o.id == first).unwrap().transform[3][0], 0.0); } #[test] fn instance_world_move_under_rotated_parent_uses_selected_path() { let mut s = Session::default(); let (_, parent) = created(&mut s, "Parent"); run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0,0,1], "angle": "90 deg"})); let (_, child) = created(&mut s, "Child"); run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1})); run(&mut s, "occurrence.move", json!({"occurrence": child, "path": [parent, child], "space": "world", "translate": [10,0,0]})); let result = run(&mut s, "occurrence.properties", json!({"occurrence": child, "path": [parent,child]})); near(&result["total"]["center_of_mass"][0], 9.5); near(&result["total"]["center_of_mass"][1], 0.5); } #[test] fn instance_authoring_at_second_placement_keeps_definition_shared() { let mut s = Session::default(); let (component, first) = created(&mut s, "Block"); let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); run(&mut s, "component.activate", json!({"component": component, "occurrence": second, "path": [second]})); run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [20,0,0], "operation": "new"})); for (occurrence, x) in [(first, 1.0), (second, 21.0)] { let result = run(&mut s, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]})); near(&result["total"]["center_of_mass"][0], x); } } #[test] fn instance_selection_survives_rename_move_capture_and_serde() { let mut s = Session::default(); let (component, _) = created(&mut s, "Block"); let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); let selected = json!({"type": "occurrence", "path": [second]}); run(&mut s, "select.set", json!({"items": [selected]})); run(&mut s, "component.rename", json!({"component": component, "name": "Renamed"})); run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [5,0,0], "capture": false})); run(&mut s, "component.capture_position", json!({})); assert_eq!(serde_json::to_value(&s.selection).unwrap(), json!([selected])); let roundtrip: Vec = serde_json::from_value(json!([selected])).unwrap(); assert_eq!(roundtrip, s.selection); } #[test] fn instance_selection_delete_removes_only_selected_instance() { let mut s = Session::default(); let (component, first) = created(&mut s, "Block"); run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2})); let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); run(&mut s, "select.set", json!({"items": [{"type": "occurrence", "path": [second]}]})); let items = s.selection.clone(); run(&mut s, "selection.delete", json!({"items": items})); assert!(s.doc.occurrences.iter().any(|o| o.id == first)); assert!(!s.doc.occurrences.iter().any(|o| o.id == second)); assert_eq!(s.world_state().bodies.len(), 1); assert!(s.doc.components.iter().any(|c| c.id == component)); } #[test] fn world_move_includes_pending_parent_and_leaf_and_shared_scope() { let mut s = Session::default(); let (parent_component, parent) = created(&mut s, "Parent"); let (_, child) = created(&mut s, "Child"); run(&mut s, "occurrence.move", json!({"occurrence":parent,"axis":[0,0,1],"angle":"90 deg","translate":[3,0,0],"capture":false})); run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[2,0,0],"capture":false})); let before = s.occurrence_placement(&[parent, child]).unwrap().world; run(&mut s, "occurrence.move", json!({"occurrence":child,"path":[parent,child],"space":"world","translate":[10,0,0],"capture":false})); let after = s.occurrence_placement(&[parent, child]).unwrap().world; assert!((after[3][0] - before[3][0] - 10.0).abs() < 1e-8); assert!((after[3][1] - before[3][1]).abs() < 1e-8); run( &mut s, "occurrence.move", json!({"occurrence":child,"path":[parent,child],"space":"world","axis":[0,0,1],"angle":"90 deg","origin":[0,0,0],"capture":false}), ); let rotated = s.occurrence_placement(&[parent, child]).unwrap().world; assert!((rotated[3][0] + after[3][1]).abs() < 1e-8); assert!((rotated[3][1] - after[3][0]).abs() < 1e-8); run(&mut s, "component.activate", json!({"component":parent_component})); let second_parent = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[30,0,0]}))["occurrence"].as_u64().unwrap(); assert_eq!(s.occurrence_scope_count(&[second_parent, child]).unwrap(), 2); assert!(s.execute("occurrence.move", &json!({"occurrence":child,"space":"world","translate":[1,0,0]})).is_err()); } #[test] fn invalid_paths_are_atomic_for_session_state() { let mut s = Session::default(); let (component, first) = created(&mut s, "Part"); let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); let pending = s.pending_moves.clone(); let active = s.active_occurrence_path.clone(); let vis = s.visibility.clone(); let doc = s.doc.clone(); for (cmd, p) in [ ("occurrence.move", json!({"occurrence":second,"path":[first],"space":"world","translate":[10,0,0]})), ("occurrence.move", json!({"occurrence":second,"space":17,"translate":[10,0,0]})), ("component.activate", json!({"component":component,"occurrence":second,"path":[first,second]})), ("browser.visibility", json!({"visible":true,"paths":[[second],[9999]]})), ("select.set", json!({"items":[{"type":"occurrence","path":[first,second]}]})), ("occurrence.copy", json!({"occurrence":second,"path":[first]})), ] { assert!(s.execute(cmd, &p).is_err(), "{cmd}"); assert_eq!(s.pending_moves, pending); assert_eq!(s.active_occurrence_path, active); assert_eq!(s.visibility, vis); assert_eq!(s.doc, doc); } } #[test] fn exact_visibility_isolate_rename_and_async_accept() { let mut s = Session::default(); let (component, first) = created(&mut s, "Part"); run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); let first_body = s.occurrence_bodies(&[first], true).unwrap()[0].clone(); let second_body = s.occurrence_bodies(&[second], true).unwrap()[0].clone(); run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); assert!(s.body_visible(&first_body)); assert!(!s.body_visible(&second_body)); let mut scratch = s.scratch(); assert_eq!(scratch.visibility, s.visibility); run(&mut scratch, "component.rename", json!({"component":component,"name":"Changed"})); s.accept_computed(scratch); assert!(!s.occurrence_visible(&[second])); run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[second]],"isolate":true})); assert!(!s.body_visible(&first_body)); assert!(s.body_visible(&second_body)); run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"})); assert!(s.body_visible(&first_body)); assert!(s.body_visible(&second_body)); } #[test] fn synthesized_name_collision_keeps_source_and_instance_separate() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); let root = s.model.state().bodies[0].name.clone(); let (_, first) = created(&mut s, "Part"); run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); run(&mut s, "body.rename", json!({"body":root,"name":candidate})); let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); assert_ne!(instance, candidate); let frames = s.doc.body_frames(&s.model.state()); let world = s.world_state(); assert!(frames.iter().all(|(name, _, _, _)| world.body(name).is_some())); assert_eq!(world.bodies.len(), world.bodies.iter().map(|b| &b.name).collect::>().len()); run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); assert!(s.body_visible(&candidate)); assert!(!s.body_visible(&instance)); let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone(); run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"})); assert_eq!(s.doc.materials.get(&source).map(String::as_str), Some("Brass")); assert!(!s.doc.materials.contains_key(&candidate)); } #[test] fn duplicate_nested_labels_use_full_path_and_reopen_undo_preserve_placements() { let mut s = Session::default(); let (parent_component, parent) = created(&mut s, "Parent"); let (_, child) = created(&mut s, "Child"); run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); let parent2 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap(); let parent3 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap(); for o in s.doc_mut().occurrences.iter_mut().filter(|o| o.id == parent2 || o.id == parent3) { o.name = "Same".into(); } s.refresh(); let a = s.occurrence_bodies(&[parent2, child], false).unwrap()[0].clone(); let b = s.occurrence_bodies(&[parent3, child], false).unwrap()[0].clone(); assert_ne!(a, b); let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()); assert_eq!(reopened.occurrence_bodies(&[parent2, child], false).unwrap()[0], a); assert_eq!(reopened.occurrence_bodies(&[parent3, child], false).unwrap()[0], b); run(&mut reopened, "selection.delete", json!({"occurrences":[parent3]})); assert!(reopened.occurrence_placement(&[parent3, child]).is_err()); run(&mut reopened, "edit.undo", json!({})); assert!(reopened.occurrence_placement(&[parent3, child]).is_ok()); assert!(reopened.occurrence_placement(&[parent, child]).is_ok()); } #[test] fn compact_duplicated_assembly_exact_resolution_and_expansion_limit() { let mut s = Session::default(); let mut parent = 0; let mut path = Vec::new(); for i in 0..18 { let component = s.doc_mut().add_component(Some(&format!("Level{i}")), parent).unwrap(); let occurrence = s.doc.occurrence_of(component).unwrap().id; path.push(occurrence); s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap(); parent = component; } assert!(s.occurrence_placement(&path).is_ok()); let top = [path[0]]; assert!(s.occurrence_bodies(&top, true).is_err()); assert!(s.execute("select.set", &json!({"items":[{"type":"occurrence","path":top}]})).is_err()); assert!(s.selection.is_empty()); } #[test] fn identity_instances_map_world_keys_to_shared_sources() { let mut s = Session::default(); let (_, first) = created(&mut s, "Part"); run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); let placement = s.occurrence_placement(&[second]).unwrap(); let body = &placement.bodies[0]; assert_ne!(body.source, body.world); run(&mut s, "material.assign", json!({"bodies":[body.world],"material":"Brass"})); assert_eq!(s.doc.materials.get(&body.source).map(String::as_str), Some("Brass")); } #[test] fn dialog_baseline_move_preserves_other_pending_positions_without_drift() { let mut s = Session::default(); let (_, first) = created(&mut s, "Part"); let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[5,0,0],"capture":false})); run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[3,0,0],"capture":false})); let baseline = s.occurrence_placement(&[second]).unwrap().local; for offset in [10.0, 15.0, 0.0] { run( &mut s, "occurrence.move", json!({"occurrence":second,"path":[second],"space":"world","base_transform":baseline,"translate":[offset,0,0],"capture":false}), ); assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0); assert_eq!(s.pending_moves.get(&second).unwrap()[3][0], 23.0 + offset); } let pending = s.pending_moves.clone(); assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,1]]})).is_err()); assert_eq!(s.pending_moves, pending); for base in [ [[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], ] { assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":base})).is_err()); assert_eq!(s.pending_moves, pending); } assert!(s.execute("component.revert_position", &json!({"occurrences":[second,9999]})).is_err()); assert_eq!(s.pending_moves, pending); run(&mut s, "component.revert_position", json!({"occurrences":[second]})); assert!(!s.pending_moves.contains_key(&second)); assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0); } #[test] fn hostile_moves_copies_and_reused_ids_do_not_leak_state() { let mut s = Session::default(); let (_, first) = created(&mut s, "Part"); run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[10,0,0],"capture":false})); let pending = s.pending_moves.clone(); let doc = s.doc.clone(); for (cmd, p) in [ ("occurrence.move", json!({"occurrence":first,"origin":[1e308,0,0],"angle":"180 deg"})), ("occurrence.move", json!({"occurrence":first,"translate":[1e9,0,0],"capture":false})), ("occurrence.move", json!({"occurrence":first,"translate":[1,2]})), ("occurrence.move", json!({"occurrence":first,"angle":false})), ("occurrence.move", json!({"occurrence":first,"axis":[1e308,0,0],"angle":"90 deg"})), ("occurrence.move", json!({"occurrence":first,"axis":[0,0,0],"angle":"90 deg"})), ("occurrence.copy", json!({"occurrence":first,"translate":[1e9,0,0]})), ("occurrence.copy", json!({"occurrence":first,"translate":"oops"})), ] { assert!(s.execute(cmd, &p).is_err(), "{cmd}"); assert_eq!(s.pending_moves, pending); assert_eq!(s.doc, doc); } run(&mut s, "edit.undo", json!({})); assert!(s.pending_moves.is_empty()); let (_, other) = created(&mut s, "Other"); assert_eq!(first, other); assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0); run(&mut s, "occurrence.move", json!({"occurrence":other,"translate":[20,0,0],"capture":false})); run(&mut s, "component.capture_position", json!({})); assert!(s.pending_moves.is_empty()); run(&mut s, "edit.undo", json!({})); assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0); } #[test] fn isolate_nested_child_hides_ancestor_material_and_sibling() { let mut s = Session::default(); let (_, parent) = created(&mut s, "Parent"); run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); let (child_component, child) = created(&mut s, "Child"); run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); let parent_component = s.doc.occurrences.iter().find(|o| o.id == child).unwrap().parent; let sibling = s .doc_mut() .add_occurrence( child_component, parent_component, solvecraft_doc::rigid(crate::geom::Vec3::new(20.0, 0.0, 0.0), crate::geom::Vec3::ZERO, crate::geom::Vec3::Z, 0.0), ) .unwrap(); s.refresh(); run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[parent,child]],"isolate":true})); for (path, expected) in [(vec![parent], false), (vec![parent, child], true), (vec![parent, sibling], false)] { let body = s.occurrence_bodies(&path, false).unwrap()[0].clone(); assert_eq!(s.body_visible(&body), expected); } } #[test] fn context_new_child_uses_inactive_parent_placement() { let mut s = Session::default(); let (parent_component, parent) = created(&mut s, "Parent"); let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); run(&mut s, "component.activate", json!({"component":0})); let child = run(&mut s, "component.create", json!({"parent":parent_component,"parent_path":[second],"name":"Child"}))["occurrence"].as_u64().unwrap(); assert_eq!(s.active_occurrence_path, Some(vec![second, child])); assert_eq!(s.active_component_frame()[3][0], 20.0); let doc = s.doc.clone(); let active = s.active_occurrence_path.clone(); assert!(s.execute("component.create", &json!({"parent":parent_component,"parent_path":[999],"name":"Invalid"})).is_err()); assert_eq!(s.doc, doc); assert_eq!(s.active_occurrence_path, active); } #[test] fn paste_new_uses_selected_pending_placement() { let mut s = Session::default(); let (component, first) = created(&mut s, "Part"); run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[30,0,0],"capture":false})); let copied = run(&mut s, "component.paste_new", json!({"component":component,"occurrence":second,"path":[second],"translate":[5,0,0]}))["component"] .as_u64() .unwrap(); assert_eq!(s.doc.occurrence_of(copied).unwrap().transform[3][0], 55.0); let result = run(&mut s, "component.properties", json!({"component":copied})); near(&result["total"]["center_of_mass"][0], 55.5); let result = run(&mut s, "occurrence.properties", json!({"occurrence":first,"path":[first]})); near(&result["total"]["center_of_mass"][0], 0.5); } #[test] fn instance_styles_follow_source_identity_after_collision_rename_and_pending_move() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); let root = s.model.state().bodies[0].name.clone(); let (component, first) = created(&mut s, "Part"); run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); run(&mut s, "body.rename", json!({"body":root,"name":candidate})); let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone(); run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"})); let world = s.world_state(); assert_eq!(s.doc.body_color(world.body(&instance).unwrap()), s.doc.body_color(world.body(&source).unwrap())); run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"color":"#0000ff","opacity":0.7})); assert_eq!(s.doc.body_color(s.world_state().body(&instance).unwrap()), Some([0.0, 0.0, 1.0])); run(&mut s, "appearance.assign", json!({"bodies":[instance],"color":"#ff0000","opacity":0.5})); run(&mut s, "appearance.assign", json!({"faces":[{"body":instance,"point":[21,1,2]}],"color":"#00ff00","opacity":0.4})); // Exercise the point-based face fallback as well as persistent face-name matching. for face in &mut s.doc_mut().appearances.faces { face.name = None; } run(&mut s, "body.rename", json!({"body":source,"name":"Shared Renamed"})); run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); let world = s.world_state(); for id in [first, second] { let bodyname = s.occurrence_bodies(&[id], false).unwrap()[0].clone(); let body = world.body(&bodyname).unwrap(); assert_eq!(body.definition_name(), "Shared Renamed"); assert_eq!(body.component, component); let look = s.doc.model_body_look(body).unwrap(); assert_eq!(look.color, [255, 0, 0]); assert_eq!(look.opacity, 0.5); assert!(s.doc.face_colors(body).iter().any(|(_, l)| l.color == [0, 255, 0] && l.opacity == 0.4)); let measured = run(&mut s, "inspect.measure", json!({"bodies":[bodyname]})); assert_eq!(measured["bodies"][0]["material"], "Brass"); assert!((measured["bodies"][0]["mass_g"].as_f64().unwrap() - 0.068).abs() < 1e-8); } let definition = s.model.state(); assert_eq!(solvecraft_kernel::measure(&definition.body("Shared Renamed").unwrap().body).unwrap().bbox.min, crate::geom::Vec3::ZERO); let painted = s.doc.painted(&world); let secondname = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); let body = painted.body(&secondname).unwrap(); assert_eq!(body.body.color(), Some([1.0, 0.0, 0.0])); assert!(body.body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0])); let bytes = solvecraft_io::export(&painted, &[secondname], solvecraft_io::Format::Step, "InstanceStyles").unwrap(); let imported = solvecraft_kernel::step_import(&String::from_utf8(bytes).unwrap()).unwrap(); assert_eq!(imported.bodies.len(), 1); assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0])); assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0])); }