Files
OneCAD/crates/kernel/src/plane_fit.rs
T
Irakli KourtisandClaude Opus 5.5 201e9d5f9f Patch fills non-planar edge loops with one smooth face (#250) (#472)
* Add a one-surface fill of closed 3D loops to geom

A closed loop of curves that does not lie in a plane has no exact surface;
the usual answer is one smooth face fitted over it. fill_loop does that:

- the loop's best-fit plane (Newell normal), with a frame chosen from the
  loop itself so the result does not depend on where the loop starts, its
  direction, or where the part sits
- the loop seen along that normal must be simple and must not fold or run
  along the normal; otherwise it is refused ("not supported yet"), never
  filled with wrong geometry
- one non-rational bicubic B-spline height field over the loop's box,
  fitted by least squares to the loop's samples (The NURBS Book 9.4.1,
  9.4.3, eq. 9.62/9.65) with a thin-plate smoothness term
  lambda*A*int(h_uu^2 + 2 h_uv^2 + h_vv^2), A one grid cell's area, so the
  fit is the same at any scale; only planes are reproduced exactly
- span counts per direction (long narrow loops fit), refined until the
  loop is within tolerance, else refused with the deviation reached

bspline: bisected span search, basis functions on a stack array, one
clamping helper shared by curves and surfaces, one binomial helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Patch fills non-planar loops of edges with one smooth face (#250)

surface.patch with edges refused any loop that is not planar ("not
supported yet: patching a loop that is not planar"). It now fills it:

- planar loops still get one exact planar face, but planarity is certified
  conservatively: lines by their ends, B-spline/NURBS edges by their control
  points, intersection curves by their samples lying on a support plane
  within tolerance; a loop that can't be certified is filled instead
- other loops get one face from geom's fill_loop, bounded by the body's own
  chained edges, whole (no split): its history is one generated face whose
  edges are the body's, and it stitches back into a closed solid
- intersection-curve edges are sampled by a seeded, guarded double
  projection shared by the planarity test and the fill (plane_fit)
- the face tolerance is 10x the planarity tolerance (documented: later
  operations take the face to contain its edges within it)
- edge picking runs inside the guard; the geom->truck conversion is shared
  with the T-spline sheets (spline_conv); the Newell plane fit is shared with
  STEP import

Tests: 3- to 8-sided, concave (dart, arrow, plus), flat-corner, two-edge and
imported rims fill as one face and stitch closed (volumes against an
independent integral); finite mesh normals, same result for any pick order
or placement; non-height-field loops and too-fine detail are refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Test the Patch dialog filling a non-planar loop of picked edges

The Patch dialog's only test used a sketch profile. Picking the four
curved rim edges of a box cut by a sideways cylinder now makes one
surface.patch command whose result is a single smooth face over the rim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:37:01 -04:00

134 lines
6.2 KiB
Rust

//! Planes fitted to points around a loop (Newell's method), for planar faces of polygons read
//! from files and for patches filling a planar loop of edges, and points sampled along edges
//! (shared with the fill of a loop that is not planar).
use mt::{BoundedCurve, EuclideanSpace, InnerSpace, MetricSpace, ParametricCurve};
use solidcraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{from_p3, p3};
use crate::{KernelError, Result};
/// The plane through points round a loop: its normal by Newell's method (turning with the
/// loop), through their centroid; `None` for fewer than three points or no area.
pub(crate) fn plane_through(pts: &[mt::Point3]) -> Option<mt::Plane> {
let n = pts.len();
if n < 3 {
return None;
}
let mut nrm = mt::Vector3::new(0.0, 0.0, 0.0);
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
for i in 0..n {
let (a, b) = (pts.get(i)?, pts.get((i + 1) % n)?);
nrm.x += (a.y - b.y) * (a.z + b.z);
nrm.y += (a.z - b.z) * (a.x + b.x);
nrm.z += (a.x - b.x) * (a.y + b.y);
c += a.to_vec();
}
let len = nrm.magnitude();
if !(len > 1e-12) {
return None;
}
let z = nrm / len;
let o = mt::Point3::from_vec(c / n as f64);
let a = if z.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
let x = (a - z * a.dot(z)).normalize();
let y = z.cross(x);
Some(mt::Plane::new(o, o + x, o + y))
}
/// The point of `c` at `t` (`None` where an intersection curve cannot find it).
fn curve_point(c: &mt::Curve, t: f64) -> Option<Vec3> {
let p = match c {
mt::Curve::IntersectionCurve(ic) => ic.search_triple(t)?.0,
other => other.subs(t),
};
let p = from_p3(p);
p.is_finite().then_some(p)
}
/// Points along an edge, in its own direction, at `s ∈ [0, 1]` of its parameter range. An
/// intersection curve's point is searched from the surface parameters of the point found before
/// when that one is near (an eighth of the range), and kept only when it lies near the curve's
/// leader (a seeded search can slide to another branch of the surfaces' intersection); else the
/// unseeded search decides. `None` where no point is found.
pub(crate) struct EdgeSampler {
curve: mt::Curve,
range: (f64, f64),
hint: Option<(f64, mt::Point2, mt::Point2)>,
}
impl EdgeSampler {
pub(crate) fn new(edge: &mt::Edge) -> Option<EdgeSampler> {
let curve = edge.oriented_curve();
let range = curve.range_tuple();
(range.0.is_finite() && range.1.is_finite() && range.0 < range.1).then_some(EdgeSampler { curve, range, hint: None })
}
pub(crate) fn at(&mut self, s: f64) -> Option<Vec3> {
let (t0, t1) = self.range;
let t = if s >= 1.0 { t1 } else { t0 + (t1 - t0) * s };
let mt::Curve::IntersectionCurve(ic) = &self.curve else { return curve_point(&self.curve, t) };
let leader = ic.leader();
let (p, d) = (leader.subs(t), leader.der(t));
let near = |q: mt::Point3| q.distance(p) <= 4.0 * ic.tolerance() + 1e-6;
let seeded = self
.hint
.filter(|(tp, _, _)| (t - tp).abs() <= (t1 - t0) / 8.0)
.and_then(|(_, a, b)| mt::double_projection(ic.surface0(), Some((a.x, a.y)), ic.surface1(), Some((b.x, b.y)), p, d, 100))
.filter(|f| near(f.0));
let found = seeded.or_else(|| mt::double_projection(ic.surface0(), None, ic.surface1(), None, p, d, 100))?;
self.hint = Some((t, found.1, found.2));
let q = from_p3(found.0);
q.is_finite().then_some(q)
}
}
/// The planar face bounded by the closed loop `chain` (each edge oriented along it) when its
/// edges are certainly in one plane, `None` otherwise (also when an edge cannot be sampled).
/// The plane is fitted to points along the edges ([`plane_through`], the normal turning with the
/// loop); the edges are certified within `tol` of it conservatively: a line by its ends, a
/// B-spline or NURBS curve with positive weights by its control points (the curve lies in their
/// convex hull), an intersection curve only when one of its two surfaces is a plane that the
/// loop's points lie on and the fitted plane agrees with over the loop (the curve lies in that
/// plane). Samples alone are never trusted: a spline can bulge out of the plane between them.
pub(crate) fn planar_face(chain: &[mt::Edge], tol: f64) -> Result<Option<mt::Face>> {
const SAMPLES: usize = 16;
let mut pts = Vec::with_capacity(chain.len() * SAMPLES);
for e in chain {
let Some(mut sampler) = EdgeSampler::new(e) else { return Ok(None) };
for k in 0..SAMPLES {
let Some(p) = sampler.at(k as f64 / SAMPLES as f64) else { return Ok(None) };
pts.push(p3(p));
}
}
let Some(plane) = plane_through(&pts) else { return Ok(None) };
let (o, n) = (plane.origin(), plane.normal());
let off = |p: mt::Point3, o: mt::Point3, n: mt::Vector3| (p - o).dot(n).abs();
let on_plane = |p: mt::Point3| off(p, o, n) <= tol;
if !pts.iter().all(|p| on_plane(*p)) {
return Ok(None);
}
// A support plane `q` of an intersection curve holds the loop when the loop's points lie on
// it and their feet on the fitted plane do too.
let holds = |q: &mt::Plane| {
let (qo, qn) = (q.origin(), q.normal());
pts.iter().all(|p| off(*p, qo, qn) <= tol && off(*p - n * (*p - o).dot(n), qo, qn) <= tol)
};
for e in chain {
let certified = match e.curve() {
mt::Curve::Line(l) => on_plane(l.0) && on_plane(l.1),
mt::Curve::BSplineCurve(b) => b.control_points().iter().all(|p| on_plane(*p)),
mt::Curve::NurbsCurve(c) => {
c.non_rationalized().control_points().iter().all(|h| h.w > 0.0 && on_plane(mt::Point3::new(h.x / h.w, h.y / h.w, h.z / h.w)))
}
mt::Curve::IntersectionCurve(ic) => [ic.surface0(), ic.surface1()].into_iter().any(|s| matches!(s, mt::Surface::Plane(q) if holds(q))),
};
if !certified {
return Ok(None);
}
}
let wire: mt::Wire = chain.to_vec().into();
Ok(Some(mt::Face::try_new(vec![wire], mt::Surface::Plane(plane)).map_err(|e| KernelError::Failed(format!("patch: {e}")))?))
}