Files
OneCAD/crates/engine/src/cmd/occurrence_tests.rs
T
bherbruck c1d819f235 feat: preserve body history and placed modelling workflows
Record ownership transfers in the timeline so earlier Join/Cut membership,
world placement, machining data and styles survive component moves, edits,
independent copying, undo and reopening. Preserve pending positions in history
snapshots and keep constructor transfers ordered during feature redefinition.

Retain shallow sheet pockets and exact planar intersection islands through
unfold/refold, with depth-aware flat previews and DXF export. Store explicit
3D wire coordinate spaces and migrate known legacy provenance without moving
saved points or analytic fits.

Infer unique eligible cylindrical Emboss targets, align Revolve manipulators
with actual extent geometry, and discard obsolete preview errors after input
correction. Certify constructed and immutable imported face styles using exact
source metadata while preserving legacy face names. Upgrade designs to format10
with bounded validation and geometry-preserving legacy migration.
2026-10-11 03:59:12 -04:00

427 lines
24 KiB
Rust

// 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<crate::Sel> = 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::<std::collections::BTreeSet<_>>().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.pending_moves.get(&other).unwrap()[3][0], 20.0);
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 0.0);
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 20.0);
run(&mut s, "edit.redo", json!({}));
assert!(s.pending_moves.is_empty());
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 20.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]));
}