diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 7e342096..281f998f 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -327,9 +327,9 @@ fn merge_split_circles(geom: Vec) -> Vec { let near = |p: Vec2, q: Vec2| p.dist(q) <= TOL * (1.0 + p.len().max(q.len())); let mut out = geom.clone(); for i in 0..geom.len() { - let Some(LinkGeom::Arc { c, a, b }) = geom.get(i).copied() else { continue }; + let Some(LinkGeom::Arc { c, a, b }) = geom.get(i).cloned() else { continue }; for j in i + 1..geom.len() { - if let Some(LinkGeom::Arc { c: c2, a: a2, b: b2 }) = geom.get(j).copied() + if let Some(LinkGeom::Arc { c: c2, a: a2, b: b2 }) = geom.get(j).cloned() && near(c, c2) && near(a, a2) && near(b, b2) @@ -365,14 +365,136 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec } _ => Vec::new(), }, + // Free-form curves in a parallel plane map exactly through their defining points. + Some(CurveKind::Ellipse { c, m, r }) if parallel => match (sk.point(*c), sk.point(*m)) { + (Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }], + _ => Vec::new(), + }, + Some(CurveKind::Spline { pts, control, degree }) if parallel => { + let p: Option> = pts.iter().map(|q| sk.point(*q).map(map)).collect(); + p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default() + } + Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) { + (Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }], + _ => Vec::new(), + }, Some(_) => { - let pts: Vec = sk.segs(ci).iter().flat_map(|s| s.polyline(1e-3)).map(|p| from.to_world(p)).collect(); + let pts: Vec = sk.polyline(ci).iter().map(|p| from.to_world(*p)).collect(); fit_world(to, &pts) } None => Vec::new(), } } +/// A circle in space (centre, unit normal, radius) through the polyline's points, and whether +/// the polyline closes. +fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> { + let n = pts.len(); + if n < 4 { + return None; + } + let closed = pts.first()?.dist(*pts.last()?) <= 1e-9 * pts.first()?.len().max(1.0); + let (a, b, c) = if closed { (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?) } else { (*pts.first()?, *pts.get(n / 2)?, *pts.last()?) }; + let (ab, ac) = (b - a, c - a); + let nrm = ab.cross(ac); + let l2 = nrm.len2(); + if l2 < 1e-24 { + return None; + } + // Circumcentre of the triangle a, b, c. + let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) / (2.0 * l2); + let r = centre.dist(a); + let un = nrm.normalized()?; + let tol = 1e-7 * r.max(1.0); + pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - centre).dot(un).abs() < tol).then_some((centre, un, r, closed)) +} + +/// A circle seen at a slant is an ellipse, exactly: a full circle becomes an ellipse; an arc +/// becomes conics (rational quadratic Béziers of at most 90 degrees each — parallel projection +/// keeps their weights). +fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option> { + let (c, n, r, closed) = circle3(pts)?; + let sn = plane.normal(); + let cosang = n.dot(sn).abs(); + if cosang > 1.0 - 1e-12 || cosang < 1e-9 { + return None; + } + if closed { + let u = n.cross(sn).normalized()?; + return Some(vec![LinkGeom::Ellipse { c: plane.to_local(c), major: plane.to_local(c + u * r), minor: r * cosang }]); + } + // Sweep from the first point along the polyline's direction. + let (p0, p1) = (*pts.first()?, *pts.last()?); + let e1 = (p0 - c).normalized()?; + let e2 = n.cross(e1); + let ang = |p: Vec3| (p - c).dot(e2).atan2((p - c).dot(e1)); + let mut sweep = 0.0; + for w in pts.windows(2) { + let mut d = ang(w[1]) - ang(w[0]); + while d > std::f64::consts::PI { + d -= std::f64::consts::TAU; + } + while d < -std::f64::consts::PI { + d += std::f64::consts::TAU; + } + sweep += d; + } + let _ = p1; + let k = ((sweep.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8); + let h = sweep / k as f64; + let at = |t: f64| c + (e1 * t.cos() + e2 * t.sin()) * r; + let w = (h.abs() / 2.0).cos(); + let rho = w / (1.0 + w); + let mut out = Vec::new(); + for i in 0..k { + let (t0, t1) = (h * i as f64, h * (i + 1) as f64); + let apex = c + (e1 * ((t0 + t1) / 2.0).cos() + e2 * ((t0 + t1) / 2.0).sin()) * (r / w); + out.push(LinkGeom::Conic { a: plane.to_local(at(t0)), apex: plane.to_local(apex), b: plane.to_local(at(t1)), rho }); + } + Some(out) +} + +/// A smooth chain that fitting broke into many short lines is better as one fit spline (a +/// projected spline edge or elliptical arc): when a spline through a few of its points stays +/// within `tol` of all of them. +fn as_spline(pts: &[Vec2], tol: f64) -> Option { + let n = pts.len(); + if n < 4 { + return None; + } + // No sharp corners. + for w in pts.windows(3) { + let (Some(u), Some(v)) = ((w[1] - w[0]).normalized(), (w[2] - w[1]).normalized()) else { continue }; + if u.dot(v) < 30f64.to_radians().cos() { + return None; + } + } + for k in [8usize, 16, 32, 64] { + let step = (n - 1) as f64 / k as f64; + let mut fit: Vec = (0..=k).map(|i| pts[((i as f64 * step).round() as usize).min(n - 1)]).collect(); + fit.dedup_by(|a, b| a.dist(*b) < 1e-12); + if fit.len() < 2 { + return None; + } + let poly = solvecraft_sketch::spline_polyline(&fit, false, 3); + let ok = pts.iter().all(|q| { + poly.windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + let d = b - a; + let t = if d.len2() > 0.0 { ((*q - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 }; + q.dist(a + d * t) + }) + .fold(f64::INFINITY, f64::min) + <= tol + }); + if ok { + return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 }); + } + } + None +} + /// The line where another plane crosses the sketch plane (None when parallel). fn plane_trace(sk: &Plane, other: &Plane) -> Option { let m = other.normal(); @@ -389,9 +511,19 @@ fn plane_trace(sk: &Plane, other: &Plane) -> Option { /// Project a world polyline and fit lines and arcs to it. fn fit_world(plane: &Plane, pts: &[Vec3]) -> Vec { + if let Some(e) = tilted_circle(plane, pts) { + return e; + } let p2: Vec = pts.iter().map(|p| plane.to_local(*p)).collect(); let mut out = Vec::new(); fit_chain(&p2, None, &mut out); + // Many little lines from one smooth edge: a spline instead. + if out.len() > 4 && out.iter().all(|g| matches!(g, LinkGeom::Line(..))) { + let ext = p2.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x).min(p.y), hi.max(p.x).max(p.y))); + if let Some(sp) = as_spline(&p2, (ext.1 - ext.0).abs().max(1e-9) * 1e-4) { + return vec![sp]; + } + } out } diff --git a/crates/doc/src/project3d.rs b/crates/doc/src/project3d.rs index e09bdff0..6b8e0a7b 100644 --- a/crates/doc/src/project3d.rs +++ b/crates/doc/src/project3d.rs @@ -404,6 +404,10 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec { (0..=48).map(|i| c + Vec2::from_angle(s0 + sw * i as f64 / 48.0) * r).collect() } LinkGeom::Conic { a, apex, b, rho } => (0..=32).map(|i| solvecraft_sketch::conic_point(a, apex, b, rho, i as f64 / 32.0)).collect(), + LinkGeom::Ellipse { c, major, minor } => { + (0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect() + } + LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree), LinkGeom::Point(_) => Vec::new(), } } diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index a8c20ffd..11201f52 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -298,3 +298,55 @@ fn isoparametric_curves_of_a_cylinder() { assert!(zs.iter().cloned().fold(f64::MIN, f64::max) > 19.9 && zs.iter().cloned().fold(f64::MAX, f64::min) < 0.1, "{zs:?}"); assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3)); } + +#[test] +fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() { + let mut s = Session::default(); + run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20})); + run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": -10})); + // The base circle (centred at the origin) seen from above. + let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, 5, 0]}]})); + // One seam half: exact conics (rational quadratic arcs). + let si = inspect(&mut s); + let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone(); + assert_eq!(c["type"], "conic", "{c}"); + let st = s.model.state(); + let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch; + for ci in sk.link_curves(r["links"][0]["link"].as_str().unwrap()) { + for q in sk.polyline(ci) { + // On the ellipse x²/(5/√2)² + y²/5² = 1. + let e = (q.x / (5.0 * std::f64::consts::FRAC_1_SQRT_2)).powi(2) + (q.y / 5.0).powi(2); + assert!((e - 1.0).abs() < 1e-9, "{q:?}"); + } + } + // The whole face (both halves of its rim, plus its seam) gives the full ellipse. + let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"face": [0, 0, 0]}]})); + let si = inspect(&mut s); + let types: Vec = r["links"][0]["curves"] + .as_array() + .unwrap() + .iter() + .map(|id| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == *id).unwrap()["type"].as_str().unwrap().to_string()) + .collect(); + assert!(types.iter().all(|t| t == "conic" || t == "ellipse"), "{types:?}"); + let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, -5, 0]}]})); + let si = inspect(&mut s); + let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone(); + if c["type"] == "ellipse" { + assert!((c["minor_radius"].as_f64().unwrap() - 5.0 * std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6, "{c}"); + let major = &c["major_at"]; + assert!(((major[0].as_f64().unwrap().powi(2) + major[1].as_f64().unwrap().powi(2)).sqrt() - 5.0).abs() < 1e-6, "{c}"); + } + run(&mut s, "SketchStop", json!({})); + // A spline of a parallel sketch projects as the same spline. + run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 30})); + let sp = run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone(); + run(&mut s, "SketchStop", json!({})); + let sk1 = s.doc.features.last().unwrap().id; + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": sp}]})); + let si = inspect(&mut s); + let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone(); + assert_eq!(c["type"], "spline", "{c}"); + assert_eq!(c["points"].as_array().unwrap().len(), 3); +} diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 8eac0a12..835b07ee 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -110,7 +110,7 @@ pub struct Link { } /// Geometry a link produces, in sketch coordinates. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub enum LinkGeom { Point(Vec2), Line(Vec2, Vec2), @@ -128,6 +128,18 @@ pub enum LinkGeom { b: Vec2, rho: f64, }, + /// Full ellipse: centre, end of the major axis, minor radius. + Ellipse { + c: Vec2, + major: Vec2, + minor: f64, + }, + /// Spline through (`control == false`) or over (`control`) the points. + Spline { + pts: Vec, + control: bool, + degree: u8, + }, } impl LinkGeom { @@ -139,6 +151,12 @@ impl LinkGeom { LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9, LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL, LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6, + LinkGeom::Ellipse { c, major, minor } => { + ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9 + } + LinkGeom::Spline { pts, degree, .. } => { + (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) && (1..=7).contains(degree) + } } } } @@ -157,6 +175,10 @@ enum LCurve { Circle(usize, f64), Arc(usize, usize, usize), Conic(usize, usize, usize, f64), + Ellipse(usize, usize, f64), + /// Start, end, first interior point and interior count (interior points are consecutive + /// and never shared), control flag, degree. + Spline(usize, usize, usize, usize, bool, u8), } fn layout(geom: &[LinkGeom]) -> Result { @@ -172,7 +194,7 @@ fn layout(geom: &[LinkGeom]) -> Result { pts.len() - 1 } for g in geom.iter().filter(|g| g.finite()) { - match *g { + match g.clone() { LinkGeom::Point(p) => { let i = pt(&mut l.pts, p); if !l.lone.contains(&i) { @@ -203,6 +225,21 @@ fn layout(geom: &[LinkGeom]) -> Result { l.curves.push(LCurve::Conic(a, b, x, rho)); } } + LinkGeom::Ellipse { c, major, minor } => { + let (c, m) = (pt(&mut l.pts, c), pt(&mut l.pts, major)); + if c != m { + l.curves.push(LCurve::Ellipse(c, m, minor)); + } + } + LinkGeom::Spline { pts, control, degree } => { + let (Some(first), Some(last)) = (pts.first(), pts.last()) else { continue }; + let s0 = pt(&mut l.pts, *first); + let first_in = l.pts.len(); + let inner = pts.get(1..pts.len() - 1).unwrap_or_default(); + l.pts.extend(inner.iter().copied()); + let s1 = pt(&mut l.pts, *last); + l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree)); + } } } // Lone points that a curve also uses are not lone. @@ -212,6 +249,8 @@ fn layout(geom: &[LinkGeom]) -> Result { LCurve::Circle(c, _) => c == i, LCurve::Arc(c, a, b) => c == i || a == i || b == i, LCurve::Conic(a, b, x, _) => a == i || b == i || x == i, + LCurve::Ellipse(c, m, _) => c == i || m == i, + LCurve::Spline(a, b, f, n, ..) => a == i || b == i || (i >= f && i < f + n), }) }; let curves = l.curves.clone(); @@ -226,6 +265,8 @@ fn same_shape(a: &LCurve, b: &LCurve) -> bool { | (LCurve::Circle(..), LCurve::Circle(..)) | (LCurve::Arc(..), LCurve::Arc(..)) | (LCurve::Conic(..), LCurve::Conic(..)) + | (LCurve::Ellipse(..), LCurve::Ellipse(..)) + | (LCurve::Spline(..), LCurve::Spline(..)) ) } @@ -248,6 +289,8 @@ fn same_geometry(a: &[(LCurve, Vec)], b: &[(LCurve, Vec)]) -> bool { (LCurve::Circle(_, ra), LCurve::Circle(_, rb)) => all() && close(*ra, *rb), (LCurve::Arc(..), LCurve::Arc(..)) => all(), (LCurve::Conic(.., ra), LCurve::Conic(.., rb)) => all() && close(*ra, *rb), + (LCurve::Ellipse(.., ra), LCurve::Ellipse(.., rb)) => all() && close(*ra, *rb), + (LCurve::Spline(.., ca, da), LCurve::Spline(.., cb, db)) => ca == cb && da == db && all(), _ => false, } }; @@ -274,6 +317,13 @@ fn curve_points(c: &LCurve) -> Vec { LCurve::Circle(c, _) => vec![c], LCurve::Arc(c, a, b) => vec![c, a, b], LCurve::Conic(a, b, x, _) => vec![a, b, x], + LCurve::Ellipse(c, m, _) => vec![c, m], + LCurve::Spline(a, b, f, n, ..) => { + let mut v = vec![a]; + v.extend(f..f + n); + v.push(b); + v + } } } @@ -346,6 +396,15 @@ impl Sketch { LCurve::Circle(c, r) => (CurveKind::Circle { c: point(self, c)?, r }, "c"), LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"), LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"), + LCurve::Ellipse(c, m, r) => (CurveKind::Ellipse { c: point(self, c)?, m: point(self, m)?, r }, "e"), + LCurve::Spline(a, b, f, n, control, degree) => { + let mut pts = vec![point(self, a)?]; + for i in f..f + n { + pts.push(point(self, i)?); + } + pts.push(point(self, b)?); + (CurveKind::Spline { pts, control, degree }, "s") + } }; let cid = self.fresh(prefix); self.curves.push(Curve { id: cid, kind, construction: false, reversed: false, link: Some(id.to_string()), centerline: false }); @@ -376,6 +435,18 @@ impl Sketch { 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)?)), Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)), + Some(CurveKind::Ellipse { c, m, r }) => Some(LCurve::Ellipse(local(*c)?, local(*m)?, *r)), + Some(CurveKind::Spline { pts, control, degree }) => { + let (a, b) = (local(*pts.first()?)?, local(*pts.last()?)?); + let inner: Vec = + pts.get(1..pts.len().saturating_sub(1)).unwrap_or_default().iter().map(|q| local(*q)).collect::>()?; + let f = inner.first().copied().unwrap_or(0); + // Interior points were made one after another. + if inner.iter().enumerate().any(|(k, x)| *x != f + k) { + return None; + } + Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree)) + } _ => None, }) .collect(); @@ -404,6 +475,17 @@ impl Sketch { visit(b); visit(x); } + LCurve::Ellipse(c, m, _) => { + visit(c); + visit(m); + } + LCurve::Spline(a, b, f, n, ..) => { + visit(a); + for i in f..f + n { + visit(i); + } + visit(b); + } } } for i in &lay.lone { @@ -418,6 +500,8 @@ impl Sketch { LCurve::Circle(c, r) => LCurve::Circle(renum(c), r), LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)), LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r), + LCurve::Ellipse(c, m, r) => LCurve::Ellipse(renum(c), renum(m), r), + LCurve::Spline(a, b, f, n, ct, d) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d), }) .collect(); let same = match &cur { @@ -431,6 +515,10 @@ impl Sketch { (LCurve::Circle(a0, _), LCurve::Circle(b0, _)) => a0 == b0, (LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2, (LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2, + (LCurve::Ellipse(a0, a1, _), LCurve::Ellipse(b0, b1, _)) => a0 == b0 && a1 == b1, + (LCurve::Spline(a0, a1, a2, a3, a4, a5), LCurve::Spline(b0, b1, b2, b3, b4, b5)) => { + a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 + } _ => false, } }) @@ -454,6 +542,9 @@ impl Sketch { if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) { *rho = *r; } + if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) { + *slot = *r; + } } return Ok(false); }