//! Construction axes, points and planes (SOLID ▸ CONSTRUCT), one test per type, against a //! 40 × 30 × 20 box at the origin, a cylinder, a sphere and a torus. use serde_json::{Value, json}; use solidcraft_geom::{Plane, Vec3}; use crate::Session; use crate::view::{construction_axes, construction_planes, construction_points}; 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 part() -> Session { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Box"})); run(&mut s, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 5, "height": 10, "body_name": "Cyl"})); run(&mut s, "solid.sphere", json!({"center": [0, 0, 60], "radius": 5, "body_name": "Ball"})); run(&mut s, "solid.torus", json!({"center": [0, 60, 0], "major": 10, "minor": 2, "body_name": "Ring"})); s } fn near(a: Vec3, b: Vec3) -> bool { a.dist(b) < 1e-3 } fn parallel(a: Vec3, b: Vec3) -> bool { a.normalized().zip(b.normalized()).is_some_and(|(a, b)| a.cross(b).len() < 1e-6) } fn axis(s: &Session, name: &str) -> (Vec3, Vec3) { construction_axes(s).into_iter().find(|a| a.1 == name).map(|a| (a.2, a.3)).unwrap_or_else(|| panic!("no axis {name}")) } fn point(s: &Session, name: &str) -> Vec3 { construction_points(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no point {name}")) } fn plane(s: &Session, name: &str) -> Plane { construction_planes(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no plane {name}")) } /// The line through `o` along `d` contains `p`. fn on_line(o: Vec3, d: Vec3, p: Vec3) -> bool { let d = d.normalized().unwrap_or(Vec3::Z); (p - o - d * (p - o).dot(d)).len() < 1e-3 } fn on_plane(pl: &Plane, p: Vec3) -> bool { (p - pl.origin).dot(pl.normal()).abs() < 1e-3 } #[test] fn axis_through_cylinder_cone_or_torus() { let mut s = part(); run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [65, 0, 5]}, "name": "A"})); let (o, d) = axis(&s, "A"); assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 0.0)), "{o:?} {d:?}"); run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [12, 60, 0]}, "name": "T"})); let (o, d) = axis(&s, "T"); assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(0.0, 60.0, 0.0)), "{o:?} {d:?}"); // A flat face has no axis. assert!(s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [20, 15, 20]}})).is_err()); } #[test] fn axis_perpendicular_to_a_face_at_a_point() { let mut s = part(); run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [10, 10, 20]}, "point": {"point": [10, 10, 25]}, "name": "N"})); let (o, d) = axis(&s, "N"); assert!(near(o, Vec3::new(10.0, 10.0, 20.0)) && parallel(d, Vec3::Z), "{o:?} {d:?}"); // On the cylinder: radial through the point. run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [65, 0, 5]}, "point": {"point": [70, 0, 5]}, "name": "R"})); let (o, d) = axis(&s, "R"); assert!(near(o, Vec3::new(65.0, 0.0, 5.0)) && parallel(d, Vec3::X), "{o:?} {d:?}"); } #[test] fn axis_through_two_planes() { let mut s = part(); run(&mut s, "construct.axis.two_planes", json!({"a": {"plane": "XZ"}, "b": {"face": [40, 15, 10]}, "name": "L"})); let (o, d) = axis(&s, "L"); assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(40.0, 0.0, 0.0)), "{o:?} {d:?}"); assert!(s.execute("construct.axis.two_planes", &json!({"a": "XY", "b": {"face": [20, 15, 20]}})).is_err(), "parallel"); } #[test] fn axis_through_two_points() { let mut s = part(); run(&mut s, "construct.axis.two_points", json!({"a": {"vertex": [0.5, 0.5, 0.5]}, "b": {"vertex": [39, 29, 19]}, "name": "D"})); let (o, d) = axis(&s, "D"); assert!(on_line(o, d, Vec3::ZERO) && parallel(d, Vec3::new(40.0, 30.0, 20.0)), "{o:?} {d:?}"); } #[test] fn axis_through_an_edge() { let mut s = part(); run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 0, 0]}, "name": "E"})); let (o, d) = axis(&s, "E"); assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}"); // A circular edge is not straight. assert!(s.execute("construct.axis.edge", &json!({"edge": {"edge": [65, 0, 10]}})).is_err()); } #[test] fn point_at_a_vertex() { let mut s = part(); run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [39, 29, 19]}, "name": "P"})); assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0))); } #[test] fn point_through_two_edges() { let mut s = part(); run(&mut s, "construct.point.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 0]}, "name": "P"})); assert!(near(point(&s, "P"), Vec3::new(40.0, 0.0, 0.0)), "{:?}", point(&s, "P")); } #[test] fn point_through_three_planes() { let mut s = part(); run( &mut s, "construct.point.three_planes", json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "P"}), ); assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)), "{:?}", point(&s, "P")); } #[test] fn point_at_the_centre_of_a_circle_sphere_or_torus() { let mut s = part(); run(&mut s, "construct.point.center", json!({"of": {"edge": [65, 0, 10]}, "name": "C"})); assert!(near(point(&s, "C"), Vec3::new(60.0, 0.0, 10.0)), "{:?}", point(&s, "C")); run(&mut s, "construct.point.center", json!({"of": {"face": [5, 0, 60]}, "name": "S"})); assert!(near(point(&s, "S"), Vec3::new(0.0, 0.0, 60.0)), "{:?}", point(&s, "S")); run(&mut s, "construct.point.center", json!({"of": {"face": [12, 60, 0]}, "name": "T"})); assert!(near(point(&s, "T"), Vec3::new(0.0, 60.0, 0.0)), "{:?}", point(&s, "T")); } #[test] fn point_at_edge_and_plane() { let mut s = part(); run( &mut s, "construct.point.edge_plane", json!({"edge": {"edge": [20, 0, 0]}, "plane": {"plane": {"origin": [12, 0, 0], "normal": [1, 0, 0]}}, "name": "P"}), ); assert!(near(point(&s, "P"), Vec3::new(12.0, 0.0, 0.0)), "{:?}", point(&s, "P")); } #[test] fn point_along_a_path() { let mut s = part(); run(&mut s, "construct.point.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.25, "name": "P"})); let p = point(&s, "P"); assert!(near(p, Vec3::new(10.0, 0.0, 0.0)) || near(p, Vec3::new(30.0, 0.0, 0.0)), "{p:?}"); } #[test] fn midplane_between_parallel_and_meeting_faces() { let mut s = part(); run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "M"})); let m = plane(&s, "M"); assert!(parallel(m.normal(), Vec3::Z) && on_plane(&m, Vec3::new(5.0, 5.0, 10.0)), "{m:?}"); // Two sides meeting at the corner (40, 30): the diagonal plane through it. run(&mut s, "construct.plane.midplane", json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "name": "Q"})); let q = plane(&s, "Q"); assert!(on_plane(&q, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&q, Vec3::new(41.0, 31.0, 5.0)), "{q:?}"); } #[test] fn plane_through_two_edges() { let mut s = part(); run(&mut s, "construct.plane.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [20, 30, 20]}, "name": "E"})); let e = plane(&s, "E"); // The front-bottom and back-top edges: the slanted plane through both. assert!(on_plane(&e, Vec3::new(5.0, 0.0, 0.0)) && on_plane(&e, Vec3::new(5.0, 30.0, 20.0)), "{e:?}"); assert!(s.execute("construct.plane.two_edges", &json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 20]}})).is_err(), "skew"); } #[test] fn plane_through_three_points() { let mut s = part(); run( &mut s, "construct.plane.three_points", json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 0, 20]}, "c": {"vertex": [40, 30, 0]}, "name": "T"}), ); let t = plane(&s, "T"); assert!(on_plane(&t, Vec3::new(0.0, 0.0, 20.0)) && on_plane(&t, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&t, Vec3::new(0.0, 30.0, 0.0)), "{t:?}"); } #[test] fn plane_tangent_to_a_cylinder() { let mut s = part(); run(&mut s, "construct.plane.tangent", json!({"face": {"face": [65, 0, 5]}, "name": "T"})); let t = plane(&s, "T"); assert!(parallel(t.normal(), Vec3::X) && on_plane(&t, Vec3::new(65.0, 3.0, 9.0)), "{t:?}"); // And to the sphere, where the pick lies over it. run(&mut s, "construct.plane.tangent", json!({"face": {"face": [0, 0, 65]}, "name": "U"})); let u = plane(&s, "U"); assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(3.0, 3.0, 65.0)), "{u:?}"); } #[test] fn plane_along_a_path() { let mut s = part(); run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.5, "name": "P"})); let p = plane(&s, "P"); assert!(parallel(p.normal(), Vec3::X) && on_plane(&p, Vec3::new(20.0, 7.0, 3.0)), "{p:?}"); // A sketch can be placed on it. run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"})); } #[test] fn construction_on_a_3d_wire_ignores_its_sketch_plane_and_follows_edits() { let mut s = Session::default(); let sketch = run(&mut s, "sketch.create", json!({"plane":{"origin":[100,200,300],"x_dir":[0,1,0],"y_dir":[0,0,1]}}))["sketch"].as_u64().unwrap(); run(&mut s, "sketch.options", json!({"sketch_3d":true})); let wire = run(&mut s, "sketch.line3d", json!({"points":[[10,20,10],[30,35,40]]}))["curves"][0].as_str().unwrap().to_string(); run(&mut s, "sketch.finish", json!({})); let path = json!({"sketch":sketch,"curve":wire}); run(&mut s, "construct.plane.along_path", json!({"path":path,"t":0.5,"name":"BridgePlane"})); run(&mut s, "construct.point.along_path", json!({"path":path,"t":0.25,"name":"Quarter"})); let p = plane(&s, "BridgePlane"); assert!(near(p.origin, Vec3::new(20.0, 27.5, 25.0)), "{p:?}"); assert!(parallel(p.normal(), Vec3::new(20.0, 15.0, 30.0))); assert!(near(point(&s, "Quarter"), Vec3::new(15.0, 23.75, 17.5))); run(&mut s, "sketch.edit", json!({"sketch":sketch})); run(&mut s, "sketch.move3d", json!({"wire":wire,"index":1,"to":[50,35,40]})); run(&mut s, "sketch.finish", json!({})); let updated = plane(&s, "BridgePlane"); assert!(near(updated.origin, Vec3::new(30.0, 27.5, 25.0)), "{updated:?}"); assert!(parallel(updated.normal(), Vec3::new(40.0, 15.0, 30.0))); let restored = solidcraft_doc::format::read(&s.doc.to_json()).unwrap(); let mut cold = Session { doc: restored.into(), ..Default::default() }; cold.refresh(); assert!(near(plane(&cold, "BridgePlane").origin, updated.origin)); } #[test] fn plane_on_a_3d_wire_moves_with_its_component() { let mut s = Session::default(); run(&mut s, "component.create", json!({"name":"Part"})); let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); run(&mut s, "sketch.options", json!({"sketch_3d":true})); let wire = run(&mut s, "sketch.line3d", json!({"points":[[0,0,10],[0,0,40]]}))["curves"][0].as_str().unwrap().to_string(); run(&mut s, "sketch.finish", json!({})); run(&mut s, "construct.plane.along_path", json!({"path":{"sketch":sketch,"curve":wire},"t":0.5,"name":"Section"})); run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,20,0],"capture":true})); let p = plane(&s, "Section"); assert!(near(p.origin, Vec3::new(50.0, 20.0, 25.0)), "{p:?}"); assert!(parallel(p.normal(), Vec3::Z)); } /// Built from model geometry, they follow when the model changes: the box gets taller. #[test] fn construction_follows_the_model() { let mut s = Session::default(); run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"})); run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "body_name": "Box"})); run( &mut s, "construct.point.three_planes", json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "Corner"}), ); run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 30, 20]}, "name": "TopBack"})); run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "Mid"})); run(&mut s, "parameters.change", json!({"name": "h", "expression": "50 mm"})); assert!(near(point(&s, "Corner"), Vec3::new(40.0, 30.0, 50.0)), "{:?}", point(&s, "Corner")); let (o, d) = axis(&s, "TopBack"); assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::new(0.0, 30.0, 50.0)), "{o:?} {d:?}"); assert!(on_plane(&plane(&s, "Mid"), Vec3::new(1.0, 1.0, 25.0))); // Construction from construction: an axis through two construction points. run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [0, 0, 0]}, "name": "Origin"})); run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "Origin"}, "b": {"construct_point": "Corner"}, "name": "Diag"})); let (o, d) = axis(&s, "Diag"); assert!(on_line(o, d, Vec3::new(40.0, 30.0, 50.0)) && on_line(o, d, Vec3::ZERO)); } /// In a component moved 50 mm along X, picks given in the world land on its geometry, and the /// axis and point move with the occurrence. #[test] fn construction_lives_in_the_component_frame() { let mut s = Session::default(); run(&mut s, "component.create", json!({"name": "Part"})); run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "body_name": "Pin"})); run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]})); run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [55, 0, 5]}, "name": "PinAxis"})); run(&mut s, "construct.point.center", json!({"of": {"edge": [55, 0, 10]}, "name": "PinTop"})); let (o, d) = axis(&s, "PinAxis"); assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(50.0, 0.0, 0.0)), "{o:?} {d:?}"); assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 0.0, 10.0)), "{:?}", point(&s, "PinTop")); run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 20, 0]})); assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 20.0, 10.0)), "{:?}", point(&s, "PinTop")); } #[test] fn plane_perpendicular_to_a_plane_through_a_line() { let mut s = part(); // Through the box's front bottom edge (along X), square to the bottom: the front face's plane. run(&mut s, "construct.plane.perpendicular", json!({"plane": {"face": [20, 15, 0]}, "line": {"edge": [20, 0, 0]}, "name": "F"})); let f = plane(&s, "F"); assert!(parallel(f.normal(), Vec3::Y) && on_plane(&f, Vec3::new(7.0, 0.0, 13.0)), "{f:?}"); // Through the world Z axis, square to XZ: the YZ plane. run(&mut s, "construct.plane.perpendicular", json!({"plane": "XZ", "line": {"axis": "Z"}, "name": "G"})); let g = plane(&s, "G"); assert!(parallel(g.normal(), Vec3::X) && on_plane(&g, Vec3::new(0.0, 5.0, 9.0)), "{g:?}"); // A line square to the plane has no single answer. assert!(s.execute("construct.plane.perpendicular", &json!({"plane": "XY", "line": {"axis": "Z"}})).is_err()); } #[test] fn ambiguous_plane_aliases_refuse_without_document_or_history_changes() { for alias in ["Top", "Front", "Right", "XY", "XZ", "YZ"] { let mut s = Session::default(); run(&mut s, "parameters.add", json!({"name":"height","expression":"19.05 mm"})); run(&mut s, "sketch.create", json!({"plane":alias})); run(&mut s, "sketch.finish", json!({})); let made = run(&mut s, "construct.plane.offset", json!({"base":{"origin":[0,0,0],"normal":[0,0,1]},"offset":"height","name":"Lid"})); let id = made["feature"].as_u64().unwrap(); legacy_plane_name(&mut s, id, alias); let before = s.doc.to_json(); let history = s.undo.len(); for command in ["sketch.create", "inspect.section"] { let error = s.execute(command, &json!({"plane":format!(" {} ",alias.to_lowercase())})).unwrap_err(); assert!(error.to_string().contains("ambiguous"), "{error}"); assert!(error.to_string().contains(&format!("feature id {id}")), "{error}"); let other = match alias { "Top" => "XY", "XY" => "Top", "Front" => "XZ", "XZ" => "Front", "Right" => "YZ", _ => "Right", }; assert!(error.to_string().contains(&format!("use `{other}` for the origin plane")), "{error}"); assert_eq!(s.doc.to_json(), before); assert_eq!(s.undo.len(), history); assert!(s.section.is_none()); } run(&mut s, "sketch.create", json!({"plane":id})); let inspected = run(&mut s, "sketch.inspect", json!({})); assert_eq!(inspected["plane"]["origin"], json!([0.0, 0.0, 19.05])); let cut = run(&mut s, "inspect.section", json!({"plane":id})); assert_eq!(cut["section"]["origin"], json!([0.0, 0.0, 19.05])); run(&mut s, "parameters.change", json!({"name":"height","expression":"25.4 mm"})); let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); let old = run(&mut reopened, "sketch.inspect", json!({"sketch":"Sketch1"})); assert_eq!(old["plane"]["origin"], json!([0.0, 0.0, 0.0]), "saved origin reference is not reinterpreted"); let linked = run(&mut reopened, "sketch.inspect", json!({"sketch":"Sketch2"})); assert_eq!(linked["plane"]["origin"], json!([0.0, 0.0, 25.4]), "disambiguated reference follows edits and reopen"); } } #[test] fn disambiguated_top_plane_pocket_survives_undo_and_cold_reopen() { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane":"XY"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,200]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"distance":19.05,"name":"Plate"})); let top = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":19.05,"name":"Lid"})); legacy_plane_name(&mut s, top["feature"].as_u64().unwrap(), "Top"); assert!(s.execute("sketch.create", &json!({"plane":"Top"})).unwrap_err().to_string().contains("ambiguous")); run(&mut s, "sketch.create", json!({"plane":top["feature"]})); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[3,3],"p1":[97,30]})); run(&mut s, "sketch.finish", json!({})); let before = s.doc.to_json(); run(&mut s, "solid.extrude", json!({"distance":12.7,"direction":"negative","operation":"cut","name":"Pocket"})); let saved = s.doc.to_json(); let expected = 100.0 * 200.0 * 19.05 - 94.0 * 27.0 * 12.7; let measured = run(&mut s, "inspect.measure", json!({})); assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - expected).abs() < 1e-6, "{measured}"); run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc.to_json(), before); run(&mut s, "edit.redo", json!({})); assert_eq!(s.doc.to_json(), saved); let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap()); let measured = run(&mut reopened, "inspect.measure", json!({})); assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - expected).abs() < 1e-6, "{measured}"); } /// A construction plane named like an origin plane, as an older saved design can hold. fn legacy_plane_name(s: &mut Session, id: u64, name: &str) { s.doc_mut().feature_mut(id).unwrap().name = name.to_string(); } #[test] fn construction_planes_cannot_take_origin_plane_names() { let mut s = Session::default(); let made = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":5,"name":"Lid"})); let id = made["feature"].as_u64().unwrap(); for alias in ["XY", "XZ", "YZ", "Top", "Front", "Right", " top ", "xz"] { let before = s.doc.to_json(); let history = s.undo.len(); let created = s.execute("construct.plane.offset", &json!({"base":"XY","offset":10,"name":alias})).unwrap_err(); assert!(created.to_string().contains("reserved for an origin plane"), "{created}"); let renamed = s.execute("timeline.rename", &json!({"feature":id,"name":alias})).unwrap_err(); assert!(renamed.to_string().contains("reserved for an origin plane"), "{renamed}"); assert_eq!(s.doc.to_json(), before); assert_eq!(s.undo.len(), history); } // Other features may still use the names; only a plane parameter could confuse them. run(&mut s, "sketch.create", json!({"plane":"XY"})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "timeline.rename", json!({"feature":"Sketch1","name":"Top"})); run(&mut s, "timeline.rename", json!({"feature":id,"name":"Topside"})); } #[test] fn ambiguity_error_names_the_other_origin_alias() { for (name, other) in [("Top", "XY"), ("XY", "Top"), ("Front", "XZ"), ("XZ", "Front"), ("Right", "YZ"), ("YZ", "Right")] { let mut s = Session::default(); let made = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":5,"name":"Lid"})); legacy_plane_name(&mut s, made["feature"].as_u64().unwrap(), name); let error = s.execute("sketch.create", &json!({"plane":name})).unwrap_err().to_string(); assert!(error.contains(&format!("use `{other}` for the origin plane")), "{error}"); // The suggested alias is unambiguous and reaches the origin plane. run(&mut s, "sketch.create", json!({"plane":other})); let inspected = run(&mut s, "sketch.inspect", json!({})); assert_eq!(inspected["plane"]["origin"], json!([0.0, 0.0, 0.0])); } }