kernel: Extend grows a surface of several faces face by face

A surface body of more than one face (a fit-point spline's spans extruded, #3830) was refused.
Each face with a picked edge is now extended alone by SMLib's extension of its surface, and the
faces are sewn back together, each the face it was. A side that lengthens onto a face meeting it
tangent takes that face along, past its edge continuing the picked one; across a crease the
neighbour grows only when its edge is picked too, otherwise the extension is refused. Faces whose
lengthened sides no longer meet are refused. Up to a target keeps the pieces every grown face
lies in.

3830's extensions now build; its split still fails (the tool point picks one span, and a face
tool cuts with that face's surface alone), so it stays on the list with that reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 20:47:22 -04:00
committed by jyviko
parent 6853c116df
commit dd88674140
3 changed files with 239 additions and 34 deletions
+237 -32
View File
@@ -11,7 +11,15 @@
//! The face is extended where it is its surface's whole parameter box and the edge is a whole
//! side of it: the box grows past that side, the sides next to it continue along the surface's
//! parameter lines, and the face stays one face on the extended surface. Edges picked on two
//! sides grow the box on both, meeting at the box's corner. Up to a target, the face is extended
//! sides grow the box on both, meeting at the box's corner.
//!
//! A body of several faces grows face by face, each extended alone as above and the faces sewn
//! back together. A side that lengthens is shared with the face next to it: that face grows too,
//! past its edge continuing the picked one, when the two meet tangent along the side (the faces
//! of one smooth surface, a spline's spans extruded, grow as one); across a crease it grows only
//! when that edge is picked as well, and otherwise the extension is refused (it would run past
//! the face beside it). The lengthened sides must meet again: faces whose extensions part are
//! refused. Up to a target, the face is extended
//! past it and trimmed back where the target meets it ([`crate::trim_keep`]: SMLib's exact
//! intersection). Back to the bounds is the face untrimmed to its surface's box
//! ([`crate::untrim`]). A plane's straight edges pushed out with their neighbours lengthened
@@ -19,7 +27,9 @@
//!
//! Refused rather than built: edges between two faces, edges of holes, and (not supported yet)
//! an edge that is not a whole side of its surface's box (a trimmed edge, whose extension is no
//! curve SMLib holds exactly), or a body of more than one face.
//! curve SMLib holds exactly).
use std::collections::{BTreeMap, BTreeSet};
use solidcraft_geom::Vec3;
use solidcraft_geom::brep::{self, eval::CurveEval};
@@ -29,7 +39,8 @@ use crate::body::Body;
use crate::history::{Change, FaceOrigin};
use crate::native::{body_from_smlib_ordered, smlib_body_ordered};
use crate::surface_trim::TrimKeep;
use crate::{KernelError, Result, SplitTool};
use crate::surfaces::Parts;
use crate::{KernelError, Result, SplitTool, record};
/// Most edge picks.
const MAX_PICKS: usize = 10_000;
@@ -104,11 +115,14 @@ pub fn extend_surface(body: &Body, at: &[Vec3], opts: &ExtendOptions) -> Result<
{
return crate::surfaces::extend(body, at, d);
}
let faces = picked_faces(&record, body.size(), at)?;
let picked = picked_edges(&record, body.size(), at)?;
match opts.limit {
ExtendLimit::Bounds => back_to_bounds(body, &faces),
ExtendLimit::Distance(d) => continued(body, at, d, opts.mode),
ExtendLimit::UpTo(tool) => up_to(body, at, tool, opts.mode),
ExtendLimit::Bounds => {
let faces: BTreeSet<usize> = picked.iter().map(|(_, f)| *f).collect();
back_to_bounds(body, &faces.into_iter().collect::<Vec<_>>())
}
ExtendLimit::Distance(d) => continued(body, at, &picked, d, opts.mode).map(|(out, _)| out),
ExtendLimit::UpTo(tool) => up_to(body, at, &picked, tool, opts.mode),
}
}
@@ -120,10 +134,10 @@ fn plain_planar(record: &brep::Brep) -> bool {
&& edges.iter().all(|u| record.edges.get(u.edge as usize).is_some_and(|e| matches!(e.curve, brep::Curve::Line { .. })))
}
/// The faces (in face order, each once) of the boundary edges the points pick: for each point,
/// the record edge nearest it (by samples along each edge), within a twentieth of the body's
/// size. An edge two faces share, or one of a hole, is refused.
fn picked_faces(record: &brep::Brep, size: f64, at: &[Vec3]) -> Result<Vec<usize>> {
/// The boundary edges the points pick, each with its face: for each point, the record edge
/// nearest it (by samples along each edge), within a twentieth of the body's size. An edge two
/// faces share, or one of a hole, is refused.
fn picked_edges(record: &brep::Brep, size: f64, at: &[Vec3]) -> Result<Vec<(usize, usize)>> {
let curves: Vec<CurveEval> = record
.edges
.iter()
@@ -131,7 +145,7 @@ fn picked_faces(record: &brep::Brep, size: f64, at: &[Vec3]) -> Result<Vec<usize
.map(|(i, e)| CurveEval::new(e.parameters()).map_err(|err| KernelError::Failed(format!("extend: edge {i}'s curve: {err}"))))
.collect::<Result<_>>()?;
let reach = size * 0.05 + 1e-6;
let mut faces = Vec::new();
let mut picked = Vec::with_capacity(at.len());
for p in at {
let mut best: Option<(f64, usize)> = None;
for (i, (e, c)) in record.edges.iter().zip(&curves).enumerate() {
@@ -144,8 +158,8 @@ fn picked_faces(record: &brep::Brep, size: f64, at: &[Vec3]) -> Result<Vec<usize
}
}
}
let edge = match best {
Some((d, i)) if d <= reach => record.edges.get(i),
let (index, edge) = match best {
Some((d, i)) if d <= reach => record.edges.get(i).map(|e| (i, e)),
_ => None,
}
.ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
@@ -156,23 +170,163 @@ fn picked_faces(record: &brep::Brep, size: f64, at: &[Vec3]) -> Result<Vec<usize
if only.r#loop != 0 {
return Err(KernelError::Invalid("not supported yet: extending an edge of a hole (Untrim takes holes away)".into()));
}
let face = only.face as usize;
if !faces.contains(&face) {
faces.push(face);
picked.push((index, only.face as usize));
}
Ok(picked)
}
/// The faces of `body` continued `distance` past the picked edges (`at`, which `picked` names
/// with their faces), as `mode` says, and the faces that grew. One face is extended by SMLib
/// ([`one_face`]); a body of several grows face by face ([`grown`] says which past which edges),
/// each face extended alone and the faces sewn back together, each the face it was. Faces whose
/// lengthened sides part (no longer one edge) are refused.
fn continued(body: &Body, at: &[Vec3], picked: &[(usize, usize)], distance: f64, mode: ExtendMode) -> Result<(Body, Vec<usize>)> {
if body.face_count() == 1 {
return Ok((one_face(body, at, distance, mode)?, vec![0]));
}
let r = body.record();
let grow = grown(r, picked)?;
let mut pieces = Vec::with_capacity(grow.len() + 1);
for (&f, edges) in &grow {
let alone = crate::surfaces::taken(body, r, &[(f, false)])?;
let points = edges.iter().map(|e| edge_middle(r, *e)).collect::<Result<Vec<_>>>()?;
let longer = one_face(&alone, &points, distance, mode)?;
pieces.push((longer.record().clone(), vec![record::from_face(0, f, Change::Modified)]));
}
let rest: Vec<(usize, bool)> = (0..r.faces.len()).filter(|f| !grow.contains_key(f)).map(|f| (f, false)).collect();
if !rest.is_empty() {
let c = Parts::of(r, &rest)?.canonical()?;
let kept = c
.faces
.iter()
.map(|p| rest.get(*p).map(|(f, _)| record::from_face(0, *f, Change::Kept)))
.collect::<Option<Vec<_>>>()
.ok_or_else(|| failed("sew", "a face of the rest of the body is missing"))?;
pieces.push((c.brep, kept));
}
let out = crate::surfaces::sheet_of(pieces, true, &[body])?;
if (out.face_count(), out.edge_count()) != (body.face_count(), body.edge_count()) {
return Err(KernelError::Invalid(
"the extended faces part along the sides they lengthen: their extensions don't meet again (extend each face on its own)".into(),
));
}
Ok((out, grow.into_keys().collect()))
}
/// The boundary edges each face grows past (by face): the `picked` ones (edge, face), and those
/// that continue a grown edge across a side it lengthens onto a face meeting its own tangent
/// there. A side lengthening along a crease onto a face that does not grow past the edge
/// continuing the grown one is refused: the extension would run past that face.
fn grown(r: &brep::Brep, picked: &[(usize, usize)]) -> Result<BTreeMap<usize, BTreeSet<usize>>> {
let sides = crate::surfaces::face_sides(r);
let sign = |f: usize| sides.get(f).map(|forward| if *forward { 1.0 } else { -1.0 });
let mut grow: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
let mut todo = picked.to_vec();
// (face, its edge that must grow, the creased side between): checked once all have grown.
let mut creased = Vec::new();
let mut count = 0usize;
while let Some((e, f)) = todo.pop() {
if !grow.entry(f).or_default().insert(e) {
continue;
}
count += 1;
if count > MAX_PICKS {
return Err(KernelError::Invalid("too many edges to extend".into()));
}
for (side, at) in next_to(r, f, e)? {
let uses = &record_edge(r, side)?.uses;
let other = match uses.as_slice() {
[_] => continue,
[a, b] => [a.face, b.face].into_iter().map(|g| g as usize).find(|g| *g != f),
_ => return Err(KernelError::Invalid(format!("not supported yet: extending along edge {side}, which more than two faces share"))),
};
// A seam: the face lengthens along it on both sides at once.
let Some(g) = other else { continue };
let cont = continuing(r, g, side, at)?;
if !matches!(record_edge(r, cont)?.uses.as_slice(), [only] if only.r#loop == 0) {
return Err(KernelError::Invalid(format!(
"not supported yet: extending past edge {e}, whose side {side} runs on into the inside of the face next to it"
)));
}
if crate::tangent_edge::tangent_all_along(r, sign, side, crate::smooth_edges::ANGLE)? {
todo.push((cont, g));
} else {
creased.push((g, cont, side));
}
}
}
faces.sort_unstable();
Ok(faces)
for (g, cont, side) in creased {
if !grow.get(&g).is_some_and(|edges| edges.contains(&cont)) {
return Err(KernelError::Invalid(format!(
"extending the edge lengthens its face along edge {side}, a crease with the face next to it: pick that face's edge {cont} too"
)));
}
}
Ok(grow)
}
fn record_edge(r: &brep::Brep, e: usize) -> Result<&brep::Edge> {
r.edges.get(e).ok_or_else(|| failed("topology", format!("no edge {e}")))
}
/// The outer loop of face `f` and where edge `e` is in it.
fn in_outer_loop(r: &brep::Brep, f: usize, e: usize) -> Result<(&[brep::OrientedEdge], usize)> {
let face = r.faces.get(f).ok_or_else(|| failed("topology", format!("no face {f}")))?;
let Some(brep::Loop::Edges { edges }) = face.loops.first() else { return Err(failed("topology", format!("face {f} has no edge loop"))) };
let i = edges.iter().position(|u| u.edge as usize == e).ok_or_else(|| failed("topology", format!("edge {e} is not on face {f}'s outer loop")))?;
Ok((edges, i))
}
/// The vertices edge use `u` runs from and to round its loop.
fn ends(r: &brep::Brep, u: &brep::OrientedEdge) -> Result<[u32; 2]> {
let [a, b] = record_edge(r, u.edge as usize)?.vertices;
Ok(if u.forward { [a, b] } else { [b, a] })
}
/// The sides of face `f` next to its edge `e` round its outer loop, each with the vertex it
/// shares with `e`: the edges an extension past `e` lengthens.
fn next_to(r: &brep::Brep, f: usize, e: usize) -> Result<Vec<(usize, u32)>> {
let (edges, i) = in_outer_loop(r, f, e)?;
let n = edges.len();
let (Some(prev), Some(this), Some(next)) = (edges.get((i + n - 1) % n), edges.get(i), edges.get((i + 1) % n)) else { return Ok(Vec::new()) };
let [from, to] = ends(r, this)?;
let mut out = Vec::with_capacity(2);
for (u, v) in [(prev, from), (next, to)] {
if u.edge as usize != e && !out.iter().any(|(s, _)| *s == u.edge as usize) {
out.push((u.edge as usize, v));
}
}
Ok(out)
}
/// The edge of face `g`'s outer loop that meets its side `side` at vertex `at`.
fn continuing(r: &brep::Brep, g: usize, side: usize, at: u32) -> Result<usize> {
let (edges, i) = in_outer_loop(r, g, side)?;
let n = edges.len();
let this = edges.get(i).ok_or_else(|| failed("topology", "a loop position"))?;
let [from, to] = ends(r, this)?;
let k = if from == at {
(i + n - 1) % n
} else if to == at {
(i + 1) % n
} else {
return Err(failed("topology", format!("edge {side} does not end at vertex {at}")));
};
match edges.get(k) {
Some(u) if u.edge as usize != side => Ok(u.edge as usize),
_ => Err(KernelError::Invalid(format!("not supported yet: extending along edge {side}, the only side of the face next to it"))),
}
}
/// The point halfway along record edge `e`.
fn edge_middle(r: &brep::Brep, e: usize) -> Result<Vec3> {
let edge = record_edge(r, e)?;
let c = CurveEval::new(edge.parameters()).map_err(|err| failed("edge", format!("edge {e}'s curve: {err}")))?;
c.point(0.5 * (edge.interval[0] + edge.interval[1])).ok_or_else(|| failed("edge", format!("edge {e} has no middle")))
}
/// The one face of `body` continued `distance` past each picked edge, as `mode` says.
fn continued(body: &Body, at: &[Vec3], distance: f64, mode: ExtendMode) -> Result<Body> {
if body.face_count() != 1 {
return Err(KernelError::Invalid(format!(
"not supported yet: extending a surface of more than one face (this one has {})",
body.face_count()
)));
}
fn one_face(body: &Body, at: &[Vec3], distance: f64, mode: ExtendMode) -> Result<Body> {
let how = match mode {
ExtendMode::Natural => Continuation::Natural,
ExtendMode::Tangent => Continuation::Tangent,
@@ -207,12 +361,12 @@ fn continued(body: &Body, at: &[Vec3], distance: f64, mode: ExtendMode) -> Resul
Ok(out.with_checked_history(history, &crate::record::counts(&[body])))
}
/// The face extended past the target and trimmed back where the target meets it, keeping the
/// piece the face itself lies in.
fn up_to(body: &Body, at: &[Vec3], tool: &SplitTool, mode: ExtendMode) -> Result<Body> {
let inside = crate::surface_untrim::face_point(body, 0)?;
let long = continued(body, at, body.size().max(1e-6) * REACH, mode)?;
let cut = crate::trim_keep(&long, tool, TrimKeep::Regions(&[inside]))?;
/// The faces extended past the target and trimmed back where the target meets them, keeping the
/// pieces the faces themselves lie in.
fn up_to(body: &Body, at: &[Vec3], picked: &[(usize, usize)], tool: &SplitTool, mode: ExtendMode) -> Result<Body> {
let (long, faces) = continued(body, at, picked, body.size().max(1e-6) * REACH, mode)?;
let inside = faces.iter().map(|f| crate::surface_untrim::face_point(body, *f)).collect::<Result<Vec<_>>>()?;
let cut = crate::trim_keep(&long, tool, TrimKeep::Regions(&inside))?;
Ok(crate::record::after(cut, &long, &crate::record::counts(&[body])))
}
@@ -411,6 +565,57 @@ mod tests {
assert!((measure(&back).unwrap().bbox.max.z - 15.0).abs() < 1e-6);
}
/// Three cubic spans of a spline meeting tangent, extruded 0…20 along Z: a sheet of three
/// faces, as a fit-point spline's surface is (#3830).
fn spline_spans() -> Body {
let v = Vec2::new;
let c = |p0, p1, p2, p3| Seg2::Cubic { p0, p1, p2, p3 };
let chain = vec![
c(v(0.0, 0.0), v(5.0, 0.0), v(10.0, 5.0), v(15.0, 5.0)),
c(v(15.0, 5.0), v(20.0, 5.0), v(25.0, 0.0), v(30.0, 0.0)),
c(v(30.0, 0.0), v(35.0, 0.0), v(40.0, 5.0), v(45.0, 5.0)),
];
crate::extrude_sheet(&Plane::XY, &[chain], 0.0, 20.0).unwrap()
}
/// A sheet of several faces grows face by face (#3830): its end edge (the last span's
/// generator) 10 further, the last span continued, 10 · 20 more; the middle span's top edge
/// 5 up takes its tangent neighbours with it, the whole spline 25 tall. The faces stay three,
/// sewn as before, each the face it was.
#[test]
fn a_sheet_of_tangent_faces_grows_face_by_face() {
let s = spline_spans();
assert_eq!(s.face_count(), 3);
let base = area(&s);
let end = extend_surface(&s, &[Vec3::new(45.0, 5.0, 10.0)], &natural(10.0)).unwrap();
assert!(rel(area(&end), base + 200.0) < 1e-4, "{} vs {}", area(&end), base + 200.0); // (measured on meshes)
assert_eq!((end.face_count(), edges(&end)), (3, edges(&s)));
assert!(measure(&end).unwrap().bbox.max.x > 50.0, "{:?}", measure(&end).unwrap().bbox);
let h = end.history().unwrap();
let changes: Vec<Change> = h.faces.iter().map(|f| f.change).collect();
assert_eq!(changes.iter().filter(|c| **c == Change::Modified).count(), 1, "{changes:?}");
let up = extend_surface(&s, &[Vec3::new(22.5, 2.5, 20.0)], &natural(5.0)).unwrap();
assert!(rel(area(&up), base * 25.0 / 20.0) < 1e-4, "{} vs {}", area(&up), base * 25.0 / 20.0); // (measured on meshes)
assert_eq!((up.face_count(), edges(&up)), (3, edges(&s)));
assert!((measure(&up).unwrap().bbox.max.z - 25.0).abs() < 1e-6);
}
/// Across a crease the face next door grows only when its edge is picked too: an L of two
/// planes, one top edge refused (it would run past the other wall), both 5 up, 25 · 20.
#[test]
fn a_crease_grows_only_when_both_edges_are_picked() {
let l =
vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }, Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 10.0) }];
let s = crate::extrude_sheet(&Plane::XY, &[l], 0.0, 20.0).unwrap();
assert_eq!(s.face_count(), 2);
let e = extend_surface(&s, &[Vec3::new(5.0, 0.0, 20.0)], &natural(5.0)).unwrap_err();
assert!(e.to_string().contains("crease"), "{e}");
let both = extend_surface(&s, &[Vec3::new(5.0, 0.0, 20.0), Vec3::new(10.0, 5.0, 20.0)], &natural(5.0)).unwrap();
assert!(rel(area(&both), 500.0) < 1e-9, "{}", area(&both));
assert_eq!((both.face_count(), edges(&both)), (2, edges(&s)));
}
/// Hostile and unsupported requests are refused, never panic.
#[test]
fn hostile_requests_are_refused() {
+1 -1
View File
@@ -236,7 +236,7 @@ impl Parts {
/// Whether each face of `r` is used along its surface's natural normal (by its first oriented
/// face).
fn face_sides(r: &Brep) -> Vec<bool> {
pub(crate) fn face_sides(r: &Brep) -> Vec<bool> {
let mut side = vec![None; r.faces.len()];
for s in &r.shells {
if let b::Shell::Faces { faces, .. } = s {
+1 -1
View File
@@ -3,4 +3,4 @@
# and on a listed case that passes: a fix removes its line in the same pull request.
# Baseline: develop 5ada4fc8a with SMLib 90aabff (the run on 60b1c6a, aarch64 Linux).
3604-cut-with-g2-swept-tool-along-projected-edge step 12 (solid.sweep): the path curves are not connected end to end
3830-split-cup-by-extended-spline-surface not supported yet: surface.extend of a surface of more than one face (this one has 3)
3830-split-cup-by-extended-spline-surface step 12 (solid.split_body): the tool does not split the body (the tool point picks one span of the three-face spline surface, and a face tool cuts with that face's surface alone)