From 73f68f3f50b23f44e68ecc6cf85232ce30d865f1 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 22:15:55 +0000 Subject: [PATCH] Sketch tools: Midpoint Line, Tangent Arc, 2-Tangent and 3-Tangent Circle, Center Point Slot, three-point and center-point Arc Slots; Trim, Extend, Break, sketch Fillet (radius dimension), equal/distance-angle/two-distance Chamfers, Offset (chains, side point), Mirror (symmetry constraints), Circular and Rectangular sketch patterns, Sketch Scale, sketch.move (move/rotate/copy) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/cmd/sketch.rs | 36 +- crates/engine/src/cmd/sketch_create.rs | 293 ++++++ crates/engine/src/cmd/sketch_modify.rs | 993 +++++++++++++++++++ crates/engine/src/cmd/sketch_modify_tests.rs | 207 ++++ crates/engine/src/tests.rs | 13 + crates/sketch/src/lib.rs | 2 + crates/sketch/src/profiles.rs | 92 +- crates/sketch/src/shape.rs | 166 ++++ docs/parity.md | 52 +- 10 files changed, 1723 insertions(+), 135 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_create.rs create mode 100644 crates/engine/src/cmd/sketch_modify.rs create mode 100644 crates/engine/src/cmd/sketch_modify_tests.rs create mode 100644 crates/sketch/src/shape.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1bfb710a..ab1ea1d1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -9,6 +9,8 @@ pub use features::auto_operation; mod file; mod inspect; mod sketch; +mod sketch_create; +mod sketch_modify; mod sketch_project; use serde::Serialize; @@ -106,6 +108,8 @@ pub fn in_sketch(s: &Session) -> std::result::Result<(), String> { pub fn command_specs() -> Vec<&'static CommandSpec> { let mut v: Vec<&'static CommandSpec> = Vec::new(); v.extend(sketch::COMMANDS.iter()); + v.extend(sketch_create::COMMANDS.iter()); + v.extend(sketch_modify::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index e30586b5..c1e12517 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -164,7 +164,7 @@ pub static COMMANDS: &[CommandSpec] = &[ // Helpers /// Resolve the sketch a command acts on: `sketch` param (id or name) or the active sketch. -fn target_sketch(s: &Session, p: &Value, cmd: &str) -> Result { +pub(super) fn target_sketch(s: &Session, p: &Value, cmd: &str) -> Result { if let Some(v) = p.get("sketch") { let key = match v { Value::Number(n) => n.to_string(), @@ -182,7 +182,7 @@ fn target_sketch(s: &Session, p: &Value, cmd: &str) -> Result { /// Edit a sketch: clone it, apply `f`, solve with the document's dimension values and store /// it. With `strict`, an edit that makes the constraints unsolvable is rejected. -fn edit( +pub(super) fn edit( s: &mut Session, p: &Value, cmd: &str, @@ -207,18 +207,18 @@ fn edit( /// A point argument: `[x, y]` (new point), a point reference string, or `{at, ref}`. #[derive(Clone)] -enum PArg { +pub(super) enum PArg { At(Vec2), Ref(usize, Vec2), } impl PArg { - fn pos(&self) -> Vec2 { + pub(super) fn pos(&self) -> Vec2 { match self { PArg::At(p) | PArg::Ref(_, p) => *p, } } - fn idx(&self) -> Option { + pub(super) fn idx(&self) -> Option { match self { PArg::Ref(i, _) => Some(*i), PArg::At(_) => None, @@ -226,7 +226,7 @@ impl PArg { } } -fn parg(sk: &Sketch, v: &Value, cmd: &str) -> Result { +pub(super) fn parg(sk: &Sketch, v: &Value, cmd: &str) -> Result { if let Some(p) = vec2(v) { return Ok(PArg::At(p)); } @@ -245,29 +245,29 @@ fn parg(sk: &Sketch, v: &Value, cmd: &str) -> Result { Err(bad(cmd, "a point must be [x, y], a point id like \"l1.end\", or {at, ref}")) } -fn req_parg(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result { +pub(super) fn req_parg(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result { parg(sk, p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?, cmd) } -fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result { +pub(super) fn curve_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 curve id")))?; sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`"))) } -fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result { +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}`"))) } -fn ids_of(sk: &Sketch, curves: &[usize]) -> Vec { +pub(super) fn ids_of(sk: &Sketch, curves: &[usize]) -> Vec { curves.iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect() } -fn add_c(sk: &mut Sketch, k: ConstraintKind) -> Result { +pub(super) fn add_c(sk: &mut Sketch, k: ConstraintKind) -> Result { Ok(sk.add_constraint(k, None)?) } -fn mark_construction(sk: &mut Sketch, curves: &[usize], p: &Value) { +pub(super) fn mark_construction(sk: &mut Sketch, curves: &[usize], p: &Value) { if bool_(p, "construction").unwrap_or(false) { for c in curves { if let Some(cu) = sk.curves.get_mut(*c) { @@ -277,7 +277,7 @@ fn mark_construction(sk: &mut Sketch, curves: &[usize], p: &Value) { } } -fn result(sk_out: (Vec, Vec), info: Value) -> Value { +pub(super) fn result(sk_out: (Vec, Vec), info: Value) -> Value { json!({"curves": sk_out.0, "constraints": sk_out.1, "sketch": info}) } @@ -529,7 +529,7 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result { Ok(result(out, info)) } -fn radius_arg(p: &Value, cmd: &str) -> Result { +pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result { let r = match (num(p, "radius"), num(p, "diameter")) { (Some(r), _) => r, (None, Some(d)) => d / 2.0, @@ -567,7 +567,7 @@ fn circle_two(s: &mut Session, p: &Value) -> Result { } /// Circle through three points. -fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> { +pub(super) fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> { let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); if d.abs() < 1e-12 { return None; @@ -727,7 +727,7 @@ fn polygon_edge(s: &mut Session, p: &Value) -> Result { polygon(s, p, cmd, center, rv, start, n, true) } -fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result { +pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result { let h = w / 2.0; let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?; let n = d.perp(); @@ -941,7 +941,7 @@ fn c_fix(s: &mut Session, p: &Value) -> Result { // --------------------------------------------------------------------------------------------- // Dimensions -fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> { +pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> { match sk.curves.get(l)?.kind { CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)), _ => None, @@ -1084,7 +1084,7 @@ fn angle_by_sector(a: usize, b: usize, (a0, a1): (Vec2, Vec2), (b0, b1): (Vec2, None } -fn round6(x: f64) -> f64 { +pub(super) fn round6(x: f64) -> f64 { (x * 1e6).round() / 1e6 } diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs new file mode 100644 index 00000000..86a57775 --- /dev/null +++ b/crates/engine/src/cmd/sketch_create.rs @@ -0,0 +1,293 @@ +//! More sketch creation tools: midpoint line, tangent arc, tangent circles, center-point and +//! arc slots. + +use serde_json::{Value, json}; +use solvecraft_geom::Vec2; +use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch}; + +use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot}; +use super::{CommandSpec, in_sketch}; +use crate::params::{bad, num, req_vec2, str_, string_list}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = + &[ + CommandSpec::new("SketchMidpointLine", "Midpoint Line", midpoint_line) + .at("SKETCH", "CREATE") + .icon("line_mid") + .enabled(in_sketch) + .params("mid: [x,y] or point ref, end: [x,y]; construction?"), + CommandSpec::new("ArcTangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params( + "start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)", + ), + CommandSpec::new("CircleTanTanRadius", "2-Tangent Circle", circle_tan_tan) + .at("SKETCH", "CREATE") + .icon("circle_tt") + .enabled(in_sketch) + .params("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)"), + CommandSpec::new("CircleThreeTangent", "3-Tangent Circle", circle_three_tan) + .at("SKETCH", "CREATE") + .icon("circle_ttt") + .enabled(in_sketch) + .params("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)"), + CommandSpec::new("ShapeSlotCenterPoint", "Center Point Slot", slot_center_point) + .at("SKETCH", "CREATE") + .icon("slot") + .enabled(in_sketch) + .params("center: slot centre, end: one arc centre, width"), + CommandSpec::new("ShapeArcSlotThreePoint", "Three Point Arc Slot", arc_slot_three) + .at("SKETCH", "CREATE") + .icon("arc_slot") + .enabled(in_sketch) + .params("start, through, end: points on the centre arc, width"), + CommandSpec::new("ShapeArcSlotCenterTwoPoint", "Center Point Arc Slot", arc_slot_center) + .at("SKETCH", "CREATE") + .icon("arc_slot") + .enabled(in_sketch) + .params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"), + ]; + +fn midpoint_line(s: &mut Session, p: &Value) -> Result { + let cmd = "SketchMidpointLine"; + let end = req_vec2(cmd, p, "end")?; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let m = req_parg(sk, p, "mid", cmd)?; + let start = m.pos() * 2.0 - end; + if start.dist(end) < 1e-9 { + return Err(bad(cmd, "the end must differ from the midpoint")); + } + let l = sk.add_line(start, end, None, None, None)?; + let mp = match m.idx() { + Some(i) => i, + None => sk.add_point(m.pos(), None)?, + }; + let k = add_c(sk, ConstraintKind::Midpoint { p: mp, l })?; + mark_construction(sk, &[l], p); + Ok((ids_of(sk, &[l]), vec![k])) + })?; + Ok(result(out, info)) +} + +/// Direction leaving curve `ci` at its end point `pi` (continuing the curve past that end). +fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { + match sk.curves.get(ci)?.kind { + CurveKind::Line { a, b } => { + let (pa, pb) = (sk.point(a)?, sk.point(b)?); + if pi == b { + (pb - pa).normalized() + } else if pi == a { + (pa - pb).normalized() + } else { + None + } + } + CurveKind::Arc { c, a, b } => { + let (pc, q) = (sk.point(c)?, sk.point(pi)?); + let ccw = (q - pc).perp().normalized()?; + if pi == b { + Some(ccw) + } else if pi == a { + Some(-ccw) + } else { + None + } + } + CurveKind::Circle { .. } => None, + } +} + +fn arc_tangent(s: &mut Session, p: &Value) -> Result { + let cmd = "ArcTangent"; + let end = req_vec2(cmd, p, "end")?; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let st = req_parg(sk, p, "start", cmd)?; + let pi = st.idx().ok_or_else(|| bad(cmd, "`start` must be the end point of a line or arc"))?; + let ci = match str_(p, "curve") { + Some(id) => sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?, + None => sk + .curves + .iter() + .enumerate() + .find(|(i, c)| !c.construction && leaving_dir(sk, *i, pi).is_some()) + .map(|(i, _)| i) + .ok_or_else(|| bad(cmd, "no line or arc ends at `start`"))?, + }; + let t = leaving_dir(sk, ci, pi).ok_or_else(|| bad(cmd, "`start` is not an end of that curve"))?; + let s0 = st.pos(); + let n = t.perp(); + let d = end - s0; + let den = 2.0 * n.dot(d); + if den.abs() < 1e-9 * d.len().max(1e-9) || d.len() < 1e-9 { + return Err(bad(cmd, "the end is straight ahead: draw a line instead")); + } + let center = s0 + n * (d.len2() / den); + let a = if den > 0.0 { + sk.add_arc(center, s0, end, [None, Some(pi), None], None)? + } else { + let a = sk.add_arc(center, end, s0, [None, None, Some(pi)], None)?; + if let Some(c) = sk.curves.get_mut(a) { + c.reversed = true; + } + a + }; + let k = add_c(sk, ConstraintKind::Tangent { a: ci, b: a })?; + Ok((ids_of(sk, &[a]), vec![k])) + })?; + Ok(result(out, info)) +} + +/// Initial radius for a circle near `at` touching the curves: the mean distance to them. +fn guess_radius(sk: &Sketch, curves: &[usize], at: Vec2) -> f64 { + let ds: Vec = curves.iter().filter_map(|c| sk.shape(*c)).map(|sh| sh.project(at).dist(at)).collect(); + let r = ds.iter().sum::() / ds.len().max(1) as f64; + if r.is_finite() && r > 1e-6 { r } else { 1.0 } +} + +fn tangent_curves(sk: &Sketch, p: &Value, cmd: &str, n: usize) -> Result> { + let ids = string_list(p, "curves"); + if ids.len() != n { + return Err(bad(cmd, format!("`curves` must list {n} curves"))); + } + ids.iter().map(|id| sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))).collect() +} + +/// A circle tangent to `curves`, solved from a start near `near`. +fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Option) -> Result { + let near = req_vec2(cmd, p, "near")?; + let (out, info) = edit(s, p, cmd, true, |sk, doc| { + let cs = tangent_curves(sk, p, cmd, n)?; + let r0 = match &radius { + Some(e) => doc.eval(e, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?, + None => guess_radius(sk, &cs, near), + }; + if !(r0 > 1e-9 && r0 < 1e8) { + return Err(bad(cmd, "radius must be positive")); + } + // Start where the circle already nearly touches: move the centre so the guessed circle + // sits between the curves. + let first_new = sk.points.len(); + let c = sk.add_circle(near, r0, None, None)?; + let mut cons = Vec::new(); + for k in &cs { + cons.push(add_c(sk, ConstraintKind::Tangent { a: *k, b: c })?); + } + if let Some(e) = &radius { + let pname = doc.new_model_param(e, "mm"); + let k = sk.add_constraint(ConstraintKind::Radius { c, value: r0 }, Some(pname))?; + cons.push(k); + } + solve_new_only(sk, first_new); + Ok((ids_of(sk, &[c]), cons)) + })?; + if info["solved"] != json!(true) { + return Err(bad(cmd, "no tangent circle there")); + } + Ok(result(out, info)) +} + +fn circle_tan_tan(s: &mut Session, p: &Value) -> Result { + let cmd = "CircleTanTanRadius"; + let r = match (crate::params::expr(p, "radius"), num(p, "diameter")) { + (Some(r), _) => r, + (None, Some(d)) => format!("{}", d / 2.0), + _ => return Err(bad(cmd, "needs `radius` or `diameter`")), + }; + tangent_circle(s, p, cmd, 2, Some(r)) +} + +fn circle_three_tan(s: &mut Session, p: &Value) -> Result { + tangent_circle(s, p, "CircleThreeTangent", 3, None) +} + +fn width(p: &Value, cmd: &str) -> Result { + num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive")) +} + +fn slot_center_point(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeSlotCenterPoint"; + let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?); + let w = width(p, cmd)?; + slot(s, p, cmd, c * 2.0 - e, e, w) +} + +/// Arc slot around `o` (centre-line radius `r`) from angle `t0` sweeping `sweep` (> 0). +fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, sweep: f64, w: f64) -> Result { + let h = w / 2.0; + if h >= r { + return Err(bad(cmd, "the slot is wider than its centre arc's diameter")); + } + if !(sweep > 1e-6 && sweep < std::f64::consts::TAU - 1e-6) { + return Err(bad(cmd, "the centre arc must have a length")); + } + let t1 = t0 + sweep; + let at = |t: f64, rr: f64| o + Vec2::from_angle(t) * rr; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let oc = sk.add_point(o, None)?; + let ps = sk.add_point(at(t0, r), None)?; + let pe = sk.add_point(at(t1, r), None)?; + let (os, is) = (sk.add_point(at(t0, r + h), None)?, sk.add_point(at(t0, r - h), None)?); + let (oe, ie) = (sk.add_point(at(t1, r + h), None)?, sk.add_point(at(t1, r - h), None)?); + let outer = sk.add_arc(o, at(t0, r + h), at(t1, r + h), [Some(oc), Some(os), Some(oe)], None)?; + let inner = sk.add_arc(o, at(t0, r - h), at(t1, r - h), [Some(oc), Some(is), Some(ie)], None)?; + let cap_e = sk.add_arc(at(t1, r), at(t1, r + h), at(t1, r - h), [Some(pe), Some(oe), Some(ie)], None)?; + let cap_s = sk.add_arc(at(t0, r), at(t0, r - h), at(t0, r + h), [Some(ps), Some(is), Some(os)], None)?; + let centre = sk.add_arc(o, at(t0, r), at(t1, r), [Some(oc), Some(ps), Some(pe)], None)?; + if let Some(c) = sk.curves.get_mut(centre) { + c.construction = true; + } + let mut cons = Vec::new(); + for (a, b) in [(outer, cap_e), (inner, cap_e), (outer, cap_s), (inner, cap_s)] { + cons.push(add_c(sk, ConstraintKind::Tangent { a, b })?); + } + cons.push(add_c(sk, ConstraintKind::Equal { a: cap_s, b: cap_e })?); + Ok((ids_of(sk, &[outer, cap_e, inner, cap_s]), cons)) + })?; + Ok(result(out, info)) +} + +fn arc_slot_three(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeArcSlotThreePoint"; + let (a, t, b) = (req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "through")?, req_vec2(cmd, p, "end")?); + let w = width(p, cmd)?; + let (o, r) = circumcircle(a, t, b).ok_or_else(|| bad(cmd, "the points are collinear"))?; + let ang = |q: Vec2| (q - o).angle(); + let span = |from: f64, to: f64| (to - from).rem_euclid(std::f64::consts::TAU); + // Counter-clockwise from start must pass the through point; otherwise go from the end. + let (t0, sweep) = if span(ang(a), ang(t)) < span(ang(a), ang(b)) { (ang(a), span(ang(a), ang(b))) } else { (ang(b), span(ang(b), ang(a))) }; + arc_slot(s, p, cmd, o, r, t0, sweep, w) +} + +fn arc_slot_center(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeArcSlotCenterTwoPoint"; + let (o, a, b) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "end")?); + let w = width(p, cmd)?; + let r = o.dist(a); + if r < 1e-9 || o.dist(b) < 1e-9 { + return Err(bad(cmd, "the points must differ from the centre")); + } + let t0 = (a - o).angle(); + let sweep = ((b - o).angle() - t0).rem_euclid(std::f64::consts::TAU); + arc_slot(s, p, cmd, o, r, t0, sweep, w) +} + +/// Solve moving only the points from `first_new` on (the geometry a tool just made), so the +/// existing drawing stays put when the new entities alone can satisfy the constraints. Falls +/// back to leaving everything to the normal solve. +pub(super) fn solve_new_only(sk: &mut Sketch, first_new: usize) { + let mut trial = sk.clone(); + for (i, p) in trial.points.iter_mut().enumerate() { + if i < first_new { + p.fixed = true; + } + } + if solvecraft_sketch::solve(&mut trial).ok() { + for (p, t) in sk.points.iter_mut().zip(&trial.points).skip(first_new) { + p.pos = t.pos; + } + for (c, t) in sk.curves.iter_mut().zip(&trial.curves) { + if let (CurveKind::Circle { r, .. }, CurveKind::Circle { r: tr, .. }) = (&mut c.kind, &t.kind) { + *r = *tr; + } + } + } +} diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs new file mode 100644 index 00000000..0c7f9b8f --- /dev/null +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -0,0 +1,993 @@ +//! Sketch modify tools: trim, extend, break, fillet, chamfer, offset, mirror, circular and +//! rectangular patterns, move/copy and scale. + +use std::f64::consts::TAU; + +use serde_json::{Value, json}; +use solvecraft_geom::Vec2; +use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections}; + +use super::sketch::{add_c, edit, ids_of}; +use super::{CommandSpec, in_sketch}; +use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("FilletSketchCmd", "Fillet", fillet) + .at("SKETCH", "MODIFY") + .icon("sketch_fillet") + .enabled(in_sketch) + .params("point: corner shared by two lines (\"l1.end\") | a, b: two lines meeting at a corner; radius: expr"), + CommandSpec::new("ChamferSketchEqualDistance", "Equal Distance Chamfer", chamfer_equal) + .at("SKETCH", "MODIFY") + .icon("sketch_chamfer") + .enabled(in_sketch) + .params("point | a, b: a corner of two lines; distance"), + CommandSpec::new("ChamferSketchDistanceAngle", "Distance and Angle Chamfer", chamfer_dist_angle) + .at("SKETCH", "MODIFY") + .icon("sketch_chamfer") + .enabled(in_sketch) + .params("point | a, b: a corner of two lines; distance: along a, angle: deg from a"), + CommandSpec::new("ChamferSketchDistanceDistance", "Two Distance Chamfer", chamfer_two) + .at("SKETCH", "MODIFY") + .icon("sketch_chamfer") + .enabled(in_sketch) + .params("point | a, b: a corner of two lines; distance: along a, distance2: along b"), + CommandSpec::new("Offset", "Offset", offset) + .at("SKETCH", "MODIFY") + .icon("offset") + .key("O") + .enabled(in_sketch) + .params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"), + CommandSpec::new("TrimSketchCmd", "Trim", trim) + .at("SKETCH", "MODIFY") + .icon("trim") + .key("T") + .enabled(in_sketch) + .params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"), + CommandSpec::new("ExtendSketchCmd", "Extend", extend) + .at("SKETCH", "MODIFY") + .icon("extend") + .enabled(in_sketch) + .params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"), + CommandSpec::new("BreakSketchCmd", "Break", break_cmd) + .at("SKETCH", "MODIFY") + .icon("break") + .enabled(in_sketch) + .params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"), + CommandSpec::new("SketchScaleCmd", "Sketch Scale", scale) + .at("SKETCH", "MODIFY") + .icon("scale") + .enabled(in_sketch) + .params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"), + CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy) + .enabled(in_sketch) + .params("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool"), + CommandSpec::new("MirrorSketchCommand", "Mirror", mirror) + .at("SKETCH", "CREATE") + .icon("sketch_mirror") + .enabled(in_sketch) + .params("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)"), + CommandSpec::new("CircularSketchPatternCommand", "Circular Pattern", pattern_circular) + .at("SKETCH", "CREATE") + .icon("sketch_circ_pattern") + .enabled(in_sketch) + .params("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)"), + CommandSpec::new("RectangularSketchPatternCommand", "Rectangular Pattern", pattern_rect) + .at("SKETCH", "CREATE") + .icon("sketch_rect_pattern") + .enabled(in_sketch) + .params("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)"), +]; + +const TOL: f64 = 1e-7; +const MAX_COPIES: usize = 500; + +fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result { + let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a curve id"))?; + let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?; + if sk.is_linked_curve(c) { + return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first"))); + } + Ok(c) +} + +/// Parameter of `p` on the bounded shape, ends included. +fn on_bounded(sh: &Shape, p: Vec2) -> Option { + if sh.dist(p) > TOL * 10.0 { + return None; + } + let t = sh.param(p); + let (t0, t1) = sh.range(); + let slack = match *sh { + Shape::Line { a, b } => TOL * 10.0 / a.dist(b).max(1e-12), + Shape::Round { r, .. } => TOL * 10.0 / r.max(1e-12), + }; + if sh.is_full() || (t >= t0 - slack && t <= t1 + slack) { Some(t) } else { None } +} + +/// Points where other curves meet curve `ci` (crossings and end points touching it), with the +/// curve that makes each one and the parameter on `ci`. +fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> { + let Some(sh) = sk.shape(ci) else { return Vec::new() }; + let mut out: Vec<(f64, Vec2, usize)> = Vec::new(); + for (oi, _) in sk.curves.iter().enumerate() { + if oi == ci { + continue; + } + let Some(other) = sk.shape(oi) else { continue }; + let mut pts = intersections(&sh, &other); + // Overlapping or touching ends that the analytic intersection misses (parallel lines). + if let Shape::Line { a, b } = other { + pts.extend([a, b]); + } + if let Shape::Round { sweep, .. } = other + && sweep < TAU - 1e-12 + { + pts.extend([other.point(0.0), other.point(sweep)]); + } + for q in pts { + if on_bounded(&other, q).is_some() + && let Some(t) = on_bounded(&sh, q) + && !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0) + { + out.push((t, q, oi)); + } + } + } + out +} + +/// Constrain a new end point onto the curve that cut there. +fn pin(sk: &mut Sketch, p: usize, cutter: usize) -> Result<()> { + let at = sk.point(p).unwrap_or_default(); + // An end point of the cutter there: coincident with it; otherwise on the curve. + let ends: Vec = match sk.curves.get(cutter).map(|c| c.kind.clone()) { + Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => vec![a, b], + _ => Vec::new(), + }; + if let Some(e) = ends.into_iter().find(|e| sk.point(*e).is_some_and(|q| q.dist(at) < TOL * 10.0)) { + if e != p { + add_c(sk, ConstraintKind::Coincident { p, q: e })?; + } + return Ok(()); + } + add_c(sk, ConstraintKind::PointOnCurve { p, c: cutter })?; + Ok(()) +} + +/// Split curve `ci` at interior parameters `ts` (sorted). Returns the pieces in parameter +/// order (the first keeps the original index) and the new points. +fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec, Vec)> { + let sh = sk.shape(ci).ok_or_else(|| bad("split", "curve"))?; + let kind = sk.curves.get(ci).map(|c| c.kind.clone()).ok_or_else(|| bad("split", "curve"))?; + let construction = sk.curves.get(ci).is_some_and(|c| c.construction); + let mut pts = Vec::new(); + for t in ts { + pts.push(sk.add_point(sh.point(*t), None)?); + } + let mut pieces = vec![ci]; + let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> { + let id = sk.fresh(prefix); + sk.curves.push(Curve { id, kind, construction, reversed: false, link: None }); + pieces.push(sk.curves.len() - 1); + Ok(()) + }; + match kind { + CurveKind::Line { a, b } => { + let mut chain = vec![a]; + chain.extend(&pts); + chain.push(b); + if let Some(c) = sk.curves.get_mut(ci) { + c.kind = CurveKind::Line { a, b: chain.get(1).copied().unwrap_or(b) }; + } + for w in chain.windows(2).skip(1) { + push(sk, CurveKind::Line { a: w[0], b: w[1] }, "l")?; + } + } + CurveKind::Arc { c, a, b } => { + let mut chain = vec![a]; + chain.extend(&pts); + chain.push(b); + if let Some(cu) = sk.curves.get_mut(ci) { + cu.kind = CurveKind::Arc { c, a, b: chain.get(1).copied().unwrap_or(b) }; + } + for w in chain.windows(2).skip(1) { + push(sk, CurveKind::Arc { c, a: w[0], b: w[1] }, "a")?; + } + } + CurveKind::Circle { c, .. } => { + if pts.len() < 2 { + return Err(bad("split", "a circle needs two points to split at")); + } + let mut chain = pts.clone(); + if let Some(f) = pts.first() { + chain.push(*f); + } + if let Some(cu) = sk.curves.get_mut(ci) { + cu.kind = CurveKind::Arc { c, a: chain[0], b: chain[1] }; + cu.reversed = false; + } + for w in chain.windows(2).skip(1) { + push(sk, CurveKind::Arc { c, a: w[0], b: w[1] }, "a")?; + } + } + } + Ok((pieces, pts)) +} + +/// Keep the pieces of a split tied together: lines collinear, arcs on one circle. +fn tie(sk: &mut Sketch, pieces: &[usize]) -> Result<()> { + for w in pieces.windows(2) { + let (a, b) = (w[0], w[1]); + let line = matches!(sk.curves.get(a).map(|c| &c.kind), Some(CurveKind::Line { .. })); + add_c(sk, if line { ConstraintKind::Collinear { a, b } } else { ConstraintKind::Equal { a, b } })?; + } + Ok(()) +} + +/// The cut parameters bracketing `tq` on curve `ci`: (before, after) with their cutter curves. +fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) { + let Some(sh) = sk.shape(ci) else { return (Vec::new(), false) }; + let (t0, t1) = sh.range(); + let full = sh.is_full(); + let span = t1 - t0; + let mut cuts: Vec<(f64, Vec2, usize)> = + cut_points(sk, ci).into_iter().filter(|(t, _, _)| full || (*t > t0 + 1e-9 * span && *t < t1 - 1e-9 * span)).collect(); + cuts.sort_by(|a, b| a.0.total_cmp(&b.0)); + if full { + if cuts.len() < 2 { + return (Vec::new(), true); + } + // Cuts around tq on the circle (wrapping). + let after = cuts.iter().position(|c| c.0 > tq).unwrap_or(0); + let before = (after + cuts.len() - 1) % cuts.len(); + let mut two = vec![cuts[before], cuts[after]]; + two.sort_by(|a, b| a.0.total_cmp(&b.0)); + return (two, true); + } + let before = cuts.iter().rev().find(|c| c.0 < tq).copied(); + let after = cuts.iter().find(|c| c.0 > tq).copied(); + (before.into_iter().chain(after).collect(), false) +} + +fn trim(s: &mut Session, p: &Value) -> Result { + let cmd = "TrimSketchCmd"; + let at = req_vec2(cmd, p, "at")?; + let (out, info) = edit(s, p, cmd, false, |sk, doc| { + let ci = curve_arg(sk, p, cmd)?; + let id = sk.curves.get(ci).map(|c| c.id.clone()).unwrap_or_default(); + let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?; + let tq = sh.param(sh.project(at)); + let (cuts, full) = bracket(sk, ci, tq); + if cuts.is_empty() { + // Nothing crosses it: the whole curve goes. + sk.remove_curves(&[ci]); + doc.prune_model_params(); + return Ok(json!({"deleted": id})); + } + let ts: Vec = cuts.iter().map(|c| c.0).collect(); + let (pieces, pts) = split_at(sk, ci, &ts)?; + // The piece containing tq. + let k = if full { + let (a, b) = (ts[0], ts[1]); + if tq > a && tq < b { 0 } else { 1 } + } else { + ts.iter().filter(|t| **t < tq).count() + }; + for (pi, c) in pts.iter().zip(&cuts) { + pin(sk, *pi, c.2)?; + } + let gone = pieces.get(k).copied().ok_or_else(|| bad(cmd, "piece"))?; + let keep: Vec = pieces.iter().copied().filter(|x| *x != gone).collect(); + // The original curve keeps its id (and constraints): swap geometry into it if needed. + let gone = match keep.first() { + Some(&other) if gone == ci => { + let ka = sk.curves.get(ci).map(|c| c.kind.clone()); + let kb = sk.curves.get(other).map(|c| c.kind.clone()); + if let (Some(ka), Some(kb)) = (ka, kb) { + if let Some(c) = sk.curves.get_mut(ci) { + c.kind = kb; + } + if let Some(c) = sk.curves.get_mut(other) { + c.kind = ka; + } + } + other + } + _ => gone, + }; + let keep: Vec = pieces.iter().copied().filter(|x| *x != gone).collect(); + tie(sk, &keep)?; + sk.remove_curves(&[gone]); + doc.prune_model_params(); + Ok(json!({"trimmed": id})) + })?; + Ok(json!({"result": out, "sketch": info})) +} + +fn break_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "BreakSketchCmd"; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let ci = curve_arg(sk, p, cmd)?; + let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?; + let (ts, cutters): (Vec, Vec>) = if let Some(list) = p.get("points").and_then(Value::as_array) { + let mut v: Vec = list.iter().take(1000).filter_map(vec2).map(|q| sh.param(sh.project(q))).collect(); + v.sort_by(|a, b| a.total_cmp(b)); + let n = v.len(); + (v, vec![None; n]) + } else { + let at = req_vec2(cmd, p, "at")?; + let (cuts, _) = bracket(sk, ci, sh.param(sh.project(at))); + (cuts.iter().map(|c| c.0).collect(), cuts.iter().map(|c| Some(c.2)).collect()) + }; + let (t0, t1) = sh.range(); + if ts.is_empty() || (!sh.is_full() && ts.iter().any(|t| *t <= t0 || *t >= t1)) { + return Err(bad(cmd, "nothing to break at there")); + } + let (pieces, pts) = split_at(sk, ci, &ts)?; + for (pi, c) in pts.iter().zip(&cutters) { + if let Some(c) = c { + pin(sk, *pi, *c)?; + } + } + tie(sk, &pieces)?; + Ok(ids_of(sk, &pieces)) + })?; + Ok(json!({"curves": out, "sketch": info})) +} + +fn extend(s: &mut Session, p: &Value) -> Result { + let cmd = "ExtendSketchCmd"; + let at = req_vec2(cmd, p, "at")?; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let ci = curve_arg(sk, p, cmd)?; + let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?; + let (pa, pb) = match sk.curves.get(ci).map(|c| c.kind.clone()) { + Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => (a, b), + _ => return Err(bad(cmd, "only lines and arcs can be extended")), + }; + let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default()); + let at_b = at.dist(qb) <= at.dist(qa); + let end = if at_b { pb } else { pa }; + let shared = sk.curves.iter().enumerate().filter(|(i, c)| *i != ci && uses(c, end)).count(); + if shared > 0 || sk.points.get(end).is_some_and(|q| q.fixed) { + return Err(bad(cmd, "that end is connected to other geometry")); + } + // Candidate hits on the unbounded curve beyond the chosen end. + let unbounded = match sh { + Shape::Line { .. } => sh, + Shape::Round { c, r, start, .. } => Shape::Round { c, r, start, sweep: TAU }, + }; + let (_, t1) = sh.range(); + let mut best: Option<(f64, Vec2, usize)> = None; + for (oi, _) in sk.curves.iter().enumerate() { + if oi == ci { + continue; + } + let Some(other) = sk.shape(oi) else { continue }; + for q in intersections(&unbounded, &other) { + if on_bounded(&other, q).is_none() { + continue; + } + let t = sh.param(q); + let gap = match sh { + Shape::Line { a, b } => { + let l = a.dist(b); + if at_b { (t - 1.0) * l } else { -t * l } + } + Shape::Round { r, .. } => { + if at_b { + (t - t1) * r + } else { + (TAU - t) * r + } + } + }; + if gap > TOL * 10.0 && best.is_none_or(|b| gap < b.0) { + best = Some((gap, q, oi)); + } + } + } + let (_, q, oi) = best.ok_or_else(|| bad(cmd, "nothing to extend to"))?; + if let Some(pt) = sk.points.get_mut(end) { + pt.pos = q; + } + pin(sk, end, oi)?; + Ok(sk.points.get(end).map(|x| x.id.clone()).unwrap_or_default()) + })?; + Ok(json!({"moved": out, "sketch": info})) +} + +fn uses(c: &Curve, p: usize) -> bool { + match c.kind { + CurveKind::Line { a, b } => a == p || b == p, + CurveKind::Circle { c, .. } => c == p, + CurveKind::Arc { c, a, b } => c == p || a == p || b == p, + } +} + +// --------------------------------------------------------------------------------------------- +// Corners: fillet and chamfer + +/// A corner of two lines: (line a, line b, corner point, far end of a, far end of b). +fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usize, usize)> { + let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => Some((*a, *b)), + _ => None, + }; + let (la, lb) = if let Some(r) = str_(p, "point") { + let q = sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?; + let lines: Vec = (0..sk.curves.len()).filter(|i| ends(*i).is_some_and(|(a, b)| a == q || b == q) && !sk.is_linked_curve(*i)).collect(); + match lines[..] { + [a, b] => (a, b), + _ => return Err(bad(cmd, "the corner must join exactly two lines")), + } + } else { + let a = str_(p, "a").and_then(|i| sk.curve_index(i)).ok_or_else(|| bad(cmd, "`a` must be a line"))?; + let b = str_(p, "b").and_then(|i| sk.curve_index(i)).ok_or_else(|| bad(cmd, "`b` must be a line"))?; + (a, b) + }; + let (a0, a1) = ends(la).ok_or_else(|| bad(cmd, "fillets and chamfers work on two lines"))?; + let (b0, b1) = ends(lb).ok_or_else(|| bad(cmd, "fillets and chamfers work on two lines"))?; + let shared = [a0, a1].into_iter().find(|x| *x == b0 || *x == b1).ok_or_else(|| bad(cmd, "the lines must share a corner point"))?; + let fa = if a0 == shared { a1 } else { a0 }; + let fb = if b0 == shared { b1 } else { b0 }; + Ok((la, lb, shared, fa, fb)) +} + +/// Replace the corner end of line `l` with point `np`. +fn reattach(sk: &mut Sketch, l: usize, corner: usize, np: usize) { + if let Some(Curve { kind: CurveKind::Line { a, b }, .. }) = sk.curves.get_mut(l) { + if *a == corner { + *a = np; + } else if *b == corner { + *b = np; + } + } +} + +/// Points at distances `da` along a and `db` along b from the corner; lines shortened to them. +fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: usize, da: f64, db: f64, cmd: &str) -> Result<(usize, usize, Vec2)> { + let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); + if !(da > 1e-9 && db > 1e-9 && da < pc.dist(pa) - 1e-9 && db < pc.dist(pb) - 1e-9) { + return Err(bad(cmd, "too large for these lines")); + } + let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; + let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; + let ta = sk.add_point(pc + u * da, None)?; + let tb = sk.add_point(pc + v * db, None)?; + reattach(sk, la, c, ta); + reattach(sk, lb, c, tb); + let used = sk.curves.iter().any(|x| uses(x, c)); + if !used && c != 0 { + sk.remove_points(&[c]); + } + // Indices may have shifted when the corner point was removed. + let fix = |i: usize| if !used && c != 0 && i > c { i - 1 } else { i }; + Ok((fix(ta), fix(tb), pc)) +} + +fn fillet(s: &mut Session, p: &Value) -> Result { + let cmd = "FilletSketchCmd"; + let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?; + let (out, info) = edit(s, p, cmd, false, |sk, doc| { + let r = doc.eval(&r_expr, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?; + if !(r > 1e-9 && r < 1e8) { + return Err(bad(cmd, "radius must be positive")); + } + let (la, lb, c, fa, fb) = corner(sk, p, cmd)?; + let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); + let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; + let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; + let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs(); + if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 { + return Err(bad(cmd, "the lines are parallel")); + } + let d = r / half.tan(); + let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?; + let center = pc + bis * (r / half.sin()); + let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?; + let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default()); + let ccw = (qa - center).cross(qb - center) > 0.0; + let arc = if ccw { + sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)? + } else { + sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)? + }; + let mut cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?]; + let pname = doc.new_model_param(&r_expr, "mm"); + cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?); + Ok((ids_of(sk, &[arc]), cons, pname)) + })?; + Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info})) +} + +fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result { + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (la, lb, c, fa, fb) = corner(sk, p, cmd)?; + let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); + let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y)); + let corner_angle = u.cross(v).atan2(u.dot(v)).abs(); + let (da, db) = dists(corner_angle)?; + let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?; + let l = sk.add_line_pts(ta, tb, None)?; + Ok(ids_of(sk, &[l])) + })?; + Ok(json!({"curves": out, "sketch": info})) +} + +fn chamfer_equal(s: &mut Session, p: &Value) -> Result { + let cmd = "ChamferSketchEqualDistance"; + let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?; + chamfer(s, p, cmd, |_| Ok((d, d))) +} + +fn chamfer_two(s: &mut Session, p: &Value) -> Result { + let cmd = "ChamferSketchDistanceDistance"; + let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?; + let d2 = num(p, "distance2").ok_or_else(|| bad(cmd, "`distance2` must be a number"))?; + chamfer(s, p, cmd, |_| Ok((d, d2))) +} + +fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result { + let cmd = "ChamferSketchDistanceAngle"; + let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?; + let a = num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be a number (degrees)"))?.to_radians(); + chamfer(s, p, cmd, |phi| { + // Triangle corner–Ta–Tb: angle phi at the corner, `a` at Ta. + let opp = std::f64::consts::PI - a - phi; + if !(a > 1e-6 && opp > 1e-6) { + return Err(bad(cmd, "that angle does not meet the other line")); + } + Ok((d, d * a.sin() / opp.sin())) + }) +} + +// --------------------------------------------------------------------------------------------- +// Offset + +/// Curves of the connected chain through `start` (joined where exactly two curves meet), in +/// order, with whether each is walked from its stored start (`a`) to its end (`b`). +fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool) { + let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)), + _ => None, + }; + let Some((s0, s1)) = ends(start) else { return (vec![(start, true)], true) }; + let next = |from: usize, at: usize, used: &[usize]| -> Option { + let users: Vec = pool.iter().copied().filter(|c| ends(*c).is_some_and(|(a, b)| a == at || b == at)).collect(); + if users.len() != 2 { + return None; + } + users.into_iter().find(|c| *c != from && !used.contains(c)) + }; + let mut fwd = vec![(start, true)]; + let mut used = vec![start]; + let (mut cur, mut at) = (start, s1); + let mut closed = false; + while let Some(n) = next(cur, at, &used) { + let Some((a, b)) = ends(n) else { break }; + let forward = a == at; + fwd.push((n, forward)); + used.push(n); + cur = n; + at = if forward { b } else { a }; + if used.len() > 10_000 { + break; + } + } + if at == s0 && fwd.len() > 1 { + closed = true; + } else { + // Walk backwards from the start. + let (mut cur, mut at) = (start, s0); + let mut back = Vec::new(); + while let Some(n) = next(cur, at, &used) { + let Some((a, b)) = ends(n) else { break }; + let forward = b == at; + back.push((n, forward)); + used.push(n); + cur = n; + at = if forward { a } else { b }; + if used.len() > 10_000 { + break; + } + } + back.reverse(); + back.extend(fwd); + fwd = back; + } + (fwd, closed) +} + +/// An offset piece: shape walked in chain direction, offset by `d` to the left. +#[derive(Clone, Copy)] +enum Piece { + Line(Vec2, Vec2), + /// Centre point index, centre, radius, start point, end point (walk direction), ccw walk. + Arc(usize, Vec2, f64, Vec2, Vec2, bool), +} + +fn offset_piece(sk: &Sketch, c: usize, forward: bool, d: f64) -> Option { + match sk.curves.get(c)?.kind { + CurveKind::Line { a, b } => { + let (mut p, mut q) = (sk.point(a)?, sk.point(b)?); + if !forward { + std::mem::swap(&mut p, &mut q); + } + let n = (q - p).normalized()?.perp(); + Some(Piece::Line(p + n * d, q + n * d)) + } + CurveKind::Arc { c: ci, a, b } => { + let (cc, pa, pb) = (sk.point(ci)?, sk.point(a)?, sk.point(b)?); + let r = cc.dist(pa); + // Walking counter-clockwise, left is toward the centre. + let nr = if forward { r - d } else { r + d }; + if nr <= 1e-9 { + return None; + } + let at = |q: Vec2| cc + (q - cc).normalized().unwrap_or(Vec2::X) * nr; + let (s, e) = if forward { (at(pa), at(pb)) } else { (at(pb), at(pa)) }; + Some(Piece::Arc(ci, cc, nr, s, e, forward)) + } + CurveKind::Circle { .. } => None, + } +} + +fn piece_shape(p: &Piece) -> Shape { + match *p { + Piece::Line(a, b) => Shape::Line { a, b }, + Piece::Arc(_, c, r, ..) => Shape::Round { c, r, start: 0.0, sweep: TAU }, + } +} + +fn offset(s: &mut Session, p: &Value) -> Result { + let cmd = "Offset"; + let ids = string_list(p, "curves"); + if ids.is_empty() { + return Err(bad(cmd, "`curves` must list curves")); + } + let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?; + let side = p.get("side").and_then(vec2); + let use_chain = bool_(p, "chain").unwrap_or(true); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let sel: Vec = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::>()?; + let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?; + let mut made = Vec::new(); + let mut cons = Vec::new(); + // Circles offset on their own. + if let Some(CurveKind::Circle { c, r }) = sk.curves.get(first).map(|c| c.kind.clone()) { + let cc = sk.point(c).unwrap_or_default(); + let grow = match side { + Some(q) => q.dist(cc) > r, + None => d0 < 0.0, + }; + let nr = if grow { r + d0.abs() } else { r - d0.abs() }; + if nr <= 1e-9 { + return Err(bad(cmd, "the offset circle would have no size")); + } + let ci = sk.add_circle(cc, nr, Some(c), None)?; + made.push(ci); + return Ok((ids_of(sk, &made), cons)); + } + let pool: Vec = if use_chain { + (0..sk.curves.len()) + .filter(|i| sk.curves.get(*i).is_some_and(|c| c.construction == sk.curves.get(first).is_some_and(|f| f.construction))) + .collect() + } else { + sel.clone() + }; + let (order, closed) = chain(sk, first, &pool); + // Side: toward `side` (compare both offsets' distance to it), else the sign. + let d = match side { + Some(q) => { + let dist = |dd: f64| { + offset_piece(sk, first, order.iter().find(|x| x.0 == first).is_none_or(|x| x.1), dd) + .map(|pc| piece_shape(&pc).dist(q)) + .unwrap_or(f64::MAX) + }; + if dist(d0.abs()) <= dist(-d0.abs()) { d0.abs() } else { -d0.abs() } + } + None => d0, + }; + let pieces: Vec = + order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?; + let n = pieces.len(); + let start_of = |p: &Piece| match *p { + Piece::Line(a, _) => a, + Piece::Arc(_, _, _, s, _, _) => s, + }; + let end_of = |p: &Piece| match *p { + Piece::Line(_, b) => b, + Piece::Arc(_, _, _, _, e, _) => e, + }; + // Junction points between consecutive pieces. + let joint = |x: &Piece, y: &Piece| -> Vec2 { + let (e, s0) = (end_of(x), start_of(y)); + if e.dist(s0) < TOL * 10.0 { + return e; + } + let guess = (e + s0) * 0.5; + intersections(&piece_shape(x), &piece_shape(y)).into_iter().min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))).unwrap_or(guess) + }; + let mut joints: Vec = Vec::new(); + joints.push(if closed { joint(&pieces[n - 1], &pieces[0]) } else { start_of(&pieces[0]) }); + for k in 0..n.saturating_sub(1) { + joints.push(joint(&pieces[k], &pieces[k + 1])); + } + if !closed { + joints.push(end_of(&pieces[n - 1])); + } + let pts: Vec = joints.iter().map(|q| sk.add_point(*q, None)).collect::>()?; + for (k, pc) in pieces.iter().enumerate() { + let (pa, pb) = (pts[k], if closed { pts[(k + 1) % n] } else { pts[k + 1] }); + let (orig, _) = order[k]; + match *pc { + Piece::Line(..) => { + let l = sk.add_line_pts(pa, pb, None)?; + cons.push(add_c(sk, ConstraintKind::Parallel { a: orig, b: l })?); + made.push(l); + } + Piece::Arc(ci, cc, _, _, _, ccw) => { + let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default()); + let a = if ccw { + sk.add_arc(cc, qa, qb, [Some(ci), Some(pa), Some(pb)], None)? + } else { + sk.add_arc(cc, qb, qa, [Some(ci), Some(pb), Some(pa)], None)? + }; + made.push(a); + } + } + } + Ok((ids_of(sk, &made), cons)) + })?; + Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info})) +} + +// --------------------------------------------------------------------------------------------- +// Copies: mirror, patterns, move, scale + +/// Curves and lone points named in `entities`. +fn entities(sk: &Sketch, p: &Value, cmd: &str) -> Result<(Vec, Vec)> { + let ids = string_list(p, "entities"); + if ids.is_empty() { + return Err(bad(cmd, "`entities` must list sketch curves or points")); + } + let (mut cs, mut ps) = (Vec::new(), Vec::new()); + for id in &ids { + if let Some(c) = sk.curve_index(id) { + cs.push(c); + } else if let Some(q) = sk.resolve_point(id) { + ps.push(q); + } else { + return Err(bad(cmd, format!("unknown sketch entity `{id}`"))); + } + } + Ok((cs, ps)) +} + +/// Points used by curves plus lone points, in a stable order. +fn involved_points(sk: &Sketch, cs: &[usize], ps: &[usize]) -> Vec { + let mut v: Vec = Vec::new(); + for c in cs { + match sk.curves.get(*c).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => v.extend([*a, *b]), + Some(CurveKind::Circle { c, .. }) => v.push(*c), + Some(CurveKind::Arc { c, a, b }) => v.extend([*c, *a, *b]), + None => {} + } + } + v.extend(ps); + let mut out = Vec::new(); + for q in v { + if !out.contains(&q) { + out.push(q); + } + } + out +} + +/// Copy curves and points through `f`. `flip` reverses arcs (mirroring). `keep(p)`: reuse the +/// original point instead of copying it (points on a mirror line). Returns the new curves and +/// the point map (original → copy). +fn copy_through( + sk: &mut Sketch, + cs: &[usize], + ps: &[usize], + f: &dyn Fn(Vec2) -> Vec2, + flip: bool, + keep: &dyn Fn(Vec2) -> bool, + scale_r: f64, +) -> Result<(Vec, Vec<(usize, usize)>)> { + let pts = involved_points(sk, cs, ps); + let mut map: Vec<(usize, usize)> = Vec::new(); + for q in &pts { + let pos = sk.point(*q).unwrap_or_default(); + let n = if keep(pos) { *q } else { sk.add_point(f(pos), None)? }; + map.push((*q, n)); + } + let m = |q: usize| map.iter().find(|x| x.0 == q).map(|x| x.1).unwrap_or(q); + let mut made = Vec::new(); + for c in cs { + let Some(cu) = sk.curves.get(*c).cloned() else { continue }; + let (kind, prefix) = match cu.kind { + CurveKind::Line { a, b } => (CurveKind::Line { a: m(a), b: m(b) }, "l"), + CurveKind::Circle { c, r } => (CurveKind::Circle { c: m(c), r: r * scale_r }, "c"), + CurveKind::Arc { c, a, b } => { + if flip { + (CurveKind::Arc { c: m(c), a: m(b), b: m(a) }, "a") + } else { + (CurveKind::Arc { c: m(c), a: m(a), b: m(b) }, "a") + } + } + }; + let id = sk.fresh(prefix); + sk.curves.push(Curve { id, kind, construction: cu.construction, reversed: if flip { !cu.reversed } else { cu.reversed }, link: None }); + made.push(sk.curves.len() - 1); + } + Ok((made, map)) +} + +fn mirror(s: &mut Session, p: &Value) -> Result { + let cmd = "MirrorSketchCommand"; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (cs, ps) = entities(sk, p, cmd)?; + let lid = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must be the mirror line"))?; + let l = sk.curve_index(lid).ok_or_else(|| bad(cmd, format!("unknown curve `{lid}`")))?; + let Some(Shape::Line { a, b }) = sk.shape(l) else { return Err(bad(cmd, "the mirror must be a line")) }; + let u = (b - a).normalized().ok_or_else(|| bad(cmd, "degenerate mirror line"))?; + let refl = move |q: Vec2| { + let foot = a + u * (q - a).dot(u); + foot * 2.0 - q + }; + let on_line = move |q: Vec2| u.cross(q - a).abs() < TOL * 10.0; + let cs: Vec = cs.into_iter().filter(|c| *c != l).collect(); + let (made, map) = copy_through(sk, &cs, &ps, &refl, true, &on_line, 1.0)?; + let mut cons = Vec::new(); + for (o, n) in &map { + if o != n { + cons.push(add_c(sk, ConstraintKind::Symmetric { p: *o, q: *n, l })?); + } + } + for (o, n) in cs.iter().zip(&made) { + if matches!(sk.curves.get(*o).map(|c| &c.kind), Some(CurveKind::Circle { .. })) { + cons.push(add_c(sk, ConstraintKind::Equal { a: *o, b: *n })?); + } + } + Ok((ids_of(sk, &made), cons)) + })?; + Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info})) +} + +fn count(p: &Value, k: &str, cmd: &str) -> Result { + let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + if !(1.0..=MAX_COPIES as f64).contains(&n) { + return Err(bad(cmd, format!("`{k}` must be 1…{MAX_COPIES}"))); + } + Ok(n as usize) +} + +fn point_or_ref(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result { + match p.get(k) { + Some(Value::String(r)) => sk.resolve_point(r).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, format!("unknown point `{r}`"))), + Some(v) => vec2(v).ok_or_else(|| bad(cmd, format!("`{k}` must be [x, y] or a point id"))), + None => Err(bad(cmd, format!("missing `{k}`"))), + } +} + +fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 { + move |q: Vec2| { + let d = q - c; + let (s, co) = ang.sin_cos(); + c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) + } +} + +fn pattern_circular(s: &mut Session, p: &Value) -> Result { + let cmd = "CircularSketchPatternCommand"; + let n = count(p, "count", cmd)?; + let total = num(p, "angle").unwrap_or(360.0); + if !(total.abs() > 1e-9 && total.abs() <= 360.0) { + return Err(bad(cmd, "`angle` must be in (0, 360]")); + } + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (cs, ps) = entities(sk, p, cmd)?; + let c = point_or_ref(sk, p, "center", cmd)?; + let full = (total.abs() - 360.0).abs() < 1e-9; + let step = if full { total / n as f64 } else { total / (n.max(2) - 1) as f64 }; + let mut made = Vec::new(); + for k in 1..n { + let f = rotate(c, (step * k as f64).to_radians()); + let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?; + made.extend(m); + } + Ok(ids_of(sk, &made)) + })?; + Ok(json!({"curves": out, "sketch": info})) +} + +fn pattern_rect(s: &mut Session, p: &Value) -> Result { + let cmd = "RectangularSketchPatternCommand"; + let n1 = count(p, "count", cmd)?; + let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?; + let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 }; + let s2 = num(p, "spacing2").unwrap_or(s1); + if n1 * n2 > MAX_COPIES { + return Err(bad(cmd, "too many copies")); + } + let d1 = p.get("dir").and_then(vec2).and_then(Vec2::normalized).unwrap_or(Vec2::X); + let d2 = d1.perp(); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (cs, ps) = entities(sk, p, cmd)?; + let mut made = Vec::new(); + for i in 0..n1 { + for j in 0..n2 { + if i == 0 && j == 0 { + continue; + } + let off = d1 * (s1 * i as f64) + d2 * (s2 * j as f64); + let (m, _) = copy_through(sk, &cs, &ps, &move |q| q + off, false, &|_| false, 1.0)?; + made.extend(m); + } + } + Ok(ids_of(sk, &made)) + })?; + Ok(json!({"curves": out, "sketch": info})) +} + +/// Move the points of the entities (in place) through `f`; circle radii scale by `k`. +fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Vec2) -> Vec2, k: f64, cmd: &str) -> Result<()> { + let pts = involved_points(sk, cs, ps); + if pts.iter().any(|q| sk.points.get(*q).is_some_and(|x| x.fixed || x.link.is_some())) { + return Err(bad(cmd, "fixed or projected geometry cannot move")); + } + for q in pts { + if let Some(x) = sk.points.get_mut(q) { + x.pos = f(x.pos); + } + } + for c in cs { + if let Some(Curve { kind: CurveKind::Circle { r, .. }, .. }) = sk.curves.get_mut(*c) { + *r *= k; + } + } + Ok(()) +} + +fn move_copy(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.move"; + let t = p.get("translate").and_then(vec2).unwrap_or(Vec2::ZERO); + let ang = num(p, "angle").unwrap_or(0.0).to_radians(); + let copy = bool_(p, "copy").unwrap_or(false); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (cs, ps) = entities(sk, p, cmd)?; + let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO }; + let rot = rotate(c, ang); + let f = move |q: Vec2| rot(q) + t; + if copy { + let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?; + return Ok(ids_of(sk, &m)); + } + transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?; + Ok(ids_of(sk, &cs)) + })?; + Ok(json!({"curves": out, "sketch": info})) +} + +fn scale(s: &mut Session, p: &Value) -> Result { + let cmd = "SketchScaleCmd"; + let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (cs, ps) = entities(sk, p, cmd)?; + let b = if p.get("base").is_some() { point_or_ref(sk, p, "base", cmd)? } else { Vec2::ZERO }; + let f = move |q: Vec2| b + (q - b) * k; + transform_in_place(sk, &cs, &ps, &f, k, cmd)?; + Ok(ids_of(sk, &cs)) + })?; + Ok(json!({"curves": out, "sketch": info})) +} + +#[cfg(test)] +#[path = "sketch_modify_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs new file mode 100644 index 00000000..6fffad9e --- /dev/null +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -0,0 +1,207 @@ +use serde_json::{Value, json}; +use solvecraft_geom::Vec2; +use solvecraft_sketch::CurveKind; + +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 ids(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +fn sketch(s: &Session) -> solvecraft_sketch::Sketch { + let id = s.active_sketch.unwrap(); + s.model.state().sketch(id).unwrap().sketch.clone() +} + +fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + s +} + +fn profiles(s: &Session) -> Vec { + let id = s.active_sketch.unwrap(); + let mut a: Vec = s.model.state().sketch(id).unwrap().profiles.iter().map(|p| p.area).collect(); + a.sort_by(|x, y| x.total_cmp(y)); + a +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn trim_extend_break() { + let mut s = new_sketch(); + // A cross: horizontal line crossed by a vertical one. + let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone(); + let v = ids(&run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone(); + // Trim the right half of the horizontal line. + run(&mut s, "TrimSketchCmd", json!({"curve": h, "at": [15, 0]})); + let sk = sketch(&s); + let sh = sk.shape(sk.curve_index(&h).unwrap()).unwrap(); + assert!( + matches!(sh, solvecraft_sketch::Shape::Line { a, b } if a.dist(Vec2::new(0.0, 0.0)) < 1e-9 && b.dist(Vec2::new(10.0, 0.0)) < 1e-9), + "{sh:?}" + ); + // Trim the lower half of the vertical line: the original id survives on the upper piece. + run(&mut s, "TrimSketchCmd", json!({"curve": v, "at": [10, -3]})); + let sk = sketch(&s); + assert_eq!(sk.curves.len(), 2); + // Extend: a short line reaching toward the vertical one. + let e = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone(); + run(&mut s, "ExtendSketchCmd", json!({"curve": e, "at": [4, 3]})); + let sk = sketch(&s); + let end = sk.resolve_point(&format!("{e}.end")).and_then(|i| sk.point(i)).unwrap(); + assert!(end.dist(Vec2::new(10.0, 3.0)) < 1e-7, "{end:?}"); + // Break the horizontal line where... nothing crosses it inside: error. + assert!(s.execute("BreakSketchCmd", &json!({"curve": h, "at": [5, 0]})).is_err()); + // Break the vertical at the extended line: two pieces. + let r = run(&mut s, "BreakSketchCmd", json!({"curve": v, "at": [10, 1]})); + assert_eq!(ids(&r["curves"]).len(), 2, "{r}"); + // A lone curve trims away entirely. + let lone = ids(&run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone(); + run(&mut s, "TrimSketchCmd", json!({"curve": lone, "at": [55, 50]})); + assert!(sketch(&s).curve_index(&lone).is_none()); +} + +#[test] +fn trim_circle_makes_an_arc_and_a_profile() { + let mut s = new_sketch(); + let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone(); + run(&mut s, "DrawPolyline", json!({"points": [[-20, 0], [20, 0]]})); + run(&mut s, "TrimSketchCmd", json!({"curve": c, "at": [0, -10]})); + let sk = sketch(&s); + let ci = sk.curve_index(&c).unwrap(); + assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. })); + // Upper half disc closes against the line. + let a = profiles(&s); + assert_eq!(a.len(), 1, "{a:?}"); + assert!(close(a[0], std::f64::consts::PI * 50.0), "{a:?}"); +} + +#[test] +fn fillet_and_chamfers() { + let mut s = new_sketch(); + let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})); + let l = ids(&r["curves"]); + // Pin the rectangle so a dimension change only moves the fillet. + run(&mut s, "ConstraintFix", json!({"entity": l[2]})); + run(&mut s, "ConstraintFix", json!({"entity": l[3]})); + let f = run(&mut s, "FilletSketchCmd", json!({"a": l[0], "b": l[1], "radius": 5})); + assert!(f["param"].is_string()); + let a = profiles(&s); + assert!(close(a[0], 1200.0 - (25.0 - std::f64::consts::PI * 25.0 / 4.0)), "{a:?}"); + // The fillet radius is a dimension: change it. + let pname = f["param"].as_str().unwrap().to_string(); + run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "8 mm"})); + let a = profiles(&s); + assert!(close(a[0], 1200.0 - (64.0 - std::f64::consts::PI * 64.0 / 4.0)), "{a:?}"); + run(&mut s, "ChamferSketchEqualDistance", json!({"a": l[2], "b": l[3], "distance": 4})); + let a2 = profiles(&s)[0]; + assert!(close(a[0] - a2, 8.0), "{a2}"); + run(&mut s, "ChamferSketchDistanceDistance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6})); + let a3 = profiles(&s)[0]; + assert!(close(a2 - a3, 6.0), "{a3}"); + // Distance-angle at the last corner (origin, 90 degrees): 45 deg gives an isosceles cut. + run(&mut s, "ChamferSketchDistanceAngle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45})); + let a4 = profiles(&s)[0]; + assert!(close(a3 - a4, 4.5), "{a4}"); + assert!(s.execute("FilletSketchCmd", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner"); +} + +#[test] +fn offset_chain_mirror_patterns_move_scale() { + let mut s = new_sketch(); + let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})); + let l = ids(&r["curves"]); + // Outward offset by 2 (toward a point outside). + let o = run(&mut s, "Offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]})); + assert_eq!(ids(&o["curves"]).len(), 4, "{o}"); + let a = profiles(&s); + assert!(close(a[1], 44.0 * 34.0 - 1200.0) || close(a.iter().sum::(), 44.0 * 34.0), "{a:?}"); + // Circle offset inward. + let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone(); + run(&mut s, "Offset", json!({"curves": [c], "distance": 3, "side": [100, 0]})); + // Mirror a circle about a vertical line. + let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone(); + let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [c], "line": m})); + let mc = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + assert!(sk.center(sk.curve_index(&mc).unwrap()).unwrap().dist(Vec2::new(140.0, 0.0)) < 1e-7); + // Circular pattern of the mirrored circle: 4 around (120, 0). + let r = run(&mut s, "CircularSketchPatternCommand", json!({"entities": [mc], "center": [120, 0], "count": 4})); + assert_eq!(ids(&r["curves"]).len(), 3); + let sk = sketch(&s); + let c2 = sk.center(sk.curve_index(&ids(&r["curves"])[0]).unwrap()).unwrap(); + assert!(c2.dist(Vec2::new(120.0, 20.0)) < 1e-7, "{c2:?}"); + // Rectangular pattern 3 x 2. + let small = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone(); + let r = run(&mut s, "RectangularSketchPatternCommand", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4})); + assert_eq!(ids(&r["curves"]).len(), 5); + // Move and scale. + run(&mut s, "sketch.move", json!({"entities": [small], "translate": [0, 10]})); + run(&mut s, "SketchScaleCmd", json!({"entities": [small], "base": [0, 110], "factor": 3})); + let sk = sketch(&s); + let ci = sk.curve_index(&small).unwrap(); + assert!(sk.center(ci).unwrap().dist(Vec2::new(0.0, 110.0)) < 1e-7); + assert!(close(sk.radius(ci).unwrap(), 3.0)); + let r = run(&mut s, "sketch.move", json!({"entities": [small], "angle": 90, "center": [0, 0], "copy": true})); + let cp = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + assert!(sk.center(sk.curve_index(&cp).unwrap()).unwrap().dist(Vec2::new(-110.0, 0.0)) < 1e-7); +} + +#[test] +fn new_creation_tools() { + let mut s = new_sketch(); + let r = run(&mut s, "SketchMidpointLine", json!({"mid": [0, 0], "end": [10, 5]})); + let l = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + let start = sk.resolve_point(&format!("{l}.start")).and_then(|i| sk.point(i)).unwrap(); + assert!(start.dist(Vec2::new(-10.0, -5.0)) < 1e-9); + // Tangent arc off the end of a horizontal line. + let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone(); + let r = run(&mut s, "ArcTangent", json!({"start": format!("{h}.end"), "end": [20, 30]})); + let a = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + let ai = sk.curve_index(&a).unwrap(); + assert!(sk.center(ai).unwrap().dist(Vec2::new(10.0, 30.0)) < 1e-7, "{:?}", sk.center(ai)); + assert!(close(sk.radius(ai).unwrap(), 10.0)); + // 2-tangent circle in the corner of two lines. + let x = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone(); + let y = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone(); + let r = run(&mut s, "CircleTanTanRadius", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]})); + let c = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + let ci = sk.curve_index(&c).unwrap(); + assert!(sk.center(ci).unwrap().dist(Vec2::new(105.0, 5.0)) < 1e-6, "{:?}", sk.center(ci)); + // 3-tangent circle inside a triangle: the incircle of the 3-4-5 triangle (r = 1). + let t = run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true})); + let tl = ids(&t["curves"]); + let r = run(&mut s, "CircleThreeTangent", json!({"curves": tl, "near": [201, 1.2]})); + let c = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + let ci = sk.curve_index(&c).unwrap(); + assert!(close(sk.radius(ci).unwrap(), 1.0), "{:?}", sk.radius(ci)); + assert!(sk.center(ci).unwrap().dist(Vec2::new(201.0, 1.0)) < 1e-6); + // Slots. + let before = profiles(&s).len(); + run(&mut s, "ShapeSlotCenterPoint", json!({"center": [0, -100], "end": [10, -100], "width": 4})); + run(&mut s, "ShapeArcSlotThreePoint", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2})); + run(&mut s, "ShapeArcSlotCenterTwoPoint", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2})); + let a = profiles(&s); + assert_eq!(a.len(), before + 3, "{a:?}"); + let pi = std::f64::consts::PI; + assert!(a.iter().any(|x| close(*x, 20.0 * 4.0 + pi * 4.0)), "{a:?}"); + // Half-circle arc slot: centre arc length pi*10, width 2, plus a full cap circle. + assert!(a.iter().any(|x| close(*x, pi * 10.0 * 2.0 + pi)), "{a:?}"); + assert!(a.iter().any(|x| close(*x, pi * 5.0 * 2.0 + pi)), "{a:?}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a177072..b3cef4f9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -263,6 +263,19 @@ fn hostile_params_never_panic() { "refs", "link", "project_edges", + "mid", + "near", + "factor", + "copy", + "count", + "spacing", + "count2", + "spacing2", + "dir", + "side", + "chain", + "at", + "curve", ]; let mut internal = Vec::new(); for spec in command_specs() { diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 7c6f8513..6f683412 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -12,11 +12,13 @@ mod linalg; mod link; mod model; mod profiles; +mod shape; mod solver; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError}; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; +pub use shape::{Shape, intersections}; pub use solver::{SolveReport, SolveStatus, solve}; #[cfg(test)] diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index b6bf4a3f..2d795e6f 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::{Loop2, Region2, Seg2, Vec2}; use crate::model::{CurveKind, Sketch}; +use crate::shape::{Shape, intersections}; const MERGE_TOL: f64 = 1e-6; const MAX_EDGES: usize = 50_000; @@ -77,97 +78,6 @@ fn find(p: &mut [usize], mut i: usize) -> usize { i } -/// A curve of the sketch as an analytic shape for splitting. -#[derive(Clone, Copy)] -enum Shape { - Line { - a: Vec2, - b: Vec2, - }, - /// Circle (full when `sweep` is 2π) from `start` angle sweeping counter-clockwise. - Round { - c: Vec2, - r: f64, - start: f64, - sweep: f64, - }, -} - -impl Shape { - /// Curve parameter of `p` if it lies on the curve's interior (line t in (0,1); round: - /// angle offset from start in (0, sweep)). - fn param_on(&self, p: Vec2, tol: f64) -> Option { - match *self { - Shape::Line { a, b } => { - let d = b - a; - let l2 = d.len2(); - if l2 < 1e-24 { - return None; - } - let t = (p - a).dot(d) / l2; - let q = a + d * t; - (q.dist(p) <= tol && t * l2.sqrt() > tol && (1.0 - t) * l2.sqrt() > tol).then_some(t) - } - Shape::Round { c, r, start, sweep } => { - if (p.dist(c) - r).abs() > tol { - return None; - } - let off = ((p - c).angle() - start).rem_euclid(TAU); - let ang_tol = tol / r.max(1e-12); - let full = sweep >= TAU - 1e-12; - if full { - return Some(off); - } - (off > ang_tol && off < sweep - ang_tol).then_some(off) - } - } - } - fn point(&self, t: f64) -> Vec2 { - match *self { - Shape::Line { a, b } => a.lerp(b, t), - Shape::Round { c, r, start, .. } => c + Vec2::from_angle(start + t) * r, - } - } -} - -/// Intersection points of two shapes (as circles/infinite lines; callers filter by range). -fn intersections(x: &Shape, y: &Shape) -> Vec { - match (*x, *y) { - (Shape::Line { a, b }, Shape::Line { a: c, b: d }) => { - let (r, s) = (b - a, d - c); - let den = r.cross(s); - if den.abs() < 1e-12 * r.len() * s.len() { - return Vec::new(); - } - let t = (c - a).cross(s) / den; - vec![a + r * t] - } - (Shape::Line { a, b }, Shape::Round { c, r, .. }) | (Shape::Round { c, r, .. }, Shape::Line { a, b }) => { - let d = b - a; - let f = a - c; - let (qa, qb, qc) = (d.len2(), 2.0 * f.dot(d), f.len2() - r * r); - let disc = qb * qb - 4.0 * qa * qc; - if qa < 1e-24 || disc < 0.0 { - return Vec::new(); - } - let sq = disc.sqrt(); - vec![a + d * ((-qb - sq) / (2.0 * qa)), a + d * ((-qb + sq) / (2.0 * qa))] - } - (Shape::Round { c: c1, r: r1, .. }, Shape::Round { c: c2, r: r2, .. }) => { - let dv = c2 - c1; - let d = dv.len(); - if d < 1e-12 || d > r1 + r2 || d < (r1 - r2).abs() { - return Vec::new(); - } - let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d); - let h = (r1 * r1 - a * a).max(0.0).sqrt(); - let m = c1 + dv * (a / d); - let n = dv.perp() / d; - vec![m + n * h, m - n * h] - } - } -} - /// Signed curvature at the start of a segment (left turns positive). fn curvature(s: &Seg2) -> f64 { match *s { diff --git a/crates/sketch/src/shape.rs b/crates/sketch/src/shape.rs new file mode 100644 index 00000000..804e00f1 --- /dev/null +++ b/crates/sketch/src/shape.rs @@ -0,0 +1,166 @@ +//! Analytic curve shapes for geometry operations (profiles, trim, extend, fillet…). + +use std::f64::consts::TAU; + +use solvecraft_geom::{Seg2, Vec2}; + +use crate::model::{CurveKind, Sketch}; + +/// A curve of the sketch as an analytic shape for splitting. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Shape { + Line { + a: Vec2, + b: Vec2, + }, + /// Circle (full when `sweep` is 2π) from `start` angle sweeping counter-clockwise. + Round { + c: Vec2, + r: f64, + start: f64, + sweep: f64, + }, +} + +impl Shape { + /// Curve parameter of `p` if it lies on the curve's interior (line t in (0,1); round: + /// angle offset from start in (0, sweep)). + pub fn param_on(&self, p: Vec2, tol: f64) -> Option { + match *self { + Shape::Line { a, b } => { + let d = b - a; + let l2 = d.len2(); + if l2 < 1e-24 { + return None; + } + let t = (p - a).dot(d) / l2; + let q = a + d * t; + (q.dist(p) <= tol && t * l2.sqrt() > tol && (1.0 - t) * l2.sqrt() > tol).then_some(t) + } + Shape::Round { c, r, start, sweep } => { + if (p.dist(c) - r).abs() > tol { + return None; + } + let off = ((p - c).angle() - start).rem_euclid(TAU); + let ang_tol = tol / r.max(1e-12); + let full = sweep >= TAU - 1e-12; + if full { + return Some(off); + } + (off > ang_tol && off < sweep - ang_tol).then_some(off) + } + } + } + pub fn point(&self, t: f64) -> Vec2 { + match *self { + Shape::Line { a, b } => a.lerp(b, t), + Shape::Round { c, r, start, .. } => c + Vec2::from_angle(start + t) * r, + } + } +} + +/// Intersection points of two shapes (as circles/infinite lines; callers filter by range). +pub fn intersections(x: &Shape, y: &Shape) -> Vec { + match (*x, *y) { + (Shape::Line { a, b }, Shape::Line { a: c, b: d }) => { + let (r, s) = (b - a, d - c); + let den = r.cross(s); + if den.abs() < 1e-12 * r.len() * s.len() { + return Vec::new(); + } + let t = (c - a).cross(s) / den; + vec![a + r * t] + } + (Shape::Line { a, b }, Shape::Round { c, r, .. }) | (Shape::Round { c, r, .. }, Shape::Line { a, b }) => { + let d = b - a; + let f = a - c; + let (qa, qb, qc) = (d.len2(), 2.0 * f.dot(d), f.len2() - r * r); + let disc = qb * qb - 4.0 * qa * qc; + if qa < 1e-24 || disc < 0.0 { + return Vec::new(); + } + let sq = disc.sqrt(); + vec![a + d * ((-qb - sq) / (2.0 * qa)), a + d * ((-qb + sq) / (2.0 * qa))] + } + (Shape::Round { c: c1, r: r1, .. }, Shape::Round { c: c2, r: r2, .. }) => { + let dv = c2 - c1; + let d = dv.len(); + if d < 1e-12 || d > r1 + r2 || d < (r1 - r2).abs() { + return Vec::new(); + } + let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d); + let h = (r1 * r1 - a * a).max(0.0).sqrt(); + let m = c1 + dv * (a / d); + let n = dv.perp() / d; + vec![m + n * h, m - n * h] + } + } +} + +impl Shape { + /// Unbounded curve parameter of `p` (line: t along a→b; round: angle offset from start in + /// [0, 2π)). + pub fn param(&self, p: Vec2) -> f64 { + match *self { + Shape::Line { a, b } => { + let d = b - a; + let l2 = d.len2(); + if l2 < 1e-24 { 0.0 } else { (p - a).dot(d) / l2 } + } + Shape::Round { c, start, .. } => ((p - c).angle() - start).rem_euclid(TAU), + } + } + /// Parameter range of the bounded curve. + pub fn range(&self) -> (f64, f64) { + match *self { + Shape::Line { .. } => (0.0, 1.0), + Shape::Round { sweep, .. } => (0.0, sweep), + } + } + pub fn is_full(&self) -> bool { + matches!(*self, Shape::Round { sweep, .. } if sweep >= TAU - 1e-12) + } + /// Unit tangent at parameter `t` in the direction of increasing `t`. + pub fn tangent(&self, t: f64) -> Vec2 { + match *self { + Shape::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X), + Shape::Round { start, .. } => Vec2::from_angle(start + t).perp(), + } + } + /// Closest point on the unbounded curve. + pub fn project(&self, p: Vec2) -> Vec2 { + match *self { + Shape::Line { a, b } => a.lerp(b, self.param(p)), + Shape::Round { c, r, .. } => c + (p - c).normalized().unwrap_or(Vec2::X) * r, + } + } + /// Distance from `p` to the bounded curve. + pub fn dist(&self, p: Vec2) -> f64 { + let (t0, t1) = self.range(); + let t = self.param(p); + match *self { + Shape::Line { .. } => p.dist(self.point(t.clamp(t0, t1))), + Shape::Round { c, r, sweep, .. } => { + if sweep >= TAU - 1e-12 || t <= sweep { + (p.dist(c) - r).abs() + } else { + p.dist(self.point(0.0)).min(p.dist(self.point(sweep))) + } + } + } + } +} + +impl Sketch { + /// The analytic shape of a curve (arcs counter-clockwise from their stored start). + pub fn shape(&self, ci: usize) -> Option { + match self.curves.get(ci)?.kind { + CurveKind::Line { a, b } => Some(Shape::Line { a: self.point(a)?, b: self.point(b)? }), + CurveKind::Circle { c, r } => Some(Shape::Round { c: self.point(c)?, r, start: 0.0, sweep: TAU }), + CurveKind::Arc { .. } => match self.segs(ci).into_iter().next()? { + Seg2::Arc { center, radius, start, sweep } => Some(Shape::Round { c: center, r: radius, start, sweep }), + Seg2::Line { .. } => None, + }, + } + } +} diff --git a/docs/parity.md b/docs/parity.md index e24a8ea9..64e42d7f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,12 +2,12 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**SOLID + SKETCH: 64 / 291 commands live (22%).** All Design tabs: 155 / 1175 (13%). Registered commands: 89. +**SOLID + SKETCH: 84 / 291 commands live (29%).** All Design tabs: 177 / 1175 (15%). Registered commands: 110. | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| | SOLID | CREATE | 13 | 35 | 37% | -| SOLID | MODIFY | 10 | 32 | 31% | +| SOLID | MODIFY | 11 | 32 | 34% | | SOLID | ASSEMBLE | 1 | 21 | 5% | | SOLID | CONFIGURE | 0 | 3 | 0% | | SOLID | CONSTRUCT | 2 | 20 | 10% | @@ -15,8 +15,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SOLID | INSERT | 1 | 11 | 9% | | SOLID | SELECT | 0 | 15 | 0% | | SOLID | POSITION | 0 | 2 | 0% | -| SKETCH | CREATE | 19 | 41 | 46% | -| SKETCH | MODIFY | 2 | 12 | 17% | +| SKETCH | CREATE | 29 | 41 | 71% | +| SKETCH | MODIFY | 11 | 12 | 92% | | SKETCH | CONSTRAINTS | 11 | 15 | 73% | | SKETCH | CONFIGURE | 0 | 3 | 0% | | SKETCH | INSPECT | 1 | 25 | 4% | @@ -25,7 +25,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SKETCH | SELECT | 0 | 15 | 0% | | SKETCH | FINISH SKETCH | 1 | 2 | 50% | | SURFACE | CREATE | 4 | 25 | 16% | -| SURFACE | MODIFY | 7 | 26 | 27% | +| SURFACE | MODIFY | 8 | 26 | 31% | | SURFACE | ASSEMBLE | 1 | 20 | 5% | | SURFACE | CONFIGURE | 0 | 3 | 0% | | SURFACE | CONSTRUCT | 2 | 20 | 10% | @@ -66,7 +66,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SHEET METAL | POSITION | 0 | 2 | 0% | | PLASTIC | SETUP | 0 | 2 | 0% | | PLASTIC | CREATE | 13 | 35 | 37% | -| PLASTIC | MODIFY | 10 | 32 | 31% | +| PLASTIC | MODIFY | 11 | 32 | 34% | | PLASTIC | ASSEMBLE | 1 | 20 | 5% | | PLASTIC | CONFIGURE | 0 | 3 | 0% | | PLASTIC | CONSTRUCT | 2 | 20 | 10% | @@ -115,6 +115,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Rectangular Pattern (`PatternRectangular`) - SOLID › CREATE › Circular Pattern (`PatternCircular`) - SOLID › CREATE › Mirror (`MirrorCommand`) +- SOLID › MODIFY › Press Pull (`FusionPressPullCommand`) - SOLID › MODIFY › Fillet (`FusionFilletEdgesCommand`) - SOLID › MODIFY › Chamfer (`FusionChamferCommand`) - SOLID › MODIFY › Shell (`FusionShellBodyCommand`) @@ -131,24 +132,43 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › INSPECT › Measure (`MeasureCommand`) - SOLID › INSERT › Insert Mesh (`ParaMeshInsertAlignCommand`) - SKETCH › CREATE › Line (`DrawPolyline`) +- SKETCH › CREATE › Midpoint Line (`SketchMidpointLine`) - SKETCH › CREATE › 2-Point Rectangle (`ShapeRectangleTwoPoint`) - SKETCH › CREATE › 3-Point Rectangle (`ShapeRectangleThreePoint`) - SKETCH › CREATE › Center Rectangle (`ShapeRectangleCenter`) - SKETCH › CREATE › Center Diameter Circle (`CircleCenterRadius`) - SKETCH › CREATE › 2-Point Circle (`CircleTwoPoint`) - SKETCH › CREATE › 3-Point Circle (`CircleThreePoint`) +- SKETCH › CREATE › 2-Tangent Circle (`CircleTanTanRadius`) +- SKETCH › CREATE › 3-Tangent Circle (`CircleThreeTangent`) - SKETCH › CREATE › 3-Point Arc (`ArcThreePoint`) - SKETCH › CREATE › Center Point Arc (`ArcCenterTwoPoint`) +- SKETCH › CREATE › Tangent Arc (`ArcTangent`) - SKETCH › CREATE › Circumscribed Polygon (`ShapePolygonCircumscribed`) - SKETCH › CREATE › Inscribed Polygon (`ShapePolygonInscribed`) - SKETCH › CREATE › Edge Polygon (`ShapePolygonEdge`) - SKETCH › CREATE › Center to Center Slot (`ShapeSlotCenterToCenter`) - SKETCH › CREATE › Overall Slot (`ShapeSlotOverall`) +- SKETCH › CREATE › Center Point Slot (`ShapeSlotCenterPoint`) +- SKETCH › CREATE › Three Point Arc Slot (`ShapeArcSlotThreePoint`) +- SKETCH › CREATE › Center Point Arc Slot (`ShapeArcSlotCenterTwoPoint`) - SKETCH › CREATE › Point (`DrawPoint`) +- SKETCH › CREATE › Mirror (`MirrorSketchCommand`) +- SKETCH › CREATE › Circular Pattern (`CircularSketchPatternCommand`) +- SKETCH › CREATE › Rectangular Pattern (`RectangularSketchPatternCommand`) - SKETCH › CREATE › Project (`ProjectNewCmd`) - SKETCH › CREATE › Intersect (`IntersectCmd`) - SKETCH › CREATE › Include 3D Geometry (`Include3DGeometry`) - SKETCH › CREATE › Sketch Dimension (`SketchDimension`) +- SKETCH › MODIFY › Fillet (`FilletSketchCmd`) +- SKETCH › MODIFY › Equal Distance Chamfer (`ChamferSketchEqualDistance`) +- SKETCH › MODIFY › Distance and Angle Chamfer (`ChamferSketchDistanceAngle`) +- SKETCH › MODIFY › Two Distance Chamfer (`ChamferSketchDistanceDistance`) +- SKETCH › MODIFY › Offset (`Offset`) +- SKETCH › MODIFY › Trim (`TrimSketchCmd`) +- SKETCH › MODIFY › Extend (`ExtendSketchCmd`) +- SKETCH › MODIFY › Break (`BreakSketchCmd`) +- SKETCH › MODIFY › Sketch Scale (`SketchScaleCmd`) - SKETCH › MODIFY › Move/Copy (`FusionMoveCommand`) - SKETCH › MODIFY › Change Parameters (`ChangeParameterCommand`) - SKETCH › CONSTRAINTS › Horizontal/Vertical (`ConstraintHorizontalVertical`) @@ -191,7 +211,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Create Unlinked PCB (`PCBCreateCmd`) - SOLID › CREATE › Joint Origin (`JointOrigin`) - SOLID › CREATE › Generate Timeline (`TimelineBuilderCommand`) -- SOLID › MODIFY › Press Pull (`FusionPressPullCommand`) - SOLID › MODIFY › Edit Face (`EditFaceCommand`) - SOLID › MODIFY › Scale (`ModifyScale`) - SOLID › MODIFY › Offset Face (`FusionOffsetFacesCommand`) @@ -305,38 +324,19 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › SELECT › Selection Filters (`SelectionFilterCommand`) - SOLID › POSITION › Capture Position (`SnapshotCmd`) - SOLID › POSITION › Revert Position (`AsBuiltPositionsCmd`) -- SKETCH › CREATE › Midpoint Line (`SketchMidpointLine`) - SKETCH › CREATE › SketchConstrainer (`SketchConstrainer`) -- SKETCH › CREATE › 2-Tangent Circle (`CircleTanTanRadius`) -- SKETCH › CREATE › 3-Tangent Circle (`CircleThreeTangent`) -- SKETCH › CREATE › Tangent Arc (`ArcTangent`) - SKETCH › CREATE › Ellipse (`CircleElipse`) -- SKETCH › CREATE › Center Point Slot (`ShapeSlotCenterPoint`) -- SKETCH › CREATE › Three Point Arc Slot (`ShapeArcSlotThreePoint`) -- SKETCH › CREATE › Center Point Arc Slot (`ShapeArcSlotCenterTwoPoint`) - SKETCH › CREATE › Fit Point Spline (`DrawSpline`) - SKETCH › CREATE › Control Point Spline (`DrawCVMSpline3D`) - SKETCH › CREATE › Control Point Spline (`DrawCVMSpline5D`) - SKETCH › CREATE › Conic Curve (`ConicCurveCmd`) - SKETCH › CREATE › Text (`MTextCmd`) - SKETCH › CREATE › Fit Curves to Mesh Section (`FitCurvesToSectionCommand`) -- SKETCH › CREATE › Mirror (`MirrorSketchCommand`) -- SKETCH › CREATE › Circular Pattern (`CircularSketchPatternCommand`) -- SKETCH › CREATE › Rectangular Pattern (`RectangularSketchPatternCommand`) - SKETCH › CREATE › Spun Profile (`SpunProfileCmd`) - SKETCH › CREATE › Project To Surface (`ProjectToSurface`) - SKETCH › CREATE › Intersection Curve (`IntersectionCurve`) - SKETCH › CREATE › Isoparametric Curve (`SketchIsoparametricCurve`) -- SKETCH › MODIFY › Fillet (`FilletSketchCmd`) -- SKETCH › MODIFY › Equal Distance Chamfer (`ChamferSketchEqualDistance`) -- SKETCH › MODIFY › Distance and Angle Chamfer (`ChamferSketchDistanceAngle`) -- SKETCH › MODIFY › Two Distance Chamfer (`ChamferSketchDistanceDistance`) - SKETCH › MODIFY › Blend Curve (`BlendG1CurveSketchCmd`) -- SKETCH › MODIFY › Offset (`Offset`) -- SKETCH › MODIFY › Trim (`TrimSketchCmd`) -- SKETCH › MODIFY › Extend (`ExtendSketchCmd`) -- SKETCH › MODIFY › Break (`BreakSketchCmd`) -- SKETCH › MODIFY › Sketch Scale (`SketchScaleCmd`) - SKETCH › CONSTRAINTS › AutoConstrain (`SketchAutoConstraintAndDimCmd`) - SKETCH › CONSTRAINTS › AutoConstrain from datum (`SketchAutoConstraintAndDimFromDatumCmd`) - SKETCH › CONSTRAINTS › Curvature (`ConstraintSmooth`)