diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index d7dfbe0f..6e2c6621 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -69,6 +69,9 @@ fn sketch_json(s: &Session, id: u64) -> Option { CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fully_constrained": det}), CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fully_constrained": det}), CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fully_constrained": det}), + CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fully_constrained": det}), + CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "construction": c.construction, "fully_constrained": det}), + CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fully_constrained": det}), }; if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) { o.insert("link".into(), json!(l)); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index c1e12517..aa40bf59 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -897,7 +897,7 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result { fn round_center(sk: &Sketch, c: usize) -> Option { match sk.curves.get(c)?.kind { CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => Some(c), - CurveKind::Line { .. } => None, + _ => None, } } @@ -910,12 +910,7 @@ fn c_fix(s: &mut Session, p: &Value) -> Result { vec![i] } else { let c = sk.curve_index(r).ok_or_else(|| bad(cmd, format!("unknown entity `{r}`")))?; - match sk.curves.get(c).map(|c| c.kind.clone()) { - Some(CurveKind::Line { a, b }) => vec![a, b], - Some(CurveKind::Circle { c, .. }) => vec![c], - Some(CurveKind::Arc { c, a, b }) => vec![c, a, b], - None => vec![], - } + sk.curves.get(c).map(|c| c.kind.point_ids()).unwrap_or_default() }; let is_fixed = |sk: &Sketch, q: usize| sk.constraints.iter().any(|c| c.kind == ConstraintKind::Fix { p: q }); let all_fixed = pts.iter().all(|q| is_fixed(sk, *q)); diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 86a57775..e56313a3 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -45,8 +45,133 @@ pub static COMMANDS: &[CommandSpec] = .icon("arc_slot") .enabled(in_sketch) .params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"), + CommandSpec::new("CircleElipse", "Ellipse", ellipse) + .at("SKETCH", "CREATE") + .icon("ellipse") + .enabled(in_sketch) + .params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"), + CommandSpec::new("DrawSpline", "Fit Point Spline", spline_fit) + .at("SKETCH", "CREATE") + .icon("spline") + .enabled(in_sketch) + .params("points: [[x,y] or point refs…] (2…500): the spline passes through them"), + CommandSpec::new("DrawCVMSpline3D", "Control Point Spline", spline_cv3) + .at("SKETCH", "CREATE") + .icon("spline_cv") + .enabled(in_sketch) + .params("points: control points (2…500), cubic"), + CommandSpec::new("DrawCVMSpline5D", "Control Point Spline (Degree 5)", spline_cv5) + .at("SKETCH", "CREATE") + .icon("spline_cv") + .enabled(in_sketch) + .params("points: control points (2…500), degree 5"), + CommandSpec::new("ConicCurveCmd", "Conic Curve", conic) + .at("SKETCH", "CREATE") + .icon("conic") + .enabled(in_sketch) + .params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"), ]; +fn ellipse(s: &mut Session, p: &Value) -> Result { + let cmd = "CircleElipse"; + let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?); + let u = (m - c).normalized().ok_or_else(|| bad(cmd, "the major axis end must differ from the centre"))?; + let a = c.dist(m); + let r = match (num(p, "minor_radius"), p.get("minor").and_then(crate::params::vec2)) { + (Some(r), _) => r, + (None, Some(q)) => { + // Minor radius so the ellipse passes through q. + let d = q - c; + let (x, y) = (d.dot(u), d.cross(u).abs()); + let k = 1.0 - (x / a).powi(2); + if k <= 1e-12 { + return Err(bad(cmd, "the minor point must lie within the major axis span")); + } + y / k.sqrt() + } + _ => return Err(bad(cmd, "needs `minor` [x,y] or `minor_radius`")), + }; + if !(r > 1e-9 && r < 1e8) { + return Err(bad(cmd, "the minor radius must be positive")); + } + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let ci = sk.add_point(c, None)?; + let mi = sk.add_point(m, None)?; + let e = sk.add_curve(CurveKind::Ellipse { c: ci, m: mi, r }, None)?; + mark_construction(sk, &[e], p); + Ok((ids_of(sk, &[e]), Vec::new())) + })?; + Ok(result(out, info)) +} + +fn point_list(sk: &mut Sketch, p: &Value, cmd: &str) -> Result> { + let list = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must be a list"))?; + if list.len() < 2 || list.len() > solvecraft_sketch::MAX_SPLINE_POINTS { + return Err(bad(cmd, "a spline needs 2 to 500 points")); + } + let mut out = Vec::new(); + for v in list { + let a = super::sketch::parg(sk, v, cmd)?; + let i = match a.idx() { + Some(i) => i, + None => sk.add_point(a.pos(), None)?, + }; + if out.last() == Some(&i) || out.last().and_then(|l| sk.point(*l)).is_some_and(|q| q.dist(a.pos()) < 1e-9) { + return Err(bad(cmd, "consecutive points must differ")); + } + out.push(i); + } + Ok(out) +} + +fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> Result { + let p = &super::sketch_project::snap_points(s, p, cmd)?; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let pts = point_list(sk, p, cmd)?; + let c = sk.add_curve(CurveKind::Spline { pts, control, degree }, None)?; + mark_construction(sk, &[c], p); + Ok((ids_of(sk, &[c]), Vec::new())) + })?; + Ok(result(out, info)) +} + +fn spline_fit(s: &mut Session, p: &Value) -> Result { + spline(s, p, "DrawSpline", false, 3) +} +fn spline_cv3(s: &mut Session, p: &Value) -> Result { + spline(s, p, "DrawCVMSpline3D", true, 3) +} +fn spline_cv5(s: &mut Session, p: &Value) -> Result { + spline(s, p, "DrawCVMSpline5D", true, 5) +} + +fn conic(s: &mut Session, p: &Value) -> Result { + let cmd = "ConicCurveCmd"; + let rho = num(p, "rho").unwrap_or(0.5); + if !(rho > 1e-6 && rho < 1.0 - 1e-6) { + return Err(bad(cmd, "`rho` must be between 0 and 1")); + } + let p = &super::sketch_project::snap_points(s, p, cmd)?; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let mut idx = Vec::new(); + for k in ["start", "end", "apex"] { + let a = req_parg(sk, p, k, cmd)?; + idx.push(match a.idx() { + Some(i) => i, + None => sk.add_point(a.pos(), None)?, + }); + } + let (a, b, x) = (idx[0], idx[1], idx[2]); + if a == b || a == x || b == x { + return Err(bad(cmd, "start, end and apex must differ")); + } + let c = sk.add_curve(CurveKind::Conic { a, b, apex: x, rho }, None)?; + mark_construction(sk, &[c], p); + Ok((ids_of(sk, &[c]), Vec::new())) + })?; + Ok(result(out, info)) +} + fn midpoint_line(s: &mut Session, p: &Value) -> Result { let cmd = "SketchMidpointLine"; let end = req_vec2(cmd, p, "end")?; @@ -92,7 +217,27 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { None } } - CurveKind::Circle { .. } => None, + CurveKind::Spline { ref pts, control, .. } => { + let q: Vec = pts.iter().filter_map(|i| sk.point(*i)).collect(); + if pts.last() == Some(&pi) { + solvecraft_sketch::spline_end_tangent(&q, control, true) + } else if pts.first() == Some(&pi) { + solvecraft_sketch::spline_end_tangent(&q, control, false).map(|t| -t) + } else { + None + } + } + CurveKind::Conic { a, b, apex, .. } => { + let x = sk.point(apex)?; + if pi == b { + (sk.point(b)? - x).normalized() + } else if pi == a { + (sk.point(a)? - x).normalized() + } else { + None + } + } + _ => None, } } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 0c7f9b8f..c4cdbdb9 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -212,6 +212,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec, Vec return Err(bad("split", "not supported yet for ellipses, splines and conics")), } Ok((pieces, pts)) } @@ -400,11 +401,7 @@ fn extend(s: &mut Session, p: &Value) -> Result { } 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, - } + c.kind.uses(p) } // --------------------------------------------------------------------------------------------- @@ -631,7 +628,7 @@ fn offset_piece(sk: &Sketch, c: usize, forward: bool, d: f64) -> Option { 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, + _ => None, } } @@ -771,11 +768,8 @@ fn entities(sk: &Sketch, p: &Value, cmd: &str) -> Result<(Vec, Vec 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 => {} + if let Some(k) = sk.curves.get(*c).map(|c| &c.kind) { + v.extend(k.point_ids()); } } v.extend(ps); @@ -811,7 +805,17 @@ fn copy_through( let mut made = Vec::new(); for c in cs { let Some(cu) = sk.curves.get(*c).cloned() else { continue }; + let mut kind = cu.kind.clone(); + kind.map_points(&m); let (kind, prefix) = match cu.kind { + CurveKind::Ellipse { r, .. } => { + if let CurveKind::Ellipse { r: slot, .. } = &mut kind { + *slot = r * scale_r; + } + (kind, "e") + } + CurveKind::Spline { .. } => (kind, "s"), + CurveKind::Conic { .. } => (kind, "k"), 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 } => { @@ -948,7 +952,7 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve } } for c in cs { - if let Some(Curve { kind: CurveKind::Circle { r, .. }, .. }) = sk.curves.get_mut(*c) { + if let Some(Curve { kind: CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }, .. }) = sk.curves.get_mut(*c) { *r *= k; } } diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 6fffad9e..66e06c88 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -205,3 +205,58 @@ fn new_creation_tools() { 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:?}"); } + +#[test] +fn ellipse_splines_conic() { + let pi = std::f64::consts::PI; + let mut s = new_sketch(); + let e = run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]})); + let eid = ids(&e["curves"])[0].clone(); + let a = profiles(&s); + assert!((a[0] - pi * 40.0).abs() / (pi * 40.0) < 2e-3, "{a:?}"); + // A point constrained onto the ellipse lands on it. + run(&mut s, "DrawPoint", json!({"point": [3, 5], "id": "q"})); + run(&mut s, "ConstraintCoincident", json!({"a": "q", "b": eid})); + let sk = sketch(&s); + let q = sk.point(sk.point_index("q").unwrap()).unwrap(); + let poly = sk.polyline(sk.curve_index(&eid).unwrap()); + let d = poly.windows(2).map(|w| { + let (a, b) = (w[0], w[1]); + let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0); + q.dist(a + (b - a) * t) + }); + assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}"); + // Fit spline closed by a line: one profile. + let sp = run(&mut s, "DrawSpline", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]})); + let sid = ids(&sp["curves"])[0].clone(); + run(&mut s, "DrawPolyline", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]})); + assert_eq!(profiles(&s).len(), 2); + // Moving a fit point reshapes the spline (it is a sketch point). + let sk = sketch(&s); + let si = sk.curve_index(&sid).unwrap(); + let pts = sk.curves[si].kind.point_ids(); + let pid = sk.points[pts[1]].id.clone(); + run(&mut s, "sketch.move_point", json!({"point": pid, "to": [110, 12]})); + let sk = sketch(&s); + assert!(sk.polyline(si).iter().any(|q| q.dist(Vec2::new(110.0, 12.0)) < 1e-9)); + // Control point splines (degree 3 and 5) and a conic. + run(&mut s, "DrawCVMSpline3D", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]})); + run(&mut s, "DrawCVMSpline5D", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]})); + let w = std::f64::consts::FRAC_1_SQRT_2; + let k = run(&mut s, "ConicCurveCmd", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)})); + let kid = ids(&k["curves"])[0].clone(); + // A line tangent to the conic at its end: the tangent arc tool continues it. + let r = run(&mut s, "ArcTangent", json!({"start": format!("{kid}.end"), "end": [190, 0]})); + let aid = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + // The conic is a quarter circle about (200, 0) leaving (200, 10) heading -x: the tangent + // arc from there to (190, 0) continues the same circle. + let ai = sk.curve_index(&aid).unwrap(); + assert!(sk.center(ai).unwrap().dist(Vec2::new(200.0, 0.0)) < 1e-6, "{:?}", sk.center(ai)); + assert!(s.execute("DrawSpline", &json!({"points": [[0, 0]]})).is_err()); + assert!(s.execute("ConicCurveCmd", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err()); + // Mirror and pattern copy free-form curves too. + let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone(); + let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m})); + assert_eq!(ids(&r["curves"]).len(), 2); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b3cef4f9..c9e423d8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -276,6 +276,11 @@ fn hostile_params_never_panic() { "chain", "at", "curve", + "major", + "minor", + "minor_radius", + "apex", + "rho", ]; let mut internal = Vec::new(); for spec in command_specs() { diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs new file mode 100644 index 00000000..9db6a6a5 --- /dev/null +++ b/crates/sketch/src/curves.rs @@ -0,0 +1,214 @@ +//! Free-form sketch curves: ellipses, splines (through fit points, or a clamped B-spline over +//! control points) and conics (rational quadratic Béziers). Evaluation works on positions so +//! the solver can use it with trial values. + +use std::f64::consts::TAU; + +use solvecraft_geom::Vec2; + +/// Polyline samples per spline span / conic / ellipse quarter. +const SPAN_SAMPLES: usize = 24; +/// Most points a spline may have. +pub const MAX_SPLINE_POINTS: usize = 500; + +/// Point on an ellipse with centre `c`, major-axis end `m` and minor radius `r` at angle `t`. +pub fn ellipse_point(c: Vec2, m: Vec2, r: f64, t: f64) -> Vec2 { + let u = m - c; + let a = u.len().max(1e-12); + let v = u.perp() / a * r; + c + u * t.cos() + v * t.sin() +} + +pub fn ellipse_polyline(c: Vec2, m: Vec2, r: f64) -> Vec { + let n = SPAN_SAMPLES * 4; + (0..=n).map(|i| ellipse_point(c, m, r, TAU * i as f64 / n as f64)).collect() +} + +/// Rational quadratic Bézier from `a` to `b` with control `apex` and shape `rho` in (0, 1) +/// (0.5 = parabola, below = ellipse, above = hyperbola). +pub fn conic_point(a: Vec2, apex: Vec2, b: Vec2, rho: f64, t: f64) -> Vec2 { + let w = rho / (1.0 - rho).max(1e-9); + let (b0, b1, b2) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t) * w, t * t); + let den = b0 + b1 + b2; + (a * b0 + apex * b1 + b * b2) / den.max(1e-12) +} + +pub fn conic_polyline(a: Vec2, apex: Vec2, b: Vec2, rho: f64) -> Vec { + let n = SPAN_SAMPLES * 2; + (0..=n).map(|i| conic_point(a, apex, b, rho, i as f64 / n as f64)).collect() +} + +/// Natural cubic interpolation through `p` (chord-length parameters): per span the cubic +/// Hermite data (start, end, start tangent, end tangent) in span-parameter units. +fn fit_spans(p: &[Vec2]) -> Vec<(Vec2, Vec2, Vec2, Vec2)> { + let n = p.len(); + if n < 2 { + return Vec::new(); + } + let h: Vec = p.windows(2).map(|w| w[0].dist(w[1]).max(1e-9)).collect(); + if n == 2 { + let d = p[1] - p[0]; + return vec![(p[0], p[1], d, d)]; + } + // Second derivatives M (natural ends: M0 = Mn = 0) by the tridiagonal (Thomas) solve. + let m = n - 2; + let mut diag = vec![0.0; m]; + let mut up = vec![0.0; m]; + let mut low = vec![0.0; m]; + let mut rhs = vec![Vec2::ZERO; m]; + for i in 0..m { + let (h0, h1) = (h[i], h[i + 1]); + diag[i] = 2.0 * (h0 + h1); + low[i] = h0; + up[i] = h1; + rhs[i] = ((p[i + 2] - p[i + 1]) / h1 - (p[i + 1] - p[i]) / h0) * 6.0; + } + for i in 1..m { + let w = low[i] / diag[i - 1]; + diag[i] -= w * up[i - 1]; + rhs[i] = rhs[i] - rhs[i - 1] * w; + } + let mut sol = vec![Vec2::ZERO; m]; + for i in (0..m).rev() { + let next = if i + 1 < m { sol[i + 1] * up[i] } else { Vec2::ZERO }; + sol[i] = (rhs[i] - next) / diag[i]; + } + let mm = |i: usize| if i == 0 || i == n - 1 { Vec2::ZERO } else { sol[i - 1] }; + (0..n - 1) + .map(|i| { + let hi = h[i]; + let slope = (p[i + 1] - p[i]) / hi; + // First derivatives (per unit chord parameter) at both ends of the span. + let d0 = slope - (mm(i) * 2.0 + mm(i + 1)) * (hi / 6.0); + let d1 = slope + (mm(i) + mm(i + 1) * 2.0) * (hi / 6.0); + (p[i], p[i + 1], d0 * hi, d1 * hi) + }) + .collect() +} + +fn hermite(p0: Vec2, p1: Vec2, t0: Vec2, t1: Vec2, s: f64) -> Vec2 { + let (s2, s3) = (s * s, s * s * s); + p0 * (2.0 * s3 - 3.0 * s2 + 1.0) + t0 * (s3 - 2.0 * s2 + s) + p1 * (-2.0 * s3 + 3.0 * s2) + t1 * (s3 - s2) +} + +/// Clamped uniform B-spline of `degree` over control points `p`, sampled. +fn bspline_polyline(p: &[Vec2], degree: usize) -> Vec { + let n = p.len(); + let k = degree.min(n.saturating_sub(1)).max(1); + // Knots: k+1 zeros, uniform interior, k+1 ones. + let spans = n - k; + let mut knots = vec![0.0; k + 1]; + for i in 1..spans { + knots.push(i as f64 / spans as f64); + } + knots.extend(std::iter::repeat_n(1.0, k + 1)); + let total = SPAN_SAMPLES * spans; + (0..=total) + .map(|i| { + let t = i as f64 / total as f64; + // De Boor. + let mut s = k; + while s + 1 < n && knots.get(s + 1).is_some_and(|x| *x <= t) { + s += 1; + } + let mut d: Vec = (0..=k).map(|j| p.get(j + s - k).copied().unwrap_or_default()).collect(); + for r in 1..=k { + for j in (r..=k).rev() { + let i0 = j + s - k; + let (lo, hi) = (knots.get(i0).copied().unwrap_or(0.0), knots.get(i0 + k + 1 - r).copied().unwrap_or(1.0)); + let a = if hi - lo > 1e-15 { (t - lo) / (hi - lo) } else { 0.0 }; + d[j] = d[j - 1] * (1.0 - a) + d[j] * a; + } + } + d[k] + }) + .collect() +} + +/// Polyline of a spline through (`control == false`) or over (`control`) the points. +pub fn spline_polyline(p: &[Vec2], control: bool, degree: u8) -> Vec { + if p.len() < 2 { + return p.to_vec(); + } + if control { + return bspline_polyline(p, degree.clamp(1, 7) as usize); + } + let mut out = Vec::new(); + for (p0, p1, t0, t1) in fit_spans(p) { + for i in 0..SPAN_SAMPLES { + out.push(hermite(p0, p1, t0, t1, i as f64 / SPAN_SAMPLES as f64)); + } + } + if let Some(l) = p.last() { + out.push(*l); + } + out +} + +/// Unit tangent of a spline at its start (`at_end == false`) or end, pointing along the curve. +pub fn spline_end_tangent(p: &[Vec2], control: bool, at_end: bool) -> Option { + let n = p.len(); + if n < 2 { + return None; + } + if control { + return if at_end { (p[n - 1] - p[n - 2]).normalized() } else { (p[1] - p[0]).normalized() }; + } + let spans = fit_spans(p); + if at_end { spans.last().and_then(|s| s.3.normalized()) } else { spans.first().and_then(|s| s.2.normalized()) } +} + +/// Distance from `q` to a polyline. +pub fn polyline_dist(poly: &[Vec2], q: Vec2) -> f64 { + let mut best = f64::INFINITY; + for w in poly.windows(2) { + let (a, b) = (w[0], w[1]); + let d = b - a; + let l2 = d.len2(); + let t = if l2 > 0.0 { ((q - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + best = best.min(q.dist(a + d * t)); + } + if poly.len() == 1 { + best = poly[0].dist(q); + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fit_spline_passes_through_its_points_and_is_smooth() { + let p = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 5.0), Vec2::new(20.0, 0.0), Vec2::new(30.0, 8.0)]; + let poly = spline_polyline(&p, false, 3); + for q in p { + assert!(poly.iter().any(|x| x.dist(q) < 1e-9)); + } + // A straight row of points stays straight. + let line = [Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(3.0, 3.0)]; + assert!(spline_polyline(&line, false, 3).iter().all(|q| (q.x - q.y).abs() < 1e-9)); + let t = spline_end_tangent(&line, false, false).unwrap(); + assert!((t.x - t.y).abs() < 1e-9); + } + + #[test] + fn control_spline_is_clamped_and_conic_hits_its_ends() { + let p = [Vec2::new(0.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(10.0, 10.0), Vec2::new(10.0, 0.0)]; + let poly = spline_polyline(&p, true, 3); + assert!(poly.first().unwrap().dist(p[0]) < 1e-12 && poly.last().unwrap().dist(p[3]) < 1e-12); + // Cubic Bézier midpoint. + let mid = poly[poly.len() / 2]; + assert!(mid.dist(Vec2::new(5.0, 7.5)) < 1e-9, "{mid:?}"); + let c = conic_polyline( + Vec2::new(10.0, 0.0), + Vec2::new(10.0, 10.0), + Vec2::new(0.0, 10.0), + std::f64::consts::FRAC_1_SQRT_2 / (1.0 + std::f64::consts::FRAC_1_SQRT_2), + ); + // w = cos(45°): a quarter circle of radius 10 about the origin. + assert!(c.iter().all(|q| (q.len() - 10.0).abs() < 1e-9)); + let e = ellipse_polyline(Vec2::ZERO, Vec2::new(5.0, 0.0), 2.0); + assert!(e.iter().all(|q| ((q.x / 5.0).powi(2) + (q.y / 2.0).powi(2) - 1.0).abs() < 1e-9)); + } +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 6f683412..e2d1f261 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod curves; mod linalg; mod link; mod model; @@ -15,6 +16,7 @@ mod profiles; mod shape; mod solver; +pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, spline_end_tangent, spline_polyline}; 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}; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index d7971621..2397f147 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -274,7 +274,7 @@ impl Sketch { Some(CurveKind::Line { a, b }) => Some(LCurve::Line(local(*a)?, local(*b)?)), Some(CurveKind::Circle { c, r }) => Some(LCurve::Circle(local(*c)?, *r)), Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)), - None => None, + _ => None, }) .collect(); // The new layout numbers points in first-use order: curves first, then lone points, diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 082df49f..2a453675 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -43,6 +43,82 @@ pub enum CurveKind { /// Counter-clockwise arc around `c` from `a` to `b` (radius = |a − c|; the solver keeps /// |b − c| equal to it). Arc { c: usize, a: usize, b: usize }, + /// Ellipse: centre `c`, end of the major axis `m`, minor radius `r`. + Ellipse { c: usize, m: usize, r: f64 }, + /// Spline through its points (fit points) or, with `control`, a clamped B-spline of + /// `degree` over them (control points). + Spline { + pts: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + control: bool, + #[serde(default = "three")] + degree: u8, + }, + /// Conic from `a` to `b` shaped by the `apex` point and `rho` in (0, 1) (0.5 parabola, + /// less elliptical, more hyperbolic). + Conic { a: usize, b: usize, apex: usize, rho: f64 }, +} + +fn three() -> u8 { + 3 +} + +impl CurveKind { + /// Every point the curve uses. + pub fn point_ids(&self) -> Vec { + match self { + CurveKind::Line { a, b } => vec![*a, *b], + CurveKind::Circle { c, .. } => vec![*c], + CurveKind::Arc { c, a, b } => vec![*c, *a, *b], + CurveKind::Ellipse { c, m, .. } => vec![*c, *m], + CurveKind::Spline { pts, .. } => pts.clone(), + CurveKind::Conic { a, b, apex, .. } => vec![*a, *b, *apex], + } + } + pub fn uses(&self, p: usize) -> bool { + self.point_ids().contains(&p) + } + /// Renumber the points the curve uses. + pub fn map_points(&mut self, f: &dyn Fn(usize) -> usize) { + match self { + CurveKind::Line { a, b } => { + *a = f(*a); + *b = f(*b); + } + CurveKind::Circle { c, .. } => *c = f(*c), + CurveKind::Arc { c, a, b } => { + *c = f(*c); + *a = f(*a); + *b = f(*b); + } + CurveKind::Ellipse { c, m, .. } => { + *c = f(*c); + *m = f(*m); + } + CurveKind::Spline { pts, .. } => { + for q in pts.iter_mut() { + *q = f(*q); + } + } + CurveKind::Conic { a, b, apex, .. } => { + *a = f(*a); + *b = f(*b); + *apex = f(*apex); + } + } + } + /// Start and end points of an open curve. + pub fn ends(&self) -> Option<(usize, usize)> { + match self { + CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } => Some((*a, *b)), + CurveKind::Spline { pts, .. } => Some((*pts.first()?, *pts.last()?)), + CurveKind::Circle { .. } | CurveKind::Ellipse { .. } => None, + } + } + /// Free-form curves (drawn and profiled as polylines). + pub fn is_freeform(&self) -> bool { + matches!(self, CurveKind::Ellipse { .. } | CurveKind::Spline { .. } | CurveKind::Conic { .. }) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -384,7 +460,7 @@ impl Sketch { Ok(self.points.len() - 1) } - fn curve_id(&mut self, id: Option<&str>, prefix: &str) -> Result { + pub(crate) fn curve_id(&mut self, id: Option<&str>, prefix: &str) -> Result { match id { Some(i) if self.id_taken(i) => Err(SketchError::Duplicate(i.into())), Some("") => Err(SketchError::Invalid("empty id".into())), @@ -510,7 +586,8 @@ impl Sketch { need_round(b)?; } Tangent { a, b } => { - if !(is_round(a) || is_round(b)) { + let free = |i: usize| self.curves.get(i).is_some_and(|c| c.kind.is_freeform()); + if !(is_round(a) || is_round(b) || free(a) || free(b)) { return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into())); } } @@ -535,19 +612,23 @@ impl Sketch { match self.curves.get(ci)?.kind { CurveKind::Circle { r, .. } => Some(r), CurveKind::Arc { c, a, .. } => Some(self.point(c)?.dist(self.point(a)?)), - CurveKind::Line { .. } => None, + _ => None, } } pub fn center(&self, ci: usize) -> Option { match self.curves.get(ci)?.kind { - CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => self.point(c), - CurveKind::Line { .. } => None, + CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } | CurveKind::Ellipse { c, .. } => self.point(c), + _ => None, } } /// Boundary segments of a curve (a circle gives two half arcs). pub fn segs(&self, ci: usize) -> Vec { let Some(cu) = self.curves.get(ci) else { return Vec::new() }; + if cu.kind.is_freeform() { + let poly = self.polyline(ci); + return poly.windows(2).filter(|w| w[0].dist(w[1]) > 1e-12).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect(); + } match cu.kind { CurveKind::Line { a, b } => match (self.point(a), self.point(b)) { (Some(a), Some(b)) => vec![Seg2::Line { a, b }], @@ -568,20 +649,83 @@ impl Sketch { } _ => Vec::new(), }, + _ => Vec::new(), } } + /// Polyline of a free-form curve (ellipse, spline, conic); other curves: their segments' + /// polylines. + pub fn polyline(&self, ci: usize) -> Vec { + let Some(cu) = self.curves.get(ci) else { return Vec::new() }; + let pt = |i: &usize| self.point(*i); + match &cu.kind { + CurveKind::Ellipse { c, m, r } => match (pt(c), pt(m)) { + (Some(c), Some(m)) => crate::curves::ellipse_polyline(c, m, *r), + _ => Vec::new(), + }, + CurveKind::Spline { pts, control, degree } => { + let p: Option> = pts.iter().map(pt).collect(); + p.map(|p| crate::curves::spline_polyline(&p, *control, *degree)).unwrap_or_default() + } + CurveKind::Conic { a, b, apex, rho } => match (pt(a), pt(apex), pt(b)) { + (Some(a), Some(x), Some(b)) => crate::curves::conic_polyline(a, x, b, *rho), + _ => Vec::new(), + }, + _ => { + let mut out: Vec = Vec::new(); + for s in self.segs(ci) { + for q in s.polyline(1e-3) { + if out.last().is_none_or(|l| l.dist(q) > 1e-12) { + out.push(q); + } + } + } + out + } + } + } + + /// Add a curve of any kind over existing points. + pub fn add_curve(&mut self, kind: CurveKind, id: Option<&str>) -> Result { + let ids = kind.point_ids(); + if ids.iter().any(|p| *p >= self.points.len()) { + return Err(SketchError::Invalid("curve point out of range".into())); + } + let prefix = match &kind { + CurveKind::Line { .. } => "l", + CurveKind::Circle { .. } => "c", + CurveKind::Arc { .. } => "a", + CurveKind::Ellipse { m, c, r } => { + if m == c || !(r.is_finite() && *r > 1e-9 && *r < 1e9) { + return Err(SketchError::Invalid("degenerate ellipse".into())); + } + "e" + } + CurveKind::Spline { pts, degree, .. } => { + if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=7).contains(degree) { + return Err(SketchError::Invalid("a spline needs 2 to 500 points and degree 1 to 7".into())); + } + "s" + } + CurveKind::Conic { rho, .. } => { + if !(rho.is_finite() && *rho > 1e-6 && *rho < 1.0 - 1e-6) { + return Err(SketchError::Invalid("rho must be between 0 and 1".into())); + } + "k" + } + }; + let id = self.curve_id(id, prefix)?; + self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None }); + Ok(self.curves.len() - 1) + } + /// Remove curves (by index) and then any points no longer used by a curve (except the /// origin and free points), and constraints that referred to removed entities. pub fn remove_curves(&mut self, which: &[usize]) { let mut owned: Vec = Vec::new(); for (i, c) in self.curves.iter().enumerate() { if which.contains(&i) { - match c.kind { - CurveKind::Line { a, b } => owned.extend([a, b]), - CurveKind::Circle { c, .. } => owned.push(c), - CurveKind::Arc { c, a, b } => owned.extend([c, a, b]), - } + owned.extend(c.kind.point_ids()); } } self.drop_curves(which); @@ -593,11 +737,7 @@ impl Sketch { } fn point_used_by_curve(&self, p: usize) -> bool { - self.curves.iter().any(|c| 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, - }) + self.curves.iter().any(|c| c.kind.uses(p)) } /// Remove curves only (points stay), remapping constraints. @@ -627,17 +767,8 @@ impl Sketch { return; } let gone = |p: usize| p != 0 && which.contains(&p); - let dead_curves: Vec = self - .curves - .iter() - .enumerate() - .filter(|(_, c)| match c.kind { - CurveKind::Line { a, b } => gone(a) || gone(b), - CurveKind::Circle { c, .. } => gone(c), - CurveKind::Arc { c, a, b } => gone(c) || gone(a) || gone(b), - }) - .map(|(i, _)| i) - .collect(); + let dead_curves: Vec = + self.curves.iter().enumerate().filter(|(_, c)| c.kind.point_ids().into_iter().any(gone)).map(|(i, _)| i).collect(); self.drop_curves(&dead_curves); let mut pmap = vec![None; self.points.len()]; let mut n = 0; @@ -649,18 +780,7 @@ impl Sketch { } let pm = |i: usize| pmap.get(i).copied().flatten(); for c in &mut self.curves { - match &mut c.kind { - CurveKind::Line { a, b } => { - *a = pm(*a).unwrap_or(0); - *b = pm(*b).unwrap_or(0); - } - CurveKind::Circle { c, .. } => *c = pm(*c).unwrap_or(0), - CurveKind::Arc { c, a, b } => { - *c = pm(*c).unwrap_or(0); - *a = pm(*a).unwrap_or(0); - *b = pm(*b).unwrap_or(0); - } - } + c.kind.map_points(&|i| pm(i).unwrap_or(0)); } let cm = |i: usize| Some(i); self.constraints.retain_mut(|c| c.kind.remap(&pm, &cm)); @@ -686,9 +806,11 @@ impl Sketch { (r, _) => r, }; match (&c.kind, role) { - (CurveKind::Line { a, .. }, "start") | (CurveKind::Arc { a, .. }, "start") => Some(*a), - (CurveKind::Line { b, .. }, "end") | (CurveKind::Arc { b, .. }, "end") => Some(*b), - (CurveKind::Circle { c, .. }, "center") | (CurveKind::Arc { c, .. }, "center") => Some(*c), + (CurveKind::Circle { c, .. }, "center") | (CurveKind::Arc { c, .. }, "center") | (CurveKind::Ellipse { c, .. }, "center") => Some(*c), + (CurveKind::Ellipse { m, .. }, "major") => Some(*m), + (CurveKind::Conic { apex, .. }, "apex") => Some(*apex), + (k, "start") => k.ends().map(|e| e.0), + (k, "end") => k.ends().map(|e| e.1), _ => None, } } diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 2d795e6f..8cadb43d 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -128,6 +128,14 @@ pub fn find_profiles(sk: &Sketch) -> Vec { shapes.push((ci, Shape::Round { c: center, r: radius, start, sweep })); } } + // Free-form curves take part as their polylines. + _ => { + for s in sk.segs(ci) { + if let Seg2::Line { a, b } = s { + shapes.push((ci, Shape::Line { a, b })); + } + } + } } if shapes.len() > MAX_EDGES { break; diff --git a/crates/sketch/src/shape.rs b/crates/sketch/src/shape.rs index 804e00f1..86d5b763 100644 --- a/crates/sketch/src/shape.rs +++ b/crates/sketch/src/shape.rs @@ -161,6 +161,7 @@ impl Sketch { Seg2::Arc { center, radius, start, sweep } => Some(Shape::Round { c: center, r: radius, start, sweep }), Seg2::Line { .. } => None, }, + _ => None, } } } diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 76033567..3c6b9384 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -86,7 +86,7 @@ fn layout(sk: &Sketch) -> Layout { .curves .iter() .map(|c| match c.kind { - CurveKind::Circle { .. } if c.link.is_none() => { + CurveKind::Circle { .. } | CurveKind::Ellipse { .. } if c.link.is_none() => { n += 1; Some(n - 1) } @@ -136,6 +136,53 @@ impl State<'_> { fn is_line(&self, c: usize) -> bool { matches!(self.sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) } + fn is_freeform(&self, c: usize) -> bool { + self.sk.curves.get(c).is_some_and(|c| c.kind.is_freeform()) + } + /// Polyline of a free-form curve with trial values. + fn poly(&self, c: usize) -> Vec { + match self.sk.curves.get(c).map(|c| &c.kind) { + Some(CurveKind::Ellipse { c: ci, m, r }) => { + let r = match self.lay.rvar.get(c).copied().flatten() { + Some(v) => self.x.get(v).copied().unwrap_or(*r), + None => *r, + }; + crate::curves::ellipse_polyline(self.p(*ci), self.p(*m), r) + } + Some(CurveKind::Spline { pts, control, degree }) => { + let p: Vec = pts.iter().map(|q| self.p(*q)).collect(); + crate::curves::spline_polyline(&p, *control, *degree) + } + Some(CurveKind::Conic { a, b, apex, rho }) => crate::curves::conic_polyline(self.p(*a), self.p(*apex), self.p(*b), *rho), + _ => Vec::new(), + } + } + /// Unit tangent of any curve at its point `p` (an end point for open free-form curves). + fn tangent_at(&self, c: usize, p: usize) -> Option { + match self.sk.curves.get(c).map(|c| &c.kind)? { + CurveKind::Line { a, b } => (self.p(*b) - self.p(*a)).normalized(), + CurveKind::Circle { .. } | CurveKind::Arc { .. } => (self.p(p) - self.round(c).0).perp().normalized(), + CurveKind::Spline { pts, control, .. } => { + let q: Vec = pts.iter().map(|i| self.p(*i)).collect(); + crate::curves::spline_end_tangent(&q, *control, pts.last() == Some(&p) && pts.first() != Some(&p)) + } + CurveKind::Conic { a, b, apex, .. } => { + if p == *a { + (self.p(*apex) - self.p(*a)).normalized() + } else if p == *b { + (self.p(*b) - self.p(*apex)).normalized() + } else { + None + } + } + CurveKind::Ellipse { .. } => { + let poly = self.poly(c); + let q = self.p(p); + let k = (0..poly.len().saturating_sub(1)).min_by(|i, j| poly[*i].dist(q).total_cmp(&poly[*j].dist(q)))?; + (*poly.get(k + 1)? - *poly.get(k)?).normalized() + } + } + } } fn wrap_angle(a: f64) -> f64 { @@ -166,7 +213,9 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { } PointOnCurve { p, c } => { let pp = s.p(p); - if s.is_line(c) { + if s.is_freeform(c) { + out.push(crate::curves::polyline_dist(&s.poly(c), pp)); + } else if s.is_line(c) { let (a, b) = s.line(c); out.push(line_dist(pp, a, b)); } else { @@ -200,7 +249,22 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { } Tangent { a, b } => { let (la, lb) = (s.is_line(a), s.is_line(b)); - if la || lb { + if s.is_freeform(a) || s.is_freeform(b) { + // Without a shared end point: the curves touch (closest approach zero). + let (f, o) = if s.is_freeform(a) { (a, b) } else { (b, a) }; + let poly = s.poly(f); + let d = if s.is_line(o) { + let (p0, p1) = s.line(o); + poly.iter().map(|q| line_dist(*q, p0, p1).abs()).fold(f64::INFINITY, f64::min) + } else if s.is_freeform(o) { + let other = s.poly(o); + poly.iter().map(|q| crate::curves::polyline_dist(&other, *q)).fold(f64::INFINITY, f64::min) + } else { + let (cc, r) = s.round(o); + poly.iter().map(|q| (q.dist(cc) - r).abs()).fold(f64::INFINITY, f64::min) + }; + out.push(if d.is_finite() { d } else { 0.0 }); + } else if la || lb { let (l, c) = if la { (a, b) } else { (b, a) }; let (p0, p1) = s.line(l); let (cc, r) = s.round(c); @@ -284,7 +348,13 @@ fn block_residuals(s: &State, b: &Block, out: &mut Vec) { Block::TangentAt(_, k, p) => { let ConstraintKind::Tangent { a, b } = **k else { return }; let pt = s.p(*p); - if s.is_line(a) || s.is_line(b) { + if s.is_freeform(a) || s.is_freeform(b) { + // Directions at the shared end parallel. + match (s.tangent_at(a, *p), s.tangent_at(b, *p)) { + (Some(u), Some(v)) => out.push(u.cross(v) * 10.0), + _ => out.push(0.0), + } + } else if s.is_line(a) || s.is_line(b) { let (l, c) = if s.is_line(a) { (a, b) } else { (b, a) }; let (p0, p1) = s.line(l); let (cc, _) = s.round(c); @@ -308,8 +378,8 @@ fn touch_point(sk: &Sketch, k: &ConstraintKind) -> Option { let ConstraintKind::Tangent { a, b } = *k else { return None }; let ends = |c: usize| -> Vec { match sk.curves.get(c).map(|c| &c.kind) { - Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => vec![*a, *b], - _ => Vec::new(), + Some(k) => k.ends().map(|(a, b)| vec![a, b]).unwrap_or_default(), + None => Vec::new(), } }; let (ea, eb) = (ends(a), ends(b)); @@ -328,23 +398,15 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { v.extend([x, x + 1]); } }; - let curve = |v: &mut Vec, c: usize| match sk.curves.get(c).map(|c| &c.kind) { - Some(CurveKind::Line { a, b }) => { - pt(v, *a); - pt(v, *b); - } - Some(CurveKind::Circle { c: ci, .. }) => { - pt(v, *ci); + let curve = |v: &mut Vec, c: usize| { + if let Some(k) = sk.curves.get(c).map(|c| &c.kind) { + for q in k.point_ids() { + pt(v, q); + } if let Some(r) = lay.rvar.get(c).copied().flatten() { v.push(r); } } - Some(CurveKind::Arc { c, a, b }) => { - pt(v, *c); - pt(v, *a); - pt(v, *b); - } - None => {} }; use ConstraintKind::*; match b { @@ -546,7 +608,7 @@ pub fn solve(sk: &mut Sketch) -> SolveReport { } } for (i, c) in sk.curves.iter().enumerate() { - if let (CurveKind::Circle { r, .. }, Some(v)) = (&c.kind, lay.rvar.get(i).copied().flatten()) + if let (CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }, Some(v)) = (&c.kind, lay.rvar.get(i).copied().flatten()) && let Some(s) = x.get_mut(v) { *s = *r; @@ -654,7 +716,7 @@ pub fn solve(sk: &mut Sketch) -> SolveReport { } } for (i, c) in sk.curves.iter_mut().enumerate() { - if let (CurveKind::Circle { r, .. }, Some(v)) = (&mut c.kind, lay.rvar.get(i).copied().flatten()) { + if let (CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }, Some(v)) = (&mut c.kind, lay.rvar.get(i).copied().flatten()) { *r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9); } } @@ -664,16 +726,8 @@ pub fn solve(sk: &mut Sketch) -> SolveReport { None => true, }; let point_determined: Vec = (0..sk.points.len()).map(pdet).collect(); - let curve_determined = sk - .curves - .iter() - .enumerate() - .map(|(i, c)| match c.kind { - CurveKind::Line { a, b } => pdet(a) && pdet(b), - CurveKind::Circle { c, .. } => pdet(c) && vdet(lay.rvar.get(i).copied().flatten()), - CurveKind::Arc { c, a, b } => pdet(c) && pdet(a) && pdet(b), - }) - .collect(); + let curve_determined = + sk.curves.iter().enumerate().map(|(i, c)| c.kind.point_ids().into_iter().all(pdet) && vdet(lay.rvar.get(i).copied().flatten())).collect(); SolveReport { status, dof: lay.n.saturating_sub(rank), diff --git a/docs/parity.md b/docs/parity.md index 64e42d7f..adac3979 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ 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: 84 / 291 commands live (29%).** All Design tabs: 177 / 1175 (15%). Registered commands: 110. +**SOLID + SKETCH: 89 / 291 commands live (31%).** All Design tabs: 182 / 1175 (15%). Registered commands: 115. | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -15,7 +15,7 @@ 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 | 29 | 41 | 71% | +| SKETCH | CREATE | 34 | 41 | 83% | | SKETCH | MODIFY | 11 | 12 | 92% | | SKETCH | CONSTRAINTS | 11 | 15 | 73% | | SKETCH | CONFIGURE | 0 | 3 | 0% | @@ -147,11 +147,16 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SKETCH › CREATE › Circumscribed Polygon (`ShapePolygonCircumscribed`) - SKETCH › CREATE › Inscribed Polygon (`ShapePolygonInscribed`) - SKETCH › CREATE › Edge Polygon (`ShapePolygonEdge`) +- SKETCH › CREATE › Ellipse (`CircleElipse`) - 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 › Fit Point Spline (`DrawSpline`) +- SKETCH › CREATE › Control Point Spline (`DrawCVMSpline3D`) +- SKETCH › CREATE › Control Point Spline (`DrawCVMSpline5D`) +- SKETCH › CREATE › Conic Curve (`ConicCurveCmd`) - SKETCH › CREATE › Point (`DrawPoint`) - SKETCH › CREATE › Mirror (`MirrorSketchCommand`) - SKETCH › CREATE › Circular Pattern (`CircularSketchPatternCommand`) @@ -325,11 +330,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › POSITION › Capture Position (`SnapshotCmd`) - SOLID › POSITION › Revert Position (`AsBuiltPositionsCmd`) - SKETCH › CREATE › SketchConstrainer (`SketchConstrainer`) -- SKETCH › CREATE › Ellipse (`CircleElipse`) -- 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 › Spun Profile (`SpunProfileCmd`)