Intersection edge nodes advance along the curve at fine display qualities (#1615)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-16 03:00:56 -04:00
1 parent 61678654c2
commit f57a62deb7
4 files changed
+1243 -15

No files matched your search

@@ -288,3 +288,27 @@ fn cut_of_a_whole_face_beside_round_corners_after_a_chamfer() {
let top = mesh.bounds().max.z;
assert!((top - 15.0).abs() < 1e-6, "top at {top}");
}
/// The display tessellation of loft bodies joined into a bike frame plans at every display
/// quality and every refinement step. The edge nodes on an intersection of two lofts once
/// stepped back along the curve (the edge's fit rang where truck's parameter changes speed, and
/// took a leader point off the curve past a vertex), so a face's boundary crossed itself and
/// the body fell back to the reference mesher, which drew a sheet across the down tube.
#[test]
fn joined_loft_frames_plan_their_display_at_every_quality() {
use solidcraft_doc::display_quality::{DisplayQuality, MAX_REFINE};
for (script, name) in [
(include_str!("../../../examples/regressions/loft_join_bracket_hull_triangle.json"), "DT2"),
(include_str!("../../../examples/regressions/front_triangle_hull_triangle.json"), "FrontTriangle"),
] {
let s = replay(script);
let state = s.model.state();
let body = &state.body(name).unwrap().body;
for q in DisplayQuality::ALL {
for k in 0..=MAX_REFINE {
let plan = body.display_plan_angular(q.refined_chord(body.size(), k), q.refined_angle(k));
assert!(plan.is_ok(), "{name} at {} + {k}: {:?}", q.id(), plan.err());
}
}
}
}
+78 -15
View File
@@ -5,8 +5,13 @@
//! each surface in turn from no starting guess: a nearest-point search per surface per point.
//! Here the intersection is solved exactly at a set of leader parameters, and a cubic B-spline
//! is fitted through those points together with both surfaces' parameters there (a smooth
//! leader with its curves on both surfaces). Each point is then refined from the B-spline by
//! Newton's method on the four unknowns (u1, v1, u2, v2): S1(u1, v1) = S2(u2, v2), held on the
//! leader with its curves on both surfaces). The B-spline runs over the chord length of the
//! solved points, never over the curve's own parameter: truck's parameter can change speed
//! abruptly (a polyline leader with short and long spans), and a cubic interpolated over it
//! rings there, stepping back along the curve (Lee, "Choosing nodes in parametric curve
//! interpolation", CAD 21, 1989). The curve's parameter reaches the chord length through the
//! piecewise-linear map between the seeds, which is monotone. Each point is then refined from
//! the B-spline by Newton's method on the four unknowns (u1, v1, u2, v2): S1(u1, v1) = S2(u2, v2), held on the
//! plane through the B-spline point normal to the B-spline (Barnhill & Kersey, "A marching
//! method for parametric surface/surface intersection", CAGD 7, 1990; Patrikalakis & Maekawa,
//! "Shape Interrogation for CAD/CAM", 2002, ch. 5). Every point is independent once seeded. Where the surfaces are close to
@@ -34,9 +39,11 @@ const MIN_SIN: f64 = 0.05;
pub(crate) struct Intersection {
surfaces: [EvalSurface; 2],
domains: [Domain; 2],
/// The smooth leader over the curve's parameter: the intersection point (x, y, z) and both
/// surfaces' parameters (u1, v1, u2, v2), periodic directions unwrapped along it.
/// The smooth leader over the chord length of the seeds: the intersection point (x, y, z)
/// and both surfaces' parameters (u1, v1, u2, v2), periodic directions unwrapped along it.
leader: CurveN<7>,
/// The curve's parameter at each seed and the chord length there, both increasing.
seeds: Vec<(f64, f64)>,
/// Convergence tolerance (mm).
eps: f64,
}
@@ -59,8 +66,14 @@ impl Intersection {
// The leader lies within the curve's tolerance of both surfaces.
let accept = (ic.tolerance() * 100.0).max(1e-6);
let scale = (0..=SEEDS).map(|k| leader(t0 + (t1 - t0) * k as f64 / SEEDS as f64).0.len()).fold(1.0, f64::max);
let mut it = Intersection { surfaces, domains, leader: CurveN { degree: 1, knots: Vec::new(), ctrl: Vec::new() }, eps: scale * 1e-10 };
let (mut params, mut points) = (Vec::with_capacity(SEEDS + 1), Vec::with_capacity(SEEDS + 1));
let mut it = Intersection {
surfaces,
domains,
leader: CurveN { degree: 1, knots: Vec::new(), ctrl: Vec::new() },
seeds: Vec::new(),
eps: scale * 1e-10,
};
let (mut params, mut points): (Vec<f64>, Vec<[f64; 7]>) = (Vec::with_capacity(SEEDS + 1), Vec::with_capacity(SEEDS + 1));
let mut prev: Option<[f64; 4]> = None;
for k in 0..=SEEDS {
let t = t0 + (t1 - t0) * k as f64 / SEEDS as f64;
@@ -77,25 +90,60 @@ impl Intersection {
q[2 * i] = uv[0];
q[2 * i + 1] = uv[1];
}
// Exactly on the intersection where Newton converges (near-tangent seeds keep the
// leader point and its projections).
let (p, q) = d.normalized().and_then(|dir| it.newton(p, dir, q)).unwrap_or((p, q));
params.push(t);
// The fit takes only points exactly on the intersection: a leader point Newton
// cannot place (near-tangent, or the solution leaves a surface's domain where a
// polyline leader overshoots a vertex) is off the curve and would bend the fit back
// along it. The ends stay either way, so the fit spans the whole curve.
let solved = d.normalized().and_then(|dir| it.newton(p, dir, q));
prev = Some(solved.map_or(q, |(_, q)| q));
let (p, q) = match solved {
Some(x) => x,
None if k == 0 || k == SEEDS => (p, q),
None => continue,
};
// A seed that lands on the previous one adds no chord and stays out of the fit
// (whose parameters must increase).
let s = match it.seeds.last().zip(points.last()) {
Some((&(_, s), &[x, y, z, ..])) => {
let chord = p.dist(Vec3::new(x, y, z));
if !(chord > it.eps) {
continue;
}
s + chord
}
None => 0.0,
};
it.seeds.push((t, s));
params.push(s);
points.push([p.x, p.y, p.z, q[0], q[1], q[2], q[3]]);
prev = Some(q);
}
it.leader = CurveN::interpolate(&params, &points, 3).ok()?;
let degree = points.len().checked_sub(1)?.min(3);
it.leader = CurveN::interpolate(&params, &points, degree).ok()?;
Some(it)
}
/// The point at curve parameter `t` and both surfaces' parameters there, or `None`
/// (near-tangent, or Newton failed).
pub(crate) fn eval(&self, t: f64) -> Option<(Vec3, [f64; 4])> {
let (x, dx) = self.leader.eval(t)?;
let (x, dx) = self.leader.eval(self.chord_at(t)?)?;
let dir = Vec3::new(dx[0], dx[1], dx[2]).normalized()?;
self.newton(Vec3::new(x[0], x[1], x[2]), dir, [x[3], x[4], x[5], x[6]])
}
/// The chord length at curve parameter `t`: linear between the seeds, clamped to the ends.
fn chord_at(&self, t: f64) -> Option<f64> {
let (&(ta, sa), &(tb, sb)) = (self.seeds.first()?, self.seeds.last()?);
if !(t > ta) {
return Some(sa);
}
if !(t < tb) {
return Some(sb);
}
let k = self.seeds.partition_point(|&(tk, _)| tk <= t);
let (Some(&(t0, s0)), Some(&(t1, s1))) = (<[_]>::get(&self.seeds, k.checked_sub(1)?), <[_]>::get(&self.seeds, k)) else { return None };
Some(s0 + (s1 - s0) * (t - t0) / (t1 - t0))
}
/// The two surfaces, in the order of the parameters [`Intersection::eval`] gives.
pub(crate) fn surfaces(&self) -> &[EvalSurface; 2] {
&self.surfaces
@@ -186,7 +234,7 @@ mod tests {
/// Points on the intersection edges of a boolean (a cross hole: two cylinders) lie on both
/// surfaces, on truck's curve (the parameterisation follows our smooth leader, so the same
/// parameter may sit at a slightly different place along it), and advance along the curve.
/// parameter may sit at a slightly different place along it), and advance along it.
#[test]
fn intersection_points_lie_on_both_surfaces_and_on_truck_s_curve() {
let Ok(pin) = crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 6.0) else { panic!("pin") };
@@ -199,6 +247,21 @@ mod tests {
let (t0, t1) = curve.range_tuple();
let dense: Vec<Vec3> = (0..=1000).map(|k| from_p3(curve.subs(t0 + (t1 - t0) * f64::from(k) / 1000.0))).collect();
let near = |p: Vec3| dense.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
// Arc length along truck's curve to the point nearest `p`.
let along = |p: Vec3| {
let (mut run, mut best) = (0.0, (f64::MAX, 0.0));
for w in dense.windows(2) {
let (a, b) = (w[0], w[1]);
let len = a.dist(b);
let f = if len > 0.0 { ((p - a).dot(b - a) / (len * len)).clamp(0.0, 1.0) } else { 0.0 };
let d = p.dist(a + (b - a) * f);
if d < best.0 {
best = (d, run + f * len);
}
run += len;
}
best.1
};
let mut last: Option<Vec3> = None;
for k in 0..=50 {
let t = t0 + (t1 - t0) * f64::from(k) / 50.0;
@@ -208,7 +271,7 @@ mod tests {
}
assert!(near(p) < 1e-4, "t {t}: {p:?} is {} from truck's curve", near(p));
if let Some(q) = last {
assert!(p.dist(q) > 0.0, "t {t}: the points advance");
assert!(along(p) > along(q), "t {t}: the points advance along the curve");
}
last = Some(p);
checked += 1;
@@ -0,0 +1,836 @@
{
"description": "Bike front triangle (lofted tubes joined to a bottom-bracket cylinder): the display mesh drew a flat triangle inside the down tube bend at Fine/Ultra.",
"commands": [
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
12,
0.0,
18
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.947,
0.0,
-0.322
]
},
"name": "S13"
}
},
{
"command": "sketch.ellipse",
"params": {
"center": [
0,
0
],
"major": [
19.0,
0
],
"minor_radius": 18.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
-13.0,
0.0,
91.5
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.947,
0.0,
-0.322
]
},
"name": "S14"
}
},
{
"command": "sketch.ellipse",
"params": {
"center": [
0,
0
],
"major": [
27.0,
0
],
"minor_radius": 24.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
-38,
0.0,
165
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.959,
0.0,
-0.282
]
},
"name": "S15"
}
},
{
"command": "sketch.ellipse",
"params": {
"center": [
0,
0
],
"major": [
24.0,
0
],
"minor_radius": 22.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
-76.646,
0.0,
320.0
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.97,
0.0,
-0.242
]
},
"name": "S16"
}
},
{
"command": "sketch.circle.center",
"params": {
"center": [
0,
0
],
"radius": 20.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
-110.577,
0.0,
456.089
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.97,
0.0,
-0.242
]
},
"name": "S17"
}
},
{
"command": "sketch.circle.center",
"params": {
"center": [
0,
0
],
"radius": 19.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.loft",
"params": {
"sections": [
{
"sketch": "S13"
},
{
"sketch": "S14"
},
{
"sketch": "S15"
},
{
"sketch": "S16"
},
{
"sketch": "S17"
}
],
"operation": "new",
"name": "SeatTube",
"body_name": "SeatTube"
}
},
{
"command": "solid.cylinder",
"params": {
"diameter": 46,
"height": 73.0,
"base": [
0,
-36.5,
0
],
"axis": "Y",
"operation": "new",
"name": "BBShell",
"body_name": "BBShell"
}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
459.471,
0.0,
607.143
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
0.349,
0.0,
-0.937
]
},
"name": "S10"
}
},
{
"command": "sketch.ellipse",
"params": {
"center": [
0,
0
],
"major": [
22.0,
0
],
"minor_radius": 20.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
181.44,
0.0,
503.572
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
0.349,
0.0,
-0.937
]
},
"name": "S11"
}
},
{
"command": "sketch.ellipse",
"params": {
"center": [
0,
0
],
"major": [
27.0,
0
],
"minor_radius": 23.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
-96.592,
0.0,
400.0
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
0.349,
0.0,
-0.937
]
},
"name": "S12"
}
},
{
"command": "sketch.ellipse",
"params": {
"center": [
0,
0
],
"major": [
17.0,
0
],
"minor_radius": 16.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.loft",
"params": {
"sections": [
{
"sketch": "S10"
},
{
"sketch": "S11"
},
{
"sketch": "S12"
}
],
"operation": "new",
"name": "TopTube",
"body_name": "TopTube"
}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
501.231,
0.0,
519.592
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.903,
0.0,
-0.431
]
},
"name": "S1"
}
},
{
"command": "sketch.circle.center",
"params": {
"center": [
0,
0
],
"radius": 32.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
473.678,
0.0,
577.358
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.903,
0.0,
-0.431
]
},
"name": "S2"
}
},
{
"command": "sketch.circle.center",
"params": {
"center": [
0,
0
],
"radius": 28.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
447.417,
0.0,
632.416
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.903,
0.0,
-0.431
]
},
"name": "S3"
}
},
{
"command": "sketch.circle.center",
"params": {
"center": [
0,
0
],
"radius": 26.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.loft",
"params": {
"sections": [
{
"sketch": "S1"
},
{
"sketch": "S2"
},
{
"sketch": "S3"
}
],
"operation": "new",
"name": "HeadTube",
"body_name": "HeadTube"
}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
8,
0.0,
3
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.178,
0.0,
0.984
]
},
"name": "S4"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-19.0,
-18.0
],
"p1": [
19.0,
18.0
],
"radius": 10.799999999999999
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
80,
0.0,
16
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.211,
0.0,
0.977
]
},
"name": "S5"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-30.0,
-26.0
],
"p1": [
30.0,
26.0
],
"radius": 15.6
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
175,
0.0,
40
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.596,
0.0,
0.803
]
},
"name": "S6"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-38.0,
-33.0
],
"p1": [
38.0,
33.0
],
"radius": 19.8
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
314.248,
0.0,
271.657
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.857,
0.0,
0.515
]
},
"name": "S7"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-37.0,
-33.0
],
"p1": [
37.0,
33.0
],
"radius": 19.8
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
422.553,
0.0,
451.835
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.857,
0.0,
0.515
]
},
"name": "S8"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-33.0,
-30.0
],
"p1": [
33.0,
30.0
],
"radius": 18.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
484.441,
0.0,
554.793
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.857,
0.0,
0.515
]
},
"name": "S9"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-23.0,
-22.0
],
"p1": [
23.0,
22.0
],
"radius": 13.2
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.loft",
"params": {
"sections": [
{
"sketch": "S4"
},
{
"sketch": "S5"
},
{
"sketch": "S6"
},
{
"sketch": "S7"
},
{
"sketch": "S8"
},
{
"sketch": "S9"
}
],
"operation": "new",
"name": "DownTube",
"body_name": "DownTube"
}
},
{
"command": "solid.combine",
"params": {
"target": "SeatTube",
"tools": [
"BBShell"
],
"operation": "join"
}
},
{
"command": "solid.combine",
"params": {
"target": "SeatTube",
"tools": [
"TopTube"
],
"operation": "join"
}
},
{
"command": "solid.combine",
"params": {
"target": "SeatTube",
"tools": [
"HeadTube"
],
"operation": "join"
}
},
{
"command": "solid.combine",
"params": {
"target": "SeatTube",
"tools": [
"DownTube"
],
"operation": "join"
}
},
{
"command": "body.rename",
"params": {
"body": "SeatTube",
"name": "FrontTriangle"
}
}
]
}
@@ -0,0 +1,305 @@
{
"description": "A lofted down tube (rounded-rectangle sections, widest 82 mm) joined to a 46 mm \u00d7 73 mm bottom-bracket cylinder: the display mesh of the tube's side face gained a flat triangle across the inside of its bend.",
"commands": [
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
-4,
0.0,
2
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.164,
0.0,
0.986
]
},
"name": "S4b"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-19.0,
-18.0
],
"p1": [
19.0,
18.0
],
"radius": 10.799999999999999
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
80,
0.0,
16
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.205,
0.0,
0.979
]
},
"name": "S5b"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-41.0,
-33.0
],
"p1": [
41.0,
33.0
],
"radius": 19.8
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
175,
0.0,
40
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.596,
0.0,
0.803
]
},
"name": "S6b"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-41.0,
-35.0
],
"p1": [
41.0,
35.0
],
"radius": 21.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
314.248,
0.0,
271.657
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.857,
0.0,
0.515
]
},
"name": "S7b"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-39.0,
-35.0
],
"p1": [
39.0,
35.0
],
"radius": 21.0
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
422.553,
0.0,
451.835
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.857,
0.0,
0.515
]
},
"name": "S8b"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-35.0,
-32.0
],
"p1": [
35.0,
32.0
],
"radius": 19.2
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sketch.create",
"params": {
"plane": {
"origin": [
484.441,
0.0,
554.793
],
"x_dir": [
0.0,
1.0,
0.0
],
"y_dir": [
-0.857,
0.0,
0.515
]
},
"name": "S9b"
}
},
{
"command": "sketch.rectangle.rounded",
"params": {
"p0": [
-23.0,
-22.0
],
"p1": [
23.0,
22.0
],
"radius": 13.2
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.loft",
"params": {
"sections": [
{
"sketch": "S4b"
},
{
"sketch": "S5b"
},
{
"sketch": "S6b"
},
{
"sketch": "S7b"
},
{
"sketch": "S8b"
},
{
"sketch": "S9b"
}
],
"operation": "new",
"name": "DT2",
"body_name": "DT2"
}
},
{
"command": "solid.cylinder",
"params": {
"diameter": 46,
"height": 73.0,
"base": [
0,
-36.5,
0
],
"axis": "Y",
"operation": "new",
"name": "BBShell",
"body_name": "BBShell"
}
},
{
"command": "solid.combine",
"params": {
"target": "DT2",
"tools": [
"BBShell"
],
"operation": "join"
}
}
]
}