//! Components as frames: what a component holds moves with its occurrence, and picks in the //! world land in the component's own frame (user bug #4). use serde_json::{Value, json}; use solvecraft_geom::Vec3; use crate::Session; fn run(s: &mut Session, id: &str, p: Value) -> Value { match s.execute(id, &p) { Ok(v) => v, Err(e) => panic!("{id} {p}: {e}"), } } fn near(a: Vec3, b: Vec3) -> bool { a.dist(b) < 1e-6 } fn world_bbox(s: &Session, body: &str) -> (Vec3, Vec3) { let w = s.world_state(); let b = w.body(body).unwrap_or_else(|| panic!("no body {body}")).mesh().bounds(); (b.min, b.max) } fn sketch_point_world(s: &mut Session, sketch: &str, point: &str) -> Vec3 { let si = run(s, "sketch.inspect", json!({ "sketch": sketch })); let p = si["points"].as_array().unwrap().iter().find(|p| p["id"] == point).unwrap()["world"].clone(); Vec3::new(p[0].as_f64().unwrap(), p[1].as_f64().unwrap(), p[2].as_f64().unwrap()) } fn volume(s: &mut Session) -> f64 { run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap() } /// A component "Part" with a 30 x 20 x 10 block (sketch "Base" on XY, "Top" on its top face) /// and a construction plane, moved 50 mm along X. fn moved_part() -> Session { let mut s = Session::default(); run(&mut s, "component.create", json!({"name": "Part"})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"distance": 10})); run(&mut s, "sketch.create", json!({"plane": {"face": [15, 10, 10]}, "name": "Top"})); run(&mut s, "sketch.circle.center", json!({"center": [15, 10], "radius": 3})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 25, "name": "Shelf"})); run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]})); s } /// (a) Sketches and construction planes move with their component's occurrence. #[test] fn sketches_and_planes_move_with_the_component() { let mut s = moved_part(); let (lo, hi) = world_bbox(&s, "Body1"); assert!(near(lo, Vec3::new(50.0, 0.0, 0.0)) && near(hi, Vec3::new(80.0, 20.0, 10.0)), "{lo:?} {hi:?}"); assert!(near(sketch_point_world(&mut s, "Base", "p1"), Vec3::new(50.0, 0.0, 0.0))); let c = sketch_point_world(&mut s, "Top", "p1"); assert!((c.z - 10.0).abs() < 1e-6 && c.x >= 50.0 - 1e-6, "{c:?}"); let shelf = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Shelf").unwrap().2; assert!(near(shelf.origin, Vec3::new(50.0, 0.0, 25.0)), "{:?}", shelf.origin); // A turn about Z carries them too. run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [50, 0, 0]})); assert!(near(sketch_point_world(&mut s, "Base", "p3"), Vec3::new(30.0, 30.0, 0.0)), "{:?}", sketch_point_world(&mut s, "Base", "p3")); } /// (b) Picks on a moved component, given in the world, land on its geometry: fillet edges, a /// sketch on a face, a hole, a shell; new geometry of the active component goes where asked. #[test] fn world_picks_map_into_the_component() { let mut s = moved_part(); let v0 = volume(&mut s); // The edge along X at the top front: world (65, 0, 10). run(&mut s, "solid.fillet", json!({"edges": [[65, 0, 10]], "radius": 2})); assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material"); // A sketch on the moved top face sits at world z = 10 over the moved block. run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"})); run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2})); run(&mut s, "sketch.finish", json!({})); let pad = sketch_point_world(&mut s, "Pad", "p1"); assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}"); let v1 = volume(&mut s); let h = run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5})); assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}"); // A box made at world (100, 0, 0) in the active (moved) component shows there. run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0], "operation": "new"})); let last = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap(); let (lo, _) = world_bbox(&s, &last); assert!(near(lo, Vec3::new(100.0, 0.0, 0.0)), "{lo:?}"); } /// (b) A pasted instance's pick fillets the component (every instance). #[test] fn picks_on_an_instance_map_through_its_placement() { let mut s = Session::default(); run(&mut s, "component.create", json!({"name": "Pin"})); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); run(&mut s, "component.activate", json!({"component": "root"})); run(&mut s, "occurrence.copy", json!({"component": "Pin", "translate": [0, 40, 0]})); let v0 = volume(&mut s); // An edge of the second instance only (world y = 40). run(&mut s, "solid.fillet", json!({"edges": [[5, 40, 10]], "radius": 2})); let v1 = volume(&mut s); assert!(v1 < v0 - 1.0, "{v0} {v1}"); } /// (c) Moving a body into a moved component keeps it where it is in the world. #[test] fn moving_bodies_between_components_keeps_their_place() { let mut s = moved_part(); run(&mut s, "component.activate", json!({"component": "root"})); run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [0, 40, 0], "body_name": "Loose"})); let before = world_bbox(&s, "Loose"); run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "Part"})); let after = world_bbox(&s, "Loose"); assert!(near(before.0, after.0) && near(before.1, after.1), "{before:?} {after:?}"); // It moves with the component from now on, and a pick on it still lands. run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 0, 10]})); let moved = world_bbox(&s, "Loose"); assert!(near(moved.0, before.0 + Vec3::new(0.0, 0.0, 10.0)), "{moved:?}"); let v0 = volume(&mut s); run(&mut s, "solid.fillet", json!({"edges": [[2, 40, 14]], "radius": 1})); assert!(volume(&mut s) < v0 - 0.1); // Back to the root: still in place. run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "root"})); let back = world_bbox(&s, "Loose"); assert!(near(back.0, moved.0) && near(back.1, moved.1), "{back:?} {moved:?}"); } /// (d) A sketch on a body's face follows the body when a Move feature moves it. #[test] fn sketch_on_a_face_follows_a_moved_body() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10})); run(&mut s, "sketch.create", json!({"plane": {"face": [10, 10, 10]}, "name": "OnTop"})); run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 2})); run(&mut s, "sketch.finish", json!({})); // The move goes before the sketch in the timeline: the face the sketch is on moves up. run(&mut s, "timeline.roll_to", json!({"position": 1})); run(&mut s, "solid.move", json!({"bodies": ["Body1"], "translate": [0, 0, 15]})); run(&mut s, "timeline.roll_to", json!({})); let c = sketch_point_world(&mut s, "OnTop", "p1"); assert!((c.z - 25.0).abs() < 1e-6, "{c:?}"); } /// A 1 x 1 PNG for canvases. const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; /// Canvases belong to the active component and move with its occurrence; one put on a moved /// component's face from the root sits on the face (and stays the root's). #[test] fn canvases_move_with_their_component() { let mut s = moved_part(); let part = s.active_component; assert_ne!(part, 0); run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XY", "name": "Plan"})); // From the root, on the moved block's top face (world). run(&mut s, "component.activate", json!({"component": "root"})); run(&mut s, "canvas.insert", json!({"data": PNG, "plane": {"face": [65, 10, 10]}, "name": "Lid", "width": 10})); run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XZ", "name": "Root"})); let (vals, _) = s.doc.param_values(); let world = |s: &Session, name: &str| { let c = s.doc.canvases.iter().find(|c| c.name == name).unwrap(); (c.component, s.doc.canvas_plane(&vals, c).unwrap()) }; let (c, p) = world(&s, "Plan"); assert_eq!(c, part); assert!(near(p.origin, Vec3::new(50.0, 0.0, 0.0)), "{:?}", p.origin); let (c, p) = world(&s, "Lid"); assert_eq!(c, 0, "the active component's"); let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap(); let centre = p.to_world(lid.center); assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}"); assert_eq!(world(&s, "Root").0, 0); // Moving the occurrence carries its canvases, not the root's. run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 40, 0]})); let (vals, _) = s.doc.param_values(); let plan = s.doc.canvases.iter().find(|c| c.name == "Plan").unwrap(); assert!(near(s.doc.canvas_plane(&vals, plan).unwrap().origin, Vec3::new(50.0, 40.0, 0.0))); let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap(); let centre = s.doc.canvas_plane(&vals, lid).unwrap().to_world(lid.center); assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}"); let root = s.doc.canvases.iter().find(|c| c.name == "Root").unwrap(); assert!(near(s.doc.canvas_plane(&vals, root).unwrap().origin, Vec3::ZERO)); // The component survives a save and reopen. let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); assert_eq!(back.canvases, s.doc.canvases); } /// World points of a sketch's curves' points (sorted). fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec { let si = run(s, "sketch.inspect", json!({ "sketch": sketch })); si["points"] .as_array() .unwrap() .iter() .map(|p| { let w = &p["world"]; Vec3::new(w[0].as_f64().unwrap(), w[1].as_f64().unwrap(), w[2].as_f64().unwrap()) }) .collect() } /// A block "C" (40 x 30 x 20) in its own component, the root active again. fn block_in_component() -> Session { let mut s = Session::default(); run(&mut s, "component.create", json!({"name": "C"})); run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); run(&mut s, "component.activate", json!({"component": "root"})); s } /// The user's "big square": a sketch made in the root on a moved component's face lies on the /// face where it is shown, its projected loop on the face's edges (moved and turned), and it /// follows when the occurrence moves on. #[test] fn root_sketch_on_a_moved_components_face() { let mut s = block_in_component(); run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [100, 0, 0]})); let r = run(&mut s, "sketch.create", json!({"plane": {"face": [120, 15, 20]}, "name": "Top"})); assert!(!r["projected"].is_null(), "the face's edges are projected: {r}"); run(&mut s, "sketch.finish", json!({})); let pts = sketch_world_points(&mut s, "Top"); assert!(pts.len() >= 4, "{pts:?}"); // Every point but the sketch's origin (the model origin on the face plane). for p in pts.iter().filter(|p| !near(**p, Vec3::new(0.0, 0.0, 20.0))) { assert!((p.z - 20.0).abs() < 1e-6 && p.x > 100.0 - 1e-6 && p.x < 140.0 + 1e-6 && p.y > -1e-6 && p.y < 30.0 + 1e-6, "{p:?}"); } for corner in [Vec3::new(100.0, 0.0, 20.0), Vec3::new(140.0, 30.0, 20.0)] { assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}"); } assert_eq!(s.doc.find_feature("Top").unwrap().component, 0, "the sketch is the root's"); // Turned 90 degrees about Z (around the world origin): the top face is now x -30..0, y 0..40. let mut s = block_in_component(); run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]})); run(&mut s, "sketch.create", json!({"plane": {"face": [-15, 20, 20]}, "name": "Top"})); run(&mut s, "sketch.finish", json!({})); let pts = sketch_world_points(&mut s, "Top"); for corner in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(-30.0, 40.0, 20.0)] { assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}"); } // Projected geometry stays on the face when the occurrence moves on (the link re-resolves). run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [0, 0, 5]})); let pts = sketch_world_points(&mut s, "Top"); assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}"); }