//! 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(¶m).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 { 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 = [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, Option) { 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, Option), b: &(Vec, Option)) -> 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 { 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 = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect(); assert_eq!(ids.len(), 2); let items: Vec = 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}"); }