//! 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 solidcraft_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, solidcraft_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(solidcraft_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(solidcraft_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) -> solidcraft_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"), solidcraft_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(solidcraft_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, solidcraft_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); } /// Construction Geometries is a display option of one sketch, saved with it, and it changes /// nothing about the geometry: construction curves were already outside every profile, and /// they still are while hidden. Centerlines are a line type of their own and keep showing. A /// design written before the option existed carries no key for it and loads with construction /// geometry shown. #[test] fn hiding_construction_geometry_is_per_sketch_saved_and_leaves_profiles_and_centerlines_alone() { use crate::view::sketch_curve_visible; let mut s = new_sketch(); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); run(&mut s, "sketch.line", json!({"points": [[0, 45], [40, 45]], "connect": false, "infer": false})); run(&mut s, "sketch.line", json!({"points": [[0, 55], [40, 55]], "connect": false, "infer": false})); run(&mut s, "sketch.construction", json!({"curves": ["l5"], "value": true})); run(&mut s, "sketch.centerline", json!({"curves": ["l6"], "value": true})); let (rect, construction, centerline) = (0, 4, 5); let profiles = run(&mut s, "sketch.inspect", json!({}))["profiles"].clone(); assert_eq!(profiles.as_array().map(Vec::len), Some(1), "the rectangle is one profile: {profiles}"); assert_eq!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true); let o = run(&mut s, "sketch.options", json!({"show_construction": false})); assert_eq!(o["options"]["show_construction"], false); let sk = sketch(&s); assert!(sk.view.hide_construction); assert!(!sketch_curve_visible(&sk, construction), "construction geometry is hidden"); assert!(sketch_curve_visible(&sk, centerline), "a centerline is its own line type"); assert!(sketch_curve_visible(&sk, rect), "ordinary geometry stays"); // Hiding is display only: the curves, their line types and the profiles are untouched. let after = run(&mut s, "sketch.inspect", json!({})); assert_eq!(after["profiles"], profiles); assert_eq!(after["curves"][construction]["construction"], true); assert!(sketch(&s).curves[centerline].centerline, "the centerline keeps its line type"); // Undo puts the option back. run(&mut s, "edit.undo", json!({})); assert!(!sketch(&s).view.hide_construction); run(&mut s, "sketch.options", json!({"show_construction": false})); // Another sketch shows construction geometry; the first keeps the option 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!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true); assert_eq!(run(&mut s, "sketch.options", json!({"sketch": first}))["options"]["show_construction"], false); // Saved with the design. let text = serde_json::to_string(s.doc.sketch(first).expect("first")).expect("json"); assert!(text.contains("hide_construction"), "{text}"); let back: Sketch = serde_json::from_str(&text).expect("parse"); assert!(back.view.hide_construction); // An older design file has no `hide_construction` key at all: `SketchView`'s serde default // fills it in, and construction geometry shows, the way it did before the option existed. let older = r#"{"points":[{"id":"origin","pos":[0.0,0.0],"fixed":true}],"curves":[],"constraints":[], "view":{"hide_projected":true,"slice":true}}"#; let old: Sketch = serde_json::from_str(older).expect("an older sketch still loads"); assert!(old.view.hide_projected && old.view.slice && !old.view.hide_construction); } // --------------------------------------------------------------------------------------------- // 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) -> solidcraft_geom::Vec2 { solidcraft_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 dragging_a_conic_reads_rho_off_the_cursor_and_its_apex_slides_like_any_point() { let mut s = new_sketch(); run(&mut s, "sketch.conic", json!({"start": [0, 0], "end": [40, 0], "apex": [20, 20]})); let rho = |s: &Session| match sketch(s).curves[0].kind { solidcraft_sketch::CurveKind::Conic { rho, .. } => rho, ref k => panic!("not a conic: {k:?}"), }; assert!((rho(&s) - 0.5).abs() < 1e-9, "default rho: {}", rho(&s)); // Grabbed on the curve itself (its midpoint, at the default rho of 0.5): `rho` follows the // cursor, read off where it falls between the chord's midpoint (20, 0) and the apex (20, // 20). The three defining points stay exactly where they were. drag(&mut s, "k1", [20.0, 10.0], [20.0, 14.0]); assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after drag: {}", rho(&s)); assert!(pt(&s, "k1.start").dist(v(0.0, 0.0)) < 1e-9); assert!(pt(&s, "k1.end").dist(v(40.0, 0.0)) < 1e-9); assert!(pt(&s, "k1.apex").dist(v(20.0, 20.0)) < 1e-9); // Grabbed on the apex itself: it slides like any other point (not a `rho` edit), and `rho` // is unaffected. drag(&mut s, "k1.apex", [20.0, 20.0], [10.0, 30.0]); assert!(pt(&s, "k1.apex").dist(v(10.0, 30.0)) < 1e-9); assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after apex drag: {}", rho(&s)); } #[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:?}"); } /// How the arm's link lengths are set. #[derive(Clone, Copy, Debug)] enum Links { /// Length dimensions on both links. Dimensioned, /// Equal to fixed reference lines (no length dimension anywhere). EqualToFixed, } /// Two-link arm: shoulder `l1.start` fixed, |l1| = 30 and |l2| = 20, the hand `l2.end` dragged /// in one `sketch.drag` call along a straight path that stays clear of the singular poses. The /// elbow has two solutions for every hand position; a drag must keep the one it started on. /// 1000 deterministic cases (fixed-seed LCG). Returns (elbow flips, drags that stopped short). fn arm_drags(links: Links) -> (usize, usize) { use std::f64::consts::TAU; let (a, b) = (30.0, 20.0); let (lo, hi) = ((a - b) + 3.0, a + b - 3.0); let mut s = new_sketch(); // Away from the sketch origin so nothing connects to it. let o = v(200.0, 100.0); run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); match links { Links::Dimensioned => { run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a})); run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b})); } Links::EqualToFixed => { run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 100.0], [o.x + a, o.y - 100.0]], "ids": ["r1"]})); run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 120.0], [o.x + b, o.y - 120.0]], "ids": ["r2"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["r1", "r2"]})); run(&mut s, "sketch.constraint.equal", json!({"a": "r1", "b": "l1"})); run(&mut s, "sketch.constraint.equal", json!({"a": "r2", "b": "l2"})); } } let id = s.active_sketch.expect("sketch"); let sk0 = sketch(&s); assert_eq!(sk0.constraints.iter().any(|c| c.kind.is_dimension()), matches!(links, Links::Dimensioned)); let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}")); let (p1, p2, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.end")); let p2b = ix("l2.start"); let side = |sk: &Sketch| { let (x1, x2, x3) = (sk.points[p1].pos, sk.points[p2].pos, sk.points[p3].pos); (x3 - x1).cross(x2 - x1).signum() }; let mut seed = 11u64; let mut rnd = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 11) as f64 / (1u64 << 53) as f64 }; let (mut kept, mut flips, mut short) = (0, 0, 0); while kept < 1000 { let th1 = rnd() * TAU; let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 }; let v2 = o + v(a * th1.cos(), a * th1.sin()); let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin()); let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU); let to = o + v(tr * tt.cos(), tr * tt.sin()); let clear = |p: solidcraft_geom::Vec2| (lo..=hi).contains(&p.dist(o)); if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) { continue; } kept += 1; { let sk = s.doc_mut().sketch_mut(id).expect("sketch"); for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3)] { sk.points[p].pos = at; } } let before = side(&sketch(&s)); drag(&mut s, "l2.end", [v3.x, v3.y], [to.x, to.y]); let sk = sketch(&s); if side(&sk) != before { flips += 1; } if sk.points[p3].pos.dist(to) > 1e-6 { short += 1; } } (flips, short) } #[test] fn a_dragged_two_link_arm_keeps_its_elbow_side() { for links in [Links::Dimensioned, Links::EqualToFixed] { let (flips, short) = arm_drags(links); eprintln!("two-link arm ({links:?}), one-call drags: {flips}/1000 elbow flips, {short} stopped short"); assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 drags"); assert_eq!(short, 0, "{links:?}: {short}/1000 drags stopped short of the cursor"); } } /// Each step of a drag is seeded on the positions from before it, so even a drag made in one /// step (one held solve from the grab to the cursor) keeps the elbow of an arm whose links are /// dimensioned or held equal to fixed lines (the seed reads both). #[test] fn a_single_step_arm_drag_keeps_its_elbow_side() { for links in [Links::Dimensioned, Links::EqualToFixed] { let (flips, short) = super::drag::step_cap::with(1, || arm_drags(links)); eprintln!("two-link arm ({links:?}), one-step drags: {flips}/1000 elbow flips, {short} stopped short"); assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 one-step drags"); assert_eq!(short, 0, "{links:?}: {short}/1000 one-step drags stopped short of the cursor"); } } /// Two-link arm (shoulder fixed, |l1| = 30, |l2| = 20) with a thin triangle (sides 3.9, 2, 2) /// hanging off the hand: pinned there at one point, free to turn about it. The hand is dragged /// in one `sketch.drag` call along a straight path clear of the arm's singular poses, `n` /// deterministic cases with the triangle at random turns and on either side. Returns the drags /// that turned the triangle over. fn hanging_triangle_flips(n: usize) -> usize { use std::f64::consts::TAU; let (a, b) = (30.0, 20.0); let (lo, hi) = ((a - b) + 3.0, a + b - 3.0); let o = v(200.0, 100.0); let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a})); run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b})); let hand = pt(&s, "l2.end"); let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt())); run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]})); for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] { run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value})); } let id = s.active_sketch.expect("sketch"); let sk0 = sketch(&s); let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}")); let (p1, p2, p2b, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.start"), ix("l2.end")); let (t1a, t1b, t2a, t2b, t3a, t3b) = (ix("t1.start"), ix("t1.end"), ix("t2.start"), ix("t2.end"), ix("t3.start"), ix("t3.end")); let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum(); let mut seed = 23u64; let mut rnd = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 11) as f64 / (1u64 << 53) as f64 }; let (mut kept, mut flips) = (0, 0); while kept < n { let th1 = rnd() * TAU; let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 }; let v2 = o + v(a * th1.cos(), a * th1.sin()); let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin()); let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU); let to = o + v(tr * tt.cos(), tr * tt.sin()); let clear = |p: solidcraft_geom::Vec2| (lo..=hi).contains(&p.dist(o)); if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) { continue; } kept += 1; let turn = rnd() * TAU; let u = solidcraft_geom::Vec2::from_angle(turn); let normal = u.perp() * if rnd() < 0.5 { 1.0 } else { -1.0 }; let (tq, tr) = (v3 + u * 3.9, v3 + u * 1.95 + normal * (4.0f64 - 1.95 * 1.95).sqrt()); { let sk = s.doc_mut().sketch_mut(id).expect("sketch"); for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3), (t1a, v3), (t1b, tq), (t2a, tq), (t2b, tr), (t3a, tr), (t3b, v3)] { sk.points[p].pos = at; } } let before = side(&s); let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [v3.x, v3.y], "to": [to.x, to.y]})); assert_eq!(r["sketch"]["solved"], json!(true), "{r}"); assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?} vs {to:?}: {r}", pt(&s, "l2.end")); if side(&s) != before { flips += 1; } } flips } /// A rigid cluster pinned at a single point is placed by its reference pose (turned about the /// pin, never reflected): a thin triangle hanging off a dragged arm's hand never turns over, /// however few steps the drag takes. #[test] fn a_triangle_hanging_off_a_dragged_point_keeps_its_side() { let flips: Vec<(usize, usize)> = [1, 2, 4, usize::MAX].into_iter().map(|cap| (cap, super::drag::step_cap::with(cap, || hanging_triangle_flips(100)))).collect(); eprintln!("thin triangle on a dragged hand, (step cap, turned over /100): {flips:?}"); assert!(flips.iter().all(|(_, f)| *f == 0), "{flips:?}"); } /// Two-link arm with both ends fixed: the elbow is pinned to one of two places. Its drags (one /// step each) to `n` cursors across the shoulder-hand line all fail their held solve and leave /// the elbow placed there; returns how many of the sketch's own solves that follow put it on /// the other side. `move_point`: moved by `sketch.move_point` rather than `sketch.drag`. fn pinned_elbow_flips(n: usize, move_point: bool) -> usize { let o = v(200.0, 100.0); let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y + 10.0], [o.x + 40.0, o.y]], "ids": ["l1", "l2"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l2.end"]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); let start = s.doc.clone(); let side = |s: &Session| (pt(s, "l2.end") - pt(s, "l1.start")).cross(pt(s, "l1.end") - pt(s, "l1.start")).signum(); let before = side(&s); let mut flips = 0; for i in 0..n { s.doc = start.clone(); let elbow = pt(&s, "l1.end"); // Across the line, spread along it. let to = o + v(-10.0 + 60.0 * i as f64 / n as f64, -2.0 - 20.0 * ((i * 7) % n) as f64 / n as f64); let r = if move_point { run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [to.x, to.y]})) } else { run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [elbow.x, elbow.y], "to": [to.x, to.y]})) }; assert_eq!(r["sketch"]["solved"], json!(true), "{r}"); if side(&s) != before { flips += 1; } } flips } /// A drag whose held solve fails leaves the step placed for the sketch's own solve, which keeps /// the orientation of the last solved state: an elbow pinned by both ends of its arm, dragged /// across the shoulder-hand line in one step, stays on its side. #[test] fn a_failed_drag_step_keeps_the_last_solved_orientation() { for move_point in [false, true] { let flips = super::drag::step_cap::with(1, || pinned_elbow_flips(50, move_point)); eprintln!("pinned elbow, one-step moves across the line (move_point {move_point}): {flips}/50 flips"); assert_eq!(flips, 0, "move_point {move_point}"); } } /// After a failed step the drag follows as far as the geometry can, and `reached` says how far: /// an arm's hand (reach 50) dragged in one step from 49 to 50.5 from its shoulder gets about /// two thirds of the way. #[test] fn a_drag_follows_up_to_where_its_step_fails() { let o = v(200.0, 100.0); let mut s = new_sketch(); // Elbow bent so the hand is 49 from the shoulder, along +x. let c = (30.0f64 * 30.0 + 49.0 * 49.0 - 20.0 * 20.0) / (2.0 * 30.0 * 49.0); let elbow = o + v(30.0 * c, 30.0 * (1.0 - c * c).sqrt()); run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [o.x + 49.0, o.y]], "ids": ["l1", "l2"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); assert_eq!(drag_steps(&s, "l2.end", [o.x + 49.0, o.y], [o.x + 50.5, o.y]), 1); let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 49.0, o.y], "to": [o.x + 50.5, o.y]})); let reached = r["reached"].as_f64().unwrap_or(f64::NAN); eprintln!("arm hand past its reach in one step: {r}"); assert!((1.0 / 1.5 - 1.0 / 16.0..=1.0 / 1.5).contains(&reached), "{r}"); assert_eq!(r["sketch"]["solved"], json!(true), "{r}"); } /// The test-only step cap limits the steps a drag takes, not the plan tests read, and is /// restored when the capped code panics. #[test] fn the_step_cap_leaves_the_plan_and_is_restored() { let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[100, 0], [120, 0]], "ids": ["l1"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20})); let uncapped = super::drag::step_cap::get(); let plan = drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0]); assert!(plan > 1); assert_eq!(super::drag::step_cap::with(1, || drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0])), plan); assert_eq!(super::drag::step_cap::get(), uncapped); assert!(std::panic::catch_unwind(|| super::drag::step_cap::with(1, || panic!("capped"))).is_err()); assert_eq!(super::drag::step_cap::get(), uncapped); } /// Stored (counter-clockwise) sweep of an arc, degrees in [0, 360). fn sweep_deg(s: &Session, arc: &str) -> f64 { let sk = sketch(s); let Some(solidcraft_sketch::CurveKind::Arc { c, a, b }) = sk.curve_index(arc).map(|i| sk.curves[i].kind.clone()) else { panic!("no arc {arc}") }; let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos); ((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU).to_degrees() } /// A free arc of radius 10 about a fixed centre (50, 50), from angle 0 sweeping `sweep`°. fn free_arc(sweep: f64) -> Session { let mut s = new_sketch(); run(&mut s, "sketch.arc.center_point", json!({"center": [50, 50], "start": [60, 50], "sweep": sweep, "id": "a1"})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["a1.center"]})); s } fn on_arc(deg: f64) -> [f64; 2] { let t = deg.to_radians(); [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()] } /// An arc's end dragged through its start never wraps the sweep (15° → 0° → 345°, or /// 330° → 360° → 30°): the arc stops short, in one call or in many. #[test] fn a_dragged_arc_end_never_wraps_through_its_start() { // One call, clockwise through the start: 90° → -60°. let mut s = free_arc(90.0); drag(&mut s, "a1.end", on_arc(90.0), on_arc(-60.0)); let sw = sweep_deg(&s, "a1"); eprintln!("free arc, CW one call 90° → -60°: sweep {sw:.1}°"); assert!(sw > 0.0 && sw < 30.0, "CW one call: sweep {sw}°"); // One call, counter-clockwise past a full turn: 330° → 390°. let mut s = free_arc(330.0); drag(&mut s, "a1.end", on_arc(330.0), on_arc(390.0)); let sw = sweep_deg(&s, "a1"); eprintln!("free arc, CCW one call 330° → 390°: sweep {sw:.1}°"); assert!(sw > 330.0 && sw < 360.0, "CCW one call: sweep {sw}°"); // 30° calls, clockwise: 90 → -60. let mut s = free_arc(90.0); let mut last = 90.0; let mut log = Vec::new(); for deg in [60.0, 30.0, 0.0, -30.0, -60.0] { drag(&mut s, "a1.end", on_arc(last), on_arc(deg)); last = deg; log.push(sweep_deg(&s, "a1")); } eprintln!("free arc, CW 30° calls: sweeps {log:.1?}"); assert!(log.windows(2).all(|w| w[1] <= w[0] + 1e-9) && log.iter().all(|&x| x > 0.0), "CW calls: {log:?}"); // 30° calls, counter-clockwise: 90 → 450. let mut s = free_arc(90.0); let mut last = 90.0; let mut log = Vec::new(); for deg in (4..=15).map(|i| (i * 30) as f64) { drag(&mut s, "a1.end", on_arc(last), on_arc(deg)); last = deg; log.push(sweep_deg(&s, "a1")); } eprintln!("free arc, CCW 30° calls: sweeps {log:.1?}"); assert!(log.windows(2).all(|w| w[1] >= w[0] - 1e-9) && log.iter().all(|&x| x < 360.0), "CCW calls: {log:?}"); assert!(log.last().is_some_and(|&x| x > 330.0), "CCW calls: {log:?}"); } /// A fillet (radius 5, tangent to both legs) whose second leg is dragged round the corner /// 90° → 174° → 14° → 90° in 2° calls stays inside the corner; with a 40 mm leg the leg turns /// over near 14° (the tangent length passes 40) and comes back on the way up. #[test] fn a_dragged_fillet_stays_in_its_corner() { for (leg, strict) in [(80.0, true), (40.0, false)] { let mut s = new_sketch(); let o = v(100.0, 100.0); run(&mut s, "sketch.line", json!({"points": [[o.x + 40.0, o.y], [o.x, o.y], [o.x, o.y + leg]], "ids": ["l1", "l2"]})); run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"})); let sk0 = sketch(&s); let arc = sk0.curves.iter().position(|c| matches!(c.kind, solidcraft_sketch::CurveKind::Arc { .. })).expect("fillet arc"); let arc_id = sk0.curves[arc].id.clone(); let inside = |s: &Session| { let sk = sketch(s); let side = |p: solidcraft_geom::Vec2, a: solidcraft_geom::Vec2, b: solidcraft_geom::Vec2| (b - a).cross(p - a).signum(); let c = sk.center(arc).expect("centre"); let (p0, k1, k2, p3) = (pt(s, "l1.start"), pt(s, "l1.end"), pt(s, "l2.start"), pt(s, "l2.end")); let r = sk.radius(arc).unwrap_or(0.0); side(c, p0, k1) == side(p3, p0, k1) && side(c, k2, p3) == side(p0, k2, p3) && (r - 5.0).abs() < 1e-6 }; assert!(inside(&s)); let mut path: Vec = (45..=87).map(|i| (i * 2) as f64).collect(); path.extend((7..=87).rev().map(|i| (i * 2) as f64)); path.extend((8..=45).map(|i| (i * 2) as f64)); let corner = o; let at = |deg: f64| { let t = deg.to_radians(); [corner.x + leg * t.cos(), corner.y + leg * t.sin()] }; let mut bad = Vec::new(); let mut last = at(90.0); for deg in path { let to = at(deg); drag(&mut s, "l2.end", last, to); last = to; let sw = sweep_deg(&s, &arc_id); if !(inside(&s) && sw < 180.0) { bad.push(deg); } } if strict { assert!(bad.is_empty(), "leg {leg}: bad states at {bad:?}"); } assert!(inside(&s) && sweep_deg(&s, &arc_id) < 180.0, "leg {leg}: did not recover (bad at {bad:?})"); } } /// Hostile `from` / `to` are refused and change nothing; a zero-length drag changes nothing. #[test] fn hostile_drags_change_nothing() { let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]})); let l0 = snap(&s, "l1"); for bad in [json!(f64::NAN), json!(f64::INFINITY), json!(1e300), json!(-1e300), json!("x"), json!([1.0]), json!(null)] { for (from, to) in [(json!([10, 10]), json!([bad.clone(), 0])), (json!([bad.clone(), 0]), json!([10, 10]))] { for e in ["l1", "l1.end"] { let r = s.execute("sketch.drag", &json!({"entity": e, "from": from.clone(), "to": to.clone()})); assert!(r.is_err(), "{e} {from} {to}: {r:?}"); assert!(same(&snap(&s, "l1"), &l0)); } } assert!(s.execute("sketch.move_point", &json!({"point": "l1.end", "to": [bad.clone(), 0]})).is_err()); assert!(same(&snap(&s, "l1"), &l0)); } drag(&mut s, "l1", [20.0, 10.0], [20.0, 10.0]); drag(&mut s, "l1.end", [40.0, 20.0], [40.0, 20.0]); run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 20]})); assert!(same(&snap(&s, "l1"), &l0)); // A huge (but finite, in-range) jump on a constrained group is capped and never panics. run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); assert_eq!(drag_steps(&s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]), 64); drag(&mut s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]); let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end")); assert!(a.is_finite() && b.is_finite(), "{a:?} {b:?}"); } /// The solves a drag of `entity` from `from` to `to` would take. fn drag_steps(s: &Session, entity: &str, from: [f64; 2], to: [f64; 2]) -> usize { let sk = sketch(s); let e = super::ent(&sk, entity).unwrap_or_else(|| panic!("no entity {entity}")); super::drag::drag_steps(&sk, e, v(from[0], from[1]), v(to[0], to[1])) } /// Continuation costs nothing on a normal mouse frame: a short move next to the geometry is one /// held solve, as it was before; only a long one is split. #[test] fn a_short_drag_is_a_single_solve() { let mut s = new_sketch(); // Ten dimensioned 20 × 10 rectangles, free to move (clear of the origin). for i in 0..10 { let x = 100.0 + 30.0 * i as f64; let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 20.0, 10]})); let c = curve_ids(&r); run(&mut s, "sketch.dimension", json!({"entities": [c[0]], "value": 20})); run(&mut s, "sketch.dimension", json!({"entities": [c[1]], "value": 10})); } assert_eq!(drag_steps(&s, "l1.end", [120.0, 0.0], [120.5, 0.3]), 1); assert_eq!(drag_steps(&s, "l1", [110.0, 0.0], [110.5, 0.5]), 1); drag(&mut s, "l1.end", [120.0, 0.0], [120.5, 0.3]); assert!(pt(&s, "l1.end").dist(v(120.5, 0.3)) < 1e-9); // A long move (more than an eighth of the 10 mm side) is split into steps. assert!(drag_steps(&s, "l1.end", [120.5, 0.3], [140.0, -20.0]) > 1); drag(&mut s, "l1.end", [120.5, 0.3], [140.0, -20.0]); assert!(pt(&s, "l1.end").dist(v(140.0, -20.0)) < 1e-9); } /// The drag group stops at fixed points, as the solve does: a tiny dimension on geometry that /// only shares a fixed point with the dragged geometry does not shrink its steps. #[test] fn the_drag_step_ignores_geometry_beyond_a_fixed_point() { let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]})); run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20})); let before = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]); run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.01, 100]], "ids": ["lb"]})); run(&mut s, "sketch.dimension", json!({"entities": ["lb"], "value": 0.01})); assert_eq!(drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]), before); assert_eq!(before, 1); // A free radius couples like a point: a line tangent to a circle whose centre is fixed // steps by the circle's radius (r 2: steps of 0.5 mm), its only link to the line. let mut s = new_sketch(); run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 2, "connect": false})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1.center"]})); run(&mut s, "sketch.line", json!({"points": [[90, 102], [110, 102]], "ids": ["lt"], "connect": false})); run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "lt"})); assert_eq!(drag_steps(&s, "lt.end", [110.0, 102.0], [110.0, 105.0]), 6); } /// The step counts every size the constraints hold, offsets along an axis too (the solver holds /// their size on either side), except a length between two points the drag holds: a dimensioned /// line slid by its middle keeps its length whatever the step. #[test] fn the_drag_step_counts_every_held_size_but_the_dragged_length() { let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.5, 100]], "ids": ["la"]})); run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 0.5})); let steps = drag_steps(&s, "la", [100.25, 100.0], [105.25, 100.0]); eprintln!("0.5 mm line slid 5 mm: {steps} steps"); assert_eq!(steps, 1); // B 0.05 mm right of A (horizontal dimension); A dragged 1 mm right in one call: B stays // right of A instead of jumping to the other side. let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[100, 100], [100, 110]], "ids": ["la"]})); run(&mut s, "sketch.line", json!({"points": [[100.05, 120], [110, 120]], "ids": ["lb"], "connect": false})); run(&mut s, "sketch.dimension", json!({"entities": ["la.start", "lb.start"], "type": "horizontal", "value": 0.05})); assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::DistanceX { .. }))); let steps = drag_steps(&s, "la.start", [100.0, 100.0], [101.0, 100.0]); drag(&mut s, "la.start", [100.0, 100.0], [101.0, 100.0]); let (a, b) = (pt(&s, "la.start"), pt(&s, "lb.start")); eprintln!("1 mm drag with a 0.05 mm DistanceX: {steps} steps; A {a:?} B {b:?}"); assert!(steps > 1); assert!(a.dist(v(101.0, 100.0)) < 1e-9 && (b.x - a.x - 0.05).abs() < 1e-6, "B went to the other side: {a:?} {b:?}"); } /// A circle shares only its centre with a dragged line's end (coincident): the solve couples /// its centre, never its radius, so a tiny circle does not shrink the step. #[test] fn a_circle_centred_on_a_dragged_end_does_not_set_the_step() { let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]})); run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20})); run(&mut s, "sketch.circle.center", json!({"center": [120, 100], "radius": 0.01})); assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Coincident { .. }))); let steps = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]); eprintln!("1 mm drag next to a 0.01 mm circle on the end: {steps} steps"); assert_eq!(steps, 1); } /// A one-call drag whose straight path crosses where the geometry cannot reach (an arm's hand /// through the hole round its shoulder) still lands on a cursor it can reach. #[test] fn a_drag_across_an_unreachable_middle_still_reaches_the_cursor() { let mut s = new_sketch(); let o = v(200.0, 100.0); run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y], [o.x + 30.0, o.y + 20.0]], "ids": ["l1", "l2"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); // From (30, 20) to (-30, -20) about the shoulder: the path passes through it. let to = [o.x - 30.0, o.y - 20.0]; let steps = drag_steps(&s, "l2.end", [o.x + 30.0, o.y + 20.0], to); let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 30.0, o.y + 20.0], "to": to})); eprintln!("arm hand across the hole: {steps} steps, {r}"); assert!(steps > 1); assert!(pt(&s, "l2.end").dist(v(to[0], to[1])) < 1e-6, "{:?}", pt(&s, "l2.end")); assert_eq!(r["reached"], json!(1.0)); } /// Past the unreachable stretch round its shoulder, an arm's drag keeps stepping (no one jump to /// the cursor), so a thin triangle hanging off the hand (it can turn over) keeps its side. #[test] fn a_drag_past_an_unreachable_stretch_keeps_stepping() { let mut s = new_sketch(); let o = v(200.0, 100.0); let elbow = o + v(19.702247520273062, 22.62347105662377); let hand = o + v(39.654744430607906, 21.245840103473398); let to = o + v(-28.82686238241851, -31.24580379872583); run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [hand.x, hand.y]], "ids": ["l1", "l2"]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); // A triangle with sides 3.9, 2 and 2 on the hand. let hand = pt(&s, "l2.end"); let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt())); run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]})); for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] { run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value})); } let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum(); let before = side(&s); let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [hand.x, hand.y], "to": [to.x, to.y]})); eprintln!("arm with a thin triangle past the hole: {r}, side {before} -> {}", side(&s)); assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?}", pt(&s, "l2.end")); assert_eq!(side(&s), before, "the triangle turned over"); assert_eq!(r["reached"], json!(1.0)); } /// The sweep guard stops an arc's end crossing its start, never mere closeness: an arc at /// 359.99999° or 1e-7 rad still lets its own end and geometry joined to it be dragged. #[test] fn arcs_near_a_full_turn_or_nothing_can_still_be_dragged() { // (sweep, a nudge of the end towards the inside of the sweep that keeps it within 1e-6 rad // of the limit). for (sweep, nudge) in [(359.99999, -5e-7), (1e-7_f64.to_degrees(), 5e-7)] { let mut s = free_arc(sweep); // A dimensioned line hangs off the arc's end. let end = pt(&s, "a1.end"); run(&mut s, "sketch.line", json!({"points": [[end.x, end.y], [end.x + 10.0, end.y + 10.0]], "ids": ["lc"], "connect": false})); run(&mut s, "sketch.constraint.coincident", json!({"a": "a1.end", "b": "lc.start"})); run(&mut s, "sketch.dimension", json!({"entities": ["lc"], "value": 200f64.sqrt()})); let sw0 = sweep_deg(&s, "a1"); // The arc's own end. let t = (end - v(50.0, 50.0)).angle() + nudge; let to = [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()]; drag(&mut s, "a1.end", [end.x, end.y], to); assert!(pt(&s, "a1.end").dist(v(to[0], to[1])) < 1e-9, "sweep {sweep}°: the arc's end did not move"); // The joined line's far end, which pulls the arc's end along (inwards). let far = pt(&s, "lc.end"); let target = v(far.x, far.y + 1e-5 * nudge.signum()); drag(&mut s, "lc.end", [far.x, far.y], [target.x, target.y]); assert!(pt(&s, "lc.end").dist(target) < 1e-9, "sweep {sweep}°: the joined line did not move"); assert!((sweep_deg(&s, "a1") - sw0).abs() < 1.0, "sweep {sweep}° became {}°", sweep_deg(&s, "a1")); } } #[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(solidcraft_geom::Vec3::new(10.0, 0.0, 10.0)) < 1e-12); assert!(w.pts.last().expect("p").dist(solidcraft_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}"); } /// Between two points, or on a line, that don't line up with an axis, an `auto` dimension takes /// its orientation from where its text goes: across from the two the distance between them, /// past that above or below them the horizontal distance, beside them the vertical one (#257). #[test] fn auto_dimension_orientation_follows_where_its_text_is_placed() { use solidcraft_sketch::ConstraintKind; // The ends of a 30 × 40 line: 50 long, and the slope keeps "across" and "past" distinct. let dimension = |end: [f64; 2], entities: Value, extra: Value| { let mut s = new_sketch(); run(&mut s, "sketch.line", json!({"points": [[0, 0], end], "connect": false})); let mut p = json!({"entities": entities}); if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { p.extend(extra.clone()); } let r = run(&mut s, "sketch.dimension", p); let sk = sketch(&s); let kind = sk.constraints.iter().find(|c| c.kind.is_dimension()).map(|c| c.kind.clone()).expect("a dimension"); (kind, r["measured"].as_f64().unwrap_or(f64::NAN), sk) }; let points = json!(["l1.start", "l1.end"]); for entities in [points.clone(), json!(["l1"])] { for (at, expected) in [ ([15.0, 60.0], "horizontal"), ([15.0, -20.0], "horizontal"), ([-30.0, 5.0], "vertical"), ([55.0, 38.0], "vertical"), ([-20.0, 25.0], "aligned"), ([45.0, 10.0], "aligned"), ([-20.0, -20.0], "aligned"), ] { let (kind, measured, _) = dimension([30.0, 40.0], entities.clone(), json!({"text_at": at})); let (got, value) = match kind { ConstraintKind::DistanceX { .. } => ("horizontal", 30.0), ConstraintKind::DistanceY { .. } => ("vertical", 40.0), ConstraintKind::Distance { .. } | ConstraintKind::Length { .. } => ("aligned", 50.0), other => panic!("{entities} at {at:?}: {other:?}"), }; assert_eq!(got, expected, "{entities} with its text at {at:?}"); assert!((measured - value).abs() < 1e-9, "{entities} at {at:?} measured {measured}"); } // No text, an explicit type, or two ends already on an axis keep today's dimension. let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({})); assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}"); let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "distance", "text_at": [15, 60]})); assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}"); let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "vertical", "text_at": [15, 60]})); assert!(matches!(kind, ConstraintKind::DistanceY { .. }), "{kind:?}"); let (kind, ..) = dimension([40.0, 0.0], json!(["l1.start", "l1.end"]), json!({"text_at": [60, 5]})); assert!(matches!(kind, ConstraintKind::Distance { .. }), "{kind:?}"); } // The chosen dimension drives that coordinate: 24 across X, the 40 in Y kept. let (_, _, sk) = dimension([30.0, 40.0], points, json!({"text_at": [15, 60], "value": 24})); let end = sk.point(sk.resolve_point("l1.end").expect("end")).expect("end point"); let start = sk.point(sk.resolve_point("l1.start").expect("start")).expect("start point"); assert!(((end.x - start.x).abs() - 24.0).abs() < 1e-6 && ((end.y - start.y).abs() - 40.0).abs() < 1e-6, "{start:?} {end:?}"); } /// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster /// decomposition places: pinned at F by two of its corners and held at G by the third. Every /// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on. fn hung_arm() -> Session { let mut s = new_sketch(); let o = v(200.0, 100.0); let at = |x: f64, y: f64| json!([o.x + x, o.y + y]); run(&mut s, "sketch.line", json!({"points": [at(0.0, 0.0), at(5.0, 15.0), at(20.0, 25.0), at(15.0, 8.0)], "ids": ["fa", "ab", "bc"]})); run(&mut s, "sketch.line", json!({"points": ["bc.end", "ab.start"], "ids": ["ca"]})); run(&mut s, "sketch.line", json!({"points": ["fa.start", "bc.end"], "ids": ["fc"]})); run(&mut s, "sketch.line", json!({"points": [at(40.0, 0.0), "ab.end"], "ids": ["gb"]})); run(&mut s, "sketch.line", json!({"points": ["ab.end", at(35.0, 40.0), at(50.0, 30.0)], "ids": ["be", "eh"]})); for id in ["fa", "ab", "bc", "ca", "fc", "gb", "be", "eh"] { run(&mut s, "sketch.dimension", json!({"entities": [id]})); } run(&mut s, "sketch.constraint.fix", json!({"entities": ["fa.start", "gb.start"]})); s } /// The elbow's side of the shoulder–hand line. fn hung_elbow_side(s: &Session) -> f64 { let b = pt(s, "ab.end"); (pt(s, "eh.end") - b).cross(pt(s, "be.end") - b).signum() } /// The hand of `hung_arm` swung by `angle` about the shoulder. fn hung_hand_target(s: &Session, angle: f64, scale: f64) -> solidcraft_geom::Vec2 { let (b, h) = (pt(s, "ab.end"), pt(s, "eh.end")); let r = h - b; let (c, si) = (angle.cos(), angle.sin()); b + v(r.x * c - r.y * si, r.x * si + r.y * c) * scale } /// A live drag whose seed runs out of decomposition budget takes the rest of the way in short /// steps (continuation keeps the elbow the seed no longer places). #[test] fn a_drag_out_of_decomposition_budget_takes_short_steps() { let start = hung_arm(); let before = hung_elbow_side(&start); let h = pt(&start, "eh.end"); let mut flips = 0; for k in [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0] { let mut s = hung_arm(); let to = hung_hand_target(&s, k * 0.7, 1.0); let planned = drag_steps(&s, "eh.end", [h.x, h.y], [to.x, to.y]); let r = super::drag::last_drag::with_frame_work(0, || { run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [to.x, to.y], "live": true})) }); let d = super::drag::last_drag::get(); assert!(d.cut, "{d:?}"); assert!(d.count > planned, "{planned} steps planned, {d:?}: the rest in short steps"); assert!(pt(&s, "eh.end").dist(to) < 1e-6, "{r}"); if hung_elbow_side(&s) != before { flips += 1; } } assert_eq!(flips, 0); } /// All the held solves of one live frame, halvings included, share one decomposition budget: /// together they do at most a frame's work, and one solve's more. #[test] fn a_live_drag_shares_one_decomposition_budget() { let mut s = hung_arm(); let h = pt(&s, "eh.end"); let near = hung_hand_target(&s, 0.01, 1.0); run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [near.x, near.y], "live": true})); let one = super::drag::last_drag::get(); assert!(one.count == 1 && one.work > 0 && !one.cut, "{one:?}"); // Swung round and out past the arm's reach: many steps, then halvings. let mut s = hung_arm(); let far = hung_hand_target(&s, 2.0, 1.5); let budget = 3 * one.work; super::drag::last_drag::with_frame_work(budget, || { run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [far.x, far.y], "live": true})) }); let d = super::drag::last_drag::get(); assert!(d.count > 6, "{d:?}"); assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}"); }