Files
Irakli Kourtis b4edf05373 Extend any surface: natural, tangent and curvature, up to a target (#760) (#1166)
Extend works on any surface: natural (continuing the face's own surface), tangent (G1 run-off) or curvature (G2 run-off), by a distance, up to a plane or face, or back to the surface's bounds, with neighbouring edges lengthened to meet. Untrim and Extend share face parameter-loop helpers. The new SurfaceExtend fields are noted under format 21. Closes #760.
2026-10-14 22:46:29 -04:00

1694 lines
84 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Extend (Surface › Extend): boundary edges of a surface body pushed outward across their
//! face, by a distance, up to a plane or a face, or (natural only) back to the surface's own
//! bounds. Three ways to continue past an edge:
//!
//! - natural: the face's own surface continued and the face bounded again on it. Exact for a
//! plane and a surface of revolution (cylinder, cone, sphere, torus); a B-spline surface's end
//! spans continue as their own polynomials. Each point of the edge moves along the parameter
//! line that leaves the edge square to it (in space), by the arc length asked for.
//! - tangent: a ruled run-off face along the edge, in the surface's tangent plane at each point
//! (tangent-continuous, G1).
//! - curvature: a run-off face that also keeps the surface's normal curvature across the edge
//! (curvature-continuous, G2): each section `C + s·R + ½·κ·s²·n`.
//!
//! The corners: where two extended edges meet, both run out along the bisector of their
//! outward directions (a mitre); next to an edge that is not extended, the extension's side
//! continues that neighbour (it is lengthened to meet the extension), or with `neighbours` off
//! runs straight out square to the edge, as it does where the edges meet smoothly. Along the
//! edge the run-out direction turns evenly between its ends, keeping the distance measured
//! square to the edge. Faces of one body extended at a corner they share use one side edge
//! there when their sides coincide, so the body's boundary stays closed.
//!
//! Refused rather than built: an extension that folds over (a concave edge pushed past its
//! centre of curvature), crosses itself, wraps all the way round its surface or runs into a
//! pole of it; edges between two faces, and edges of holes.
use std::collections::HashMap;
use mt::{BoundedCurve, ParametricCurve, ParametricSurface};
use solidcraft_geom::Vec3;
use solidcraft_geom::tess::EvalSurface;
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::face_params::{FaceLoops, iso_curve, natural_span, parameters};
use crate::record::{Rebuild, Was, rebuilt_body};
use crate::splitface::ToolSide;
use crate::surfaces::{edge_near, surface_body};
use crate::{KernelError, Result, SplitTool, guard};
/// Samples along an edge a fitted curve or run-off starts with, and the most it is refined to.
const START_SAMPLES: usize = 17;
const MAX_SAMPLES: usize = 257;
/// Most edge picks.
const MAX_PICKS: usize = 10_000;
/// The run-out direction may lean at most this far from square to its edge (its cosine; about
/// 84°): further, the extension would run nearly along the edge.
const MIN_COS: f64 = 0.1;
/// Edges whose directions agree this closely where they meet (about 2.6°) meet smoothly.
const SMOOTH_COS: f64 = 0.999;
/// Steps a march along the surface takes over the distance it is asked for, and the most it
/// takes.
const MARCH_STEPS: usize = 64;
const MAX_MARCH_STEPS: usize = 8192;
/// Bisection steps that place where a march meets its target.
const BISECT_STEPS: usize = 60;
/// How a surface continues past its edge.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ExtendMode {
/// The surface itself, continued.
#[default]
Natural,
/// A ruled run-off in the surface's tangent plane (G1).
Tangent,
/// A run-off following the surface's normal curvature across the edge (G2).
Curvature,
}
/// How far an extension goes.
#[derive(Clone, Copy, Debug)]
pub enum ExtendLimit<'a> {
/// This far from the edge, measured across the surface square to it.
Distance(f64),
/// To where it meets a plane, or the surface of a face (extended).
UpTo(&'a SplitTool<'a>),
/// Back to the surface's own bounds (natural only), undoing a trim: a free-form surface's
/// parameter box, an analytic surface's the box of the face's own span.
Bounds,
}
/// What [`extend_surface`] does.
#[derive(Clone, Copy, Debug)]
pub struct ExtendOptions<'a> {
pub mode: ExtendMode,
pub limit: ExtendLimit<'a>,
/// Lengthen the edges next to an extended edge to meet the extension (else its sides run
/// square to the edge).
pub neighbours: bool,
}
/// The boundary edges of `body` picked by points on them, extended as `opts` says. A natural
/// extension rebuilds each face on its continued surface; a run-off adds a face along each
/// edge. The result is a surface body whose history is over `[body]`: an extended face is the
/// face it was (trimmed differently) and its moved edges the edges they were moved from; a
/// run-off face is made from its edge.
pub fn extend_surface(body: &Body, at: &[Vec3], opts: &ExtendOptions) -> Result<Body> {
body.require_brep("extend")?;
if at.is_empty() {
return Err(KernelError::Invalid("pick the edges to extend".into()));
}
if at.len() > MAX_PICKS {
return Err(KernelError::Invalid("too many edges picked".into()));
}
if at.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Invalid("an edge point is not finite".into()));
}
match opts.limit {
ExtendLimit::Distance(d) if !(d.is_finite() && d > 0.0 && d <= 1e6) => {
return Err(KernelError::Invalid("the extension distance must be positive".into()));
}
ExtendLimit::Bounds if opts.mode != ExtendMode::Natural => {
return Err(KernelError::Invalid("only a natural extension goes back to the surface's bounds".into()));
}
_ => {}
}
// A plane's straight edges pushed out with their neighbours lengthened: the exact planar
// extension (what designs saved before the other modes build).
if let (ExtendMode::Natural, ExtendLimit::Distance(d), true) = (opts.mode, opts.limit, opts.neighbours)
&& plain_planar(body)
{
return crate::surfaces::extend(body, at, d);
}
let side = match opts.limit {
ExtendLimit::UpTo(tool) => Some(ToolSide::of(tool)?),
_ => None,
};
guard("extend", || Extension::new(body, opts, side).run(at))
}
/// One planar face, one loop of straight edges: what [`crate::surfaces::extend`] takes.
fn plain_planar(body: &Body) -> bool {
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let [face] = faces.as_slice() else { return false };
let bounds = face.boundaries();
let [outer] = bounds.as_slice() else { return false };
matches!(crate::splitface::analytic_field(&face.oriented_surface()), Some(crate::splitface::Analytic::Plane { .. }))
&& outer.edge_iter().all(|e| matches!(e.curve(), mt::Curve::Line(_)))
}
/// A point of an edge with the surface's frame there.
#[derive(Clone, Copy, Debug)]
struct Local {
/// On the edge.
p: Vec3,
/// Its parameters on the surface (unwrapped along the loop).
q: [f64; 2],
/// The surface's unit normal (`S_u × S_v`, as the parameters run).
n: Vec3,
/// The edge's unit direction as the loop runs, in the tangent plane.
d: Vec3,
/// Unit, in the tangent plane, square to the edge, away from the face.
out: Vec3,
su: Vec3,
sv: Vec3,
}
/// Where the extension of a point of an edge ends.
#[derive(Clone, Copy, Debug)]
struct Far {
/// The edge's point and frame.
at: Local,
/// The run-out direction (unit, in the tangent plane) and the run-off's length along it.
r: Vec3,
l: f64,
/// The surface's normal curvature along `r` (curvature mode).
kappa: f64,
/// The far point, and (natural) its parameters.
f: Vec3,
fq: [f64; 2],
}
impl Far {
/// The run-off's section at `s` along it (tangent and curvature modes).
fn section(&self, s: f64) -> Vec3 {
self.at.p + self.r * s + self.at.n * (0.5 * self.kappa * s * s)
}
}
/// A corner of the outer loop (where edge `i − 1` ends and edge `i` starts) next to an
/// extended edge: the direction the extension runs out along there, and the loop index of the
/// neighbouring edge it lengthens (when it does).
#[derive(Clone, Copy, Debug)]
struct Corner {
r: Vec3,
lengthens: Option<usize>,
}
/// An extended edge: its loop index, curve range and the run-out angles (from square to the
/// edge, toward its direction) at its ends.
#[derive(Clone, Copy, Debug)]
struct Run {
i: usize,
t0: f64,
t1: f64,
phi0: f64,
phi1: f64,
}
/// A face being extended: its loops in parameters and its outer loop's edges.
struct Frame {
face: mt::Face,
surface: mt::Surface,
loops: FaceLoops,
/// +1 when the outer loop runs counter-clockwise in the parameters, else −1.
ccw: f64,
edges: Vec<mt::Edge>,
chosen: Vec<bool>,
}
impl Frame {
fn new(face: &mt::Face, ids: &[mt::EdgeID], accept: f64) -> Result<Frame> {
let loops = FaceLoops::of(face, accept, "extending")?;
let outer = loops.wires.get(loops.outer).ok_or_else(|| KernelError::Invalid("the face has no boundary".into()))?;
let edges: Vec<mt::Edge> = outer.edge_iter().cloned().collect();
let chosen: Vec<bool> = edges.iter().map(|e| ids.contains(&e.id())).collect();
if ids.iter().any(|id| !edges.iter().any(|e| e.id() == *id)) {
return Err(KernelError::Invalid("not supported yet: extending an edge of a hole (Untrim takes holes away)".into()));
}
let area = loops.loops.get(loops.outer).map_or(0.0, |l| l.area());
if !(area.abs() > 0.0) {
return Err(KernelError::Invalid("the face has no area on its surface".into()));
}
Ok(Frame { face: face.clone(), surface: face.surface(), ccw: area.signum(), loops, edges, chosen })
}
fn m(&self) -> usize {
self.edges.len()
}
/// The outer loop's edge `i` (as the loop runs) and its samples.
fn samples(&self, i: usize) -> Result<&(mt::Curve, Vec<(f64, Vec3, [f64; 2])>)> {
self.loops.loops.get(self.loops.outer).and_then(|l| l.edges.get(i)).ok_or_else(|| KernelError::Failed("extend: a loop edge".into()))
}
/// The curve range of edge `i` (as the loop runs).
fn range(&self, i: usize) -> Result<(f64, f64)> {
Ok(self.samples(i)?.0.range_tuple())
}
/// The point of edge `i` at curve parameter `t`, with the surface's frame there.
fn local(&self, i: usize, t: f64, accept: f64) -> Result<Local> {
let (curve, samples) = self.samples(i)?;
let p = from_p3(curve.subs(t));
let hint = samples.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map(|s| s.2);
let q = parameters(&self.loops.eval, &self.loops.dom, p, hint, accept)?;
let (_, su, sv) = self.loops.eval.derivs(q[0], q[1]).ok_or_else(|| KernelError::Failed("extend: the surface at an edge".into()))?;
let n = normal(su, sv)?;
let dc = curve.der(t);
let dc = Vec3::new(dc.x, dc.y, dc.z);
let d = (dc - n * dc.dot(n)).normalized().ok_or_else(|| KernelError::Invalid("an edge to extend has no direction".into()))?;
Ok(Local { p, q, n, d, out: d.cross(n) * self.ccw, su, sv })
}
/// The point of the surface at `q`.
fn at(&self, q: [f64; 2]) -> Result<Vec3> {
self.loops.eval.derivs(q[0], q[1]).map(|x| x.0).ok_or_else(|| KernelError::Failed("extend: the surface can't be evaluated there".into()))
}
fn is_plane(&self) -> bool {
matches!(self.loops.eval, EvalSurface::Plane { .. })
}
}
/// The unit normal from the parameter derivatives; refused at a pole (where it has none).
fn normal(su: Vec3, sv: Vec3) -> Result<Vec3> {
let c = su.cross(sv);
if !(c.len() > 1e-12 * (su.len2() + sv.len2())) {
return Err(KernelError::Invalid("the extension runs into a pole of the surface (a point with no normal)".into()));
}
c.normalized().ok_or_else(|| KernelError::Invalid("the extension runs into a pole of the surface".into()))
}
/// The parameter step `(a, b)` with `a·S_u + b·S_v = w` for `w` in the tangent plane, its
/// near-zero parts snapped to zero (a run along a parameter line stays on it).
fn tangent_coords(su: Vec3, sv: Vec3, w: Vec3) -> Result<[f64; 2]> {
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
let det = e * g - f * f;
if !(det.abs() > 1e-24 * (e * g).max(1e-300)) {
return Err(KernelError::Invalid("the extension runs into a pole of the surface".into()));
}
let (x, y) = (w.dot(su), w.dot(sv));
let mut ab = [(g * x - f * y) / det, (e * y - f * x) / det];
let big = ab[0].abs().max(ab[1].abs());
for c in &mut ab {
if c.abs() <= 1e-12 * big {
*c = 0.0;
}
}
Ok(ab)
}
/// An extension in progress: the body, what to do, and what faces extended at shared corners
/// share.
struct Extension<'a> {
body: &'a Body,
opts: &'a ExtendOptions<'a>,
side: Option<ToolSide>,
size: f64,
/// How far a fitted curve or run-off may stray.
tol: f64,
/// How far a boundary point may be from its surface.
accept: f64,
/// Faces using each edge.
users: HashMap<mt::EdgeID, usize>,
/// The far corners made at each original vertex, and the side edges run out from it.
corners: HashMap<mt::VertexID, Vec<mt::Vertex>>,
sides: HashMap<mt::VertexID, Vec<mt::Edge>>,
rec: Rebuild,
}
impl<'a> Extension<'a> {
fn new(body: &'a Body, opts: &'a ExtendOptions<'a>, side: Option<ToolSide>) -> Extension<'a> {
let size = body.size().max(1e-6);
let mut users: HashMap<mt::EdgeID, usize> = HashMap::new();
for f in body.solid.face_iter() {
for w in f.boundaries().iter() {
for e in w.edge_iter() {
*users.entry(e.id()).or_default() += 1;
}
}
}
Extension {
body,
opts,
side,
size,
tol: (size * 1e-6).max(1e-7),
accept: (size * 1e-5).max(1e-6),
users,
corners: HashMap::new(),
sides: HashMap::new(),
rec: Rebuild::default(),
}
}
fn run(mut self, at: &[Vec3]) -> Result<Body> {
let faces: Vec<mt::Face> = self.body.solid.face_iter().cloned().collect();
// The picked edges, by the face they bound.
let mut picks: Vec<(usize, Vec<mt::EdgeID>)> = Vec::new();
for p in at {
let e = edge_near(self.body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
if self.users.get(&e.id()).copied().unwrap_or(0) != 1 {
return Err(KernelError::Invalid("an edge between two faces can't be extended: pick edges on the surface's boundary".into()));
}
let fi = faces
.iter()
.position(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|x| x.id() == e.id())))
.ok_or_else(|| KernelError::Failed("extend: the face of an edge".into()))?;
match picks.iter_mut().find(|(f, _)| *f == fi) {
Some((_, ids)) if !ids.contains(&e.id()) => ids.push(e.id()),
Some(_) => {}
None => picks.push((fi, vec![e.id()])),
}
}
picks.sort_by_key(|(f, _)| *f);
let mut out = faces.clone();
let mut added = Vec::new();
for (fi, ids) in &picks {
let face = faces.get(*fi).ok_or_else(|| KernelError::Failed("extend: a face".into()))?;
let frame = Frame::new(face, ids, self.accept)?;
match self.opts.mode {
ExtendMode::Natural => {
let extended = self.natural(&frame)?;
if let Some(slot) = out.get_mut(*fi) {
*slot = extended;
}
}
ExtendMode::Tangent | ExtendMode::Curvature => added.extend(self.run_offs(&frame)?),
}
}
out.extend(added);
Ok(rebuilt_body(surface_body(out), &[&self.body.solid], &self.rec))
}
/// The corners of the frame's outer loop next to an extended edge (`None` elsewhere).
fn corners_of(&self, fr: &Frame) -> Result<Vec<Option<Corner>>> {
let m = fr.m();
let mut out = Vec::with_capacity(m);
for i in 0..m {
let pi = (i + m - 1) % m;
let (pc, nc) = (fr.chosen.get(pi).copied().unwrap_or(false), fr.chosen.get(i).copied().unwrap_or(false));
if !pc && !nc {
out.push(None);
continue;
}
let prev = fr.local(pi, fr.range(pi)?.1, self.accept)?;
let next = fr.local(i, fr.range(i)?.0, self.accept)?;
let smooth = prev.d.dot(next.d) >= SMOOTH_COS;
let (r, lengthens) = if pc && nc {
let r = (prev.out + next.out)
.normalized()
.ok_or_else(|| KernelError::Invalid("two extended edges turn back on each other at a corner".into()))?;
(r, None)
} else if pc {
if !self.opts.neighbours || smooth { (prev.out, None) } else { (-next.d, Some(i)) }
} else if !self.opts.neighbours || smooth {
(next.out, None)
} else {
(prev.d, Some(pi))
};
for (chosen, l) in [(pc, &prev), (nc, &next)] {
if chosen && r.dot(l.out) < MIN_COS {
return Err(KernelError::Invalid(if lengthens.is_some() {
"an edge next to the extended edge meets it at too sharp an angle to be lengthened (extend without lengthening the neighbouring edges)".into()
} else {
"two extended edges meet at too sharp a corner".into()
}));
}
}
out.push(Some(Corner { r, lengthens }));
}
Ok(out)
}
/// The extended edge `i` of the frame with its run-out angles from the corners at its ends.
fn run_of(&self, fr: &Frame, corners: &[Option<Corner>], i: usize) -> Result<Run> {
let m = fr.m();
let (t0, t1) = fr.range(i)?;
let (Some(Some(c0)), Some(Some(c1))) = (corners.get(i), corners.get((i + 1) % m)) else {
return Err(KernelError::Failed("extend: the corners of an edge".into()));
};
let (a, b) = (fr.local(i, t0, self.accept)?, fr.local(i, t1, self.accept)?);
Ok(Run { i, t0, t1, phi0: c0.r.dot(a.d).atan2(c0.r.dot(a.out)), phi1: c1.r.dot(b.d).atan2(c1.r.dot(b.out)) })
}
/// Where the extension of edge `run` at curve parameter `t` ends.
fn far(&self, fr: &Frame, run: &Run, t: f64) -> Result<Far> {
let at = fr.local(run.i, t, self.accept)?;
let w = if run.t1 != run.t0 { (t - run.t0) / (run.t1 - run.t0) } else { 0.0 };
let phi = run.phi0 + (run.phi1 - run.phi0) * w;
let r = at.out * phi.cos() + at.d * phi.sin();
let reach = |cos: f64| -> f64 {
match self.opts.limit {
ExtendLimit::Distance(d) => d / cos.max(MIN_COS),
_ => 0.0,
}
};
let ab = tangent_coords(at.su, at.sv, r)?;
match self.opts.mode {
ExtendMode::Tangent | ExtendMode::Curvature => {
let kappa = if self.opts.mode == ExtendMode::Curvature { normal_curvature(&fr.surface, at.q, ab, at.n) } else { 0.0 };
let mut far = Far { at, r, l: 0.0, kappa, f: at.p, fq: at.q };
far.l = match self.opts.limit {
ExtendLimit::Distance(_) => reach(phi.cos()),
ExtendLimit::UpTo(_) => self.meet(|s| Ok(far.section(s)), at.p)?,
ExtendLimit::Bounds => return Err(KernelError::Invalid("only a natural extension goes back to the surface's bounds".into())),
};
far.f = far.section(far.l);
Ok(far)
}
ExtendMode::Natural => {
let line = |s: f64| [at.q[0] + ab[0] * s, at.q[1] + ab[1] * s];
let s = match self.opts.limit {
ExtendLimit::Distance(_) => arc_param(fr, at.q, ab, reach(phi.cos()))?,
ExtendLimit::UpTo(_) => self.meet(|s| fr.at(line(s)), at.p)?,
ExtendLimit::Bounds => self.to_bounds(fr, at.q, ab)?,
};
let fq = line(s);
let (f, su, sv) =
fr.loops.eval.derivs(fq[0], fq[1]).ok_or_else(|| KernelError::Failed("extend: the surface past its edge".into()))?;
normal(su, sv)?;
Ok(Far { at, r, l: s, kappa: 0.0, f, fq })
}
}
}
/// Where a run `point(s)` (s ≥ 0, from `start`) first crosses the target: stepped out until
/// it changes sides, then bisected; zero from a point on the target. Refused when the run
/// doesn't reach it within a generous length.
fn meet(&self, point: impl Fn(f64) -> Result<Vec3>, start: Vec3) -> Result<f64> {
let side = self.side.as_ref().ok_or_else(|| KernelError::Failed("extend: no target".into()))?;
let f0 = side.at(start);
if !f0.is_finite() {
return Err(KernelError::Invalid("the edge's side of the target can't be told".into()));
}
if f0.abs() <= self.tol {
return Ok(0.0);
}
let h = self.size / 16.0;
let reach = self.size * 50.0;
let (mut s, mut prev, mut walked) = (0.0, start, 0.0);
for _ in 0..MAX_MARCH_STEPS {
let next = point(s + h)?;
walked += next.dist(prev);
let f1 = side.at(next);
if f1.is_finite() && f1 * f0 <= 0.0 {
let (mut a, mut b) = (s, s + h);
for _ in 0..BISECT_STEPS {
let m = 0.5 * (a + b);
let fm = side.at(point(m)?);
if fm.is_finite() && fm * f0 > 0.0 { a = m } else { b = m }
}
return Ok(0.5 * (a + b));
}
if walked > reach {
break;
}
(s, prev) = (s + h, next);
}
Err(KernelError::Invalid("the extension does not reach the target".into()))
}
/// How far along the parameter line `q + s·ab` the surface's own bounds are (zero from a
/// point on them).
fn to_bounds(&self, fr: &Frame, q: [f64; 2], ab: [f64; 2]) -> Result<f64> {
let outer = fr.loops.loops.get(fr.loops.outer).ok_or_else(|| KernelError::Invalid("the face has no boundary".into()))?;
let analytic = crate::offset::surf_of(&fr.face, (self.size * 1e-7).max(1e-9) * 100.0).is_some();
let span = natural_span(&fr.loops.eval, &fr.loops.dom, outer, analytic, self.accept)?;
let big = ab[0].abs().max(ab[1].abs());
let mut exit = f64::INFINITY;
for (d, (lo, hi)) in span.iter().enumerate() {
let (Some(&x), Some(&dx)) = (q.get(d), ab.get(d)) else { continue };
if dx.abs() <= 1e-9 * big {
continue;
}
let bound = if dx > 0.0 { *hi } else { *lo };
exit = exit.min((bound - x) / dx);
}
if !exit.is_finite() {
return Err(KernelError::Invalid("the extension does not reach its surface's bounds".into()));
}
let scale = (span[0].1 - span[0].0).abs().max((span[1].1 - span[1].0).abs()) / big.max(1e-300);
Ok(if exit > 1e-9 * scale { exit } else { 0.0 })
}
/// The extension's far ends of edge `run` at `n` even curve parameters; refused when none
/// moves (the edge lies on its target already).
fn fars(&self, fr: &Frame, run: &Run, n: usize) -> Result<Vec<Far>> {
let fars: Vec<Far> = (0..n).map(|k| self.far(fr, run, run.t0 + (run.t1 - run.t0) * k as f64 / (n - 1) as f64)).collect::<Result<_>>()?;
if fars.iter().all(|f| !self.moved(f)) {
return Err(KernelError::Invalid(match self.opts.limit {
ExtendLimit::Bounds => "the edge already lies on its surface's bounds".into(),
_ => "the edge already lies on the target".into(),
}));
}
Ok(fars)
}
/// Whether the extension moves the point at all.
fn moved(&self, f: &Far) -> bool {
f.f.dist(f.at.p) > self.tol * 10.0
}
/// The far corner vertex at `v` (an original vertex) for the point `p`: one made there
/// before (by this face or a neighbour) when it is at the same place.
fn corner_vertex(&mut self, v: &mt::Vertex, p: Vec3) -> mt::Vertex {
let near = self.tol * 10.0;
if from_p3(v.point()).dist(p) <= near {
return v.clone();
}
let list = self.corners.entry(v.id()).or_default();
if let Some(found) = list.iter().find(|x| from_p3(x.point()).dist(p) <= near) {
return found.clone();
}
let made = mt::Vertex::new(p3(p));
list.push(made.clone());
made
}
/// The side edge from the original vertex `v` to the far corner `f`, through `mid`: one made
/// before (by this face or a neighbour) along the same path, else `make`'s curve.
fn side_edge(&mut self, v: &mt::Vertex, f: &mt::Vertex, mid: Vec3, make: impl FnOnce() -> Result<mt::Curve>) -> Result<mt::Edge> {
let near = self.tol * 10.0;
if let Some(found) = self.sides.get(&v.id()).and_then(|list| {
list.iter().find(|e| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
e.back().id() == f.id() && from_p3(c.subs(0.5 * (t0 + t1))).dist(mid) <= near * 10.0
})
}) {
return Ok(found.clone());
}
let e = mt::Edge::try_new(v, f, make()?).map_err(|e| KernelError::Failed(format!("extend: a side edge: {e}")))?;
self.sides.entry(v.id()).or_default().push(e.clone());
Ok(e)
}
/// The face extended naturally: its surface continued and its outer loop bounded anew.
fn natural(&mut self, fr: &Frame) -> Result<mt::Face> {
let m = fr.m();
let corners = self.corners_of(fr)?;
let chosen = |i: usize| fr.chosen.get(i).copied().unwrap_or(false);
let runs: Vec<Option<Run>> =
(0..m).map(|i| if chosen(i) { self.run_of(fr, &corners, i).map(Some) } else { Ok(None) }).collect::<Result<_>>()?;
// The far end at each corner next to an extended edge (from that edge's run).
let far_at: Vec<Option<Far>> = (0..m)
.map(|i| match (runs.get(i).copied().flatten(), runs.get((i + m - 1) % m).copied().flatten()) {
(Some(r), _) => self.far(fr, &r, r.t0).map(Some),
(None, Some(r)) => self.far(fr, &r, r.t1).map(Some),
(None, None) => Ok(None),
})
.collect::<Result<_>>()?;
// The new loop, piece by piece, in the old parameters; no side where a corner stays.
let moves = |i: usize| far_at.get(i).copied().flatten().is_some_and(|f| self.moved(&f));
let mut pieces: Vec<Piece> = Vec::new();
for i in 0..m {
let ni = (i + 1) % m;
if chosen(i) {
if !chosen((i + m - 1) % m) && moves(i) {
pieces.push(Piece::Side { corner: i, out: true });
}
pieces.push(Piece::Far(i));
if !chosen(ni) && moves(ni) {
pieces.push(Piece::Side { corner: ni, out: false });
}
} else {
pieces.push(Piece::Keep(i));
}
}
// A kept edge on a parameter line, lengthened by the side next to it along that line,
// becomes one edge (unless another face shares it).
let mergeable = |i: usize| -> bool {
let Some(e) = fr.edges.get(i) else { return false };
self.users.get(&e.id()).copied().unwrap_or(0) == 1 && straight(fr, i)
};
let lengthens = |corner: usize, edge: usize| corners.get(corner).copied().flatten().and_then(|c| c.lengthens) == Some(edge);
let n = pieces.len();
let mut dropped = vec![false; n];
let mut merged: Vec<Option<Piece>> = vec![None; n];
for (k, piece) in pieces.iter().enumerate() {
let Piece::Keep(i) = *piece else { continue };
let (pk, nk) = ((k + n - 1) % n, (k + 1) % n);
if pk == k || !mergeable(i) {
continue;
}
let before = matches!(pieces.get(pk), Some(Piece::Side { corner, out: false }) if lengthens(*corner, i));
let after = matches!(pieces.get(nk), Some(Piece::Side { corner, out: true }) if lengthens(*corner, i));
if before || after {
for (at, yes) in [(pk, before), (nk, after)] {
if let Some(d) = dropped.get_mut(at) {
*d |= yes;
}
}
if let Some(slot) = merged.get_mut(k) {
*slot = Some(Piece::Merged { edge: i, from_far: before, to_far: after });
}
}
}
let pieces: Vec<Piece> = pieces
.iter()
.enumerate()
.filter(|(k, _)| !dropped.get(*k).copied().unwrap_or(false))
.map(|(k, p)| merged.get(k).copied().flatten().unwrap_or(*p))
.collect();
// Far samples of each extended edge (refined later where a curve is fitted).
let samples: Vec<Option<Vec<Far>>> = runs
.iter()
.map(|r| match r {
Some(r) => {
let fars = self.fars(fr, r, START_SAMPLES)?;
check_no_fold(&fars)?;
Ok(Some(fars))
}
None => Ok(None),
})
.collect::<Result<_>>()?;
// The new loop as a polygon in the old parameters: checked before anything is built.
let corner_q = |i: usize| -> Result<[f64; 2]> {
far_at.get(i).copied().flatten().map(|f| f.fq).ok_or_else(|| KernelError::Failed("extend: a corner".into()))
};
let vertex_q = |i: usize| -> Result<[f64; 2]> {
fr.samples(i).and_then(|s| s.1.first().map(|x| x.2).ok_or_else(|| KernelError::Failed("extend: a vertex".into())))
};
let mut poly: Vec<[f64; 2]> = Vec::new();
for p in &pieces {
match *p {
Piece::Keep(i) => {
let s = &fr.samples(i)?.1;
poly.extend(s.iter().take(s.len().saturating_sub(1)).map(|x| x.2));
}
Piece::Side { corner, out } => poly.push(if out { vertex_q(corner)? } else { corner_q(corner)? }),
Piece::Far(i) => {
let s = samples.get(i).and_then(|s| s.as_ref()).ok_or_else(|| KernelError::Failed("extend: far samples".into()))?;
poly.extend(s.iter().take(s.len().saturating_sub(1)).map(|f| f.fq));
}
Piece::Merged { edge, from_far, .. } => poly.push(if from_far { corner_q(edge)? } else { vertex_q(edge)? }),
}
}
check_loop(fr, &poly)?;
// The surface continued over the new loop.
let ext = continued(&fr.surface, &fr.loops.eval, &bounds_of(&poly))?;
// The edges.
let mut wire: Vec<mt::Edge> = Vec::with_capacity(pieces.len());
for p in &pieces {
match *p {
Piece::Keep(i) => wire.push(fr.edges.get(i).cloned().ok_or_else(|| KernelError::Failed("extend: an edge".into()))?),
Piece::Side { corner, out } => {
let far = far_at.get(corner).copied().flatten().ok_or_else(|| KernelError::Failed("extend: a corner".into()))?;
let v = fr.edges.get(corner).map(|e| e.front().clone()).ok_or_else(|| KernelError::Failed("extend: a vertex".into()))?;
let fv = self.corner_vertex(&v, far.f);
let (a, b) = (far.at.q, far.fq);
let mid = fr.at([0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])])?;
let tol = self.tol;
let e = self.side_edge(&v, &fv, mid, || uv_line(fr, &ext, a, b, tol))?;
let lengthened = corners.get(corner).copied().flatten().and_then(|c| c.lengthens).and_then(|i| fr.edges.get(i));
if let Some(old) = lengthened {
self.rec.piece(&e, old);
}
wire.push(if out { e } else { e.inverse() });
}
Piece::Far(i) => {
let run = runs.get(i).copied().flatten().ok_or_else(|| KernelError::Failed("extend: a run".into()))?;
let old = fr.edges.get(i).ok_or_else(|| KernelError::Failed("extend: an edge".into()))?;
let (Some(a), Some(b)) = (far_at.get(i).copied().flatten(), far_at.get((i + 1) % m).copied().flatten()) else {
return Err(KernelError::Failed("extend: the corners of an edge".into()));
};
let va = self.corner_vertex(old.front(), a.f);
let vb = self.corner_vertex(old.back(), b.f);
let first = samples.get(i).and_then(|s| s.as_ref()).ok_or_else(|| KernelError::Failed("extend: far samples".into()))?;
let curve = self.far_curve(fr, &ext, &run, first)?;
let e = mt::Edge::try_new(&va, &vb, curve).map_err(|e| KernelError::Failed(format!("extend: a moved edge: {e}")))?;
self.rec.piece(&e, old);
wire.push(e);
}
Piece::Merged { edge, from_far, to_far } => {
let old = fr.edges.get(edge).ok_or_else(|| KernelError::Failed("extend: an edge".into()))?;
let (a, va) = if from_far {
let f = far_at.get(edge).copied().flatten().ok_or_else(|| KernelError::Failed("extend: a corner".into()))?;
(f.fq, self.corner_vertex(old.front(), f.f))
} else {
(vertex_q(edge)?, old.front().clone())
};
let (b, vb) = if to_far {
let f = far_at.get((edge + 1) % m).copied().flatten().ok_or_else(|| KernelError::Failed("extend: a corner".into()))?;
(f.fq, self.corner_vertex(old.back(), f.f))
} else {
(vertex_q((edge + 1) % m)?, old.back().clone())
};
let b = unwrap_near(fr, b, a);
let curve = uv_line(fr, &ext, a, b, self.tol)?;
let e = mt::Edge::try_new(&va, &vb, curve).map_err(|e| KernelError::Failed(format!("extend: a lengthened edge: {e}")))?;
self.rec.piece(&e, old);
wire.push(e);
}
}
}
let holes = fr.loops.wires.iter().enumerate().filter(|(k, _)| *k != fr.loops.outer).map(|(_, w)| w.clone());
let boundaries: Vec<mt::Wire> = std::iter::once(mt::Wire::from(wire)).chain(holes).collect();
let mut f = mt::Face::try_new(boundaries, ext.surface).map_err(|e| KernelError::Failed(format!("extended face: {e}")))?;
if !fr.face.orientation() {
f.invert();
}
self.rec.face(&f, &fr.face);
Ok(f)
}
/// The far edge of a natural extension: exact on a parameter line (and straight on a plane),
/// else a cubic through the far points, refined until it follows them within the tolerance.
fn far_curve(&self, fr: &Frame, ext: &Continued, run: &Run, first: &[Far]) -> Result<mt::Curve> {
let (Some(a), Some(b)) = (first.first(), first.last()) else { return Err(KernelError::Failed("extend: far samples".into())) };
if let Some(along_u) = iso(first.iter().map(|f| f.fq)) {
let (w, from, to) = if along_u { (a.fq[1], a.fq[0], b.fq[0]) } else { (a.fq[0], a.fq[1], b.fq[1]) };
return ext.iso(along_u, w, from, to);
}
if fr.is_plane() && first.iter().all(|f| f.f.dist_to_segment(a.f, b.f) <= self.tol) {
return Ok(mt::Curve::Line(mt::Line(p3(a.f), p3(b.f))));
}
self.fit(|t| self.far(fr, run, t).map(|f| f.f), run.t0, run.t1)
}
/// A cubic through `point(t)` at even parameters from `t0` to `t1`, refined until it passes
/// within the tolerance of the points between its samples.
fn fit(&self, point: impl Fn(f64) -> Result<Vec3>, t0: f64, t1: f64) -> Result<mt::Curve> {
let mut n = START_SAMPLES;
loop {
let at = |k: f64| t0 + (t1 - t0) * k / (n - 1) as f64;
let pts: Vec<Vec3> = (0..n).map(|k| point(at(k as f64))).collect::<Result<_>>()?;
let c = crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("extend: fitting an edge".into()))?;
let mut worst = 0.0f64;
for k in 0..n - 1 {
let u = (k as f64 + 0.5) / (n - 1) as f64;
worst = worst.max(from_p3(c.subs(u)).dist(point(at(k as f64 + 0.5))?));
}
if worst <= self.tol {
return Ok(mt::Curve::BSplineCurve(c));
}
if n >= MAX_SAMPLES {
return Err(KernelError::Invalid(format!("the extended edge can't be fitted closely enough (off by up to {worst:.6} mm)")));
}
n = 2 * n - 1;
}
}
/// The run-off faces of a tangent or curvature extension, one along each extended edge.
fn run_offs(&mut self, fr: &Frame) -> Result<Vec<mt::Face>> {
let m = fr.m();
let corners = self.corners_of(fr)?;
let mut out = Vec::new();
for i in 0..m {
if !fr.chosen.get(i).copied().unwrap_or(false) {
continue;
}
let run = self.run_of(fr, &corners, i)?;
let edge = fr.edges.get(i).cloned().ok_or_else(|| KernelError::Failed("extend: an edge".into()))?;
let (surface, fars, far_curve) = self.run_off_surface(fr, &run)?;
let (Some(a), Some(b)) = (fars.first().copied(), fars.last().copied()) else {
return Err(KernelError::Failed("extend: run-off samples".into()));
};
if !self.moved(&a) || !self.moved(&b) {
return Err(KernelError::Invalid("not supported yet: a run-off that ends at a corner already on its target".into()));
}
let (fa, fb) = (self.corner_vertex(edge.front(), a.f), self.corner_vertex(edge.back(), b.f));
let curvature = self.opts.mode == ExtendMode::Curvature;
let side = |f: Far| -> mt::Curve {
if curvature {
let pts = vec![p3(f.at.p), p3(f.at.p + f.r * (0.5 * f.l)), p3(f.f)];
mt::Curve::BSplineCurve(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), pts))
} else {
mt::Curve::Line(mt::Line(p3(f.at.p), p3(f.f)))
}
};
let start = self.side_edge(edge.front(), &fa, a.section(0.5 * a.l), || Ok(side(a)))?;
let end = self.side_edge(edge.back(), &fb, b.section(0.5 * b.l), || Ok(side(b)))?;
let far = mt::Edge::try_new(&fa, &fb, mt::Curve::BSplineCurve(far_curve))
.map_err(|e| KernelError::Failed(format!("extend: a run-off edge: {e}")))?;
let wire: mt::Wire = vec![edge.clone(), end, far.inverse(), start.inverse()].into();
let mut f = mt::Face::try_new(vec![wire], mt::Surface::BSplineSurface(surface.clone()))
.map_err(|e| KernelError::Failed(format!("run-off face: {e}")))?;
// Facing the way the face it runs off faces.
let mid = self.far(fr, &run, 0.5 * (run.t0 + run.t1))?;
let facing = mid.at.n * if fr.face.orientation() { 1.0 } else { -1.0 };
let own = surface.uder(0.5, 0.0).cross(surface.vder(0.5, 0.0));
if Vec3::new(own.x, own.y, own.z).dot(facing) < 0.0 {
f.invert();
}
self.rec.made(&f, vec![Was::Edge(edge.id())]);
out.push(f);
}
Ok(out)
}
/// The run-off along edge `run`: degree 1 (tangent) or 2 (curvature) across it, a cubic
/// through its sections along it, refined until it is within the tolerance of its exact
/// sections (its near side of the edge itself) between the samples; and its samples.
fn run_off_surface(&self, fr: &Frame, run: &Run) -> Result<(mt::BSplineSurface<mt::Point3>, Vec<Far>, mt::BSplineCurve<mt::Point3>)> {
let curvature = self.opts.mode == ExtendMode::Curvature;
let mut n = START_SAMPLES;
loop {
let fars = self.fars(fr, run, n)?;
check_no_fold(&fars)?;
let rows: Vec<Vec<Vec3>> = if curvature {
vec![
fars.iter().map(|f| f.at.p).collect(),
fars.iter().map(|f| f.at.p + f.r * (0.5 * f.l)).collect(),
fars.iter().map(|f| f.f).collect(),
]
} else {
vec![fars.iter().map(|f| f.at.p).collect(), fars.iter().map(|f| f.f).collect()]
};
let curves: Vec<mt::BSplineCurve<mt::Point3>> = rows
.iter()
.map(|r| crate::build::interpolate_cubic(r))
.collect::<Option<_>>()
.ok_or_else(|| KernelError::Failed("extend: fitting a run-off".into()))?;
let ku = curves.first().map(|c| c.knot_vec().clone()).ok_or_else(|| KernelError::Failed("extend: a run-off".into()))?;
let count = curves.first().map_or(0, |c| c.control_points().len());
let net: Vec<Vec<mt::Point3>> = (0..count).map(|k| curves.iter().filter_map(|c| c.control_points().get(k).copied()).collect()).collect();
let surface = mt::BSplineSurface::try_new((ku, mt::KnotVec::bezier_knot(rows.len() - 1)), net)
.map_err(|e| KernelError::Failed(format!("extend: a run-off surface: {e}")))?;
let mut worst = 0.0f64;
for k in 0..n - 1 {
let u = (k as f64 + 0.5) / (n - 1) as f64;
let exact = self.far(fr, run, run.t0 + (run.t1 - run.t0) * u)?;
for sigma in [0.0, 0.5, 1.0] {
let want = exact.section(sigma * exact.l);
worst = worst.max(from_p3(surface.subs(u, sigma)).dist(want));
}
}
if worst <= self.tol {
check_regular(&surface, n)?;
let far = curves.last().cloned().ok_or_else(|| KernelError::Failed("extend: a run-off".into()))?;
return Ok((surface, fars, far));
}
if n >= MAX_SAMPLES {
return Err(KernelError::Invalid(format!("the run-off can't be fitted closely enough (off by up to {worst:.6} mm)")));
}
n = 2 * n - 1;
}
}
}
/// A piece of a naturally extended face's new outer loop.
#[derive(Clone, Copy, Debug)]
enum Piece {
/// Edge `i` of the old loop, kept.
Keep(usize),
/// The side from corner `corner`'s original vertex out to its far corner (`out`), or back.
Side { corner: usize, out: bool },
/// Edge `i` moved out.
Far(usize),
/// Edge `edge` lengthened along its parameter line, from its start's far corner
/// (`from_far`) or vertex, to its end's far corner (`to_far`) or vertex.
Merged { edge: usize, from_far: bool, to_far: bool },
}
/// Whether edge `i` of the frame runs straight in the surface's parameters: along a parameter
/// line, or a straight line on a plane.
fn straight(fr: &Frame, i: usize) -> bool {
let Ok((curve, samples)) = fr.samples(i) else { return false };
if fr.is_plane() {
return matches!(curve, mt::Curve::Line(_));
}
iso(samples.iter().map(|s| s.2)).is_some()
}
/// Whether the parameters all lie on one parameter line: `Some(true)` along u (v the same),
/// `Some(false)` along v.
fn iso(qs: impl Iterator<Item = [f64; 2]> + Clone) -> Option<bool> {
let span = |d: usize| -> (f64, f64) { qs.clone().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q[d]), hi.max(q[d]))) };
let (su, sv) = (span(0), span(1));
let (wu, wv) = (su.1 - su.0, sv.1 - sv.0);
let scale = wu.max(wv);
if !(scale > 0.0) {
return None;
}
let eps = 1e-9 * scale;
if wv <= eps && wu > eps {
Some(true)
} else if wu <= eps && wv > eps {
Some(false)
} else {
None
}
}
/// `b` moved by whole periods to lie near `a`.
fn unwrap_near(fr: &Frame, b: [f64; 2], a: [f64; 2]) -> [f64; 2] {
[0, 1].map(|d| crate::tess::uv::unwrap(b[d], a[d], fr.loops.dom.period[d]))
}
/// The curve along the straight parameter segment `a`–`b` (old parameters) on the continued
/// surface: exact along a parameter line or on a plane, else a fitted cubic.
fn uv_line(fr: &Frame, ext: &Continued, a: [f64; 2], b: [f64; 2], tol: f64) -> Result<mt::Curve> {
let dir = [b[0] - a[0], b[1] - a[1]];
let big = dir[0].abs().max(dir[1].abs());
if fr.is_plane() {
return Ok(mt::Curve::Line(mt::Line(p3(fr.at(a)?), p3(fr.at(b)?))));
}
if dir[1].abs() <= 1e-9 * big {
return ext.iso(true, a[1], a[0], b[0]);
}
if dir[0].abs() <= 1e-9 * big {
return ext.iso(false, a[0], a[1], b[1]);
}
let point = |t: f64| fr.at([a[0] + dir[0] * t, a[1] + dir[1] * t]);
let mut n = START_SAMPLES;
loop {
let pts: Vec<Vec3> = (0..n).map(|k| point(k as f64 / (n - 1) as f64)).collect::<Result<_>>()?;
let c = crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("extend: fitting a side edge".into()))?;
let mut worst = 0.0f64;
for k in 0..n - 1 {
let u = (k as f64 + 0.5) / (n - 1) as f64;
worst = worst.max(from_p3(c.subs(u)).dist(point(u)?));
}
if worst <= tol {
return Ok(mt::Curve::BSplineCurve(c));
}
if n >= MAX_SAMPLES {
return Err(KernelError::Invalid(format!("a side of the extension can't be fitted closely enough (off by up to {worst:.6} mm)")));
}
n = 2 * n - 1;
}
}
/// The parameter range `q + s·ab` covers from `q` for arc length `length` on the surface
/// (Simpson's rule over even steps, the last step bisected).
fn arc_param(fr: &Frame, q: [f64; 2], ab: [f64; 2], length: f64) -> Result<f64> {
let speed = |s: f64| -> Result<f64> {
let (_, su, sv) = fr
.loops
.eval
.derivs(q[0] + ab[0] * s, q[1] + ab[1] * s)
.ok_or_else(|| KernelError::Failed("extend: the surface past its edge".into()))?;
normal(su, sv)?;
Ok((su * ab[0] + sv * ab[1]).len())
};
// |S_u a + S_v b| = 1 at the start: steps of the length's share.
let h = length / MARCH_STEPS as f64;
let (mut s, mut walked, mut v0) = (0.0, 0.0, speed(0.0)?);
for _ in 0..MAX_MARCH_STEPS {
let (vm, v1) = (speed(s + 0.5 * h)?, speed(s + h)?);
let step = h / 6.0 * (v0 + 4.0 * vm + v1);
if walked + step >= length {
let part = |x: f64| -> Result<f64> { Ok(x / 6.0 * (v0 + 4.0 * speed(s + 0.5 * x)? + speed(s + x)?)) };
let (mut a, mut b) = (0.0, h);
for _ in 0..BISECT_STEPS {
let mid = 0.5 * (a + b);
if walked + part(mid)? < length { a = mid } else { b = mid }
}
return Ok(s + 0.5 * (a + b));
}
(s, walked, v0) = (s + h, walked + step, v1);
}
Err(KernelError::Invalid("the extension runs off its surface".into()))
}
/// The surface's normal curvature along the tangent `a·S_u + b·S_v` (unit) at `q`, signed
/// toward `n`.
fn normal_curvature(s: &mt::Surface, q: [f64; 2], ab: [f64; 2], n: Vec3) -> f64 {
let (uu, uv, vv) = (s.uuder(q[0], q[1]), s.uvder(q[0], q[1]), s.vvder(q[0], q[1]));
let second = uu * (ab[0] * ab[0]) + uv * (2.0 * ab[0] * ab[1]) + vv * (ab[1] * ab[1]);
let k = Vec3::new(second.x, second.y, second.z).dot(n);
if k.is_finite() { k } else { 0.0 }
}
/// Refused where the far ends run back against the edge: the extension folds over (a concave
/// edge pushed past its centre of curvature).
fn check_no_fold(fars: &[Far]) -> Result<()> {
for w in fars.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
if (b.f - a.f).dot(b.at.p - a.at.p) <= 0.0 {
return Err(KernelError::Invalid(
"the extension folds over itself (a concave edge pushed past its centre of curvature): extend it less".into(),
));
}
}
Ok(())
}
/// Refused where a run-off's normal turns over anywhere across it: it folds.
fn check_regular(s: &mt::BSplineSurface<mt::Point3>, n: usize) -> Result<()> {
for k in 0..n {
let u = k as f64 / (n - 1) as f64;
let base = s.uder(u, 0.0).cross(s.vder(u, 0.0));
for j in 1..=4 {
let v = j as f64 / 4.0;
let here = s.uder(u, v).cross(s.vder(u, v));
if !(here.x * base.x + here.y * base.y + here.z * base.z > 0.0) {
return Err(KernelError::Invalid("the extension folds over itself: extend it less".into()));
}
}
}
Ok(())
}
/// Refused where the new outer loop (a polygon in the old parameters) crosses itself or the
/// face's holes, or spans a whole turn of a periodic surface.
fn check_loop(fr: &Frame, poly: &[[f64; 2]]) -> Result<()> {
for d in 0..2 {
if let Some(p) = fr.loops.dom.period.get(d).copied().flatten() {
let (lo, hi) = poly.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q[d]), hi.max(q[d])));
if hi - lo >= p * (1.0 - 1e-9) {
return Err(KernelError::Invalid("the extension would wrap all the way round its surface onto itself".into()));
}
}
}
let segs = |pts: &[[f64; 2]]| -> Vec<([f64; 2], [f64; 2])> {
let n = pts.len();
(0..n).filter_map(|i| Some((*pts.get(i)?, *pts.get((i + 1) % n)?))).collect()
};
let outer = segs(poly);
let n = outer.len();
for i in 0..n {
for j in i + 2..n {
if i == 0 && j == n - 1 {
continue;
}
if let (Some(a), Some(b)) = (outer.get(i), outer.get(j))
&& crosses(*a, *b)
{
return Err(KernelError::Invalid("the extension crosses itself".into()));
}
}
}
for (k, lp) in fr.loops.loops.iter().enumerate() {
if k == fr.loops.outer {
continue;
}
for h in segs(&lp.polygon()) {
if outer.iter().any(|o| crosses(*o, h)) {
return Err(KernelError::Invalid("the extension crosses a hole of its face".into()));
}
}
}
Ok(())
}
/// Whether two segments properly cross (touching ends don't).
fn crosses((a, b): ([f64; 2], [f64; 2]), (c, d): ([f64; 2], [f64; 2])) -> bool {
let orient = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
let scale = [a, b, c, d].iter().map(|p| p[0].abs().max(p[1].abs())).fold(1e-300, f64::max);
let eps = 1e-12 * scale * scale;
let (o1, o2, o3, o4) = (orient(a, b, c), orient(a, b, d), orient(c, d, a), orient(c, d, b));
((o1 > eps && o2 < -eps) || (o1 < -eps && o2 > eps)) && ((o3 > eps && o4 < -eps) || (o3 < -eps && o4 > eps))
}
/// The parameter box of the points.
fn bounds_of(poly: &[[f64; 2]]) -> [(f64, f64); 2] {
let span = |d: usize| poly.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q[d]), hi.max(q[d])));
[span(0), span(1)]
}
/// A face's surface continued to cover a parameter box, and how its parameters relate to the
/// old ones (`new = old · scale + offset` per direction).
struct Continued {
surface: mt::Surface,
map: [(f64, f64); 2],
}
impl Continued {
fn to_new(&self, d: usize, x: f64) -> f64 {
let (scale, offset) = self.map.get(d).copied().unwrap_or((1.0, 0.0));
x * scale + offset
}
/// The exact parameter line at `w` (old parameters; v = w when `along_u`) from `from` to `to`.
fn iso(&self, along_u: bool, w: f64, from: f64, to: f64) -> Result<mt::Curve> {
let (dw, dt) = if along_u { (1, 0) } else { (0, 1) };
iso_curve(&self.surface, along_u, self.to_new(dw, w), self.to_new(dt, from), self.to_new(dt, to))
}
}
/// `surface` continued to cover `need` (old parameters, with a margin): a B-spline surface's
/// end spans continued as their own polynomials, a surface of revolution's profile continued
/// (a line lengthened, a B-spline profile's end spans continued); a plane as it is. Exact on the
/// old surface.
fn continued(surface: &mt::Surface, eval: &EvalSurface, need: &[(f64, f64); 2]) -> Result<Continued> {
let identity = [(1.0, 0.0), (1.0, 0.0)];
let dom = crate::tess::uv::domain(eval);
// The range each direction needs: `None` where the surface's own covers it.
let wanted = |d: usize| -> Option<(f64, f64)> {
let (lo, hi) = need.get(d).copied()?;
let (a, b) = dom.range.get(d).copied().flatten()?;
if dom.period.get(d).copied().flatten().is_some() {
return None;
}
let margin = 0.01 * (hi - lo).max(b - a);
let eps = 1e-12 * (b - a).abs().max(1e-300);
(lo < a - eps || hi > b + eps)
.then_some((lo.min(a) - if lo < a - eps { margin } else { 0.0 }, hi.max(b) + if hi > b + eps { margin } else { 0.0 }))
};
let unsupported = || KernelError::Invalid("not supported yet: continuing this kind of surface past its bounds".into());
match surface {
mt::Surface::Plane(_) => Ok(Continued { surface: surface.clone(), map: identity }),
mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => {
let (Some(EvalSurface::Nurbs { degree, knots, count, ctrl }), rational) = (Some(eval), matches!(surface, mt::Surface::NurbsSurface(_)))
else {
return Err(unsupported());
};
let [nu, nv] = *count;
// rows[i][j]: control point i along u, j along v.
let mut rows: Vec<Vec<[f64; 4]>> = (0..nu).map(|i| (0..nv).filter_map(|j| ctrl.get(j * nu + i).copied()).collect()).collect();
let [mut ku, mut kv] = knots.clone();
if let Some(range) = wanted(0) {
(ku, rows) = continue_span(&ku, degree[0], &rows, range).ok_or_else(unsupported)?;
}
if let Some(range) = wanted(1) {
let cols = transpose(&rows);
let (k, cols) = continue_span(&kv, degree[1], &cols, range).ok_or_else(unsupported)?;
kv = k;
rows = transpose(&cols);
}
Ok(Continued { surface: nurbs_surface(ku, kv, rows, rational)?, map: identity })
}
mt::Surface::RevolutedCurve(proc) => {
let pd = if proc.orientation() { 0 } else { 1 };
let Some((lo, hi)) = wanted(pd) else { return Ok(Continued { surface: surface.clone(), map: identity }) };
let rev = proc.entity();
let (profile, map) = match rev.entity_curve() {
mt::Curve::Line(l) => {
let at = |t: f64| l.0 + (l.1 - l.0) * t;
let mut map = identity;
if let Some(slot) = map.get_mut(pd) {
*slot = (1.0 / (hi - lo), -lo / (hi - lo));
}
(mt::Curve::Line(mt::Line(at(lo), at(hi))), map)
}
c @ (mt::Curve::BSplineCurve(_) | mt::Curve::NurbsCurve(_)) => {
let ec = crate::tess::surface::eval_curve(c).ok_or_else(unsupported)?;
let rows: Vec<Vec<[f64; 4]>> = ec.ctrl.iter().map(|p| vec![*p]).collect();
let (k, rows) = continue_span(&ec.knots, ec.degree, &rows, (lo, hi)).ok_or_else(unsupported)?;
(nurbs_curve(k, rows.iter().filter_map(|r| r.first().copied()).collect(), matches!(c, mt::Curve::NurbsCurve(_)))?, identity)
}
mt::Curve::IntersectionCurve(_) => return Err(unsupported()),
};
let turned = proc.map_ref(|r| mt::RevolutedCurve::by_revolution(profile, r.origin(), r.axis()));
Ok(Continued { surface: mt::Surface::RevolutedCurve(turned), map })
}
}
}
fn transpose(rows: &[Vec<[f64; 4]>]) -> Vec<Vec<[f64; 4]>> {
let width = rows.first().map_or(0, Vec::len);
(0..width).map(|j| rows.iter().filter_map(|r| r.get(j).copied()).collect()).collect()
}
/// `a + (b − a)·t` on homogeneous points.
fn lerp4(a: [f64; 4], b: [f64; 4], t: f64) -> [f64; 4] {
[0, 1, 2, 3].map(|k| a[k] + (b[k] - a[k]) * t)
}
/// A clamped B-spline (degree `p`, control `rows[i]` the points at index `i` along the
/// direction, across the other) continued to cover `(lo, hi)`: each end span's polynomial over
/// a longer span. `None` for one that is not clamped or malformed, or whose weights would not
/// stay positive.
fn continue_span(knots: &[f64], p: usize, rows: &[Vec<[f64; 4]>], (lo, hi): (f64, f64)) -> Option<(Vec<f64>, Vec<Vec<[f64; 4]>>)> {
let (k, c) = continue_end(knots, p, rows, hi)?;
// The start: the same on the curve run backwards.
let k: Vec<f64> = k.iter().rev().map(|x| -x).collect();
let c: Vec<Vec<[f64; 4]>> = c.into_iter().rev().collect();
let (k, c) = continue_end(&k, p, &c, -lo)?;
let k: Vec<f64> = k.iter().rev().map(|x| -x).collect();
let c: Vec<Vec<[f64; 4]>> = c.into_iter().rev().collect();
c.iter().flatten().all(|x| x[3] > 0.0 && x.iter().all(|v| v.is_finite())).then_some((k, c))
}
/// [`continue_span`] at the end only: the last knot `a` below the end `b` repeated to the
/// degree (Boehm's knot insertion; the B-spline is unchanged), which leaves the last span's
/// own Bézier points; then de Casteljau's subdivision at `(to − a)/(b − a) > 1` gives the
/// Bézier points of the same polynomial over `a…to` (NURBS Book §5.2, A5.1; §1.4).
fn continue_end(knots: &[f64], p: usize, rows: &[Vec<[f64; 4]>], to: f64) -> Option<(Vec<f64>, Vec<Vec<[f64; 4]>>)> {
let mut k = knots.to_vec();
let mut c = rows.to_vec();
if p == 0 || c.len() <= p || k.len() != c.len() + p + 1 || k.iter().any(|x| !x.is_finite()) {
return None;
}
let b = *k.last()?;
if !(to > b) {
return Some((k, c));
}
if k.iter().rev().take(p + 1).any(|x| *x != b) || !(*k.get(c.len() - 1)? < b) {
return None;
}
let a = *k.get(c.len() - 1)?;
while k.iter().filter(|x| **x == a).count() < p {
let n = c.len();
let span = n - 1;
let mut next = Vec::with_capacity(n + 1);
for i in 0..=n {
let row = if i + p <= span {
c.get(i)?.clone()
} else if i > span {
c.get(i - 1)?.clone()
} else {
let (ui, uip) = (*k.get(i)?, *k.get(i + p)?);
let alpha = if uip > ui { (a - ui) / (uip - ui) } else { 0.0 };
let (pi, pm) = (c.get(i)?, c.get(i - 1)?);
pm.iter().zip(pi).map(|(y, x)| lerp4(*y, *x, alpha)).collect()
};
next.push(row);
}
k.insert(span + 1, a);
c = next;
}
let n = c.len();
let first = n - p - 1;
let tau = (to - a) / (b - a);
let width = c.first()?.len();
for j in 0..width {
let mut pts: Vec<[f64; 4]> = (first..n).map(|i| c.get(i).and_then(|r| r.get(j)).copied()).collect::<Option<_>>()?;
let mut left = Vec::with_capacity(p + 1);
left.push(*pts.first()?);
for r in 1..=p {
for i in 0..=(p - r) {
let (x, y) = (*pts.get(i)?, *pts.get(i + 1)?);
*pts.get_mut(i)? = lerp4(x, y, tau);
}
left.push(*pts.first()?);
}
for (i, x) in left.into_iter().enumerate() {
*c.get_mut(first + i)?.get_mut(j)? = x;
}
}
let len = k.len();
for x in k.iter_mut().skip(len - p - 1) {
*x = to;
}
Some((k, c))
}
/// A truck B-spline surface from knots and homogeneous control rows (rows[i][j], i along u):
/// rational when `rational`, else of plain points.
fn nurbs_surface(ku: Vec<f64>, kv: Vec<f64>, rows: Vec<Vec<[f64; 4]>>, rational: bool) -> Result<mt::Surface> {
let kvs = (mt::KnotVec::from(ku), mt::KnotVec::from(kv));
let bad = |e: String| KernelError::Failed(format!("extend: the continued surface: {e}"));
if rational {
let net: Vec<Vec<mt::Vector4>> = rows.iter().map(|r| r.iter().map(|h| mt::Vector4::new(h[0], h[1], h[2], h[3])).collect()).collect();
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::try_new(kvs, net).map_err(|e| bad(e.to_string()))?)))
} else {
let net: Vec<Vec<mt::Point3>> =
rows.iter().map(|r| r.iter().map(|h| mt::Point3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])).collect()).collect();
Ok(mt::Surface::BSplineSurface(mt::BSplineSurface::try_new(kvs, net).map_err(|e| bad(e.to_string()))?))
}
}
/// A truck B-spline curve from knots and homogeneous control points.
fn nurbs_curve(knots: Vec<f64>, ctrl: Vec<[f64; 4]>, rational: bool) -> Result<mt::Curve> {
let kv = mt::KnotVec::from(knots);
let bad = |e: String| KernelError::Failed(format!("extend: the continued profile: {e}"));
if rational {
let pts: Vec<mt::Vector4> = ctrl.iter().map(|h| mt::Vector4::new(h[0], h[1], h[2], h[3])).collect();
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::try_new(kv, pts).map_err(|e| bad(e.to_string()))?)))
} else {
let pts: Vec<mt::Point3> = ctrl.iter().map(|h| mt::Point3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])).collect();
Ok(mt::Curve::BSplineCurve(mt::BSplineCurve::try_new(kv, pts).map_err(|e| bad(e.to_string()))?))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{measure, patch_region};
use mt::InnerSpace;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
use std::collections::HashSet;
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
fn area(b: &Body) -> f64 {
measure(b).unwrap().area
}
fn with(mode: ExtendMode, limit: ExtendLimit, neighbours: bool) -> ExtendOptions {
ExtendOptions { mode, limit, neighbours }
}
fn natural(d: f64) -> ExtendOptions<'static> {
with(ExtendMode::Natural, ExtendLimit::Distance(d), true)
}
fn edges(b: &Body) -> usize {
b.solid.edge_iter().map(|e| e.id()).collect::<HashSet<_>>().len()
}
/// Edges bounding two faces.
fn shared(b: &Body) -> usize {
let mut count: HashMap<mt::EdgeID, usize> = HashMap::new();
for f in b.solid.face_iter() {
for w in f.boundaries().iter() {
for e in w.edge_iter() {
*count.entry(e.id()).or_default() += 1;
}
}
}
count.values().filter(|n| **n == 2).count()
}
fn square() -> Body {
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
holes: vec![],
};
patch_region(&Plane::XY, &r).unwrap()
}
/// A strip of the cylinder of radius 10 about Z, heights 0…20, angles 0…`sweep`: one face on
/// an exact surface of revolution (a line turned about the axis), its edges two generators
/// and two arcs.
fn cylinder_strip(sweep: f64) -> Body {
let pt = mt::Point3::new;
let rev = mt::RevolutedCurve::by_revolution(
mt::Curve::Line(mt::Line(pt(10.0, 0.0, 0.0), pt(10.0, 0.0, 20.0))),
pt(0.0, 0.0, 0.0),
mt::Vector3::unit_z(),
);
let s = mt::Surface::RevolutedCurve(mt::Processor::new(rev));
// Counter-clockwise in (height, angle): up the first generator, along the top arc, down
// the last generator, back along the bottom arc.
let corners = [(0.0, 0.0), (1.0, 0.0), (1.0, sweep), (0.0, sweep)];
let v: Vec<mt::Vertex> = corners.iter().map(|(u, a)| mt::Vertex::new(s.subs(*u, *a))).collect();
let edges: Vec<mt::Edge> = (0..4)
.map(|i| {
let ((u0, a0), (u1, a1)) = (corners[i], corners[(i + 1) % 4]);
let c = if a0 == a1 { iso_curve(&s, true, a0, u0, u1) } else { iso_curve(&s, false, u0, a0, a1) };
mt::Edge::new(&v[i], &v[(i + 1) % 4], c.unwrap())
})
.collect();
surface_body(vec![mt::Face::new(vec![edges.into()], s)])
}
/// Arc length of z = x²/20 from 0 to x.
fn parabola_arc(x: f64) -> f64 {
0.5 * x * (1.0 + (x / 10.0).powi(2)).sqrt() + 5.0 * (x / 10.0).asinh()
}
/// The parabolic sheet z = x²/20 over 0 ≤ x, y ≤ 10: a B-spline quadratic along x (u),
/// straight along y (v), its four edges on its parameter lines.
fn parabola() -> Body {
let pt = mt::Point3::new;
let net = vec![
vec![pt(0.0, 0.0, 0.0), pt(0.0, 10.0, 0.0)],
vec![pt(5.0, 0.0, 0.0), pt(5.0, 10.0, 0.0)],
vec![pt(10.0, 0.0, 5.0), pt(10.0, 10.0, 5.0)],
];
let s = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)), net);
let v = [pt(0.0, 0.0, 0.0), pt(10.0, 0.0, 5.0), pt(10.0, 10.0, 5.0), pt(0.0, 10.0, 0.0)].map(mt::Vertex::new);
let bez =
|a: mt::Point3, m: mt::Point3, b: mt::Point3| mt::Curve::BSplineCurve(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![a, m, b]));
let edges = vec![
mt::Edge::new(&v[0], &v[1], bez(pt(0.0, 0.0, 0.0), pt(5.0, 0.0, 0.0), pt(10.0, 0.0, 5.0))),
mt::Edge::new(&v[1], &v[2], mt::Curve::Line(mt::Line(v[1].point(), v[2].point()))),
mt::Edge::new(&v[2], &v[3], bez(pt(10.0, 10.0, 5.0), pt(5.0, 10.0, 0.0), pt(0.0, 10.0, 0.0))),
mt::Edge::new(&v[3], &v[0], mt::Curve::Line(mt::Line(v[3].point(), v[0].point()))),
];
surface_body(vec![mt::Face::new(vec![edges.into()], mt::Surface::BSplineSurface(s))])
}
/// The default (natural, by a distance, neighbours lengthened) on a plane's straight edges
/// is today's planar extension, exactly; the general path builds the same square.
#[test]
fn a_planar_extension_is_unchanged() {
let s = square();
let old = crate::extend(&s, &[Vec3::new(10.0, 5.0, 0.0)], 5.0).unwrap();
let new = extend_surface(&s, &[Vec3::new(10.0, 5.0, 0.0)], &natural(5.0)).unwrap();
assert_eq!(measure(&old).unwrap().area, measure(&new).unwrap().area);
assert_eq!(edges(&old), edges(&new));
// Up to the plane x = 15 takes the general path: the same 15 × 10, its lengthened sides
// one edge each.
let tool = SplitTool::Plane(Plane::from_normal(Vec3::new(15.0, 0.0, 0.0), Vec3::X).unwrap());
let up = extend_surface(&s, &[Vec3::new(10.0, 5.0, 0.0)], &with(ExtendMode::Natural, ExtendLimit::UpTo(&tool), true)).unwrap();
assert!(rel(area(&up), 150.0) < 1e-9, "{}", area(&up));
assert_eq!((up.face_count(), edges(&up)), (1, 4));
// Up to x = 17: 170.
let tool = SplitTool::Plane(Plane::from_normal(Vec3::new(17.0, 0.0, 0.0), Vec3::X).unwrap());
let up = extend_surface(&s, &[Vec3::new(10.0, 5.0, 0.0)], &with(ExtendMode::Natural, ExtendLimit::UpTo(&tool), true)).unwrap();
assert!(rel(area(&up), 170.0) < 1e-9, "{}", area(&up));
let bb = measure(&up).unwrap().bbox;
assert!((bb.max.x - 17.0).abs() < 1e-9, "{bb:?}");
}
/// A quadrilateral (0,0), (10,0), (10,10), (0,20) (area 150) with its right edge pushed out
/// by 5. Lengthening the neighbours follows the slanted top down to (15, 5): + 37.5. Without
/// them the extension is a 5 × 10 strip: + 50. A tangent run-off is the same trapezoid as a
/// face of its own, sharing the edge.
#[test]
fn neighbours_lengthened_or_square() {
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 20.0)]),
holes: vec![],
};
let q = patch_region(&Plane::XY, &r).unwrap();
let at = [Vec3::new(10.0, 5.0, 0.0)];
let lengthened = extend_surface(&q, &at, &natural(5.0)).unwrap();
assert!(rel(area(&lengthened), 187.5) < 1e-9, "{}", area(&lengthened));
let square_ends = extend_surface(&q, &at, &with(ExtendMode::Natural, ExtendLimit::Distance(5.0), false)).unwrap();
assert!(rel(area(&square_ends), 200.0) < 1e-9, "{}", area(&square_ends));
let run_off = extend_surface(&q, &at, &with(ExtendMode::Tangent, ExtendLimit::Distance(5.0), true)).unwrap();
assert_eq!((run_off.face_count(), shared(&run_off)), (2, 1));
assert!(rel(area(&run_off), 187.5) < 1e-6, "{}", area(&run_off));
let h = run_off.history().unwrap();
assert_eq!(h.faces.iter().map(|f| f.change).collect::<Vec<_>>(), vec![crate::history::Change::Kept, crate::history::Change::Generated]);
}
/// A cylinder strip (radius 10, a quarter turn, heights 0…20), on an exact surface of
/// revolution: extended 5 along its axis (its top arc picked), 10 · π/2 · 25; 5 around its
/// circumference (a straight edge picked), 10 · (π/2 + ½) · 20; both, meeting at a mitre,
/// 10 · (π/2 + ½) · 25; up to the plane z = 30, 10 · π/2 · 30. Each stays one cylindrical
/// face; the sides along the extension are the old edges lengthened.
#[test]
fn a_cylinder_strip_extended_along_its_axis_and_around() {
let c = cylinder_strip(FRAC_PI_2);
assert!(rel(area(&c), 100.0 * PI) < 1e-3);
let top = Vec3::new(10.0 * FRAC_PI_4.cos(), 10.0 * FRAC_PI_4.sin(), 20.0);
let side = Vec3::new(0.0, 10.0, 10.0);
let cylinder =
|b: &Body| matches!(crate::face_surface(b, 0), Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - 10.0).abs() < 1e-6);
let up = extend_surface(&c, &[top], &natural(5.0)).unwrap();
let want = 10.0 * FRAC_PI_2 * 25.0;
assert!(rel(area(&up), want) < 1e-3, "{} vs {want}", area(&up)); // (measured on meshes)
assert!((measure(&up).unwrap().bbox.max.z - 25.0).abs() < 1e-6);
assert!(up.face_count() == 1 && cylinder(&up) && edges(&up) == 4, "{}", edges(&up));
let round = extend_surface(&c, &[side], &natural(5.0)).unwrap();
let want = 10.0 * (FRAC_PI_2 + 0.5) * 20.0;
assert!(rel(area(&round), want) < 1e-3, "{} vs {want}", area(&round)); // (measured on meshes)
assert!(round.face_count() == 1 && cylinder(&round) && edges(&round) == 4);
// The moved edge is at angle π/2 + ½ (5 mm of arc): x = −10 sin ½.
assert!((measure(&round).unwrap().bbox.min.x + 10.0 * 0.5f64.sin()).abs() < 1e-6, "{:?}", measure(&round).unwrap().bbox);
let both = extend_surface(&c, &[top, side], &natural(5.0)).unwrap();
let want = 10.0 * (FRAC_PI_2 + 0.5) * 25.0;
assert!(rel(area(&both), want) < 1e-3, "{} vs {want}", area(&both)); // (measured on meshes)
let tool = SplitTool::Plane(Plane::from_normal(Vec3::new(0.0, 0.0, 30.0), Vec3::Z).unwrap());
let to = extend_surface(&c, &[top], &with(ExtendMode::Natural, ExtendLimit::UpTo(&tool), true)).unwrap();
let want = 10.0 * FRAC_PI_2 * 30.0;
assert!(rel(area(&to), want) < 1e-3, "{} vs {want}", area(&to)); // (measured on meshes)
assert!((measure(&to).unwrap().bbox.max.z - 30.0).abs() < 1e-6);
// The history: the face is the one it was, trimmed differently.
let h = up.history().unwrap();
assert_eq!(h.faces.first().map(|f| f.change), Some(crate::history::Change::Modified));
}
/// The same strip swept as a B-spline sheet (a rational arc extruded): its end spans
/// continue as themselves, so the extension stays on the cylinder.
#[test]
fn an_extruded_cylinder_strip_continues_on_its_cylinder() {
let arc = vec![Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 10.0, start: 0.0, sweep: FRAC_PI_2 }];
let sheet = crate::extrude_sheet(&Plane::XY, &[arc], 0.0, 20.0).unwrap();
let top = Vec3::new(10.0 * FRAC_PI_4.cos(), 10.0 * FRAC_PI_4.sin(), 20.0);
let up = extend_surface(&sheet, &[top], &natural(5.0)).unwrap();
let want = 10.0 * FRAC_PI_2 * 25.0;
assert!(rel(area(&up), want) < 1e-3, "{} vs {want}", area(&up)); // (measured on meshes)
let round = extend_surface(&sheet, &[Vec3::new(0.0, 10.0, 10.0)], &natural(5.0)).unwrap();
let want = 10.0 * (FRAC_PI_2 + 0.5) * 20.0;
assert!(rel(area(&round), want) < 1e-3, "{} vs {want}", area(&round)); // (measured on meshes)
assert!(matches!(crate::face_surface(&round, 0), Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - 10.0).abs() < 1e-6));
}
/// A half cylinder pushed round by 40 mm would close on itself (π + 4 > 2π): refused. So
/// is a concave edge pushed past its centre.
#[test]
fn self_intersecting_extensions_are_refused() {
let c = cylinder_strip(PI);
let side = Vec3::new(-10.0, 0.0, 10.0);
let e = extend_surface(&c, &[side], &natural(40.0)).unwrap_err().to_string();
assert!(e.contains("wrap"), "{e}");
// A 20 × 10 plate with a half-round notch of radius 5 in its top: the notch's edge
// pushed out by 2 leaves a notch of radius 3 (area 200 − 4.5π, the top edges
// lengthened into it); by 6, past the notch's centre, it folds and is refused.
let outer = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(20.0, 0.0) },
Seg2::Line { a: Vec2::new(20.0, 0.0), b: Vec2::new(20.0, 10.0) },
Seg2::Line { a: Vec2::new(20.0, 10.0), b: Vec2::new(15.0, 10.0) },
Seg2::Arc { center: Vec2::new(10.0, 10.0), radius: 5.0, start: 0.0, sweep: -PI },
Seg2::Line { a: Vec2::new(5.0, 10.0), b: Vec2::new(0.0, 10.0) },
Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, 0.0) },
],
};
let plate = patch_region(&Plane::XY, &Region2 { outer, holes: vec![] }).unwrap();
assert!(rel(area(&plate), 200.0 - 12.5 * PI) < 1e-4); // (measured on meshes)
let notch = [Vec3::new(10.0, 5.0, 0.0)];
let less = extend_surface(&plate, &notch, &natural(2.0)).unwrap();
assert!(rel(area(&less), 200.0 - 4.5 * PI) < 1e-4, "{} vs {}", area(&less), 200.0 - 4.5 * PI); // (measured on meshes)
for mode in [ExtendMode::Natural, ExtendMode::Tangent, ExtendMode::Curvature] {
let e = extend_surface(&plate, &notch, &with(mode, ExtendLimit::Distance(6.0), true)).unwrap_err().to_string();
assert!(e.contains("folds") || e.contains("crosses"), "{mode:?}: {e}");
}
}
/// The parabolic B-spline sheet z = x²/20 (0 ≤ x, y ≤ 10) extended 5 past its edge x = 10.
/// Natural: the parabola itself continues, 5 mm of arc further (to x* where its arc length
/// grows by 5): area 10 · (s(10) + 5), reaching z = x*²/20. Curvature: a run-off face along
/// the edge, its normal and its normal curvature across the edge those of the parabola
/// there (G1, G2: κ = 0.1 / 2^1.5), area 10 · ∫₀⁵ √(1 + κ²s²) ds more. Tangent: a plane
/// run-off, 50 more.
#[test]
fn a_bspline_extended_naturally_and_by_curvature() {
let p = parabola();
let base = 10.0 * parabola_arc(10.0);
assert!(rel(area(&p), base) < 1e-4, "{} vs {base}", area(&p)); // (measured on meshes)
let edge = [Vec3::new(10.0, 5.0, 5.0)];
let nat = extend_surface(&p, &edge, &natural(5.0)).unwrap();
let (mut lo, mut hi) = (10.0, 20.0);
for _ in 0..100 {
let m = 0.5 * (lo + hi);
if parabola_arc(m) - parabola_arc(10.0) < 5.0 { lo = m } else { hi = m }
}
let x = 0.5 * (lo + hi);
let bb = measure(&nat).unwrap().bbox;
assert!((bb.max.x - x).abs() < 1e-6 && (bb.max.z - x * x / 20.0).abs() < 1e-6, "{bb:?} vs x* = {x}");
assert!(rel(area(&nat), base + 50.0) < 1e-4, "{} vs {}", area(&nat), base + 50.0); // (measured on meshes)
assert_eq!(nat.face_count(), 1);
// On the extended surface the parabola goes on: its height at x = 12, past the old edge.
let s = nat.solid.face_iter().next().unwrap().surface();
let (u, v) = mt::SearchNearestParameter::<mt::D2>::search_nearest_parameter(&s, mt::Point3::new(12.0, 5.0, 7.2), None, 100).unwrap();
let off = from_p3(s.subs(u, v)).dist(Vec3::new(12.0, 5.0, 7.2));
assert!(off < 1e-7, "{off}");
let kappa = 0.1 / 2f64.powf(1.5);
let curv = extend_surface(&p, &edge, &with(ExtendMode::Curvature, ExtendLimit::Distance(5.0), true)).unwrap();
assert_eq!((curv.face_count(), shared(&curv)), (2, 1));
let arc = 2.5 * (1.0 + 25.0 * kappa * kappa).sqrt() + (5.0 * kappa).asinh() / (2.0 * kappa);
assert!(rel(area(&curv), base + 10.0 * arc) < 1e-4, "{} vs {}", area(&curv), base + 10.0 * arc); // (measured on meshes)
// G1 and G2 at the old edge, measured on the run-off along its length.
let run = curv.solid.face_iter().nth(1).unwrap().surface();
let up = mt::Vector3::new(-1.0, 0.0, 1.0) / 2f64.sqrt();
for k in 0..=8 {
let u = k as f64 / 8.0;
let (su, sv) = (run.uder(u, 0.0), run.vder(u, 0.0));
let n = su.cross(sv).normalize();
let n = if n.dot(up) < 0.0 { -n } else { n };
assert!((n - up).magnitude() < 1e-6, "normal {n:?} at {u}");
let k_across = run.vvder(u, 0.0).dot(up) / sv.magnitude2();
assert!(rel(k_across, kappa) < 1e-6, "κ {k_across} vs {kappa} at {u}");
}
let flat = extend_surface(&p, &edge, &with(ExtendMode::Tangent, ExtendLimit::Distance(5.0), true)).unwrap();
assert!(rel(area(&flat), base + 50.0) < 1e-4, "{} vs {}", area(&flat), base + 50.0); // (measured on meshes)
let run = flat.solid.face_iter().nth(1).unwrap().surface();
let n = run.uder(0.5, 0.0).cross(run.vder(0.5, 0.0)).normalize();
assert!(n.cross(up).magnitude() < 1e-6, "G1: {n:?}");
}
/// A disc of radius 5 (two half-circle edges meeting smoothly) with both edges pushed out
/// by 2: naturally, one disc of radius 7 (49π); as tangent run-offs, an annulus of two
/// faces sharing their sides where the half circles meet (25π + 24π).
#[test]
fn a_disc_grows_by_its_curved_edges() {
let disc = patch_region(&Plane::XY, &Region2 { outer: Loop2::circle(Vec2::ZERO, 5.0), holes: vec![] }).unwrap();
let both = [Vec3::new(0.0, 5.0, 0.0), Vec3::new(0.0, -5.0, 0.0)];
let grown = extend_surface(&disc, &both, &natural(2.0)).unwrap();
assert_eq!(grown.face_count(), 1);
assert!(rel(area(&grown), 49.0 * PI) < 1e-4, "{} vs {}", area(&grown), 49.0 * PI); // (measured on meshes)
let ring = extend_surface(&disc, &both, &with(ExtendMode::Tangent, ExtendLimit::Distance(2.0), true)).unwrap();
assert_eq!((ring.face_count(), shared(&ring)), (3, 4));
assert!(rel(area(&ring), 49.0 * PI) < 1e-4, "{} vs {}", area(&ring), 49.0 * PI); // (measured on meshes)
}
/// Two faces of a box (its top and front, sharing their edge along x) with their edges at
/// x = 10 pushed out by 5: the shared edge is lengthened in both, as one edge, so the two
/// extensions stay joined: 150 + 150, two shared edges.
#[test]
fn neighbouring_faces_extend_together() {
let b = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let two = crate::copy_faces(&b, &[Vec3::new(5.0, 5.0, 10.0), Vec3::new(5.0, 0.0, 5.0)]).unwrap();
assert_eq!((two.face_count(), shared(&two)), (2, 1));
let out = extend_surface(&two, &[Vec3::new(10.0, 5.0, 10.0), Vec3::new(10.0, 0.0, 5.0)], &natural(5.0)).unwrap();
assert!(rel(area(&out), 300.0) < 1e-9, "{}", area(&out));
assert_eq!((out.face_count(), shared(&out)), (2, 2));
}
/// Back to the surface's own bounds: the half saddle (a B-spline over 0…10 × 0…10, x from 0
/// to 5) goes back to its whole patch; the half cylinder trimmed by a slanted plane (top
/// from z = 15 down to 10 and back) goes back to its span, heights 0…15: 150π.
#[test]
fn extended_back_to_the_surfaces_bounds() {
let pt = |x: f64, y: f64| mt::Point3::new(x, y, x * y / 10.0);
let knots = mt::KnotVec::bezier_knot(1);
let saddle = mt::BSplineSurface::new((knots.clone(), knots), vec![vec![pt(0.0, 0.0), pt(0.0, 10.0)], vec![pt(10.0, 0.0), pt(10.0, 10.0)]]);
let corners = [pt(0.0, 0.0), pt(5.0, 0.0), pt(5.0, 10.0), pt(0.0, 10.0)];
let v: Vec<mt::Vertex> = corners.iter().map(|p| mt::Vertex::new(*p)).collect();
let e: Vec<mt::Edge> =
(0..4).map(|i| mt::Edge::new(&v[i], &v[(i + 1) % 4], mt::Curve::Line(mt::Line(corners[i], corners[(i + 1) % 4])))).collect();
let half = surface_body(vec![mt::Face::new(vec![e.into()], mt::Surface::BSplineSurface(saddle))]);
let bounds = with(ExtendMode::Natural, ExtendLimit::Bounds, true);
let whole = extend_surface(&half, &[Vec3::new(5.0, 5.0, 2.5)], &bounds).unwrap();
let bb = measure(&whole).unwrap().bbox;
assert!((bb.max.x - 10.0).abs() < 1e-9 && (bb.max.z - 10.0).abs() < 1e-9, "{bb:?}");
// Already there: refused.
assert!(extend_surface(&whole, &[Vec3::new(10.0, 5.0, 5.0)], &bounds).is_err());
let arc = vec![Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 10.0, start: 0.0, sweep: PI }];
let sheet = crate::extrude_sheet(&Plane::XY, &[arc], 0.0, 20.0).unwrap();
let tool = SplitTool::Plane(Plane::from_normal(Vec3::new(0.0, 0.0, 15.0), Vec3::new(0.0, 0.5, 1.0)).unwrap());
let cut = crate::trim(&sheet, &tool, Vec3::new(0.0, 10.0, 1.0)).unwrap();
let back = extend_surface(&cut, &[Vec3::new(0.0, 10.0, 10.0)], &bounds).unwrap();
let a = area(&back);
assert!(rel(a, 150.0 * PI) < 1e-3, "{a} vs {}", 150.0 * PI); // (measured on meshes)
assert!((measure(&back).unwrap().bbox.max.z - 15.0).abs() < 1e-6);
}
/// Hostile and unsupported requests are refused, never panic.
#[test]
fn hostile_requests_are_refused() {
let s = square();
let at = [Vec3::new(10.0, 5.0, 0.0)];
for d in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e9] {
assert!(extend_surface(&s, &at, &natural(d)).is_err(), "{d}");
}
assert!(extend_surface(&s, &[], &natural(1.0)).is_err());
assert!(extend_surface(&s, &[Vec3::new(f64::NAN, 0.0, 0.0)], &natural(1.0)).is_err());
assert!(extend_surface(&s, &vec![Vec3::ZERO; MAX_PICKS + 1], &natural(1.0)).is_err());
assert!(extend_surface(&s, &[Vec3::new(50.0, 50.0, 0.0)], &natural(1.0)).is_err());
assert!(extend_surface(&s, &at, &with(ExtendMode::Tangent, ExtendLimit::Bounds, true)).is_err());
// A solid's edges lie between two faces.
let b = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
assert!(extend_surface(&b, &[Vec3::new(10.0, 5.0, 10.0)], &natural(1.0)).is_err());
// A hole's edge.
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 20.0), Vec2::new(0.0, 20.0)]),
holes: vec![Loop2::circle(Vec2::new(10.0, 10.0), 3.0).reversed()],
};
let holed = patch_region(&Plane::XY, &r).unwrap();
let e = extend_surface(&holed, &[Vec3::new(13.0, 10.0, 0.0)], &with(ExtendMode::Tangent, ExtendLimit::Distance(1.0), true)).unwrap_err();
assert!(e.to_string().contains("hole"), "{e}");
// A target the extension never reaches (behind the edge).
let behind = SplitTool::Plane(Plane::from_normal(Vec3::new(-5.0, 0.0, 0.0), Vec3::X).unwrap());
assert!(extend_surface(&s, &at, &with(ExtendMode::Natural, ExtendLimit::UpTo(&behind), true)).is_err());
}
/// Continuing a B-spline's end span leaves it the same on its old range.
#[test]
fn a_continued_bspline_is_unchanged_on_its_range() {
// A cubic with interior knots, rational weights.
let knots = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
let ctrl: Vec<Vec<[f64; 4]>> =
[[0.0, 0.0, 0.0, 1.0], [1.0, 2.0, 0.0, 0.8], [2.0, -1.0, 1.0, 1.2], [3.0, 1.0, 0.5, 1.0], [4.0, 0.0, 2.0, 0.9], [5.0, 1.0, 0.0, 1.0]]
.iter()
.map(|p| vec![[p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]])
.collect();
let before = solidcraft_geom::tess::EvalCurve { degree: 3, knots: knots.clone(), ctrl: ctrl.iter().map(|r| r[0]).collect() };
let (k, c) = continue_span(&knots, 3, &ctrl, (-0.2, 1.25)).unwrap();
let after = solidcraft_geom::tess::EvalCurve { degree: 3, knots: k.clone(), ctrl: c.iter().map(|r| r[0]).collect() };
assert_eq!((k.first().copied(), k.last().copied()), (Some(-0.2), Some(1.25)));
// The same curve on the old range, and past it the old end spans' own polynomials
// (which the evaluator also continues).
for i in -4..=25 {
let t = i as f64 / 20.0;
let (a, b) = (before.eval(t).unwrap().0, after.eval(t).unwrap().0);
assert!(a.dist(b) < 1e-9, "{t}: {a:?} vs {b:?}");
}
}
}