* 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]>
134 lines
6.2 KiB
Rust
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}")))?))
|
|
}
|