Merge pull request #1663 from tangency-labs/fork/ssi-tangent-tracer

fix: opt-in SSI tangent-curve tracer (WR-75 addendum; fork ef4ab25, e9fde8f, d8c61db, 1693c9d)
This commit is contained in:
bherbruck
2026-10-30 03:42:53 -04:00
committed by GitHub
8 changed files with 2336 additions and 0 deletions
@@ -214,6 +214,11 @@ pub struct AdvSurfaceIntersector<'s, 't, O> {
/// Nesting depth of [`GlobalSolve::solve_branch`] (bounds the recursion on cyclic
/// tree links).
solve_depth: usize,
/// Not upstream (the fork's `m_vTangentSeeds`, opt-in
/// `Kernel::fixes.ssi_tangent_curve_tracer`, WR-75): the UV pairs where
/// [`AdvSurfaceIntersector::find_interior_curves`] found the normals (nearly)
/// parallel, seeds of the tangential curve pass.
pub(crate) tangent_curve_seeds: Vec<[Point2; 2]>,
}
/// `sm_AddSingDir`: appends `sing_vec` to `dirs[..count]` unless a stored direction
@@ -330,6 +335,7 @@ impl<'s, 't, O: SolutionObject + 't> AdvSurfaceIntersector<'s, 't, O> {
nodes: [None, None],
separate_gauss_maps: true,
solve_depth: 0,
tangent_curve_seeds: Vec::new(),
})
}
@@ -546,6 +552,10 @@ impl<'s, 't, O: SolutionObject + 't> AdvSurfaceIntersector<'s, 't, O> {
}
if angle_rad * (180.0 / PI) < 5.0 {
self.push_tangent_seed(k, &mut tangent_seeds, node1, node2)?;
// (WR-75, opt-in) a seed of the tangential curve pass
if k.fixes.ssi_tangent_curve_tracer {
self.tangent_curve_seeds.push([uv_guess1, uv_guess2]);
}
continue;
}
}
@@ -586,6 +596,10 @@ impl<'s, 't, O: SolutionObject + 't> AdvSurfaceIntersector<'s, 't, O> {
}
if angle_rad * (180.0 / PI) < 2.0 {
self.push_tangent_seed(k, &mut tangent_seeds, node1, node2)?;
// (WR-75, opt-in) a seed of the tangential curve pass
if k.fixes.ssi_tangent_curve_tracer {
self.tangent_curve_seeds.push([uv, uv2]);
}
continue;
}
}
@@ -11,5 +11,7 @@ pub mod p2;
pub mod p3;
pub mod p4;
pub mod p5;
pub(crate) mod tangent_curves;
pub(crate) mod tangent_tracer;
pub use p1::{IntersectionPointType, SrfIndex, SurfaceIntersector, TsectId, TsectPnt};
@@ -499,6 +499,8 @@ impl<'t, O: SolutionObject + Copy + PartialEq + 't> AdvSurfaceIntersector<'_, 't
trees_ready: bool,
) -> Result<SurfaceIntersection> {
let mut result = SurfaceIntersection::default();
// filled by FindInteriorCurves, read by the tangential curve pass (WR-75)
self.tangent_curve_seeds.clear();
if let Some(t) = opt_approx_tol_3d {
self.base.this_approx_tol_3d = t;
@@ -745,6 +747,44 @@ impl<'t, O: SolutionObject + Copy + PartialEq + 't> AdvSurfaceIntersector<'_, 't
deviations: core::mem::take(&mut out.deviations),
};
self.find_interior_curves(k, &mut interior)?;
// not upstream (WR-75, opt-in; the fork's FindTangentCurves): trace the
// tangential curves the marcher above cannot follow
let mut tangent_nodes = Vec::new();
if k.fixes.ssi_tangent_curve_tracer {
let size = crate::math::min2(root1.size().length(), root2.size().length());
let seeds = core::mem::take(&mut self.tangent_curve_seeds);
let mut curves = TsectCurves {
curves_3d: core::mem::take(&mut interior.curves_3d),
uv_curves: [
core::mem::take(&mut interior.surface1_uv_curves),
core::mem::take(&mut interior.surface2_uv_curves),
],
curve_types: core::mem::take(&mut interior.curve_types),
deviations: core::mem::take(&mut interior.deviations),
};
let traced = super::tangent_curves::find_tangent_curves(
k,
&mut self.base,
size,
&seeds,
&mut curves,
);
let TsectCurves {
curves_3d,
uv_curves: [surface1_uv_curves, surface2_uv_curves],
curve_types,
deviations,
} = curves;
interior = InteriorCurves {
curves_3d,
surface1_uv_curves,
surface2_uv_curves,
curve_types,
deviations,
};
tangent_nodes = traced?;
}
let InteriorCurves {
mut curves_3d,
mut surface1_uv_curves,
@@ -784,6 +824,21 @@ impl<'t, O: SolutionObject + Copy + PartialEq + 't> AdvSurfaceIntersector<'_, 't
ii += 1;
}
// not upstream (WR-75, opt-in; the fork's AddTangentNodes): report the singular
// points where crossing branches meet
if k.fixes.ssi_tangent_curve_tracer {
super::tangent_curves::add_tangent_nodes(
k,
&self.base,
&tangent_nodes,
&mut curves_3d,
&mut surface1_uv_curves,
&mut surface2_uv_curves,
&mut curve_types,
&mut deviations,
)?;
}
// set outputs
result.curves_3d.extend(curves_3d);
result
@@ -0,0 +1,808 @@
//! The tangential intersection curve pass of the surface/surface intersector: traces the
//! curves along which the two surfaces touch, and the crossing branches through the
//! contact points where crossing curves meet (opt-in, WR-75).
//!
//! Upstream: the SolidCraft fork's `source/SMLib/src/SmTangentCurveTracer.cpp`
//! (tangency-labs/smlib ef4ab25, e9fde8f, d8c61db, 1693c9d; not in the pinned SMLib).
//! The solver core (`TcTracer` and its helpers) is
//! [`crate::intersect::surface_intersector::tangent_tracer`].
//!
//! | Rust | Upstream (fork) |
//! |-------------------------|-----------------------------------------------|
//! | [`find_tangent_curves`] | `SmSurfaceIntersector::FindTangentCurves` |
//! | [`add_tangent_nodes`] | `SmSurfaceIntersector::AddTangentNodes` |
//!
//! Deviations: `m_vTangentSeeds` (filled by `FindInteriorCurves`) and `m_vTangentNodes`
//! (filled by `FindTangentCurves`, read by `AddTangentNodes`) are passed in and returned
//! instead of being intersector members, and the model size `FindTangentCurves` reads
//! from the two decomposition trees' top boxes is the caller's (the trees live with the
//! surface caches). A curve's UV curves are never `NULL` here. `SmTList::GetAllNodes`
//! of the through points is their list order. Loops are bounded as the fork's are (50
//! nodes, 12 refinement passes, 6000 points per curve); no recursion.
//! `IsDegenerate` is a plain bool in the fork: an evaluation error there counts as not
//! degenerate (`Unported` still stops the pass).
use crate::curve::curve::Curve;
use crate::error::{ignored, Result};
use crate::geom::extent2d::Extent2d;
use crate::geom::global_solver::SolverOperation;
use crate::geom::vector2d::Vec2;
use crate::intersect::surface_intersector::p1::{
IntersectionPointType, SrfIndex, SurfaceIntersector, TsectPnt,
};
use crate::intersect::surface_intersector::p2::TsectCurves;
use crate::intersect::surface_intersector::tangent_tracer::{
tc_near_poly, tc_poly_dist, tc_tangents, tc_uv_der, SolveMode, TangentTracer, TcPt,
};
use crate::kernel::Kernel;
use crate::math::{Point2, Point3, Vec3, EFF_ZERO, PI};
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::surface::surface::p2::TsectCurveType;
/// `SM_DEG2RAD`.
fn deg2rad(d: f64) -> f64 {
d * (PI / 180.0)
}
/// `TcV::unit()`: the vector scaled to length one, or as it is when of length zero.
fn unit(v: Vec3) -> Vec3 {
let l = v.dot(v).sqrt();
if l > 0.0 {
v * (1.0 / l)
} else {
v
}
}
/// `TcV::len()`.
fn len(v: Vec3) -> f64 {
v.dot(v).sqrt()
}
/// `smos_Max` / `smos_Min`.
fn smax(a: f64, b: f64) -> f64 {
if a > b {
a
} else {
b
}
}
/// `x[i]` of a parameter quadruple (`i < 4` at every call).
fn at4(x: &[f64; 4], i: usize) -> f64 {
x.get(i).copied().unwrap_or(0.0)
}
fn smin(a: f64, b: f64) -> f64 {
if a < b {
a
} else {
b
}
}
/// `pC->DropPoint(ivl, X, NULL, tol, NULL, bOk, dParam, dDist, SM_SO_MINIMIZE)`: the
/// distance of a found drop, `None` when the drop fails or finds nothing
/// ([`crate::SolidError::Unported`] propagates).
fn drop_distance(k: &Kernel, c: &Curve, x: Vec3, tol: f64) -> Result<Option<f64>> {
match c.drop_point(
k,
&c.natural_interval(),
x,
None,
tol,
None,
SolverOperation::Minimize,
) {
Ok(d) if d.success => Ok(Some(d.distance)),
Err(e) if e.is_unported() => Err(e),
Ok(_) | Err(_) => Ok(None),
}
}
/// `pC->EvaluatePoint(t, P)`: `None` on a failed evaluation
/// ([`crate::SolidError::Unported`] propagates).
fn curve_point(k: &Kernel, c: &Curve, t: f64) -> Result<Option<Point3>> {
match c.evaluate_point(k, t) {
Ok(p) => Ok(Some(p)),
Err(e) if e.is_unported() => Err(e),
Err(_) => Ok(None),
}
}
/// Whether the regular marcher's curve `c` covers `rc` within `cov_tol` at every
/// fortieth point (`pC->DropPoint` of each).
fn covers(k: &Kernel, c: &Curve, rc: &[TcPt], tol: f64, cov_tol: f64) -> Result<bool> {
let stride = (rc.len() / 40).max(1);
let mut i = 0;
while i < rc.len() {
let Some(p) = rc.get(i) else { break };
match drop_distance(k, c, p.p, tol * 10.0)? {
Some(d) if d <= cov_tol => {}
_ => return Ok(false),
}
i += stride;
}
Ok(true)
}
/// Removes curve `ii` from the five output arrays (`delete pC; RemoveAt(ii)` and the
/// guarded `RemoveAt`s of the associated arrays).
fn remove_curve(out: &mut TsectCurves, ii: usize) {
if ii < out.curves_3d.len() {
out.curves_3d.remove(ii);
}
for uv in &mut out.uv_curves {
if ii < uv.len() {
uv.remove(ii);
}
}
if ii < out.curve_types.len() {
out.curve_types.remove(ii);
}
if ii < out.deviations.len() {
out.deviations.remove(ii);
}
}
/// A traced curve and how it was traced.
struct NewCurve {
pts: Vec<TcPt>,
closed: bool,
/// `n2`'s orientation relative to `n1` along it.
sign: f64,
/// A crossing branch between contact points (else a tangential curve).
cross: bool,
}
/// `FindTangentCurves`: finds and traces the tangential intersection curves and the
/// crossing branches through contact points, and appends them to `out` through
/// [`SurfaceIntersector::flush_curve`], replacing the curves and points the regular
/// marcher produced along them; returns the contact points where crossing branches
/// meet as `(uv1, uv2)` pairs (`m_vTangentNodes`, for [`add_tangent_nodes`]).
///
/// Seeds are the ends of `out`'s curves where the normals are within 1 degree of
/// parallel (and its degenerate curves), the through points and `tangent_seeds` (the
/// node pairs `FindInteriorCurves` skipped for parallel normals). Each seed is refined
/// to a contact point; one from which the contact continues in two opposite directions
/// is traced both ways; a contact point with no tangential curve but two or more
/// crossing directions is a node whose crossing branches are traced through it and the
/// further nodes they meet. `size` is the model size (the smaller of the two trees' top
/// box diagonals).
///
/// # Errors
///
/// Those of `FlushCurve` and of the evaluations upstream checks;
/// [`crate::SolidError::Unported`] from any callee.
#[allow(clippy::too_many_lines)] // one fork routine
pub(crate) fn find_tangent_curves(
k: &Kernel,
si: &mut SurfaceIntersector<'_>,
size: f64,
tangent_seeds: &[[Point2; 2]],
out: &mut TsectCurves,
) -> Result<Vec<[Point2; 2]>> {
let mut tangent_nodes = Vec::new();
let sized = size > 0.0;
if !sized {
return Ok(tangent_nodes);
}
let tol = si.this_approx_tol_3d;
let uv_domains: [Extent2d; 2] = si.uv_domains;
let mut tr = TangentTracer::new(
[si.surface(SrfIndex::First), si.surface(SrfIndex::Second)],
uv_domains,
tol,
size,
);
// --- collect seeds (pairs: uv1, uv2)
let mut seeds: Vec<[Point2; 2]> = Vec::new();
let num_orig_curves = out.curves_3d.len();
for ii in 0..num_orig_curves {
let (Some(uv1), Some(uv2)) = (
out.uv_curves[0].get(ii).cloned(),
out.uv_curves[1].get(ii).cloned(),
) else {
break;
};
let i1 = uv1.natural_interval();
let i2 = uv2.natural_interval();
let degen = match out.curves_3d.get(ii) {
Some(c) => ignored(c.is_degenerate(k, EFF_ZERO, None))?.unwrap_or(false),
None => break,
};
for end in 0..(if degen { 1 } else { 2 }) {
let t1 = if end == 1 { i1.max() } else { i1.min() };
let t2 = if end == 1 { i2.max() } else { i2.min() };
let Some(sa) = curve_point(k, &uv1, t1)? else {
continue;
};
let Some(sb) = curve_point(k, &uv2, t2)? else {
continue;
};
let a = Point2::new(sa.x, sa.y);
let b = Point2::new(sb.x, sb.y);
if !degen {
// only curve ends where the surfaces are (nearly) tangent
let Some(e1) = tr.eval(k, 0, a.x, a.y)? else {
continue;
};
let Some(e2) = tr.eval(k, 1, b.x, b.y)? else {
continue;
};
if e1.n.dot(e2.n).abs() < deg2rad(1.0).cos() {
continue;
}
}
seeds.push([a, b]);
}
}
for &id in si.through_points() {
let p = si.point(id)?;
seeds.push([p.uv_pos(SrfIndex::First), p.uv_pos(SrfIndex::Second)]);
}
seeds.extend_from_slice(tangent_seeds);
if seeds.is_empty() {
return Ok(tangent_nodes);
}
// --- trace
let mut new_curves: Vec<NewCurve> = Vec::new();
let mut nodes: Vec<TcPt> = Vec::new(); // contact points where crossing branches meet
let mut node_dirs: Vec<Vec<Vec3>> = Vec::new();
let probe_r = 2e-3 * size;
let mut contacts: Vec<Vec3> = Vec::new(); // contact points already classified
let near_tol = smax(100.0 * tol, 1e-4 * size);
for seed in &seeds {
let mut x = [seed[0].x, seed[0].y, seed[1].x, seed[1].y];
let Some(e1) = tr.eval(k, 0, x[0], x[1])? else {
continue;
};
let Some(e2) = tr.eval(k, 1, x[2], x[3])? else {
continue;
};
if len(e1.p - e2.p) > 0.01 * size {
continue;
}
if e1.n.dot(e2.n).abs() < deg2rad(10.0).cos() {
continue;
}
tr.sign = if e1.n.dot(e2.n) < 0.0 { -1.0 } else { 1.0 };
// skip seeds on tangential curves found already
let mid = (e1.p + e2.p) * 0.5;
if new_curves
.iter()
.any(|c| tc_near_poly(mid, &c.pts, near_tol))
{
continue;
}
let Some(p) = tr.solve(
k,
&mut x,
SolveMode::Free,
Vec3::ZERO,
Vec3::ZERO,
None,
0.0,
)?
else {
continue;
};
if new_curves
.iter()
.any(|c| tc_near_poly(p.p, &c.pts, near_tol))
|| contacts.iter().any(|&c| len(c - p.p) < near_tol)
{
continue;
}
contacts.push(p.p);
let mut dirs = Vec::new();
let num_dirs = tr.probe(k, &p, &mut dirs)?;
// the surfaces coincide around p: that region is not a tangential curve, so this
// seed gives none, but seeds elsewhere on a partly coincident pair still can
if num_dirs < 0 {
continue;
}
if num_dirs == 0 {
// no tangential curve here: a touching point, or a point where crossing
// branches meet (a saddle-like contact)
tr.tangent = false;
let mut cross_dirs = Vec::new();
let num_cross = tr.probe(k, &p, &mut cross_dirs)?;
tr.tangent = true;
let node_known = nodes.iter().any(|n| len(n.p - p.p) < near_tol);
if num_cross >= 2 && !node_known {
nodes.push(p);
node_dirs.push(cross_dirs);
}
continue;
}
let (Some(&d0), Some(&d1)) = (dirs.first(), dirs.get(1).or(dirs.first())) else {
continue;
};
if num_dirs <= 2 {
// one curve through p (or ending at p on a boundary)
let mut fwd = Vec::new();
let mut bwd = Vec::new();
let closed = tr.march(k, &p, d0, &p, &mut fwd, None)?;
if !closed {
let t1 = if num_dirs == 2 { d1 } else { d0 * -1.0 };
tr.march(k, &p, t1, &p, &mut bwd, None)?;
}
let mut all: Vec<TcPt> = bwd.into_iter().rev().collect();
all.push(p);
all.extend(fwd);
if all.len() < 3 {
continue;
}
new_curves.push(NewCurve {
pts: all,
closed,
sign: tr.sign,
cross: false,
});
} else {
// a node of several tangential branches: trace each branch from it
for &dir in &dirs {
let mut fwd = Vec::new();
tr.march(k, &p, dir, &p, &mut fwd, None)?;
if fwd.len() < 2 {
continue;
}
let mut all = vec![p];
all.extend(fwd);
new_curves.push(NewCurve {
pts: all,
closed: false,
sign: tr.sign,
cross: false,
});
}
}
}
// --- crossing branches through contact points (nodes): trace every branch through
// a node, on through the other nodes it meets (found on the way and added), to
// the domain boundaries or back to itself
tr.tangent = false;
let mut in_ = 0;
while in_ < nodes.len() && in_ < 50 {
let node = *nodes
.get(in_)
.ok_or_else(|| crate::SolidError::invalid_input(OP, "node index"))?;
// the node, and the node in other parameter charts: a surface corner can map to
// the same point as another corner (folded/collapsed edges)
let mut charts = vec![node];
{
let mut alt: [Vec<Point2>; 2] = [Vec::new(), Vec::new()];
for (s, (alt_s, &dom)) in alt.iter_mut().zip(uv_domains.iter()).enumerate() {
let own = Point2::new(at4(&node.x, 2 * s), at4(&node.x, 2 * s + 1));
alt_s.push(own);
let corners = [
dom.min(),
dom.max(),
Point2::new(dom.min().x, dom.max().y),
Point2::new(dom.max().x, dom.min().y),
];
for c in corners {
if c.distance(own) < 1e-9 * (1.0 + dom.max_dimension()) {
continue;
}
if let Some(ec) = tr.eval(k, s, c.x, c.y)? {
if len(ec.p - node.p) < 10.0 * tol {
alt_s.push(c);
}
}
}
}
for (i0, a0) in alt[0].iter().enumerate() {
for (i1, a1) in alt[1].iter().enumerate() {
if i0 == 0 && i1 == 0 {
continue;
}
let mut c = node;
c.x = [a0.x, a0.y, a1.x, a1.y];
let Some(e1) = tr.eval(k, 0, c.x[0], c.x[1])? else {
continue;
};
let Some(e2) = tr.eval(k, 1, c.x[2], c.x[3])? else {
continue;
};
c.p = (e1.p + e2.p) * 0.5;
c.n = e1.n;
c.n2 = e2.n;
c.gap = len(e1.p - e2.p);
c.ang = smin(1.0, len(e1.n.cross(e2.n))).asin();
if c.gap < 10.0 * tol {
charts.push(c);
}
}
}
}
if in_ >= node_dirs.len() {
let mut dirs = Vec::new();
if tr.probe(k, &node, &mut dirs)? < 0 {
dirs.clear();
}
node_dirs.push(dirs);
}
for (ic, &n) in charts.iter().enumerate() {
// the node's own chart has its directions already (node_dirs grows only above)
let dirs: Vec<Vec3> = if ic == 0 {
node_dirs.get(in_).cloned().unwrap_or_default()
} else {
let mut chart_dirs = Vec::new();
if tr.probe(k, &n, &mut chart_dirs)? < 0 {
chart_dirs.clear();
}
chart_dirs
};
for &dir in &dirs {
let pr = n.p + dir * probe_r;
if new_curves
.iter()
.any(|c| tc_poly_dist(pr, &c.pts).0 < 0.05 * probe_r)
{
continue;
}
let mut fwd = Vec::new();
let mut bwd = Vec::new();
let closed = tr.march(k, &n, dir, &n, &mut fwd, Some(&mut nodes))?;
if !closed {
// the smooth continuation backwards through N
let mut best = deg2rad(45.0).cos();
let mut back: Option<usize> = None;
for (j, d) in dirs.iter().enumerate() {
let c = -d.dot(dir);
if c > best {
best = c;
back = Some(j);
}
}
if let Some(bd) = back.and_then(|j| dirs.get(j).copied()) {
tr.march(k, &n, bd, &n, &mut bwd, Some(&mut nodes))?;
}
}
if fwd.is_empty() && bwd.is_empty() {
continue;
}
let mut all: Vec<TcPt> = bwd.into_iter().rev().collect();
all.push(n);
all.extend(fwd);
new_curves.push(NewCurve {
pts: all,
closed,
sign: 1.0,
cross: true,
});
}
}
in_ += 1;
}
for n in &nodes {
tangent_nodes.push([Point2::new(n.x[0], n.x[1]), Point2::new(n.x[2], n.x[3])]);
}
// --- refine spans until the cubic Hermite spans FlushCurve() builds (3d and uv)
// are within tolerance; keep a dense sampling of each curve for the comparisons
// with the curves the regular marcher produced
let mut new_tan: Vec<Vec<Vec3>> = Vec::with_capacity(new_curves.len());
let mut new_uv_der: Vec<Vec<f64>> = Vec::with_capacity(new_curves.len());
let mut dense: Vec<Vec<TcPt>> = Vec::with_capacity(new_curves.len());
for nc in &mut new_curves {
tr.tangent = !nc.cross;
tr.sign = nc.sign;
let rc = &mut nc.pts;
for _pass in 0..12 {
let (tan, fd) = tc_tangents(rc, nc.closed)?;
let mut refined: Vec<TcPt> = Vec::new();
let mut inserted = false;
for i in 0..rc.len().saturating_sub(1) {
let (Some(&a), Some(&b)) = (rc.get(i), rc.get(i + 1)) else {
break;
};
refined.push(a);
if rc.len() + refined.len() > 6000 {
continue;
}
let h = len(b.p - a.p);
if h < 10.0 * tol {
continue;
}
let (Some(&ta), Some(&tb)) = (tan.get(i), tan.get(i + 1)) else {
break;
};
let hm = (a.p + b.p) * 0.5 + (ta - tb) * (h / 8.0);
let t = unit(b.p - a.p);
let mut xg = [0.0; 4];
for ((g, &xa), &xb) in xg.iter_mut().zip(&a.x).zip(&b.x) {
*g = 0.5 * (xa + xb);
}
let Some(q) = tr.solve(k, &mut xg, SolveMode::Plane, hm, t, None, 0.0)? else {
continue;
};
if len(q.p - hm) > 0.25 * h {
continue;
}
let mut err = len(q.p - hm);
{
// the uv Hermite spans too
let d0 = tc_uv_der(&tr, k, rc, &tan, &fd, i)?;
let d1 = tc_uv_der(&tr, k, rc, &tan, &fd, i + 1)?;
for s in 0..2 {
let (iu, iv) = (2 * s, 2 * s + 1);
let um = 0.5 * (at4(&a.x, iu) + at4(&b.x, iu))
+ (at4(&d0, iu) - at4(&d1, iu)) * (h / 8.0);
let vm = 0.5 * (at4(&a.x, iv) + at4(&b.x, iv))
+ (at4(&d0, iv) - at4(&d1, iv)) * (h / 8.0);
if let Some(em) = tr.eval(k, s, um, vm)? {
err = smax(err, len(em.p - q.p));
}
}
}
if err <= 0.5 * tol {
continue;
}
refined.push(q);
inserted = true;
}
if let Some(&last) = rc.last() {
refined.push(last);
}
*rc = refined;
if !inserted {
break;
}
}
let (tan, uv_der) = tc_tangents(rc, nc.closed)?;
// dense sampling of the Hermite spans
let mut d = Vec::new();
for i in 0..rc.len() {
let Some(&a) = rc.get(i) else { break };
d.push(a);
let Some(&b) = rc.get(i + 1) else { break };
let (Some(&ta), Some(&tb)) = (tan.get(i), tan.get(i + 1)) else {
break;
};
let h = len(b.p - a.p);
for j in 1..8 {
let t = f64::from(j) / 8.0;
let t2 = t * t;
let t3 = t2 * t;
let mut q = a;
q.p = a.p * (2.0 * t3 - 3.0 * t2 + 1.0)
+ ta * (h * (t3 - 2.0 * t2 + t))
+ b.p * (-2.0 * t3 + 3.0 * t2)
+ tb * (h * (t3 - t2));
q.ang = (1.0 - t) * a.ang + t * b.ang;
d.push(q);
}
}
new_tan.push(tan);
new_uv_der.push(uv_der);
dense.push(d);
}
// a crossing branch an existing curve already covers is dropped (the regular
// marcher's curve is kept); existing curves lying along the remaining crossing
// branches are replaced by them
let mut keep = vec![true; new_curves.len()];
for (nc, kp) in new_curves.iter().zip(keep.iter_mut()) {
if !nc.cross {
continue;
}
for c in &out.curves_3d {
if !*kp {
break;
}
if ignored(c.is_degenerate(k, EFF_ZERO, None))?.unwrap_or(false) {
continue;
}
if covers(k, c, &nc.pts, tol, near_tol)? {
*kp = false;
}
}
}
if !nodes.is_empty() {
let mut ii = 0;
while ii < out.curves_3d.len() {
let Some(c) = out.curves_3d.get(ii) else {
break;
};
let ivl = c.natural_interval();
let mut on = true;
for j in 0..=32 {
let Some(sp) = curve_point(k, c, ivl.evaluate(f64::from(j) / 32.0))? else {
on = false;
break;
};
let mut near = false;
for (kk, rb) in dense.iter().enumerate() {
let (Some(nc), Some(&kp)) = (new_curves.get(kk), keep.get(kk)) else {
break;
};
if !nc.cross || !kp {
continue;
}
let (dist, seg, _) = tc_poly_dist(sp, rb);
// where the surfaces cross at a small angle a curve traced to
// tolerance can stray across by tolerance / sin(angle)
let a0 = rb.get(seg).map_or(0.0, |p| p.ang);
let a1 = rb
.get(seg.saturating_add(1).min(rb.len().saturating_sub(1)))
.map_or(0.0, |p| p.ang);
let sin = smin(a0, a1).sin();
let band = smax(near_tol, 20.0 * tol / smax(sin, 1e-12));
if dist < smin(band, 0.02 * size) {
near = true;
break;
}
}
if !near {
on = false;
break;
}
}
if on {
remove_curve(out, ii);
continue;
}
ii += 1;
}
}
// --- reconcile with what the regular marcher produced and flush
for (kk, nc) in new_curves.iter().enumerate() {
if !keep.get(kk).copied().unwrap_or(false) {
continue;
}
let (Some(tan), Some(uv_der), Some(dn)) =
(new_tan.get(kk), new_uv_der.get(kk), dense.get(kk))
else {
break;
};
tr.tangent = !nc.cross;
tr.sign = nc.sign;
let rc = &nc.pts;
let length: f64 = rc
.windows(2)
.map(|w| match w {
[a, b] => len(b.p - a.p),
_ => 0.0,
})
.sum();
if length < 10.0 * tol {
continue;
}
// existing curve covering this one: keep the existing one
let mut covered = false;
for (ii, c) in out.curves_3d.iter().enumerate() {
if ignored(c.is_degenerate(k, EFF_ZERO, None))?.unwrap_or(false) {
continue;
}
// a tangential curve traced by the regular marcher can lie across anywhere
// in the band where the surfaces are within tolerance of each other, which
// is wide for high order contact
let cov_tol = if !nc.cross && out.curve_types.get(ii) == Some(&TsectCurveType::Tangent)
{
0.02 * size
} else {
near_tol
};
if covers(k, c, rc, tol, cov_tol)? {
covered = true;
break;
}
}
if covered {
continue;
}
// existing curves (and points) lying along this tangential curve: remove them
if !nc.cross {
let mut ii = 0;
while ii < out.curves_3d.len() {
let Some(c) = out.curves_3d.get(ii) else {
break;
};
let ivl = c.natural_interval();
let mut on = true;
for j in 0..=32 {
let Some(sp) = curve_point(k, c, ivl.evaluate(f64::from(j) / 32.0))? else {
on = false;
break;
};
if tc_poly_dist(sp, dn).0 > 0.02 * size {
on = false;
break;
}
}
if on {
remove_curve(out, ii);
continue;
}
ii += 1;
}
}
let mut param = 0.0;
for i in 0..rc.len() {
let Some(&p) = rc.get(i) else { break };
if let Some(&prev) = i.checked_sub(1).and_then(|j| rc.get(j)) {
param += len(p.p - prev.p);
}
let t = tan.get(i).copied().unwrap_or(Vec3::ZERO);
let duv = tc_uv_der(&tr, k, rc, tan, uv_der, i)?;
let mut tp = TsectPnt::zeroed();
tp.point_type = if nc.cross {
IntersectionPointType::Crossing
} else {
IntersectionPointType::TangentCurve
};
*tp.uv_pos_mut(SrfIndex::First) = Point2::new(p.x[0], p.x[1]);
*tp.uv_pos_mut(SrfIndex::Second) = Point2::new(p.x[2], p.x[3]);
*tp.uv_deriv_mut(SrfIndex::First) = Vec2::new(duv[0], duv[1]);
*tp.uv_deriv_mut(SrfIndex::Second) = Vec2::new(duv[2], duv[3]);
*tp.crv_pos_mut() = p.p;
*tp.crv_deriv_mut() = t;
tp.curve_parameter = param;
tp.deviation = p.gap;
tp.tangent_plane_angle_rad = if nc.cross { p.ang } else { 0.0 };
let id = si.alloc_point(tp);
si.add_point_to_curve(id)?;
}
si.flush_curve(k, out)?;
}
Ok(tangent_nodes)
}
/// `AddTangentNodes`: appends each contact point [`find_tangent_curves`] found where
/// crossing branches meet as a touching point (degenerate 3D and UV curves at it, type
/// `SM_TC_TOUCHING`, deviation the distance between the two surface points).
///
/// # Errors
///
/// Those of the surface evaluations and of `CreateDegenerateCurve` (`SER`).
#[allow(clippy::too_many_arguments)] // the fork's five output arrays
pub(crate) fn add_tangent_nodes(
k: &Kernel,
si: &SurfaceIntersector<'_>,
tangent_nodes: &[[Point2; 2]],
curves_3d: &mut Vec<Curve>,
surface1_uv_curves: &mut Vec<Curve>,
surface2_uv_curves: &mut Vec<Curve>,
curve_types: &mut Vec<TsectCurveType>,
deviations: &mut Vec<f64>,
) -> Result<()> {
for &[uv1, uv2] in tangent_nodes {
let p1 = si.surface(SrfIndex::First).evaluate_point(k, uv1)?;
let p2 = si.surface(SrfIndex::Second).evaluate_point(k, uv2)?;
let p = Point3::new(
(p1.x + p2.x) / 2.0,
(p1.y + p2.y) / 2.0,
(p1.z + p2.z) / 2.0,
);
curves_3d.push(Curve::from(BSplineCurve::create_degenerate_curve(3, p)?));
surface1_uv_curves.push(Curve::from(BSplineCurve::create_degenerate_curve(
2,
Point3::new(uv1.x, uv1.y, 0.0),
)?));
surface2_uv_curves.push(Curve::from(BSplineCurve::create_degenerate_curve(
2,
Point3::new(uv2.x, uv2.y, 0.0),
)?));
deviations.push(p1.distance(p2));
curve_types.push(TsectCurveType::Touching);
}
Ok(())
}
const OP: &str = "intersect::surface_intersector::tangent_curves::find_tangent_curves";
File diff suppressed because it is too large Load Diff
+2
View File
@@ -383,6 +383,8 @@ pub struct UpstreamFixes {
pub ssi_singular_start_inside_direction: bool,
/// `SmSurfaceIntersector::StepBackFromSingularity` leaves each point its own curve tangent: the tangent at a surface pole comes from the pole's limit normal and is exact, and copying the previous point's over it bent the Hermite span into the pole (a line projected through a sphere's pole, r 20, came out 2.02e-4 off the sphere). Only the surface derivatives come from the stepped-back point (WR-550; the fork's tangency-labs/smlib@8d95600).
pub ssi_pole_point_keeps_its_tangent: bool,
/// `DoIntersection` traces the tangential intersection curves the marcher cannot follow (the surfaces touch along them, `n1 x n2 = 0`), and the crossing branches through the contact points where crossing curves meet, which it reports as touching points (WR-75; tangency-labs/smlib ef4ab25, e9fde8f, d8c61db, 1693c9d). `FindInteriorCurves` keeps the node pairs it skips for parallel normals as seeds.
pub ssi_tangent_curve_tracer: bool,
/// `SmFilletIntersector::ComputeNextPoint` rejects a next point that is more than half a step short of where the step aimed, when the step was not clipped: where `StepSolve`'s plane-adjusting retry lands on another root of the sphere equations (a variable round whose law is evaluated past the end of its edge), the span passes the Hermite test along a straight trace, but its rail parameter advances by the whole step over a fraction of it, and the rail doubles back (WR-503).
pub fillet_trace_rejects_short_step: bool,
/// `SmCurve::CalculateTightBoundingBox` adds only the B-spline knots strictly inside the queried interval (the interval's ends are already in the box). Upstream adds every tangent-discontinuous knot and always the last knot whatever the interval, so the tight box of an edge on part of a NURBS curve (an imported sphere cap's seam, 30 deg of a quarter-circle NURBS) reaches points the edge never does (WR-474, #1054; the fork's \"a curve's tight box holds the knots inside its interval only\").
@@ -0,0 +1,210 @@
//! WR-75 (fork tangency-labs/smlib ef4ab25, e9fde8f, d8c61db, 1693c9d): with
//! `Kernel::fixes.ssi_tangent_curve_tracer` the surface/surface intersection traces the
//! curves along which the surfaces touch, and reports the singular points where crossing
//! branches meet. Two ssi-corpus cases (jyviko/ssi-corpus), the surfaces rebuilt here
//! from their definitions:
//!
//! - `c2n_quartic_circle`: the quartic `z = (x^2 + y^2 - 1)^2` over `[-2, 2]^2` (a
//! degree 4 Bezier patch, its control net computed below) against the plane `z = 0`.
//! They touch along the unit circle; upstream returns nothing.
//! - `c4_steinmetz`: two perpendicular unit cylinders (axes z and x, height 4) given, as
//! the corpus gives them, as rational B-spline surfaces (an analytic `Cone` pair takes
//! the cylinder/cylinder path, which never reaches the tracer); their two ellipses
//! cross at `(0, +-1, 0)`, which upstream does not report.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::cast_precision_loss
)] // test helpers
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::tol::approx_tol3d;
use solidcore::geom::vector2d::Vec2;
use solidcore::geom::vector3d::Vec3;
use solidcore::kernel::Kernel;
use solidcore::nurbs::b_spline_surface::BSplineSurface;
use solidcore::nurbs::math::CPoint;
use solidcore::nurbs::srf_basic::Surface as NurbSurface;
use solidcore::surface::plane::Plane;
use solidcore::surface::surface::p2::{SurfaceIntersection, TsectCurveType};
use solidcore::surface::surface::Surface;
/// `C(n, k)`.
fn choose(n: usize, k: usize) -> f64 {
(0..k).fold(1.0, |acc, i| acc * (n - i) as f64 / (i + 1) as f64)
}
/// The degree 4 Bernstein coefficients of `x(t)^a`, `x = -2 + 4 t`, for `a` in 0..=4.
fn bernstein_powers() -> Vec<[f64; 5]> {
let mut power = vec![1.0];
let mut out = Vec::new();
for _ in 0..=4 {
let mut p = [0.0; 5];
p[..power.len()].copy_from_slice(&power);
let mut b = [0.0; 5];
for (i, bi) in b.iter_mut().enumerate() {
*bi = (0..=i).map(|k| choose(i, k) / choose(4, k) * p[k]).sum();
}
out.push(b);
// multiply by x(t) = -2 + 4 t
let mut next = vec![0.0; power.len() + 1];
for (i, c) in power.iter().enumerate() {
next[i] += -2.0 * c;
next[i + 1] += 4.0 * c;
}
power = next;
}
out
}
/// `z = (x^2 + y^2 - 1)^2` over `[-2, 2]^2` as a degree (4, 4) Bezier patch.
fn quartic() -> Surface {
let b = bernstein_powers();
// (x^2 + y^2 - 1)^2 = x^4 + y^4 + 2 x^2 y^2 - 2 x^2 - 2 y^2 + 1
let terms = [
(4, 0, 1.0),
(0, 4, 1.0),
(2, 2, 2.0),
(2, 0, -2.0),
(0, 2, -2.0),
(0, 0, 1.0),
];
let net: Vec<Vec<CPoint>> = (0..5)
.map(|i| {
(0..5)
.map(|j| {
let z: f64 = terms.iter().map(|&(a, c, m)| m * b[a][i] * b[c][j]).sum();
CPoint::new(-2.0 + i as f64, -2.0 + j as f64, z, 1.0)
})
.collect()
})
.collect();
let knots = vec![0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let nurb = NurbSurface::new(net, 4, 4, knots.clone(), knots).unwrap();
Surface::from(BSplineSurface::from_nurb(nurb, false))
}
fn plane() -> Surface {
let dom = Extent2d::from_bounds(-3.0, -3.0, 3.0, 3.0);
let p = Plane::new(Vec3::ZERO, Vec3::X, Vec3::Y, Vec2::new(1.0, 1.0), &dom);
let _ = p.natural_uv_domain();
Surface::from(p)
}
/// The unit cylinder from `origin` along `x` cross `y`, length 4, as a degree (2, 1)
/// rational B-spline (the nine-point circle in the `x`, `y` plane, swept along the axis).
fn cylinder(origin: Vec3, x: Vec3, y: Vec3) -> Surface {
let axis = x.cross(y);
let w = std::f64::consts::FRAC_1_SQRT_2;
let circle = [
(1.0, 0.0, 1.0),
(1.0, 1.0, w),
(0.0, 1.0, 1.0),
(-1.0, 1.0, w),
(-1.0, 0.0, 1.0),
(-1.0, -1.0, w),
(0.0, -1.0, 1.0),
(1.0, -1.0, w),
(1.0, 0.0, 1.0),
];
let net: Vec<Vec<CPoint>> = circle
.iter()
.map(|&(a, b, wt)| {
[0.0, 4.0]
.iter()
.map(|&h| {
let p = origin + x * a + y * b + axis * h;
CPoint::new(p.x * wt, p.y * wt, p.z * wt, wt)
})
.collect()
})
.collect();
let ku = vec![
0.0, 0.0, 0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0, 1.0, 1.0,
];
let kv = vec![0.0, 0.0, 1.0, 1.0];
let nurb = NurbSurface::new(net, 2, 1, ku, kv).unwrap();
Surface::from(BSplineSurface::from_nurb(nurb, false))
}
/// `GlobalSurfaceIntersect` at the corpus tolerance (1e-6).
fn intersect(fix: bool, s1: &Surface, s2: &Surface) -> SurfaceIntersection {
let mut k = Kernel::default();
k.fixes.ssi_tangent_curve_tracer = fix;
let d1 = s1.natural_uv_domain_with_nurb(&k).unwrap();
let d2 = s2.natural_uv_domain_with_nurb(&k).unwrap();
let tol = approx_tol3d(&k, 1e-6);
s1.global_surface_intersect(&k, &d1, s2, &d2, [true, true], Some(tol), None)
.unwrap()
}
/// How many of 36 ten-degree sectors of the unit circle in `z = 0` a curve of the result
/// covers (sampled at 400 points, within `1e-4`).
fn circle_sectors(x: &SurfaceIntersection) -> usize {
let k = Kernel::default();
let mut best = 0;
for c in &x.curves_3d {
let ivl = c.natural_interval();
let mut hit = [false; 36];
for j in 0..=400 {
let p = c
.evaluate_point(&k, ivl.evaluate(f64::from(j) / 400.0))
.unwrap();
let r = (p.x * p.x + p.y * p.y).sqrt();
if (r - 1.0).abs() < 1e-4 && p.z.abs() < 1e-4 {
let a = p.y.atan2(p.x).to_degrees().rem_euclid(360.0);
hit[((a / 10.0) as usize).min(35)] = true;
}
}
best = best.max(hit.iter().filter(|&&h| h).count());
}
best
}
#[test]
fn wr75_quartic_touching_a_plane_along_a_circle() {
let (q, p) = (quartic(), plane());
let upstream = intersect(false, &q, &p);
assert!(circle_sectors(&upstream) < 18, "{:?}", upstream.curve_types);
let fixed = intersect(true, &q, &p);
assert_eq!(circle_sectors(&fixed), 36, "{:?}", fixed.curve_types);
assert!(
fixed.curve_types.contains(&TsectCurveType::Tangent),
"{:?}",
fixed.curve_types
);
}
/// Whether the result has a touching point within `1e-3` of `at`.
fn touches_at(x: &SurfaceIntersection, at: Vec3) -> bool {
let k = Kernel::default();
x.curves_3d.iter().zip(&x.curve_types).any(|(c, t)| {
*t == TsectCurveType::Touching
&& c.evaluate_point(&k, c.natural_interval().min())
.unwrap()
.distance(at)
< 1e-3
})
}
#[test]
fn wr75_steinmetz_crossing_points_are_reported() {
let a = cylinder(Vec3::new(0.0, 0.0, -2.0), Vec3::X, Vec3::Y);
let b = cylinder(Vec3::new(-2.0, 0.0, 0.0), Vec3::Y, Vec3::Z);
let nodes = [Vec3::new(0.0, 1.0, 0.0), Vec3::new(0.0, -1.0, 0.0)];
let upstream = intersect(false, &a, &b);
assert!(
!nodes.iter().all(|&n| touches_at(&upstream, n)),
"{:?}",
upstream.curve_types
);
let fixed = intersect(true, &a, &b);
for n in nodes {
assert!(touches_at(&fixed, n), "{n:?}: {:?}", fixed.curve_types);
}
}
@@ -0,0 +1,24 @@
Updates: WR-75
#### WR-75 (update, 2026-10-29). Tangential curve tracing ported from the fork (opt-in)
The SolidCraft fork's tangential curve tracer (tangency-labs/smlib ef4ab25, e9fde8f, d8c61db and 1693c9d, `SmTangentCurveTracer.cpp`) is ported as the opt-in `Kernel::fixes.ssi_tangent_curve_tracer` (default `false`, upstream behaviour).
- **What it changes:** `DoIntersection` runs a pass after `FindInteriorCurves` (`intersect::surface_intersector::tangent_curves::find_tangent_curves`). Its seeds are:
- the curve ends where the normals are parallel;
- the through points;
- the node pairs `FindInteriorCurves` skips for parallel normals.
The pass handles each seed as follows:
- It refines the seed to a contact point (Levenberg-Marquardt on `S1 - S2 = 0`, `n1 -+ n2 = 0`; the solver core is `intersect::surface_intersector::tangent_tracer`).
- It probes the tangent plane for the directions in which the contact continues.
- It traces a tangential curve both ways, and the crossing branches through the contact points where crossing curves meet (nodes).
- It refines spans until `FlushCurve`'s Hermite spans are within tolerance.
- The new curve replaces the curves and points the marcher produced along it, or is dropped when an existing curve covers it.
After the degenerate-curve sweep, the nodes are reported as touching points (`add_tangent_nodes`).
- **Corpus:** the fork moves `y23_t2_ex1_tangent_line`, `y23_fig1f_high_order_contact`, `c2n_quartic_circle`, `y23_t2_ex2_tangential_curve`, `y23_fig1d_two_quads` and `y23_t2_ex4_tangential_curve_saddle` from fail to pass (score 81 to 87 of 119, no regression). e9fde8f adds the crossing branches through nodes (`y23_t2_ex3_loop_two_curves`, `c4_steinmetz`'s singular points).
- **Tests:** `crates/solidcore/tests/wr75_ssi_tangent_curves.rs`, both of which fail without the opt-in:
- `c2n_quartic_circle`: the quartic touching the plane along the unit circle; a tangent curve covers the circle.
- `c4_steinmetz`: the two crossing points `(0, +-1, 0)` are reported as touching points.
- **Difftest:** no change on the default path. The SSI corpus run uses the default kernel.