Sketch: Project onto Surface keeps the side the projection meets first, in one curve

SMLib's parallel projection (CreateParallelProjectionCurves) gives every curve where the
projecting lines cross the face: a line projected down onto a ball came back as its top and its
bottom, each split at the pole, and the link's first wire was half the bottom. A projection onto
a surface is where those lines first hit it (owner's decision, as mainstream CAD does): a curve
is now kept only when the first hit on the body's mesh along the line through its middle is the
curve itself (within 4 chord tolerances), and the pieces that meet end to end are joined in
whatever order and direction they come, a piece run the other way exactly (its knots and
control points reversed; a line's, circle's or ellipse's parameter mirrored). The ball's top
is then one wire from x = -10 to 10.

three_d_sketch_curves now expects that exact curve: one wire, its ends at (±10, 0, √300), every
point of its exact curves on the sphere of radius 20 in y = 0 over the top, to the projection's
own tolerance (a millionth of the body's size), and sweeps_and_pipes_follow_3d_sketch_curves
pipes along the whole joined top. Both pass on the pinned SMLib (90aabff): the pieces lie 9.1e-6
off the sphere against a 4e-5 tolerance and meet at the pole within 4.7e-8 rad, since the fork's
8d95600 keeps the pole's exact tangent in StepBackFromSingularity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 00:46:51 -04:00
committed by Irakli Kourtis
parent ac70662d63
commit 7664925fbf
2 changed files with 122 additions and 18 deletions
+104 -14
View File
@@ -366,8 +366,9 @@ fn edge_curves(body: Option<&crate::ModelBody>, e: usize) -> Vec<WireCurve> {
}
/// The sketch segments `segs` of `ss` projected along the sketch normal `normal` (turned toward
/// the face) onto the face of `target` at `at`: SMLib's projection, the pieces that meet end to
/// end joined into one wire each, every wire its points (for display) and its exact curves.
/// the face) onto the face of `target` at `at`: SMLib's projection, the side of the face the
/// projection meets first ([`first_hits`]), the pieces that meet end to end joined into one wire
/// each, every wire its points (for display) and its exact curves.
fn projected_wires(
target: &crate::ModelBody,
at: Vec3,
@@ -378,30 +379,119 @@ fn projected_wires(
) -> Result<(Vec<Vec<Vec3>>, Vec<Vec<WireCurve>>)> {
let from = segs.first().map(|s| ss.plane.to_world(s.start())).unwrap_or(at);
let toward = if (at - from).dot(normal) < 0.0 { normal * -1.0 } else { normal };
joined_wires(crate::kernel::project_onto_face(&target.body, at, &ss.plane, segs, toward)?, size * 1e-6)
let curves = crate::kernel::project_onto_face(&target.body, at, &ss.plane, segs, toward)?;
joined_wires(first_hits(target, curves, toward)?, size * 1e-6)
}
/// Record curves as wires: curves that meet end to end (within `tol`) one wire, each wire its
/// points (for display) and its exact curves.
/// Of the curves a parallel projection along `toward` made on a face of `target`, the ones the
/// projection meets first: SMLib gives every curve where the projecting lines cross the face
/// (both sides of a sphere), and a projection onto a surface is where they first hit it. A curve
/// is kept when the first hit along the line through its middle, on the body's mesh, is the
/// curve itself (within the mesh's chord tolerance, a few times over), not a point of the body
/// in front of it.
fn first_hits(
target: &crate::ModelBody,
curves: Vec<(solidcraft_geom::brep::Curve, [f64; 2])>,
toward: Vec3,
) -> Result<Vec<(solidcraft_geom::brep::Curve, [f64; 2])>> {
let Some(dir) = toward.normalized() else { return Ok(curves) };
let mesh = target.mesh();
let reach = 2.0 * mesh.bounds().diagonal().max(1e-9);
let slack = 4.0 * target.display_tol();
let mut kept = Vec::with_capacity(curves.len());
for (curve, interval) in curves {
let eval = solidcraft_geom::brep::eval::CurveEval::new(&curve).map_err(|e| DocError::Invalid(format!("projected curve: {e}")))?;
let mid = eval.point(0.5 * (interval[0] + interval[1])).ok_or_else(|| DocError::Invalid("projected curve: no middle point".into()))?;
let origin = mid - dir * reach;
let front = mesh.raycast(origin, dir).is_some_and(|(t, _)| t < reach - slack);
if !front {
kept.push((curve, interval));
}
}
Ok(kept)
}
/// Record curves as wires: curves that meet end to end (within `tol`) one wire, in any order and
/// direction they come (a projection split at a sphere's pole into two pieces that both end there
/// joins again there, the second run the other way), each wire its points (for display) and its
/// exact curves.
fn joined_wires(curves: Vec<(solidcraft_geom::brep::Curve, [f64; 2])>, tol: f64) -> Result<(Vec<Vec<Vec3>>, Vec<Vec<WireCurve>>)> {
let (mut wires, mut exact): (Vec<Vec<Vec3>>, Vec<Vec<WireCurve>>) = (Vec::new(), Vec::new());
for (curve, interval) in curves {
let pts = crate::kernel::PathPiece::Segs(crate::kernel::curve_path(&curve, interval)?).points();
let Some(start) = pts.first().copied() else { continue };
match (wires.last_mut(), exact.last_mut()) {
(Some(w), Some(e)) if w.last().is_some_and(|end| end.dist(start) <= tol) => {
w.extend(pts.into_iter().skip(1));
e.push(WireCurve { curve, interval });
}
_ => {
wires.push(pts);
exact.push(vec![WireCurve { curve, interval }]);
if pts.is_empty() {
continue;
}
wires.push(pts);
exact.push(vec![WireCurve { curve, interval }]);
}
// Join a wire onto the one that ends where it starts (or ends, turned round), until no two
// meet: each join removes a wire.
'join: loop {
for i in 0..wires.len() {
for j in 0..wires.len() {
let (Some(a), Some(b)) = (wires.get(i), wires.get(j)) else { continue };
let (Some(end), Some(start), Some(last)) = (a.last(), b.first(), b.last()) else { continue };
let turned = if i == j {
continue;
} else if end.dist(*start) <= tol {
false
} else if end.dist(*last) <= tol && exact.get(j).is_some_and(|e| e.iter().all(|c| reversed(c).is_some())) {
true
} else {
continue;
};
let (mut w, mut e) = (wires.remove(j), exact.remove(j));
if turned {
w.reverse();
e = e.iter().rev().filter_map(reversed).collect();
}
let i = if j < i { i - 1 } else { i };
if let (Some(into), Some(curves)) = (wires.get_mut(i), exact.get_mut(i)) {
into.extend(w.into_iter().skip(1));
curves.extend(e);
}
continue 'join;
}
}
break;
}
Ok((wires, exact))
}
/// `c` run the other way: the same points, its parameter `t` now `c0 + c1 - t` over the curve's
/// own knots (a line's `1 - t`, a circle's or ellipse's angle negated with its y axis turned).
/// Exact; `None` for a curve kind it doesn't turn.
fn reversed(c: &WireCurve) -> Option<WireCurve> {
use solidcraft_geom::brep::{Curve, Frame};
let [t0, t1] = c.interval;
let flip = |frame: &Frame| Frame { origin: frame.origin, x_axis: frame.x_axis, y_axis: frame.y_axis.map(|v| -v) };
let (curve, interval) = match &c.curve {
Curve::Line { start, end } => (Curve::Line { start: *end, end: *start }, [1.0 - t1, 1.0 - t0]),
Curve::Circle { frame, radius } => (Curve::Circle { frame: flip(frame), radius: *radius }, [-t1, -t0]),
Curve::Ellipse { frame, radii } => (Curve::Ellipse { frame: flip(frame), radii: *radii }, [-t1, -t0]),
Curve::Spline { knots, points } => {
let sum = knots.first()? + knots.last()?;
(
Curve::Spline { knots: knots.iter().rev().map(|k| sum - k).collect(), points: points.iter().rev().copied().collect() },
[sum - t1, sum - t0],
)
}
Curve::RationalSpline { knots, homogeneous } => {
let sum = knots.first()? + knots.last()?;
(
Curve::RationalSpline {
knots: knots.iter().rev().map(|k| sum - k).collect(),
homogeneous: homogeneous.iter().rev().copied().collect(),
},
[sum - t1, sum - t0],
)
}
_ => return None,
};
Some(WireCurve { curve, interval })
}
/// Curve `ci` of the solved sketch `ss` as its exact pieces in space, in the order its polyline
/// runs; none where a piece has no record curve.
fn sketch_curves(ss: &crate::eval::SolvedSketch, ci: usize) -> Vec<WireCurve> {
+18 -4
View File
@@ -379,14 +379,28 @@ fn three_d_sketch_curves() {
let src = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
// The line's projection where it first meets the sphere: the arc of the great circle in y = 0
// from x = -10 to 10 over the top (z = √(400 − x²)), one wire though SMLib splits it at the
// pole, and no curve on the far side.
assert_eq!(r["links"][0]["wires"].as_array().unwrap().len(), 1, "{r}");
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(&wid).unwrap()];
assert!(w.pts.len() > 20);
for p in &w.pts {
assert!((p.len() - 20.0).abs() < 0.1, "{p:?}");
assert!(p.z > 0.0);
assert!(!w.exact.is_empty());
// SMLib makes projected curves to a millionth of the body's size (the sphere's 40 mm box).
let tol = 40.0 * 1e-6;
let ends = [*w.pts.first().unwrap(), *w.pts.last().unwrap()];
let top = 300f64.sqrt();
assert!(ends.iter().all(|e| (e.x.abs() - 10.0).abs() < tol && e.y.abs() < tol && (e.z - top).abs() < tol), "{ends:?}");
assert!((ends[0].x + ends[1].x).abs() < tol, "{ends:?}");
// Along its exact curves (the wire's points are its spans' polygon), on the sphere's top.
for c in &w.exact {
let eval = solidcraft_geom::brep::eval::CurveEval::new(&c.curve).unwrap();
for k in 0..=32 {
let p = eval.point(c.interval[0] + (c.interval[1] - c.interval[0]) * f64::from(k) / 32.0).unwrap();
assert!((p.len() - 20.0).abs() < tol && p.y.abs() < tol && p.z >= top - tol, "{p:?}");
}
}
// Delete the wire.
run(&mut s, "sketch.delete", json!({"entities": [wid]}));