* Kernel: the B-rep record and the native body file come from solidcore-record SolidCraft's portable B-rep record (`solidcraft_geom::brep`: brep.rs, topology, history, envelope, part, curve2) and the native body file (`solidcraft_kernel::native`: the tagged binary codec and the container) move to solidcore's `solidcore-record` crate: geometry as data, with structural validation and no maths (solidcore's docs/layers.md, stage 1). Every caller uses `solidcore_record::brep` and `solidcore_record::native`; the old copies are deleted. What computes from coordinates stays with the kernel, in geom's `brep`: - `brep::check`: the record's geometric checks (orthonormal frames, non-degenerate planes, unit revolution axes, non-singular placements). `check::validate` and `check::validate_built` are the record's validation and these together, and every caller of `Brep::validate` uses them, so what is accepted is unchanged; - `brep::label`: a surface's natural ranges (a cone's apex), `curve_interval`, `edge_curve_interval` and `form_turns`; - `brep::pcurve::eval`: a pcurve's point; - `geometry::check`, `placement::moved` and `split_edges` become functions on the record. The kernel's native module keeps the conversion between a body and its saved record (metadata.rs) and checks the decoded record's geometry. `native/analytic.rs`, which no module declared, is deleted. solidcore-record is pinned to its rev on solidcore's `layer-record` branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT * Geometry: Vec2, Vec3, Plane and the profile types come from solidcore-record The plain data of vectors and points, sketch planes and planar profiles (`Seg2`, `Loop2`, `Region2`) moves to `solidcore-record`, with its constants and component-wise operators only (owner decision, solidcore layer split). Their geometry stays in geom as traits: `Vec2Geom`, `Vec3Geom`, `PlaneGeom`, `Seg2Geom`, `Loop2Geom`, `Region2Geom`, `Seg2ArcFit`, `Loop2ArcFit` and `Region2ArcFit`, in scope with `use solidcraft_geom::prelude::*`. Every caller names the types from `solidcore_record`; geom no longer defines or re-exports them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT * Kernel: pin solidcore-record to solidcore main f5e8e9a29 solidcore-record is merged on solidcore main (tangency-labs/solidcore#2519); its crate there is identical to the rev pinned before but for its clippy configuration. The lock is regenerated against the merged rev. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
835 lines
46 KiB
Rust
835 lines
46 KiB
Rust
//! 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 solidcore_record::{Plane, Vec3};
|
||
use solidcraft_geom::prelude::*;
|
||
|
||
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}"))
|
||
}
|
||
|
||
#[test]
|
||
fn construction_plane_names_cannot_be_reused_by_new_features() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 5, "body_name": "Block"}));
|
||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 10, "name": "Mid"}));
|
||
let before = s.doc.clone();
|
||
for (command, params) in [
|
||
("construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "Mid"})),
|
||
("construct.plane.offset", json!({"base": "XY", "offset": 20, "name": " Mid "})),
|
||
("sketch.create", json!({"plane": "XY", "name": "Mid"})),
|
||
("solid.box", json!({"length": 1, "width": 1, "height": 1, "name": "Mid"})),
|
||
("construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "Box1"})),
|
||
] {
|
||
let error = s.execute(command, ¶ms).unwrap_err().to_string();
|
||
assert!(error.contains("another feature is called"), "{command}: {error}");
|
||
assert_eq!(s.doc, before, "{command}: a refused creation changes nothing");
|
||
}
|
||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "Upper"}));
|
||
run(&mut s, "solid.mirror", json!({"bodies": ["Block"], "plane": "Upper"}));
|
||
let measured = run(&mut s, "inspect.measure", json!({}));
|
||
let mirrored = measured["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "Block (2)").unwrap();
|
||
assert_eq!(mirrored["bbox"]["min"][2], 35.0);
|
||
assert_eq!(mirrored["bbox"]["max"][2], 40.0);
|
||
}
|
||
|
||
#[test]
|
||
fn redefining_a_construction_plane_keeps_its_existing_name() {
|
||
let mut s = Session::default();
|
||
let id = run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 10, "name": "Mid"}))["feature"].as_u64().unwrap();
|
||
run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "Profile"}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(
|
||
&mut s,
|
||
"timeline.redefine",
|
||
json!({"feature": id, "command": "construct.plane.offset", "params": {"base": "XY", "offset": 20, "name": "Mid"}}),
|
||
);
|
||
assert_eq!(s.doc.feature(id).unwrap().name, "Mid");
|
||
assert_eq!(s.doc.features.len(), 2);
|
||
assert!(near(plane(&s, "Mid").origin, Vec3::new(0.0, 0.0, 20.0)));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(near(plane(&s, "Mid").origin, Vec3::new(0.0, 0.0, 10.0)));
|
||
run(&mut s, "edit.redo", json!({}));
|
||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||
reopened.refresh();
|
||
assert_eq!(reopened.doc.feature(id).unwrap().name, "Mid");
|
||
assert!(near(plane(&reopened, "Mid").origin, Vec3::new(0.0, 0.0, 20.0)));
|
||
assert!(reopened.model.results.iter().all(|r| r.error.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn renamed_construction_references_survive_edits_and_reopening() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "construct.axis.two_planes", json!({"a": "XZ", "b": "YZ", "name": "Spine"}));
|
||
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XZ", "line": {"axis": "Spine"}, "name": "Fence"}));
|
||
run(&mut s, "construct.point.three_planes", json!({"a": "XY", "b": "XZ", "c": "YZ", "name": "Pin"}));
|
||
run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "Pin"}, "b": {"point": [30, 30, 30]}, "name": "Brace"}));
|
||
run(&mut s, "construct.point.vertex", json!({"point": {"construct_point": "Pin"}, "name": "Copy"}));
|
||
let before = (*s.doc).clone();
|
||
run(&mut s, "timeline.rename", json!({"feature": "Spine", "name": "Mast"}));
|
||
run(&mut s, "timeline.rename", json!({"feature": "Pin", "name": "Peg"}));
|
||
let fence = run(&mut s, "timeline.inspect", json!({"feature": "Fence"}));
|
||
assert_eq!(fence["definition"]["plane"]["line"]["name"], "Mast");
|
||
let brace = run(&mut s, "timeline.inspect", json!({"feature": "Brace"}));
|
||
assert_eq!(brace["definition"]["def"]["a"]["name"], "Peg");
|
||
let renamed = (*s.doc).clone();
|
||
for _ in 0..2 {
|
||
run(&mut s, "edit.undo", json!({}));
|
||
}
|
||
assert_eq!(*s.doc, before);
|
||
for _ in 0..2 {
|
||
run(&mut s, "edit.redo", json!({}));
|
||
}
|
||
assert_eq!(*s.doc, renamed);
|
||
run(&mut s, "timeline.edit", json!({"feature": "Mast", "set": {"def.b": {"type": "plane", "plane": {"type": "origin", "name": "XY"}}}}));
|
||
let saved = serde_json::to_vec(&*s.doc).unwrap();
|
||
let mut reopened = Session::new(serde_json::from_slice(&saved).unwrap());
|
||
reopened.refresh();
|
||
for session in [&s, &reopened] {
|
||
assert!(session.model.results.iter().all(|r| r.error.is_none()), "{:?}", session.model.results);
|
||
assert!(parallel(plane(session, "Fence").normal(), Vec3::Z));
|
||
assert!(parallel(axis(session, "Brace").1, Vec3::new(1.0, 1.0, 1.0)));
|
||
assert!(near(point(session, "Copy"), Vec3::ZERO));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn renamed_hidden_axes_and_points_stay_hidden_through_undo_redo() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "construct.axis.two_planes", json!({"a": "XZ", "b": "YZ", "name": "Spine"}));
|
||
run(&mut s, "construct.point.three_planes", json!({"a": "XY", "b": "XZ", "c": "YZ", "name": "Pin"}));
|
||
run(&mut s, "browser.visibility", json!({"items": ["Spine", "Pin", "XY"], "visible": false}));
|
||
for (old, new) in [("Spine", "Mast"), ("Pin", "Peg")] {
|
||
run(&mut s, "timeline.rename", json!({"feature": old, "name": new}));
|
||
assert!(!s.construction_visible(new));
|
||
assert!(!s.visibility.hidden_origin.iter().any(|n| n == old));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(!s.construction_visible(old));
|
||
assert!(!s.visibility.hidden_origin.iter().any(|n| n == new));
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert!(!s.construction_visible(new));
|
||
}
|
||
assert!(!s.construction_visible("XY"));
|
||
}
|
||
|
||
/// 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 plane_through_axis_and_point_contains_both_and_rejects_an_on_axis_point() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "construct.plane.axis_point", json!({"axis": {"axis": "Z"}, "point": {"point": [10, 20, 30]}, "name": "Fence"}));
|
||
let pl = plane(&s, "Fence");
|
||
for p in [Vec3::ZERO, Vec3::Z * 100.0, Vec3::new(10.0, 20.0, 30.0)] {
|
||
assert!(on_plane(&pl, p), "{p:?} is on {pl:?}");
|
||
}
|
||
assert!(parallel(pl.normal(), Vec3::new(-20.0, 10.0, 0.0)));
|
||
let before = s.doc.to_json();
|
||
let error = s.execute("construct.plane.axis_point", &json!({"axis": {"axis": "Z"}, "point": {"point": [0, 0, 50]}})).unwrap_err();
|
||
assert!(error.to_string().contains("point is on the axis"), "{error}");
|
||
assert_eq!(s.doc.to_json(), before, "a degenerate plane leaves the design unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn plane_through_axis_and_point_follows_renamed_sources_edits_undo_and_reopen() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[5,6,7]},"b":{"point":[5,6,17]},"name":"Spine"}));
|
||
run(&mut s, "construct.point.vertex", json!({"point":{"point":[15,6,7]},"name":"Pin"}));
|
||
run(&mut s, "construct.plane.axis_point", json!({"axis":{"axis":"Spine"},"point":{"construct_point":"Pin"},"name":"Fence"}));
|
||
assert!(parallel(plane(&s, "Fence").normal(), Vec3::Y));
|
||
run(&mut s, "timeline.rename", json!({"feature":"Spine","name":"Mast"}));
|
||
run(&mut s, "timeline.rename", json!({"feature":"Pin","name":"Peg"}));
|
||
let definition = run(&mut s, "timeline.inspect", json!({"feature":"Fence"}));
|
||
assert_eq!(definition["definition"]["plane"]["axis"]["name"], "Mast");
|
||
assert_eq!(definition["definition"]["plane"]["point"]["name"], "Peg");
|
||
run(&mut s, "timeline.edit", json!({"feature":"Peg","set":{"def.point":{"type":"point","p":[5,16,7]}}}));
|
||
assert!(parallel(plane(&s, "Fence").normal(), Vec3::X), "point edit rotates the dependent plane");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(parallel(plane(&s, "Fence").normal(), Vec3::Y));
|
||
run(&mut s, "edit.redo", json!({}));
|
||
let reopened = solidcraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||
let mut cold = Session::new(reopened);
|
||
cold.refresh();
|
||
assert!(cold.model.results.iter().all(|r| r.error.is_none()), "{:?}", cold.model.results);
|
||
let pl = plane(&cold, "Fence");
|
||
assert!(parallel(pl.normal(), Vec3::X));
|
||
assert!(on_plane(&pl, Vec3::new(5.0, 16.0, 7.0)));
|
||
run(&mut cold, "sketch.create", json!({"plane":"Fence"}));
|
||
}
|
||
|
||
#[test]
|
||
fn plane_through_axis_and_point_accepts_straight_model_and_sketch_lines() {
|
||
let mut s = part();
|
||
run(&mut s, "construct.plane.axis_point", json!({"axis":{"edge":[20,0,0]},"point":{"vertex":[40,30,0]},"name":"Base"}));
|
||
assert!(parallel(plane(&s, "Base").normal(), Vec3::Z));
|
||
let sk = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||
let line = run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
|
||
let curve = line["curves"][0].as_str().unwrap();
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "construct.plane.axis_point", json!({"axis":{"sketch":sk,"curve":curve},"point":{"point":[0,0,10]},"name":"Side"}));
|
||
assert!(parallel(plane(&s, "Side").normal(), Vec3::Y));
|
||
}
|
||
|
||
#[test]
|
||
fn plane_through_axis_and_point_in_a_moved_component_keeps_its_world_picks() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "component.create", json!({"name":"Part","grounded":false}));
|
||
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,0,0]}));
|
||
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[50,0,0]},"b":{"point":[50,0,10]},"name":"Mast"}));
|
||
run(&mut s, "construct.plane.axis_point", json!({"axis":{"axis":"Mast"},"point":{"point":[60,0,0]},"name":"Fence"}));
|
||
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[0,20,0]}));
|
||
let pl = plane(&s, "Fence");
|
||
assert!(parallel(pl.normal(), Vec3::Y));
|
||
for p in [Vec3::new(50.0, 20.0, 0.0), Vec3::new(50.0, 20.0, 10.0), Vec3::new(60.0, 20.0, 0.0)] {
|
||
assert!(on_plane(&pl, p), "{p:?} on {pl:?}");
|
||
}
|
||
run(&mut s, "component.activate", json!({"component":0}));
|
||
run(
|
||
&mut s,
|
||
"timeline.redefine",
|
||
json!({"feature":"Fence","command":"construct.plane.axis_point","params":{"axis":{"axis":"Mast"},"point":{"point":[70,20,0]}}}),
|
||
);
|
||
let definition = run(&mut s, "timeline.inspect", json!({"feature":"Fence"}));
|
||
assert_eq!(
|
||
definition["definition"]["plane"]["point"],
|
||
json!({"type":"point","p":[20.,0.,0.]}),
|
||
"redefine uses the feature's authoring frame, not the active root"
|
||
);
|
||
}
|
||
|
||
#[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:?}");
|
||
// On an arc it is the arc's own point by arc length, to rounding: a third of a quarter turn
|
||
// of radius 10 is 30° round. A t past either end is refused, not moved onto it.
|
||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Round"}));
|
||
let arc = run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]}))["created"]["curves"][0]
|
||
.as_str()
|
||
.unwrap()
|
||
.to_string();
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
let path = json!({"sketch": "Round", "curve": arc});
|
||
run(&mut s, "construct.point.along_path", json!({"path": path, "t": "1/3", "name": "Third"}));
|
||
let third = point(&s, "Third");
|
||
let want = Vec3::new(10.0 * (std::f64::consts::PI / 6.0).cos(), 5.0, 0.0);
|
||
assert!(third.dist(want) < 1e-12, "{third:?} is {:e} off", third.dist(want));
|
||
for t in [json!(1.5), json!(-0.25), json!("2")] {
|
||
let e = s.execute("construct.point.along_path", &json!({"path": path, "t": t})).unwrap_err();
|
||
assert!(e.to_string().contains("0 (its start) to 1 (its end)"), "t {t}: {e}");
|
||
assert!(s.execute("construct.plane.along_path", &json!({"path": path, "t": t})).is_err(), "plane at t {t}");
|
||
}
|
||
}
|
||
|
||
#[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:?}");
|
||
}
|
||
|
||
/// #8822: with a reference plane or planar face, the plane touches where the face turns towards
|
||
/// the reference (parallel to it, on the side of the pick), turned by an angle about the axis,
|
||
/// and follows the reference when it moves.
|
||
#[test]
|
||
fn plane_tangent_lined_up_with_a_reference() {
|
||
let mut s = part();
|
||
// Picked 60° round the cylinder (axis Z through (60, 0)); the XZ plane faces ±Y.
|
||
let pick = json!([62.5, 4.330127, 5]);
|
||
run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"plane": "XZ"}, "name": "T"}));
|
||
let t = plane(&s, "T");
|
||
assert!(parallel(t.normal(), Vec3::Y) && on_plane(&t, Vec3::new(60.0, 5.0, 9.0)), "{t:?}");
|
||
// Turned 30° about the axis: still on the pick's side.
|
||
run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"plane": "XZ"}, "angle": "30 deg", "name": "A"}));
|
||
let a = plane(&s, "A");
|
||
let d = Vec3::new(-0.5, 3f64.sqrt() / 2.0, 0.0);
|
||
assert!(parallel(a.normal(), d) && on_plane(&a, Vec3::new(60.0, 0.0, 2.0) + d * 5.0), "{a:?}");
|
||
// A planar face of the box (x = 40, facing X) as the reference.
|
||
run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"face": [40, 15, 10]}, "name": "F"}));
|
||
let f = plane(&s, "F");
|
||
assert!(parallel(f.normal(), Vec3::X) && on_plane(&f, Vec3::new(65.0, 1.0, 1.0)), "{f:?}");
|
||
// The torus (axis Z through (0, 60)): on its outer equator.
|
||
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [0, 72, 0]}, "reference": {"plane": "XZ"}, "name": "R"}));
|
||
let r = plane(&s, "R");
|
||
assert!(parallel(r.normal(), Vec3::Y) && on_plane(&r, Vec3::new(3.0, 72.0, 1.0)), "{r:?}");
|
||
// The sphere (centre (0, 0, 60)), picked below its middle: under it, parallel to XY.
|
||
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [3, 0, 56]}, "reference": {"plane": "XY"}, "name": "U"}));
|
||
let u = plane(&s, "U");
|
||
assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(1.0, 1.0, 55.0)), "{u:?}");
|
||
|
||
// A reference built on a parameter: the tangent plane follows it.
|
||
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"}));
|
||
run(&mut s, "construct.plane.at_angle", json!({"base": "XZ", "axis": "Z", "angle": "turn", "name": "Ref"}));
|
||
run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"plane": "Ref"}, "name": "M"}));
|
||
assert!(on_plane(&plane(&s, "M"), Vec3::new(60.0, 5.0, 1.0)));
|
||
run(&mut s, "parameters.change", json!({"name": "turn", "expression": "-90 deg"}));
|
||
let m = plane(&s, "M");
|
||
assert!(parallel(m.normal(), Vec3::X) && on_plane(&m, Vec3::new(65.0, 2.0, 1.0)), "{m:?}");
|
||
|
||
// What has no answer is refused and adds nothing.
|
||
let count = construction_planes(&s).len();
|
||
for (p, why) in [
|
||
(json!({"face": {"face": pick}, "reference": {"plane": "XY"}}), "a reference square to the axis"),
|
||
(json!({"face": {"face": [3, 0, 56]}, "reference": {"plane": "XY"}, "angle": "10 deg"}), "an angle on a sphere"),
|
||
(json!({"face": {"face": pick}, "angle": "10 deg"}), "an angle without a reference"),
|
||
(json!({"face": {"face": pick}, "reference": {"plane": "XZ"}, "angle": "2 mm"}), "a length for the angle"),
|
||
] {
|
||
assert!(s.execute("construct.plane.tangent", &p).is_err(), "{why}");
|
||
}
|
||
assert_eq!(construction_planes(&s).len(), count);
|
||
}
|
||
|
||
#[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","grounded":false}));
|
||
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));
|
||
}
|
||
|
||
/// Once a design has a component, the root's constructions are built in the model placed in the
|
||
/// world. A plane through a construction axis of the root finds the axis there and follows it
|
||
/// (it failed with "unknown construction `Ax`" on every evaluation after the component came).
|
||
#[test]
|
||
fn a_plane_through_a_construction_axis_follows_it_in_a_design_with_a_component() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "parameters.add", json!({"name": "d", "expression": "10 mm"}));
|
||
run(&mut s, "construct.plane.offset", json!({"base": "XZ", "offset": "d", "name": "P1"}));
|
||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "5 mm", "name": "P2"}));
|
||
run(&mut s, "construct.axis.two_planes", json!({"a": {"plane": "P1"}, "b": {"plane": "P2"}, "name": "Ax"}));
|
||
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XY", "line": {"axis": "Ax"}, "name": "Q"}));
|
||
run(&mut s, "component.create", json!({"name": "Part"}));
|
||
run(&mut s, "parameters.change", json!({"name": "d", "expression": "30 mm"}));
|
||
let errors: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||
assert!(errors.is_empty(), "{errors:?}");
|
||
let q = plane(&s, "Q");
|
||
assert!(on_plane(&q, Vec3::new(0.0, 30.0, 5.0)) && parallel(q.normal(), Vec3::Y), "{q:?}");
|
||
}
|
||
|
||
/// 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));
|
||
}
|
||
|
||
/// #568: a plane through three vertices follows them when a parameter moves them. Two of them
|
||
/// sit on the box's end at x = 40; the box grows to 100 mm, so the vertices nearest the old
|
||
/// picks are the ones at x = 0, on a line with the first: found by the names of the edges that
|
||
/// meet there, they are the moved ones.
|
||
#[test]
|
||
fn a_plane_through_three_vertices_follows_them() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "parameters.add", json!({"name": "len", "expression": "40 mm"}));
|
||
run(&mut s, "solid.box", json!({"length": "len", "width": 30, "height": 20, "body_name": "Box"}));
|
||
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"}),
|
||
);
|
||
run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
|
||
let id = s.doc.features.iter().find(|f| f.name == "T").map(|f| f.id).unwrap_or_else(|| panic!("feature T"));
|
||
let error = s.model.result(id).and_then(|r| r.error.clone());
|
||
assert_eq!(error, None);
|
||
let t = plane(&s, "T");
|
||
for p in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(100.0, 0.0, 20.0), Vec3::new(100.0, 30.0, 0.0)] {
|
||
assert!(on_plane(&t, p), "{p:?} is off {t:?}");
|
||
}
|
||
}
|
||
|
||
/// A corner of one box where another box's edge runs past it, as where a wall meets the side
|
||
/// wall it stands against (the render-studio example, #8650): the vertex stays that corner. Its
|
||
/// edges were found by name and then picked again by a point, which took the first edge through
|
||
/// the corner of any body, the side wall's, and so its end 3 mm away. Three such corners were
|
||
/// "on one line".
|
||
#[test]
|
||
fn a_vertex_where_another_bodys_edge_runs_past_stays_its_corner() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "solid.box", json!({"corner": [29, -22, 4], "length": 3, "width": 44, "height": 19, "body_name": "Side"}));
|
||
run(&mut s, "solid.box", json!({"corner": [-29, 19, 4], "length": 58, "width": 3, "height": 19, "body_name": "Back"}));
|
||
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [29, 19, 23]}, "name": "P"}));
|
||
assert!(near(point(&s, "P"), Vec3::new(29.0, 19.0, 23.0)), "{:?}", point(&s, "P"));
|
||
run(
|
||
&mut s,
|
||
"construct.plane.three_points",
|
||
json!({"a": {"vertex": [29, 22, 23]}, "b": {"vertex": [29, 22, 4]}, "c": {"vertex": [29, 19, 23]}, "name": "T"}),
|
||
);
|
||
let t = plane(&s, "T");
|
||
for p in [Vec3::new(29.0, 22.0, 23.0), Vec3::new(29.0, 22.0, 4.0), Vec3::new(29.0, 19.0, 23.0)] {
|
||
assert!(on_plane(&t, p), "{p:?} is off {t:?}");
|
||
}
|
||
}
|
||
|
||
/// 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", "grounded": false}));
|
||
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]));
|
||
}
|
||
}
|
||
|
||
/// An offset plane through a point instead of at a distance: parallel to its base, through a
|
||
/// vertex, a sketch point or a construction point, and moving with the point.
|
||
#[test]
|
||
fn offset_plane_through_a_point_follows_the_point() {
|
||
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"}));
|
||
// Through the box's top corner, parallel to XY: the top face's plane.
|
||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "through": {"vertex": [40, 30, 20]}, "name": "Lid"}));
|
||
let lid = plane(&s, "Lid");
|
||
assert!(parallel(lid.normal(), Vec3::Z) && near(lid.origin, Vec3::new(0.0, 0.0, 20.0)), "{lid:?}");
|
||
// Through a point of a sketch on XZ, parallel to the box's side face at x = 40.
|
||
let sketch = run(&mut s, "sketch.create", json!({"plane": "XZ"}))["sketch"].as_u64().unwrap();
|
||
run(&mut s, "sketch.point", json!({"point": [70, 5], "id": "mark"}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "construct.plane.offset", json!({"base": {"face": [40, 15, 10]}, "through": {"sketch": sketch, "point": "mark"}, "name": "Side"}));
|
||
let side = plane(&s, "Side");
|
||
assert!(parallel(side.normal(), Vec3::X) && on_plane(&side, Vec3::new(70.0, 9.0, 3.0)), "{side:?}");
|
||
// Through a construction point, parallel to another construction plane.
|
||
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [0, 30, 20]}, "name": "Corner"}));
|
||
run(&mut s, "construct.plane.offset", json!({"base": "XZ", "offset": 5, "name": "Fore"}));
|
||
run(&mut s, "construct.plane.offset", json!({"base": "Fore", "through": {"construct_point": "Corner"}, "name": "Back"}));
|
||
let back = plane(&s, "Back");
|
||
assert!(parallel(back.normal(), Vec3::Y) && on_plane(&back, Vec3::new(3.0, 30.0, 7.0)), "{back:?}");
|
||
// The box gets taller: the planes through its corners follow.
|
||
run(&mut s, "parameters.change", json!({"name": "h", "expression": "50 mm"}));
|
||
assert!(on_plane(&plane(&s, "Lid"), Vec3::new(1.0, 2.0, 50.0)), "{:?}", plane(&s, "Lid"));
|
||
assert!(on_plane(&plane(&s, "Back"), Vec3::new(1.0, 30.0, 2.0)), "{:?}", plane(&s, "Back"));
|
||
// Saved and opened again, the planes come back where they were.
|
||
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, "Lid").origin, plane(&s, "Lid").origin));
|
||
assert!(near(plane(&cold, "Side").origin, plane(&s, "Side").origin));
|
||
// A distance and a point at once is refused.
|
||
assert!(s.execute("construct.plane.offset", &json!({"base": "XY", "offset": 5, "through": {"vertex": [0, 0, 0]}})).is_err());
|
||
}
|
||
|
||
/// Hiding the placement a component is shown at hides its construction geometry too, as it
|
||
/// hides its bodies and sketches.
|
||
#[test]
|
||
fn hiding_an_occurrence_hides_its_construction_geometry() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "component.create", json!({"name": "Part"}));
|
||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "5 mm", "name": "Shelf"}));
|
||
run(&mut s, "construct.axis.two_planes", json!({"a": "XZ", "b": "YZ", "name": "Spine"}));
|
||
run(&mut s, "construct.point.three_planes", json!({"a": "XY", "b": "XZ", "c": "YZ", "name": "Pin"}));
|
||
let component = s.doc.features.iter().find(|f| f.name == "Shelf").map(|f| f.component).unwrap();
|
||
assert_ne!(component, 0);
|
||
let path = s.doc.first_path(component);
|
||
run(&mut s, "component.activate", json!({"component": 0}));
|
||
for name in ["Shelf", "Spine", "Pin"] {
|
||
assert!(s.construction_visible(name), "{name}");
|
||
}
|
||
run(&mut s, "browser.visibility", json!({"paths": [path], "visible": false}));
|
||
for name in ["Shelf", "Spine", "Pin"] {
|
||
assert!(!s.construction_visible(name), "{name} shows in a hidden placement");
|
||
}
|
||
}
|
||
|
||
/// #10127: an axis and a point built on a component's cylinder turn with it when Move/Copy
|
||
/// turns the cylinder, as its faces do. One also pinned to a fixed point stays where it is.
|
||
#[test]
|
||
fn construction_on_a_moved_body_moves_with_it() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "body_name": "Pin"}));
|
||
run(&mut s, "component.create", json!({"name": "Part", "parent": "root", "activate": false}));
|
||
run(&mut s, "component.move_bodies", json!({"bodies": ["Pin"], "component": "Part"}));
|
||
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [5, 0, 10]}, "name": "PinAxis"}));
|
||
run(&mut s, "construct.point.center", json!({"of": {"edge": [5, 0, 20]}, "name": "PinTop"}));
|
||
run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "PinTop"}, "b": {"point": [0, 50, 20]}, "name": "Tether"}));
|
||
let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap();
|
||
// A quarter turn about Y through the origin, then 10 mm along X: the top centre (0, 0, 20)
|
||
// goes to (30, 0, 0), and the cylinder's axis lies along X.
|
||
run(&mut s, "solid.move", json!({"bodies": [body], "translate": [10, 0, 0], "axis": [0, 1, 0], "angle": "90 deg"}));
|
||
let errors: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||
assert!(errors.is_empty(), "{errors:?}");
|
||
let (o, d) = axis(&s, "PinAxis");
|
||
assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::new(30.0, 0.0, 0.0)), "{o:?} {d:?}");
|
||
assert!(near(point(&s, "PinTop"), Vec3::new(30.0, 0.0, 0.0)), "{:?}", point(&s, "PinTop"));
|
||
let (o, d) = axis(&s, "Tether");
|
||
assert!(on_line(o, d, Vec3::new(0.0, 0.0, 20.0)) && on_line(o, d, Vec3::new(0.0, 50.0, 20.0)), "{o:?} {d:?}");
|
||
}
|