From 0dee9deec15e24f732eb6f329c3aaee73eb37317 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Mon, 12 Oct 2026 20:31:35 -0400 Subject: [PATCH] Sketch constraints accept x / y for the sketch's own axes (#394) (#448) `sketch.constraint.symmetry` (line), `parallel`, `perpendicular`, `collinear`, `tangent` and `coincident` (point on curve) now take `x` / `y` for the sketch's own axes, matching what `solid.revolve` means by lowercase x|y. A real curve with that id still wins. An axis becomes a linked construction line on it: a new `LinkSource::SketchAxis` resolved in sketch coordinates, so it stays on the sketch's axis on any plane, is shared by every constraint on that axis, is left out of profiles and is reported in `created`. Equal, concentric, midpoint and horizontal/vertical keep refusing axes, since the axis line's finite length or fixed direction would make them meaningless. Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb Co-authored-by: Claude Opus 5.5 --- crates/doc/src/project.rs | 3 + crates/engine/src/cmd/sketch.rs | 61 +++-- .../engine/src/cmd/sketch_axis_ref_tests.rs | 238 ++++++++++++++++++ crates/sketch/src/lib.rs | 2 +- crates/sketch/src/link.rs | 45 ++++ 5 files changed, 332 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_axis_ref_tests.rs diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index b3a17dea..51da7fda 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -259,6 +259,9 @@ fn resolve_uncached( _ => vec![LinkGeom::Point(o)], } } + LinkSource::SketchAxis { name } => { + vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?] + } LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?, LinkSource::Plane { name } => { let pr = diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 6888bd16..b2d4c732 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -117,12 +117,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CONSTRAINTS") .icon("c_coincident") .enabled(in_sketch) - .params("a, b: points, a point and a curve, or two distinct lines (joins their closest endpoints)"), + .params("a, b: points, a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints)"), CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent) .at("SKETCH", "CONSTRAINTS") .icon("c_tangent") .enabled(in_sketch) - .params("a, b: curves"), + .params("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"), CommandSpec::new("sketch.constraint.equal", "Equal", c_equal) .at("SKETCH", "CONSTRAINTS") .icon("c_equal") @@ -132,12 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CONSTRAINTS") .icon("c_parallel") .enabled(in_sketch) - .params("a, b: lines"), + .params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"), CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular) .at("SKETCH", "CONSTRAINTS") .icon("c_perpendicular") .enabled(in_sketch) - .params("a, b: lines"), + .params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"), CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix) .at("SKETCH", "CONSTRAINTS") .icon("c_fix") @@ -157,12 +157,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CONSTRAINTS") .icon("c_collinear") .enabled(in_sketch) - .params("a, b: lines"), + .params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"), CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry) .at("SKETCH", "CONSTRAINTS") .icon("c_symmetry") .enabled(in_sketch) - .params("a, b: points, line: symmetry line"), + .params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created)"), CommandSpec::new("sketch.construction", "Normal/Construction", construction) .at("SKETCH", "CREATE") .icon("construction") @@ -454,6 +454,22 @@ pub(super) fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) - sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`"))) } +/// A line a constraint refers to: a curve id, or `x` / `y` for the sketch's own axes (a curve +/// with that id wins). An axis becomes the sketch's linked construction line on it +/// ([`Sketch::sketch_axis_line`]), added on first use and reported in `created`. +pub(super) fn line_or_axis_ref(sk: &mut Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result { + let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a curve id, or x / y for a sketch axis")))?; + curve_or_axis(sk, id)?.ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}` (x and y name the sketch's own axes)"))) +} + +/// Curve `id`, or the line of sketch axis `id` (see [`line_or_axis_ref`]); `None` when neither. +fn curve_or_axis(sk: &mut Sketch, id: &str) -> Result> { + match sk.curve_index(id) { + Some(c) => Ok(Some(c)), + None => Ok(sk.sketch_axis_line(id)?), + } +} + pub(super) fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result { let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a point reference")))?; sk.resolve_point(id).ok_or_else(|| bad(cmd, format!("unknown sketch point `{id}`"))) @@ -1154,7 +1170,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result { // --------------------------------------------------------------------------------------------- // Constraints -fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result) -> Result { +fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result) -> Result { let mut before = None; let (id, info) = edit(s, p, cmd, true, |sk, _| { let k = f(sk)?; @@ -1179,7 +1195,7 @@ pub(super) enum Ent { } fn ent(sk: &Sketch, r: &str) -> Option { - sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).map(Ent::Curve)) + sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).or_else(|| sk.sketch_axis_curve(r)).map(Ent::Curve)) } /// The entities of a two-entity constraint in the order they were picked (`a` then `b`, or @@ -1372,15 +1388,18 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result { } let aref = p.get("a").and_then(Value::as_str).unwrap_or_default(); let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?; - // A curve picked first and a point second: the point goes onto the curve. - if let (None, Some(c), Some(q)) = (sk.resolve_point(aref), sk.curve_index(aref), sk.resolve_point(bref)) { + // A curve (or sketch axis) picked first and a point second: the point goes onto it. + if sk.resolve_point(aref).is_none() + && let Some(q) = sk.resolve_point(bref) + && let Some(c) = curve_or_axis(sk, aref)? + { return Ok(ConstraintKind::PointOnCurve { p: q, c }); } let a = point_ref(sk, p.get("a"), cmd, "a")?; if let Some(b) = sk.resolve_point(bref) { return Ok(ConstraintKind::Coincident { p: a, q: b }); } - let c = sk.curve_index(bref).ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?; + let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?; Ok(ConstraintKind::PointOnCurve { p: a, c }) }) } @@ -1420,20 +1439,27 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> { Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?)) } +/// Two curves where either may be a sketch axis (`x` / `y`, see [`line_or_axis_ref`]). +fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> { + Ok((line_or_axis_ref(sk, p.get("a"), cmd, "a")?, line_or_axis_ref(sk, p.get("b"), cmd, "b")?)) +} + fn c_tangent(s: &mut Session, p: &Value) -> Result { - constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b })) + constrain(s, p, "sketch.constraint.tangent", |sk| { + two_curves_or_axes(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }) + }) } fn c_equal(s: &mut Session, p: &Value) -> Result { constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b })) } fn c_parallel(s: &mut Session, p: &Value) -> Result { constrain(s, p, "sketch.constraint.parallel", |sk| { - two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b }) + two_curves_or_axes(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b }) }) } fn c_perpendicular(s: &mut Session, p: &Value) -> Result { constrain(s, p, "sketch.constraint.perpendicular", |sk| { - two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b }) + two_curves_or_axes(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b }) }) } fn c_concentric(s: &mut Session, p: &Value) -> Result { @@ -1443,7 +1469,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result { } fn c_collinear(s: &mut Session, p: &Value) -> Result { constrain(s, p, "sketch.constraint.collinear", |sk| { - two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b }) + two_curves_or_axes(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b }) }) } fn c_midpoint(s: &mut Session, p: &Value) -> Result { @@ -1455,7 +1481,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result { fn c_symmetry(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.constraint.symmetry"; let (ids, info) = edit(s, p, cmd, true, |sk, _| { - let l = curve_ref(sk, p.get("line"), cmd, "line")?; + let l = line_or_axis_ref(sk, p.get("line"), cmd, "line")?; let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?; let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?; let rep = solve(&mut sk.clone()); @@ -2119,6 +2145,9 @@ mod rect_tests; #[path = "sketch_ux_tests.rs"] mod ux_tests; +#[cfg(test)] +#[path = "sketch_axis_ref_tests.rs"] +mod axis_ref_tests; #[cfg(test)] #[path = "sketch_drag_live_tests.rs"] mod drag_live_tests; diff --git a/crates/engine/src/cmd/sketch_axis_ref_tests.rs b/crates/engine/src/cmd/sketch_axis_ref_tests.rs new file mode 100644 index 00000000..2f239792 --- /dev/null +++ b/crates/engine/src/cmd/sketch_axis_ref_tests.rs @@ -0,0 +1,238 @@ +//! Constraints that take a line accept `x` / `y` for the sketch's own axes (issue #394): the +//! axis becomes one linked construction line on it, shared by every constraint on that axis, +//! reported in `created`, and left out of profiles. A real curve with that id wins. + +use serde_json::{Value, json}; +use solidcraft_geom::{Vec2, Vec3}; +use solidcraft_sketch::{CurveKind, LinkSource, Sketch}; + +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 { + s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone() +} + +fn on(plane: Value) -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({ "plane": plane })); + s +} + +fn pt(sk: &Sketch, r: &str) -> Vec2 { + sk.point(sk.resolve_point(r).unwrap()).unwrap() +} + +fn strs(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +/// The sketch's axis lines: (axis name, curve id). +fn axis_lines(sk: &Sketch) -> Vec<(String, String)> { + sk.links + .iter() + .filter_map(|l| match &l.source { + LinkSource::SketchAxis { name } => Some((name.clone(), l.id.clone())), + _ => None, + }) + .flat_map(|(name, link)| sk.link_curves(&link).into_iter().map(move |c| (name.clone(), sk.curves[c].id.clone()))) + .collect() +} + +fn assert_axis_line(sk: &Sketch, id: &str, dir: Vec2) { + let c = sk.curve_index(id).unwrap(); + let curve = &sk.curves[c]; + assert!(curve.construction, "axis line {id} is construction"); + assert!(sk.is_linked_curve(c), "axis line {id} is linked"); + let CurveKind::Line { a, b } = curve.kind else { panic!("axis line {id} is a line") }; + for p in [sk.point(a).unwrap(), sk.point(b).unwrap()] { + assert!(p.cross(dir).abs() < 1e-9, "{id} end {p:?} lies on the axis {dir:?}"); + } +} + +#[test] +fn issue_394_symmetry_about_the_sketch_y_axis() { + let mut s = on(json!("XY")); + run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]})); + let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"})); + assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}"); + let sk = sketch(&s); + let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end")); + assert!((start.x + end.x).abs() < 1e-7 && (start.y - end.y).abs() < 1e-7, "mirrored about y: {start:?} {end:?}"); + assert!((start.x - -4.0).abs() < 1e-7, "the first entity stays: {start:?}"); + let axes = axis_lines(&sk); + assert_eq!(axes.len(), 1, "{axes:?}"); + let (name, line) = &axes[0]; + assert_eq!(name, "y"); + assert_axis_line(&sk, line, Vec2::Y); + assert_eq!(strs(&r["created"]["curves"]), vec![line.clone()], "the axis line is reported in created: {r}"); + assert_eq!(strs(&r["created"]["constraints"]).len(), 1, "{r}"); + + // A second constraint on the same axis reuses its line. + run(&mut s, "sketch.line", json!({"points": [[-3, 5], [8, 5]], "ids": ["b"]})); + let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "b.start", "b": "b.end", "line": "y"})); + assert!(strs(&r["created"]["curves"]).is_empty(), "{r}"); + assert_eq!(axis_lines(&sketch(&s)).len(), 1); +} + +#[test] +fn a_real_curve_named_like_an_axis_wins() { + let mut s = on(json!("XY")); + run(&mut s, "sketch.line", json!({"points": [[5, -20], [5, 20]], "ids": ["y"]})); + run(&mut s, "sketch.constraint.fix", json!({"entity": "y"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [12, 0]], "ids": ["a"]})); + run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"})); + let sk = sketch(&s); + assert!(axis_lines(&sk).is_empty(), "no axis line when a curve is called y"); + let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end")); + assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}"); +} + +#[test] +fn a_rectangle_centred_on_the_origin_keeps_one_profile() { + let mut s = on(json!("XY")); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 1], "p1": [12, 7]})); + let sk = sketch(&s); + let lines = strs(&r["curves"]); + let dir = |id: &String| { + let CurveKind::Line { a, b } = sk.curves[sk.curve_index(id).unwrap()].kind else { panic!() }; + sk.point(b).unwrap() - sk.point(a).unwrap() + }; + let horizontal = lines.iter().find(|l| dir(l).y.abs() < 1e-9).unwrap().clone(); + let vertical = lines.iter().find(|l| dir(l).x.abs() < 1e-9).unwrap().clone(); + run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{horizontal}.start"), "b": format!("{horizontal}.end"), "line": "y"})); + run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{vertical}.start"), "b": format!("{vertical}.end"), "line": "x"})); + let sk = sketch(&s); + let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY); + let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); + for l in &lines { + for end in ["start", "end"] { + let p = pt(&sk, &format!("{l}.{end}")); + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + } + assert!((lo.x + hi.x).abs() < 1e-7 && (lo.y + hi.y).abs() < 1e-7, "centred on the origin: {lo:?} {hi:?}"); + assert_eq!(axis_lines(&sk).len(), 2); + // The axis lines cross the rectangle but are construction: still one profile. + assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], json!(1)); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); +} + +#[test] +fn sketch_axes_on_a_plane_off_the_world_axes() { + // A plane whose own axes are none of the world axes: x along world Y, y along world Z, + // origin away from the world origin. + let origin = Vec3::new(5.0, 7.0, 9.0); + let mut s = on(json!({"origin": [5, 7, 9], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})); + run(&mut s, "sketch.line", json!({"points": [[3, -6], [3, 2]], "ids": ["a"]})); + let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "x"})); + assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}"); + let sk = sketch(&s); + let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end")); + assert!((start.y + end.y).abs() < 1e-7 && (start.x - end.x).abs() < 1e-7, "mirrored about the sketch x axis: {start:?} {end:?}"); + let axes = axis_lines(&sk); + assert_eq!(axes.len(), 1); + assert_axis_line(&sk, &axes[0].1, Vec2::X); + + // After a recompute the axis line is still the sketch's x axis, in world space too. + run(&mut s, "sketch.finish", json!({})); + let st = s.model.state(); + let id = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap(); + let ss = st.sketch(id).unwrap(); + let c = ss.sketch.curve_index(&axes[0].1).unwrap(); + let CurveKind::Line { a, b } = ss.sketch.curves[c].kind else { panic!() }; + for p in [a, b] { + let w = ss.plane.to_world(ss.sketch.point(p).unwrap()) - origin; + assert!(w.x.abs() < 1e-9 && w.z.abs() < 1e-9, "on the plane's x axis (world Y through its origin): {w:?}"); + } +} + +#[test] +fn other_line_constraints_take_sketch_axes() { + let mut s = on(json!("XZ")); + run(&mut s, "sketch.line", json!({"points": [[1, 1], [6, 3]], "ids": ["p"]})); + run(&mut s, "sketch.constraint.parallel", json!({"a": "p", "b": "x"})); + let sk = sketch(&s); + assert!((pt(&sk, "p.start").y - pt(&sk, "p.end").y).abs() < 1e-7, "parallel to x"); + + run(&mut s, "sketch.line", json!({"points": [[-2, 1], [-1, 6]], "ids": ["q"]})); + run(&mut s, "sketch.constraint.perpendicular", json!({"a": "x", "b": "q"})); + let sk = sketch(&s); + assert!((pt(&sk, "q.start").x - pt(&sk, "q.end").x).abs() < 1e-7, "perpendicular to x"); + + run(&mut s, "sketch.line", json!({"points": [[2, -5], [4, -8]], "ids": ["r"]})); + run(&mut s, "sketch.constraint.collinear", json!({"a": "r", "b": "y"})); + let sk = sketch(&s); + assert!(pt(&sk, "r.start").x.abs() < 1e-7 && pt(&sk, "r.end").x.abs() < 1e-7, "on the y axis"); + + run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"})); + run(&mut s, "sketch.constraint.tangent", json!({"a": "c", "b": "x"})); + let sk = sketch(&s); + let c = sk.curve_index("c").unwrap(); + let CurveKind::Circle { c: centre, r } = sk.curves[c].kind else { panic!() }; + assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "tangent to x"); + + // A point onto an axis, either pick order. + run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "u"})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "u", "b": "x"})); + assert!(pt(&sketch(&s), "u").y.abs() < 1e-7); + run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "v"})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "y", "b": "v"})); + assert!(pt(&sketch(&s), "v").x.abs() < 1e-7); + + // Still one line per axis. + let mut names: Vec = axis_lines(&sketch(&s)).into_iter().map(|(n, _)| n).collect(); + names.sort(); + assert_eq!(names, ["x", "y"]); +} + +#[test] +fn the_axis_survives_save_and_load() { + let mut s = on(json!("YZ")); + run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]})); + run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"})); + run(&mut s, "sketch.finish", json!({})); + let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let id = doc.find_feature("Sketch1").map(|f| f.id).unwrap(); + let sk = doc.sketch(id).unwrap(); + assert_eq!(axis_lines(sk).len(), 1); + assert_eq!(sk.links.iter().map(|l| &l.source).collect::>(), [&LinkSource::SketchAxis { name: "y".into() }]); +} + +#[test] +fn bad_line_references_are_errors_and_change_nothing() { + let mut s = on(json!("XY")); + run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]})); + run(&mut s, "sketch.line", json!({"points": [[0, 3], [9, 4]], "ids": ["b"]})); + let before = s.doc.to_json(); + let hostile = + [json!("Y"), json!("X"), json!("z"), json!(""), json!("x.start"), json!(" y"), json!(5), json!(null), json!(["y"]), json!({"axis": "y"})]; + for line in &hostile { + assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "a.start", "b": "a.end", "line": line})).is_err(), "symmetry line {line}"); + for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.collinear", "sketch.constraint.tangent"] { + assert!(s.execute(cmd, &json!({"a": "b", "b": line})).is_err(), "{cmd} b {line}"); + } + assert!(s.execute("sketch.constraint.coincident", &json!({"a": "b.start", "b": line})).is_err(), "coincident b {line}"); + } + // Constraints that do not take an axis still refuse x / y, and leave no axis line behind. + for cmd in ["sketch.constraint.equal", "sketch.constraint.concentric"] { + assert!(s.execute(cmd, &json!({"a": "b", "b": "x"})).is_err(), "{cmd}"); + } + assert!(s.execute("sketch.constraint.midpoint", &json!({"point": "b.start", "line": "y"})).is_err()); + assert!(s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "x"})).is_err()); + assert_eq!(s.doc.to_json(), before, "failed commands change nothing"); + // A constraint the sketch refuses rolls the axis line back with it. + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"})); + let before_redundant = s.doc.to_json(); + assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal"); + assert_eq!(s.doc.to_json(), before_redundant); + assert!(axis_lines(&sketch(&s)).is_empty()); +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 5b86ccac..05a9f42c 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -42,7 +42,7 @@ pub use curves::{ pub use decompose::Budget; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; -pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; +pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView}; pub use offset::OffsetChain; pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology}; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index cb716f8d..846e9c04 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -55,6 +55,9 @@ pub enum LinkSource { Origin, /// An origin axis (`X`, `Y`, `Z`). Axis { name: String }, + /// One of the sketch's own axes (`x` or `y`): the line through the sketch origin along it, + /// whatever plane the sketch is on (see [`sketch_axis_geom`]). + SketchAxis { name: String }, /// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the /// sketch plane. Plane { name: String }, @@ -90,6 +93,7 @@ impl LinkSource { LinkSource::SketchPoint { point, .. } => format!("sketch point {point}"), LinkSource::Origin => "origin".into(), LinkSource::Axis { name } => format!("{name} axis"), + LinkSource::SketchAxis { name } => format!("sketch {name} axis"), LinkSource::Plane { name } => format!("{name} plane"), LinkSource::OnSurface { curve, .. } => format!("{curve} on a face"), LinkSource::Intersection { a, b } => format!("intersection of {} and {}", a.describe(), b.describe()), @@ -100,6 +104,19 @@ impl LinkSource { } } +/// Half length of the line a sketch axis link produces. +pub const SKETCH_AXIS_HALF: f64 = 100.0; + +/// The geometry of sketch axis `name` (`x` or `y`, sketch coordinates); `None` for any other name. +pub fn sketch_axis_geom(name: &str) -> Option { + let u = match name { + "x" => Vec2::X, + "y" => Vec2::Y, + _ => return None, + }; + Some(LinkGeom::Line(Vec2::ZERO - u * SKETCH_AXIS_HALF, u * SKETCH_AXIS_HALF)) +} + /// One link and its state. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Link { @@ -417,6 +434,34 @@ impl Sketch { self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.lost) } + /// The line of sketch axis `name` (`x` or `y`) when the sketch already has one (see + /// [`Sketch::sketch_axis_line`]). + pub fn sketch_axis_curve(&self, name: &str) -> Option { + self.links + .iter() + .filter(|l| matches!(&l.source, LinkSource::SketchAxis { name: n } if n == name)) + .find_map(|l| self.link_curves(&l.id).into_iter().find(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. })))) + } + + /// The line of sketch axis `name` (`x` or `y`) that constraints can refer to: a linked + /// construction line on the axis (it stays there and is left out of profiles), shared by + /// every reference to that axis and added on first use. `None` when `name` is not a sketch + /// axis. + pub fn sketch_axis_line(&mut self, name: &str) -> Result> { + let Some(geom) = sketch_axis_geom(name) else { return Ok(None) }; + if let Some(c) = self.sketch_axis_curve(name) { + return Ok(Some(c)); + } + let id = self.add_link(LinkKind::Project, LinkSource::SketchAxis { name: name.into() }, &[geom])?; + let curves = self.link_curves(&id); + for c in &curves { + if let Some(cu) = self.curves.get_mut(*c) { + cu.construction = true; + } + } + curves.first().copied().map(Some).ok_or_else(|| SketchError::Invalid(format!("sketch {name} axis"))) + } + /// Add linked geometry. Returns the link id; the new curve and point ids are /// [`Sketch::link_curves`] / [`Sketch::link_points`] of it. pub fn add_link(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom]) -> Result {