diff --git a/crates/doc/src/construct.rs b/crates/doc/src/construct.rs index dc41296c3f..6b21b84653 100644 --- a/crates/doc/src/construct.rs +++ b/crates/doc/src/construct.rs @@ -428,31 +428,66 @@ fn centre(ctx: &Ctx, g: &GeoRef) -> Result { } } -/// Point and unit tangent at fraction `t` along a polyline. -fn along(pts: &[Vec3], t: f64) -> Option<(Vec3, Vec3)> { - let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum(); - if !(total > 0.0) { - return None; - } - let mut left = t.clamp(0.0, 1.0) * total; - for w in pts.windows(2) { - let l = (w[1] - w[0]).len(); - if l <= 0.0 { - continue; - } - if left <= l { - return Some((w[0] + (w[1] - w[0]) * (left / l), (w[1] - w[0]) * (1.0 / l))); - } - left -= l; - } - let (a, b) = (pts.get(pts.len() - 2)?, pts.last()?); - Some((*b, (*b - *a).normalized()?)) +/// The point a fraction `t` (0…1) of the way along a picked edge or sketch curve by arc length, +/// and the unit direction of travel there ([`kernel::along_path`]): exact on the curve itself. +fn along(ctx: &Ctx, g: &GeoRef, t: f64) -> Result<(Vec3, Vec3)> { + Ok(kernel::along_path(&path_segs(ctx, g)?, t)?) } -fn path_points(ctx: &Ctx, g: &GeoRef) -> Result> { +/// A picked edge or sketch curve as the path segments that are its curve exactly, from the end +/// the drawn curve starts at: a body edge's record curve ([`kernel::edge_path`]), a planar sketch +/// curve's exact pieces, a 3D sketch curve's arc, spline, line or linked exact curves. Only a +/// curve that exists as points alone runs along them: an intersection edge (its record holds no +/// exact curve, only the surfaces and a leader near it), a 3D curve known only as its points +/// (projected onto a face, iso curves), a spline above the exact pieces' degree. +fn path_segs(ctx: &Ctx, g: &GeoRef) -> Result> { + let lines = |pts: &[Vec3]| -> Vec { + pts.windows(2).filter(|w| w[0].dist(w[1]) > 0.0).map(|w| kernel::PathSeg::Line { a: w[0], b: w[1] }).collect() + }; match g { - GeoRef::Edge { at, name } => edge_points(ctx.st, *at, name), - GeoRef::SketchCurve { sketch, curve } => sketch_curve_points(ctx, *sketch, curve), + GeoRef::Edge { at, name } => { + let (b, i, pts) = edge_pick(ctx.st, *at, name)?; + let procedural = + b.body.record().edges.get(i).is_some_and(|e| matches!(e.parameters(), solidcraft_geom::brep::Curve::SurfaceIntersection { .. })); + if procedural { + return Ok(lines(&pts)); + } + let segs = kernel::edge_path(&b.body, i)?; + // The drawn edge sets which way t runs, as the picked edge's polyline has it. + let (Some(first), Some(start)) = (pts.first(), segs.first().map(kernel::PathSeg::start)) else { return Ok(segs) }; + let end = segs.last().map(kernel::PathSeg::end).unwrap_or(start); + Ok(if end.dist(*first) < start.dist(*first) { segs.iter().rev().map(kernel::PathSeg::reversed).collect() } else { segs }) + } + GeoRef::SketchCurve { sketch, curve } => { + let ss = ctx.st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; + // Evaluation has already mapped wires into this component's frame: their coordinates + // are independent of the sketch plane. + if let Some(wire) = ss.sketch.wires.iter().find(|wire| wire.id == *curve) { + if let Some(arc) = &wire.arc { + let angle = solidcraft_sketch::arc_sweep(arc); + return Ok(vec![kernel::PathSeg::Arc { a: arc.start, center: arc.center, axis: arc.normal, angle }]); + } + if wire.fit.len() >= 3 { + let spline = kernel::PathPiece::Spline { fit: wire.fit.clone(), reversed: false }; + if let Some(spans) = spline.spans()? { + return Ok(spans); + } + } + if let [a, b] = wire.fit.as_slice() { + return Ok(vec![kernel::PathSeg::Line { a: *a, b: *b }]); + } + if !wire.exact.is_empty() { + let mut segs = Vec::new(); + for c in &wire.exact { + segs.extend(kernel::curve_path(&c.curve, c.interval)?); + } + return Ok(segs); + } + return Ok(lines(&wire.pts)); + } + let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?; + Ok(ss.sketch.exact_pieces(ci).iter().map(|s| kernel::PathSeg::of_sketch(&ss.plane, s)).collect()) + } _ => Err(bad("pick an edge or sketch curve as the path")), } } @@ -571,7 +606,7 @@ pub(crate) fn resolve_point(ctx: &Ctx, d: &PointDef) -> Result { } PointDef::AlongPath { path, t } => { let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?; - along(&path_points(ctx, path)?, t).map(|(p, _)| p).ok_or_else(|| bad("the path has no length")) + along(ctx, path, t).map(|(p, _)| p) } } } @@ -634,7 +669,7 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result { } PlaneRef::AlongPath { path, t } => { let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?; - let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?; + let (p, tan) = along(ctx, path, t)?; Plane::from_normal(p, tan).ok_or_else(degenerate) } PlaneRef::ThroughPoint { base, point: at } => { diff --git a/crates/engine/src/construct_tests.rs b/crates/engine/src/construct_tests.rs index a124cd4b6d..9afbb31ba9 100644 --- a/crates/engine/src/construct_tests.rs +++ b/crates/engine/src/construct_tests.rs @@ -213,6 +213,24 @@ fn point_along_a_path() { run(&mut s, "construct.point.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.25, "name": "P"})); let p = point(&s, "P"); assert!(near(p, Vec3::new(10.0, 0.0, 0.0)) || near(p, Vec3::new(30.0, 0.0, 0.0)), "{p:?}"); + // On an arc it is the arc's own point by arc length, to rounding: a third of a quarter turn + // of radius 10 is 30° round. A t past either end is refused, not moved onto it. + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Round"})); + let arc = run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]}))["created"]["curves"][0] + .as_str() + .unwrap() + .to_string(); + run(&mut s, "sketch.finish", json!({})); + let path = json!({"sketch": "Round", "curve": arc}); + run(&mut s, "construct.point.along_path", json!({"path": path, "t": "1/3", "name": "Third"})); + let third = point(&s, "Third"); + let want = Vec3::new(10.0 * (std::f64::consts::PI / 6.0).cos(), 5.0, 0.0); + assert!(third.dist(want) < 1e-12, "{third:?} is {:e} off", third.dist(want)); + for t in [json!(1.5), json!(-0.25), json!("2")] { + let e = s.execute("construct.point.along_path", &json!({"path": path, "t": t})).unwrap_err(); + assert!(e.to_string().contains("0 (its start) to 1 (its end)"), "t {t}: {e}"); + assert!(s.execute("construct.plane.along_path", &json!({"path": path, "t": t})).is_err(), "plane at t {t}"); + } } #[test] diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 7b65f1d863..8dfbb96c41 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1234,6 +1234,62 @@ impl PathSeg { } } + /// The segment as the rational Bézier span in space that is the same curve on the same + /// parameter: a sketch cubic, Bézier or conic carried onto its plane (an affine image keeps + /// the weights). `None` for a line or an arc, which have closed forms of their own, and for a + /// span that does not evaluate. + fn space_bezier(&self) -> Option { + use solidcraft_geom::bspline::HPoint; + let PathSeg::Curve { plane, seg } = *self else { + return match *self { + PathSeg::Bezier(b) => Some(b), + _ => None, + }; + }; + let w = |p: Vec2, weight: f64| HPoint::weighted(plane.to_world(p), weight); + match seg { + Seg2::Cubic { p0, p1, p2, p3 } => crate::point_path::SpaceBezier::new(&[w(p0, 1.0), w(p1, 1.0), w(p2, 1.0), w(p3, 1.0)]), + Seg2::Bezier { n, p } => { + let ctrl: Vec = p.get(..=usize::from(n))?.iter().map(|q| w(*q, 1.0)).collect(); + crate::point_path::SpaceBezier::new(&ctrl) + } + Seg2::Conic { a, apex, b, w: weight } => crate::point_path::SpaceBezier::new(&[w(a, 1.0), w(apex, weight), w(b, 1.0)]), + Seg2::Line { .. } | Seg2::Arc { .. } => None, + } + } + + /// The segment's length: a line's and an arc's in closed form, a free-form span's by its + /// arc-length integral ([`crate::SpaceBezier::length`]). `None` for a segment that does not + /// evaluate. + pub fn length(&self) -> Option { + let l = match *self { + PathSeg::Line { a, b } => a.dist(b), + PathSeg::Arc { a, center, angle, .. } => a.dist(center) * angle.abs(), + PathSeg::Curve { plane, seg: seg @ (Seg2::Line { .. } | Seg2::Arc { .. }) } => return PathSeg::of_sketch(&plane, &seg).length(), + PathSeg::Curve { .. } | PathSeg::Bezier(_) => self.space_bezier()?.length()?, + }; + l.is_finite().then_some(l) + } + + /// The point a fraction `f` (0…1) of the way along the segment by arc length, and the unit + /// direction of travel there: a line's and an arc's in closed form (an arc's length grows + /// with its angle), a free-form span's at the parameter where its arc-length integral reaches + /// that fraction ([`crate::SpaceBezier::along`]). `None` for a segment that does not + /// evaluate or has no direction. + pub fn along(&self, f: f64) -> Option<(Vec3, Vec3)> { + let f = f.clamp(0.0, 1.0); + match *self { + PathSeg::Line { a, b } => Some((a.lerp(b, f), (b - a).normalized()?)), + PathSeg::Arc { a, center, axis, angle } => { + let k = axis.normalized()?; + let p = rotate_about(a, center, k, angle * f); + Some((p, (k.cross(p - center) * angle.signum()).normalized()?)) + } + PathSeg::Curve { plane, seg: seg @ (Seg2::Line { .. } | Seg2::Arc { .. }) } => PathSeg::of_sketch(&plane, &seg).along(f), + PathSeg::Curve { .. } | PathSeg::Bezier(_) => self.space_bezier()?.along(f), + } + } + /// The segment the other way round. pub fn reversed(&self) -> PathSeg { match *self { diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 350f646a78..a0cce026bb 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -201,7 +201,8 @@ pub use native::{brep_record, decode_native_body, encode_native_body, native_pre pub use offset::with_same_surface; pub use ops::{BoolOp, boolean, half_space, join_apart, place_matrix, split_by_plane, transform, transform_matrix, union_all, union_apart}; pub use point_path::{ - PathPiece, PointPath, SpaceBezier, curve_path, edge_path, face_section_along, project_onto_face, sketch_record_curve, sweep_point_path_with, + PathPiece, PointPath, SpaceBezier, along_path, curve_path, edge_path, face_section_along, project_onto_face, sketch_record_curve, + sweep_point_path_with, }; pub use polybool::faceted_boolean; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; diff --git a/crates/kernel/src/point_path.rs b/crates/kernel/src/point_path.rs index 14174d2a06..94bb0384e9 100644 --- a/crates/kernel/src/point_path.rs +++ b/crates/kernel/src/point_path.rs @@ -65,7 +65,7 @@ impl PathPiece { /// The piece's exact segments in the direction of travel: a spline of three or more fit /// points as its spans ([`PathSeg::Bezier`]), a curve held exactly as its segments; `None` /// for a polyline (and a spline of one or two points, a point or a line). - pub(crate) fn spans(&self) -> Result>> { + pub fn spans(&self) -> Result>> { let (fit, reversed) = match self { PathPiece::Spline { fit, reversed } => (fit, reversed), PathPiece::Segs(segs) => return Ok(Some(segs.clone())), @@ -141,6 +141,33 @@ pub fn sweep_point_path_with(plane: &Plane, region: &Region2, path: &PointPath, crate::sweep_path::smlib_sweep_points(plane, region, path, ctl) } +/// The point a fraction `t` of the way along `path` (its segments end to end) by arc length, and +/// the unit direction of travel there: 0 is where the path starts, 1 where it ends. Each +/// segment's length and its point at a length are exact ([`PathSeg::length`], +/// [`PathSeg::along`]): closed forms for lines and arcs, the arc-length parameter of a free-form +/// span. A `t` outside 0…1 is refused: the path does not go on past its ends. +pub fn along_path(path: &[PathSeg], t: f64) -> Result<(Vec3, Vec3)> { + if !(0.0..=1.0).contains(&t) { + return Err(KernelError::Invalid(format!("t is {t}: a point along a path is 0 (its start) to 1 (its end) of the way along it"))); + } + let bad = || KernelError::Invalid("a segment of the path does not evaluate".into()); + let lengths: Vec = path.iter().map(|s| s.length().ok_or_else(bad)).collect::>()?; + let total: f64 = lengths.iter().sum(); + if !(total > 0.0 && total.is_finite()) { + return Err(KernelError::Invalid("the path has no length".into())); + } + let mut left = t * total; + let live = || path.iter().zip(&lengths).filter(|(_, l)| **l > 0.0); + let last = live().next_back().ok_or_else(bad)?; + for (seg, &l) in live() { + if left <= l || std::ptr::eq(seg, last.0) { + return seg.along((left / l).min(1.0)).ok_or_else(bad); + } + left -= l; + } + Err(bad()) +} + /// Most segments one edge's curve may give a path. const MAX_EDGE_SEGS: usize = 1000; diff --git a/tests/kernel-cases/construction/PATHS b/tests/kernel-cases/construction/PATHS index e90299883a..153661e563 100644 --- a/tests/kernel-cases/construction/PATHS +++ b/tests/kernel-cases/construction/PATHS @@ -4,6 +4,8 @@ crates/engine/src/cmd/construct.rs # How a construction reference resolves, and the planes the document resolves. crates/doc/src/construct.rs crates/doc/src/document.rs +# A point along a path: the path's exact curves by arc length. +crates/kernel/src/point_path.rs # The constructions as evaluated, which the grader reads. crates/engine/src/view.rs tests/kernel-cases/construction/** diff --git a/tests/kernel-cases/construction/cov-point-along-path-a-third-of-an-arc/expected.json b/tests/kernel-cases/construction/cov-point-along-path-a-third-of-an-arc/expected.json index 22389d2aa6..a51e07b65a 100644 --- a/tests/kernel-cases/construction/cov-point-along-path-a-third-of-an-arc/expected.json +++ b/tests/kernel-cases/construction/cov-point-along-path-a-third-of-an-arc/expected.json @@ -2,7 +2,7 @@ "sources": [], "what": "a point a third along a quarter-circle arc", "source": "analytic", - "method": "the arc r 10 about the origin from (10,0) to (0,10) counter-clockwise: a third of its length is 30 deg round: (10 cos 30, 10 sin 30, 0) = (8.6603, 5, 0)", + "method": "the arc r 10 about the origin from (10,0) to (0,10) counter-clockwise: a third of its length is 30 deg round: (10 cos 30, 10 sin 30, 0) = (8.6603, 5, 0); the point is on the arc itself, so it is graded to 1e-9 mm (a chord polyline is 0.87 um off)", "construction": { "P": { "point": [ @@ -11,5 +11,8 @@ 0 ] } + }, + "tolerance": { + "abs_mm": 1e-09 } -} +} \ No newline at end of file diff --git a/tests/kernel-cases/construction/cov-point-along-path-middle-of-a-symmetric-spline/expected.json b/tests/kernel-cases/construction/cov-point-along-path-middle-of-a-symmetric-spline/expected.json new file mode 100644 index 0000000000..dcb4a3af83 --- /dev/null +++ b/tests/kernel-cases/construction/cov-point-along-path-middle-of-a-symmetric-spline/expected.json @@ -0,0 +1,18 @@ +{ + "sources": [], + "what": "a point half way along a cubic spline symmetric about x = 20", + "source": "analytic", + "method": "the Bezier (0,0), (10,10), (30,10), (40,0) is its own mirror image in x = 20 (the parameter running backwards), so half its length is reached at its middle, u = 1/2: B(1/2) = (P0 + 3 P1 + 3 P2 + P3) / 8 = (20, 7.5, 0). Graded to 1e-9 mm: the point by the spline's own arc length, not a polyline's", + "tolerance": { + "abs_mm": 1e-09 + }, + "construction": { + "P": { + "point": [ + 20.0, + 7.5, + 0.0 + ] + } + } +} \ No newline at end of file diff --git a/tests/kernel-cases/construction/cov-point-along-path-middle-of-a-symmetric-spline/recipe.json b/tests/kernel-cases/construction/cov-point-along-path-middle-of-a-symmetric-spline/recipe.json new file mode 100644 index 0000000000..999c2023d4 --- /dev/null +++ b/tests/kernel-cases/construction/cov-point-along-path-middle-of-a-symmetric-spline/recipe.json @@ -0,0 +1,49 @@ +{ + "commands": [ + { + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "Path" + } + }, + { + "command": "sketch.spline.control_point", + "params": { + "points": [ + [ + 0, + 0 + ], + [ + 10, + 10 + ], + [ + 30, + 10 + ], + [ + 40, + 0 + ] + ] + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "construct.point.along_path", + "params": { + "path": { + "sketch": "Path", + "curve": "s1" + }, + "t": 0.5, + "name": "P" + } + } + ] +} \ No newline at end of file diff --git a/tests/kernel-cases/construction/cov-point-along-path-past-the-end/expected.json b/tests/kernel-cases/construction/cov-point-along-path-past-the-end/expected.json index bc40d5224f..13bdaf691b 100644 --- a/tests/kernel-cases/construction/cov-point-along-path-past-the-end/expected.json +++ b/tests/kernel-cases/construction/cov-point-along-path-past-the-end/expected.json @@ -1,15 +1,8 @@ { "sources": [], - "what": "a point at t 1.5, past the end of a sketch line", + "what": "a point at t 1.5, past the end of a sketch line, is refused", "source": "analytic", - "method": "t runs 0..1 along the path; a point along it can go no further than its end: (40,0,0)", - "construction": { - "P": { - "point": [ - 40, - 0, - 0 - ] - } - } -} + "method": "t runs 0..1 along the path (the command's contract: `t: expr, 0\u20261 along it`); 1.5 is past its end, and a point along the path can go no further, so it is refused rather than moved back to the end", + "outcome": "refused", + "refusal": "0 (its start) to 1 (its end)" +} \ No newline at end of file diff --git a/tests/kernel-cases/construction/cov-point-along-path-quarter-of-a-line/expected.json b/tests/kernel-cases/construction/cov-point-along-path-quarter-of-a-line/expected.json index 6d5d351439..801a5a368d 100644 --- a/tests/kernel-cases/construction/cov-point-along-path-quarter-of-a-line/expected.json +++ b/tests/kernel-cases/construction/cov-point-along-path-quarter-of-a-line/expected.json @@ -2,7 +2,7 @@ "sources": [], "what": "a point a quarter along a sketch line", "source": "analytic", - "method": "the line l1 from (0,0,0) to (40,0,0): t 0.25 is (10,0,0)", + "method": "the line l1 from (0,0,0) to (40,0,0): t 0.25 is (10,0,0); exact, graded to 1e-9 mm", "construction": { "P": { "point": [ @@ -11,5 +11,8 @@ 0 ] } + }, + "tolerance": { + "abs_mm": 1e-09 } -} +} \ No newline at end of file