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 {