Files
OneCAD/crates/engine/src/cmd/sketch_ux_tests.rs
T
bherbruck 4e39869023 Merge pull request #179 from tbreckle/fix/177-centerline-to-construction
fix(sketch): a centerline can be switched straight to construction
2026-10-11 16:46:04 -04:00

672 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sketch interaction tests: dimension placement, Fix/Unfix, the construction toggle, and
//! which entity moves when a two-entity constraint is applied.
use serde_json::{Value, json};
use solvecraft_sketch::{DimFrame, Sketch, dim_frame, dim_layout};
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 sketch(s: &Session) -> Sketch {
let id = s.active_sketch.expect("a sketch is being edited");
s.doc.sketch(id).expect("the sketch").clone()
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
// ---------------------------------------------------------------------------------------------
// Dimensions
#[test]
fn every_dimension_kind_has_a_drawable_frame() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "sketch.arc.center_point", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-20, 25]]}));
for (ents, ty) in [
(json!(["l1"]), "auto"),
(json!(["l2"]), "vertical"),
(json!(["l1.start", "c1.center"]), "horizontal"),
(json!(["c1"]), "auto"),
(json!(["a1"]), "radius"),
(json!(["a1"]), "arc_length"),
(json!(["l5", "l6"]), "angle"),
] {
run(&mut s, "sketch.dimension", json!({"entities": ents, "type": ty}));
}
run(&mut s, "sketch.dimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
let sk = sketch(&s);
let dims: Vec<_> = sk.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 8);
let mut kinds = Vec::new();
for c in dims {
let f = dim_frame(&sk, &c.kind).unwrap_or_else(|| panic!("no frame for {c:?}"));
let l = dim_layout(&f, None, 0.1, solvecraft_geom::Vec2::new(1.0, 0.5));
assert!(!l.lines.is_empty() && !l.arrows.is_empty(), "{c:?}");
assert!(l.text.is_finite());
kinds.push(std::mem::discriminant(&f));
}
for f in [
DimFrame::Linear { p0: Default::default(), p1: Default::default(), dir: Default::default() },
DimFrame::Radial { center: Default::default(), radius: 1.0, diameter: true },
DimFrame::Angular { vertex: Default::default(), d0: Default::default(), sweep: 1.0 },
DimFrame::ArcLength { center: Default::default(), radius: 1.0, start: 0.0, sweep: 1.0 },
] {
assert!(kinds.contains(&std::mem::discriminant(&f)));
}
}
#[test]
fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "connect": false}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let text = |s: &Session| {
let sk = sketch(s);
let c = sk.constraints.iter().find(|c| c.kind.is_dimension()).cloned().expect("dimension");
let f = dim_frame(&sk, &c.kind).expect("frame");
dim_layout(&f, c.text, 0.1, Default::default()).text
};
assert!(text(&s).dist(solvecraft_geom::Vec2::new(20.0, -8.0)) < 1e-9);
// Moved by the parameter name; then the line moves and the text goes with it.
run(&mut s, "sketch.dimension_text", json!({"dimension": param, "at": [30, 12]}));
assert!(text(&s).dist(solvecraft_geom::Vec2::new(30.0, 12.0)) < 1e-9);
run(&mut s, "sketch.move_point", json!({"point": "l1.start", "to": [0, 10]}));
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 10]}));
// Still 10 along and 12 across from the middle of the (moved) line.
let sk = sketch(&s);
let Some(DimFrame::Linear { p0, p1, dir }) = sk.constraints.iter().find_map(|c| dim_frame(&sk, &c.kind)) else { panic!("linear frame") };
assert!(p0.y > 5.0, "the line moved: {p0:?}");
let off = text(&s) - (p0 + p1) * 0.5;
assert!((off.dot(dir) - 10.0).abs() < 1e-9 && (off.dot(dir.perp()) - 12.0).abs() < 1e-9, "{off:?}");
// Reset puts it back to the default place; undo restores the drag.
run(&mut s, "sketch.dimension_text", json!({"dimension": param, "reset": true}));
assert!(sketch(&s).constraints.iter().all(|c| c.text.is_none()));
assert!(s.execute("sketch.dimension_text", &json!({"dimension": "nope", "at": [0, 0]})).is_err());
assert!(s.execute("sketch.dimension_text", &json!({"dimension": param, "at": [f64::NAN, 0]})).is_err());
}
#[test]
fn deleting_a_dimension_frees_its_parameter() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["c1"]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let id = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == Some(param.as_str())).map(|c| c.id.clone()).expect("dim");
run(&mut s, "sketch.delete", json!({"entities": [id]}));
assert!(sketch(&s).constraints.iter().all(|c| !c.kind.is_dimension()));
assert!(s.doc.param(&param).is_none());
}
// ---------------------------------------------------------------------------------------------
// Fix / Unfix
fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 {
let sk = sketch(s);
sk.resolve_point(id).and_then(|i| sk.point(i)).unwrap_or_else(|| panic!("no point {id}"))
}
fn dof(s: &mut Session) -> i64 {
run(s, "sketch.inspect", json!({}))["dof"].as_i64().unwrap_or(-1)
}
#[test]
fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]], "connect": false}));
let free = dof(&mut s);
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], true);
assert_eq!(dof(&mut s), free - 2);
let corner = pt(&s, "l1.end");
// Dragging the fixed point does nothing; dragging its neighbours never moves it.
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [50, 50]}));
assert_eq!(pt(&s, "l1.end"), corner);
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "sketch.move_point", json!({"point": "l2.end", "to": [40, 25]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 12}));
assert!(pt(&s, "l1.end").dist(corner) < 1e-12, "{:?}", pt(&s, "l1.end"));
assert!((pt(&s, "l1.start").dist(corner) - 12.0).abs() < 1e-6);
// Unfix: the point moves again.
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], false);
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [30, -5]}));
assert!(pt(&s, "l1.end").dist(corner) > 1.0);
}
#[test]
fn fixing_curves_holds_their_points_and_radius() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "sketch.line", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "sketch.ellipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
let free = dof(&mut s);
// Multi-select fixes them all (circle 3, line 4, arc 5, ellipse 5 degrees of freedom).
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["changed"], 4);
assert_eq!(dof(&mut s), free - 17);
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fixed"] == true)), "{si}");
// A fixed circle keeps centre and radius; a dimension on it conflicts.
run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [0, 0]}));
assert_eq!(pt(&s, "c1.center"), solvecraft_geom::Vec2::new(10.0, 10.0));
assert!(s.execute("sketch.dimension", &json!({"entities": ["c1"], "value": 20})).is_err());
let r0 = sketch(&s).radius(0);
assert_eq!(r0, Some(5.0));
// Toggle without `fixed`: all fixed, so all are unfixed.
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["fixed"], false);
assert_eq!(dof(&mut s), free);
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
assert!((sketch(&s).radius(0).unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Mixed selection: one fixed, one free → both end up fixed.
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "l1.start", "a1"]}));
assert_eq!(r["result"]["fixed"], true);
assert!(sketch(&s).curves.iter().filter(|c| c.id == "l1" || c.id == "a1").all(|c| c.fixed));
}
#[test]
fn projected_geometry_counts_as_fixed() {
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": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
let sk = sketch(&s);
let ci = sk.curves.iter().position(|c| c.link.is_some()).unwrap_or_else(|| panic!("projected: {r}"));
let id = sk.curves[ci].id.clone();
assert!(sk.curve_locked(ci));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": id}));
// Already fixed: the toggle unfixes, which projected geometry ignores.
assert_eq!(r["result"]["changed"], 0);
assert!(sketch(&s).curve_locked(ci));
}
// ---------------------------------------------------------------------------------------------
// Construction toggle
fn profiles(s: &mut Session) -> i64 {
run(s, "sketch.solve", json!({}))["profiles"].as_i64().unwrap_or(-1)
}
fn curve_ids(v: &Value) -> Vec<String> {
v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
#[test]
fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
let mut s = new_sketch();
let rect = curve_ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "sketch.slot.center_to_center", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "sketch.circle.center", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "sketch.ellipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
assert_eq!(profiles(&mut s), 5);
for (shape, left) in [(&rect, 4), (&slot, 3), (&poly, 2), (&circ, 1), (&ell, 0)] {
let r = run(&mut s, "sketch.construction", json!({ "curves": shape }));
assert_eq!(r["construction"], true);
assert_eq!(profiles(&mut s), left, "{shape:?}");
}
// Back again in one go.
let all: Vec<String> = [rect, slot, poly, circ, ell].concat();
let r = run(&mut s, "sketch.construction", json!({ "curves": all }));
assert_eq!(r["construction"], false);
assert_eq!(profiles(&mut s), 5);
// A mixed selection becomes construction as a whole.
run(&mut s, "sketch.construction", json!({"curves": ["l1"]}));
let r = run(&mut s, "sketch.construction", json!({"curves": ["l1", "l2", "l3", "l4"]}));
assert_eq!(r["construction"], true);
assert!(sketch(&s).curves.iter().filter(|c| ["l1", "l2", "l3", "l4"].contains(&c.id.as_str())).all(|c| c.construction));
assert!(s.execute("sketch.construction", &json!({"curves": ["nope"]})).is_err());
}
#[test]
fn centerline_can_become_construction_directly() {
let mut s = new_sketch();
let l = curve_ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]})));
run(&mut s, "sketch.centerline", json!({ "curves": l, "value": true }));
let r = run(&mut s, "sketch.construction", json!({ "curves": l, "value": true }));
assert_eq!(r["changed"], 1);
let sk = sketch(&s);
assert!(sk.curves.iter().filter(|c| l.contains(&c.id)).all(|c| c.construction && !c.centerline));
// And back to a centerline.
run(&mut s, "sketch.centerline", json!({ "curves": l, "value": true }));
let sk = sketch(&s);
assert!(sk.curves.iter().filter(|c| l.contains(&c.id)).all(|c| c.centerline && !c.construction));
}
#[test]
fn projected_geometry_can_become_construction() {
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": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
let id = sketch(&s).curves.iter().find(|c| c.link.is_some()).map(|c| c.id.clone()).expect("projected");
let r = run(&mut s, "sketch.construction", json!({ "curves": [id.clone()] }));
assert_eq!(r["changed"], 1);
let sk = sketch(&s);
assert!(sk.curves.iter().any(|c| c.id == id && c.construction && c.link.is_some()));
}
// ---------------------------------------------------------------------------------------------
// Two-entity constraints: the first pick stays, the second moves
/// Positions of an entity's points (and its radius).
fn snap(s: &Session, id: &str) -> (Vec<solvecraft_geom::Vec2>, Option<f64>) {
let sk = sketch(s);
if let Some(i) = sk.curve_index(id) {
let pts = sk.curves[i].kind.point_ids().into_iter().filter_map(|p| sk.point(p)).collect();
return (pts, sk.radius(i));
}
(vec![pt(s, id)], None)
}
fn same(a: &(Vec<solvecraft_geom::Vec2>, Option<f64>), b: &(Vec<solvecraft_geom::Vec2>, Option<f64>)) -> bool {
a.0.len() == b.0.len() && a.0.iter().zip(&b.0).all(|(p, q)| p.dist(*q) < 1e-9) && a.1.zip(b.1).is_none_or(|(x, y)| (x - y).abs() < 1e-9)
}
/// A free line l1 (5,3)–(45,3) and a circle c1 at (20,18) with radius 5.
fn line_and_circle() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 18], "radius": 5}));
s
}
/// Apply `cmd` with `a` then `b`; the first must not move, the second must.
fn check_order(mut s: Session, cmd: &str, a: &str, b: &str) {
let (a0, b0) = (snap(&s, a), snap(&s, b));
run(&mut s, cmd, json!({"a": a, "b": b}));
assert!(same(&snap(&s, a), &a0), "{cmd}: first `{a}` moved: {a0:?} -> {:?}", snap(&s, a));
assert!(!same(&snap(&s, b), &b0), "{cmd}: second `{b}` did not move");
}
#[test]
fn tangent_moves_only_the_second_pick() {
// Line then circle: only the circle moves (and keeps its size).
let mut s = line_and_circle();
let l0 = snap(&s, "l1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
assert!(same(&snap(&s, "l1"), &l0), "line moved: {:?}", snap(&s, "l1"));
let c = snap(&s, "c1");
assert!((c.1.unwrap_or(0.0) - 5.0).abs() < 1e-9 && (c.0[0].y - 8.0).abs() < 1e-6, "{c:?}");
// Circle then line: only the line moves.
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let l0 = snap(&s, "l1");
let c0 = snap(&s, "c1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "l1"), &l0));
assert!(!same(&snap(&s, "c1"), &c0));
// Fixed first: the second moves as usual.
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entity": "c1"}));
let c0 = snap(&s, "c1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "c1"), &c0));
}
#[test]
fn two_fully_constrained_entities_conflict_and_nothing_moves() {
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fully_constrained"] == true)), "{si}");
let before = sketch(&s);
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1"}));
assert!(e.is_err(), "{e:?}");
assert_eq!(sketch(&s), before);
}
#[test]
fn line_pairs_keep_the_first_line() {
let lines = || {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]], "connect": false}));
run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]}));
s
};
let len = |s: &Session, l: &str| {
let p = snap(s, l).0;
p[0].dist(p[1])
};
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.equal", "sketch.constraint.collinear"] {
check_order(lines(), cmd, "l1", "l2");
check_order(lines(), cmd, "l2", "l1");
// The second line turns and slides; only Equal changes its length.
let mut s = lines();
let l0 = len(&s, "l2");
run(&mut s, cmd, json!({"a": "l1", "b": "l2"}));
if cmd != "sketch.constraint.equal" {
assert!((len(&s, "l2") - l0).abs() < 1e-6, "{cmd}: {l0} -> {}", len(&s, "l2"));
} else {
assert!((len(&s, "l2") - len(&s, "l1")).abs() < 1e-6);
}
}
}
#[test]
fn circle_pairs_keep_the_first_circle() {
let circles = || {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 10], "radius": 8}));
s
};
for cmd in ["sketch.constraint.concentric", "sketch.constraint.equal", "sketch.constraint.tangent"] {
check_order(circles(), cmd, "c1", "c2");
check_order(circles(), cmd, "c2", "c1");
}
// Equal resizes the second to the first.
let mut s = circles();
run(&mut s, "sketch.constraint.equal", json!({"a": "c2", "b": "c1"}));
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
}
#[test]
fn point_constraints_keep_the_first_pick() {
let pts = || {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 25]]}));
s
};
check_order(pts(), "sketch.constraint.coincident", "l2.start", "l1.end");
check_order(pts(), "sketch.constraint.coincident", "l1.end", "l2.start");
// Curve picked first, point second: the point goes onto the line.
check_order(pts(), "sketch.constraint.coincident", "l1", "l2.start");
// Midpoint: the point stays, the line moves.
let mut s = pts();
let p0 = snap(&s, "l2.end");
run(&mut s, "sketch.constraint.midpoint", json!({"point": "l2.end", "line": "l1"}));
assert!(same(&snap(&s, "l2.end"), &p0));
// Symmetry: the first point and the line stay, the second mirrors.
let mut s = pts();
run(&mut s, "sketch.point", json!({"point": [3, 7]}));
run(&mut s, "sketch.point", json!({"point": [4, -9]}));
let (a0, l0) = (snap(&s, "p1"), snap(&s, "l1"));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
assert!(same(&snap(&s, "p1"), &a0) && same(&snap(&s, "l1"), &l0));
assert!(pt(&s, "p2").dist(solvecraft_geom::Vec2::new(3.0, -7.0)) < 1e-6, "{:?}", pt(&s, "p2"));
}
#[test]
fn coincident_line_pairs_resolve_ties_independently_of_pick_order() {
for (a, b) in [("l1", "l2"), ("l2", "l1")] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,10],[30,10]],"infer":false,"connect":false}));
run(&mut s, "sketch.line", json!({"points":[[10,20],[30,20]],"infer":false,"connect":false}));
run(&mut s, "sketch.constraint.coincident", json!({"a":a,"b":b}));
let sk = sketch(&s);
let p = sk.resolve_point("l1.start").unwrap();
let q = sk.resolve_point("l2.start").unwrap();
assert!(sk.point(p).unwrap().dist(sk.point(q).unwrap()) < 1e-6);
let count = sk.constraints.len();
let result = run(&mut s, "sketch.constraint.coincident", json!({"a":b,"b":a}));
assert_eq!(result["existing"], true);
assert_eq!(sketch(&s).constraints.len(), count);
}
}
#[test]
fn coincident_refuses_invalid_curve_pairs_atomically() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,10],[30,10]],"infer":false,"connect":false}));
run(&mut s, "sketch.circle.center", json!({"center":[40,40],"radius":5}));
let before = serde_json::to_value(&s.doc).unwrap();
let undo = s.undo.len();
for (a, b, message) in [("l1", "l1", "distinct lines"), ("l1", "c1", "straight lines"), ("c1", "l1", "straight lines")] {
let error = s.execute("sketch.constraint.coincident", &json!({"a":a,"b":b})).unwrap_err();
assert!(error.to_string().contains(message), "{error}");
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
assert_eq!(s.undo.len(), undo);
}
}
// ---------------------------------------------------------------------------------------------
// Sketch Palette options
#[test]
fn palette_options_are_kept_with_each_sketch() {
let mut s = new_sketch();
let o = run(&mut s, "sketch.options", json!({}));
assert_eq!(o["options"]["show_dimensions"], true);
assert_eq!(o["options"]["slice"], false);
let o = run(&mut s, "sketch.options", json!({"show_dimensions": false, "snap": false, "slice": true, "sketch_3d": true}));
assert_eq!(o["options"]["show_dimensions"], false);
assert_eq!(o["options"]["snap"], false);
assert_eq!(o["options"]["slice"], true);
let v = sketch(&s).view;
assert!(v.hide_dimensions && v.no_snap && v.slice && v.three_d && !v.hide_points);
// Another sketch starts with the defaults; the first keeps its options after finishing.
let first = s.active_sketch.expect("sketch");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
assert_eq!(sketch(&s).view, solvecraft_sketch::SketchView::default());
let o = run(&mut s, "sketch.options", json!({"sketch": first}));
assert_eq!(o["options"]["show_dimensions"], false);
// Saved with the design.
let text = serde_json::to_string(&s.doc.sketch(first).expect("first").clone()).expect("json");
let back: Sketch = serde_json::from_str(&text).expect("parse");
assert!(back.view.slice);
assert!(s.execute("sketch.options", &json!({"bogus": true})).is_err());
// Undo restores the option.
run(&mut s, "sketch.options", json!({"show_points": false}));
run(&mut s, "edit.undo", json!({}));
assert!(!sketch(&s).view.hide_points);
}
// ---------------------------------------------------------------------------------------------
// Dragging
fn drag(s: &mut Session, e: &str, from: [f64; 2], to: [f64; 2]) {
run(s, "sketch.drag", json!({"entity": e, "from": from, "to": to}));
}
fn v(x: f64, y: f64) -> solvecraft_geom::Vec2 {
solvecraft_geom::Vec2::new(x, y)
}
#[test]
fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
drag(&mut s, "l1", [25.0, 15.0], [30.0, 25.0]);
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9 && pt(&s, "l1.end").dist(v(45.0, 30.0)) < 1e-9);
drag(&mut s, "l1.end", [45.0, 30.0], [50.0, 0.0]);
assert!(pt(&s, "l1.end").dist(v(50.0, 0.0)) < 1e-9);
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9);
}
#[test]
fn dragging_a_rectangle_side_moves_that_side_only() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [40, 30]}));
// l2 is the right side (x = 40): dragged right, it stays vertical, the left side stays.
let left = snap(&s, "l4");
drag(&mut s, "l2", [40.0, 20.0], [50.0, 20.0]);
assert!(same(&snap(&s, "l4"), &left), "left side moved");
let (a, b) = (pt(&s, "l2.start"), pt(&s, "l2.end"));
assert!((a.x - 50.0).abs() < 1e-6 && (b.x - 50.0).abs() < 1e-6, "{a:?} {b:?}");
}
#[test]
fn dragging_circles_and_arcs() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
// The edge changes the radius about a fixed centre; the centre moves the circle.
drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
assert!(pt(&s, "c1.center").dist(v(0.0, 0.0)) < 1e-9);
drag(&mut s, "c1.center", [0.0, 0.0], [20.0, 5.0]);
assert!(pt(&s, "c1.center").dist(v(20.0, 5.0)) < 1e-9);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
drag(&mut s, "a1", [57.07, 7.07], [50.0 + 200f64.sqrt(), 200f64.sqrt()]);
assert!(pt(&s, "a1.center").dist(v(50.0, 0.0)) < 1e-9);
assert!((snap(&s, "a1").1.unwrap_or(0.0) - 20.0).abs() < 1e-9, "{:?}", snap(&s, "a1"));
assert!(pt(&s, "a1.start").dist(v(70.0, 0.0)) < 1e-9, "{:?}", pt(&s, "a1.start"));
}
#[test]
fn constrained_geometry_resists_drags() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end"]}));
let l0 = snap(&s, "l1");
drag(&mut s, "l1", [25.0, 10.0], [25.0, 30.0]);
assert!(same(&snap(&s, "l1"), &l0));
// A horizontal line dragged at its end stays horizontal; its length dimension holds.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
drag(&mut s, "l1.end", [40.0, 10.0], [45.0, 20.0]);
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
}
#[test]
fn fully_constrained_feedback_is_per_entity() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 3);
for c in si["curves"].as_array().expect("curves") {
assert_eq!(c["fully_constrained"], c["type"] == "line", "{c}");
}
// Colours follow: fully constrained lines in the fixed colour, the free circle in blue.
let sk = sketch(&s);
let st = s.model.state();
let ss = st.sketch(s.active_sketch.expect("sketch")).expect("solved");
let lines = crate::view::sketch_lines(&sk, &ss.plane, true, &ss.report.curve_determined);
assert!(lines.iter().filter(|l| l.1 == crate::view::colors::SKETCH_FIXED).count() >= 4);
assert!(lines.iter().any(|l| l.1 == crate::view::colors::SKETCH));
}
// ---------------------------------------------------------------------------------------------
// Selection inside sketches
#[test]
fn chain_follows_shared_and_coincident_ends() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.line", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 0], "radius": 3}));
let chain = |s: &mut Session, c: &str| -> Vec<String> {
let v = run(s, "sketch.chain", json!({"curve": c}));
v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
};
assert_eq!(chain(&mut s, "l3"), ["l3", "l1", "l2", "l4"]);
assert_eq!(chain(&mut s, "l5"), ["l5"]);
assert_eq!(chain(&mut s, "c1"), ["c1"]);
// Joined by a coincident constraint, the two lines are one chain.
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.end", "b": "l6.start"}));
assert_eq!(chain(&mut s, "l6"), ["l6", "l5"]);
assert!(s.execute("sketch.chain", &json!({"curve": "nope"})).is_err());
}
#[test]
fn constraints_and_dimensions_can_be_selected_and_deleted_together() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "connect": false}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
let ids: Vec<String> = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect();
assert_eq!(ids.len(), 2);
let items: Vec<Value> = ids.iter().map(|id| json!({"type": "sketch_constraint", "id": id})).collect();
run(&mut s, "select.set", json!({ "items": items }));
assert_eq!(s.selection.len(), 2);
run(&mut s, "selection.delete", json!({"items": items}));
assert!(sketch(&s).constraints.is_empty());
}
// ---------------------------------------------------------------------------------------------
// 3D sketch
#[test]
fn three_d_curves_need_the_option_and_move_joined() {
let mut s = new_sketch();
assert!(s.execute("sketch.line3d", &json!({"points": [[0, 0, 0], [0, 0, 10]]})).is_err());
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 10], [10, 0, 10]]}));
let ids = curve_ids(&r);
assert_eq!(ids.len(), 2);
run(&mut s, "sketch.point3d", json!({"point": [5, 5, 5]}));
let sp = run(&mut s, "sketch.spline3d", json!({"points": [[10, 0, 10], [15, 5, 12], [20, 0, 20]]}));
let sid = curve_ids(&sp)[0].clone();
let sk = sketch(&s);
let w = &sk.wires[sk.wire_index(&sid).expect("spline")];
assert!(w.pts.len() > 10);
assert!(w.pts.first().expect("p").dist(solvecraft_geom::Vec3::new(10.0, 0.0, 10.0)) < 1e-12);
assert!(w.pts.last().expect("p").dist(solvecraft_geom::Vec3::new(20.0, 0.0, 20.0)) < 1e-12);
// The shared corner moves for both lines (and the spline that starts there).
let r = run(&mut s, "sketch.move3d", json!({"wire": ids[1], "index": 1, "by": [0, 0, 5]}));
assert_eq!(r["moved"], 2);
let r = run(&mut s, "sketch.move3d", json!({"wire": ids[0], "index": 1, "to": [0, 3, 12]}));
assert_eq!(r["moved"], 2);
let sk = sketch(&s);
let fit = |id: &str| sk.wires[sk.wire_index(id).expect("w")].fit.clone();
assert_eq!(fit(&ids[0])[1], fit(&ids[1])[0]);
assert!(s.execute("sketch.move3d", &json!({"wire": "nope", "to": [0, 0, 0]})).is_err());
assert!(s.execute("sketch.line3d", &json!({"points": [[0, 0, 0], [0, 0, 0]]})).is_err());
assert!(s.execute("sketch.line3d", &json!({"points": [[0, 0, 0], [f64::NAN, 0, 0]]})).is_err());
// Deleting works like any sketch entity.
run(&mut s, "sketch.delete", json!({"entities": [sid]}));
assert!(sketch(&s).wire_index(&curve_ids(&sp)[0]).is_none());
}
#[test]
fn pipe_and_sweep_follow_3d_sketch_curves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 30], [20, 0, 30]]}));
let mut path = curve_ids(&r);
let sp = run(&mut s, "sketch.spline3d", json!({"points": [[20, 0, 30], [35, 10, 35], [50, 0, 50]]}));
path.extend(curve_ids(&sp));
run(&mut s, "sketch.finish", json!({}));
let p = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
let si = run(&mut s, "document.inspect", json!({"measure": true}));
let body = si["bodies"].as_array().and_then(|b| b.first()).cloned().unwrap_or_default();
// The tube runs from the first line's start to the spline's end (radius 2 around them; the
// end cap is square to the spline, so it reaches up to 2 past the end point).
let (lo, hi) = (&body["bbox"]["min"], &body["bbox"]["max"]);
let near = |v: &Value, x: f64| (v.as_f64().unwrap_or(f64::NAN) - x).abs() < 0.5;
let within = |v: &Value, a: f64, b: f64| v.as_f64().is_some_and(|x| x > a - 0.1 && x < b + 0.1);
assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && within(&hi[0], 50.0, 52.0) && within(&hi[2], 50.0, 52.0), "{p} {body}");
assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}");
}