kernel: a seam with one face on both of its sides is a seam, and no pick takes it

seam_flags matched only an edge between two faces on one curved surface.
A whole turn of a cylinder is one face whose seam it uses twice, and
history::edge_faces lists that face once, so the seam came out as [x]
and was never flagged: it was drawn and picked, and a blend pick at the
vertex where it meets a rim (ladder 07-fillets/017's (23, 15, 20) on a
boss's base circle) resolved to the seam, which SMLib then rejected. An
edge both of whose uses lie on one curved face is now a seam too, for
every consumer of the flag (display, picking, blend resolve, projection).

The blend's own edge selection took the plain nearest edge: the tangent
chain seeded from it and grew into seams (a pick at the top of a boss's
seam ran down it into the base round's seam, ladder 08-chamfers/008), and
the variable fillet's edge and the fillet's grouping of picks could
differ from the edge the blend resolved. They now take the edges the
blend's resolve picks, and a chain never grows into a seam or a smooth
join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnQawySUFx57iaJZq7z1Fm
(cherry picked from commit 709c19c7fd60db522d35e147f21821360bf34668)
This commit is contained in:
jyviko
2026-10-24 22:49:17 -04:00
parent 3e2b109cc7
commit ec6fa9706a
4 changed files with 98 additions and 31 deletions
+35 -19
View File
@@ -310,20 +310,15 @@ pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
crate::blend_smlib::fillet_each(body, edges, radii).and_then(|b| closed(body, b))
}
/// The points' edges (the nearest edge of the body to each) grouped where they share a face or
/// a vertex, directly or through other points' edges: indices into `pts`. `None` when a point
/// The points' edges (each the edge a blend's pick there takes) grouped where they share a face
/// or a vertex, directly or through other points' edges: indices into `pts`. `None` when a point
/// has no edge near it.
fn interacting_groups(body: &Body, pts: &[Vec3]) -> Option<Vec<Vec<usize>>> {
let rec = Faces::of(body).ok()?;
let size = body.size();
let tol = (size * 2e-3).max(1e-3);
let (picked, _) = crate::blend_smlib::picked_edges(body, pts).ok()?;
// What each point's edge touches: its faces and its ends.
let mut groups: Vec<(BTreeSet<u32>, BTreeSet<u32>, Vec<usize>)> = Vec::new();
for (i, p) in pts.iter().enumerate() {
let (idx, dist) = body.nearest_edge(*p, tol).ok()??;
if dist > size * 0.05 + 1e-3 {
return None;
}
for (i, idx) in picked.into_iter().enumerate() {
let mut faces = rec.faces_of(idx);
let mut ends: BTreeSet<u32> = rec.rec.edges.get(idx)?.vertices.into_iter().collect();
let mut members = vec![i];
@@ -390,9 +385,10 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
/// The picked edges' tangent chains, for a blend that is to run along them: `picks` (each the
/// nearest edge of `body` to it), then a point on every other edge that continues one of them
/// tangentially (within 0.5° at a shared vertex), until no more do. An edge only one face uses
/// ends a chain, and so does a smooth edge (its two faces meet tangentially, within 0.5°): it has
/// no corner to round. Picks that find no edge are refused.
/// tangentially (within 0.5° at a shared vertex), until no more do. Each pick takes its edge as a
/// blend does (a pick at a vertex where a seam meets a rim takes the rim). An edge only one face
/// uses ends a chain, and so do a seam and a smooth edge (its two faces meet tangentially, within
/// 0.5°): they have no corner to round. Picks that find no edge are refused.
pub fn tangent_chain(body: &Body, picks: &[Vec3]) -> Result<Vec<Vec3>> {
/// Edges meeting at an angle below this continue one another.
const TANGENT_DEGREES: f64 = 0.5;
@@ -400,13 +396,9 @@ pub fn tangent_chain(body: &Body, picks: &[Vec3]) -> Result<Vec<Vec3>> {
return Err(KernelError::Invalid("select 1…1000 edges by finite points".into()));
}
let rec = Faces::of(body)?;
let size = body.size();
let (seeds, hidden) = crate::blend_smlib::picked_edges(body, picks)?;
let mut chain: Vec<usize> = Vec::new();
for p in picks {
let (e, dist) = body.nearest_edge(*p, (size * 2e-3).max(1e-3))?.ok_or_else(|| KernelError::Invalid("the body has no edges".into()))?;
if dist > size * 0.05 + 1e-3 {
return Err(KernelError::Invalid(format!("no edge near the pick at ({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)));
}
for e in seeds {
if !chain.contains(&e) {
chain.push(e);
}
@@ -439,7 +431,8 @@ pub fn tangent_chain(body: &Body, picks: &[Vec3]) -> Result<Vec<Vec3>> {
for v in edge.vertices {
let Some(d) = leaving(e, v) else { continue };
for (f, other) in rec.rec.edges.iter().enumerate() {
if chain.contains(&f) || other.uses.len() < 2 || !other.vertices.contains(&v) || smooth(f) {
let passed_over = hidden.get(f).copied().unwrap_or(false);
if passed_over || chain.contains(&f) || other.uses.len() < 2 || !other.vertices.contains(&v) || smooth(f) {
continue;
}
if leaving(f, v).is_some_and(|g| d.dot(g) < -cos) {
@@ -480,6 +473,29 @@ mod tests {
crate::native::brep_record(b).unwrap().vertices.iter().any(|v| v3(v.point).dist(p) < tol)
}
/// The ladder's 08-chamfers/008: a boss on a block, rounded where it meets the block, picked
/// at the top of its seam. The pick takes the top rim, and the chain keeps to it: it never
/// runs down the seam into the round's own seam, which continues it tangentially.
#[test]
fn a_chain_picked_where_a_seam_meets_a_rim_keeps_to_the_rim() {
let block = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let boss = crate::build::cylinder(Vec3::new(15.0, 15.0, 20.0), Vec3::Z, 8.0, 10.0).unwrap();
let joined = crate::boolean(&block, &boss, crate::BoolOp::Union).unwrap().unwrap();
let rec = joined.record();
// The seam: the edge both of whose uses lie on the boss's side.
let seam = rec.edges.iter().find(|e| e.uses.len() == 2 && e.uses[0].face == e.uses[1].face).unwrap();
let ends = seam.vertices.map(|v| v3(rec.vertices[v as usize].point));
let (foot, top) = if ends[0].z < ends[1].z { (ends[0], ends[1]) } else { (ends[1], ends[0]) };
let rounded = fillet(&joined, &[foot], 1.0).unwrap();
let chain = tangent_chain(&rounded, &[top]).unwrap();
assert_eq!(chain, vec![top], "the chain left the top rim");
assert!(chamfer(&rounded, &chain, 1.0).is_ok());
// Even from a pick on the seam itself, the chain takes no seam: the round's continues
// the boss's tangentially but has no corner to blend.
let side = (top + Vec3::new(top.x, top.y, 21.0)) * 0.5;
assert_eq!(tangent_chain(&rounded, &[side]).unwrap(), vec![side], "the chain ran into the round's seam");
}
#[test]
fn g2_fillet_has_the_quintics_area() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
+45 -2
View File
@@ -64,6 +64,14 @@ pub(crate) fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, spline: &
imp::variable(body, edge_at, start, spline)
}
/// The edges `picks` select, in the body's edge order, as a blend resolves them, and per edge
/// whether a pick passes it over (a seam or a smooth join: never a blend's edge), for a caller
/// that selects edges to blend ([`crate::tangent_chain`]).
pub(crate) fn picked_edges(body: &Body, picks: &[Vec3]) -> Result<(Vec<usize>, Vec<bool>)> {
let picked = imp::resolve(body, picks)?;
Ok((picked.edges, picked.hidden))
}
#[derive(Clone, Copy)]
enum Kind {
Round,
@@ -105,16 +113,18 @@ mod imp {
let mesh = body.mesh(&crate::MeshTolerance::chord((body.size() * 2e-3).max(1e-3)))?;
let seams = crate::topo::seam_flags(body, &mesh);
let (smooth, _) = crate::smooth_edges::flags(body, &mesh);
let hidden: Vec<bool> =
(0..mesh.edges.len()).map(|i| seams.get(i).copied().unwrap_or(false) || smooth.get(i).copied().unwrap_or(false)).collect();
let reach = pick_tolerance(body);
let tie = (body.size() * 1e-6).max(1e-6);
let mut out = Picked { edges: Vec::with_capacity(picks.len()), points: Vec::with_capacity(picks.len()) };
let mut out = Picked { edges: Vec::with_capacity(picks.len()), points: Vec::with_capacity(picks.len()), hidden: Vec::new() };
for p in picks {
// The nearest edge, and the nearest that is no seam.
let (mut any, mut edge): (Option<(f64, usize)>, Option<(f64, usize)>) = (None, None);
for (i, line) in mesh.edges.iter().enumerate() {
let d =
line.windows(2).filter_map(|w| if let [a, b] = w { Some(p.dist_to_segment(*a, *b)) } else { None }).fold(f64::INFINITY, f64::min);
let seam = seams.get(i).copied().unwrap_or(false) || smooth.get(i).copied().unwrap_or(false);
let seam = hidden.get(i).copied().unwrap_or(false);
for (slot, take) in [(&mut any, true), (&mut edge, !seam)] {
if take && slot.is_none_or(|(bd, _)| d < bd) {
*slot = Some((d, i));
@@ -132,6 +142,7 @@ mod imp {
out.edges.push(i);
out.points.push([mid.x, mid.y, mid.z]);
}
out.hidden = hidden;
Ok(out)
}
@@ -140,6 +151,8 @@ mod imp {
pub(super) edges: Vec<usize>,
/// A point halfway along each.
pub(super) points: Vec<[f64; 3]>,
/// Per edge of the body: does a pick pass it over (a seam or a smooth join)?
pub(super) hidden: Vec<bool>,
}
fn sizes_ok(what: &str, sizes: &[f64]) -> Result<()> {
@@ -350,6 +363,36 @@ mod tests {
}
}
#[test]
fn a_pick_where_a_bosss_seam_meets_its_base_takes_the_base_circle() {
// A boss joined onto a block, as the ladder's running part has (07-fillets/017 picks
// its base circle where the seam starts): its side lies on both sides of its seam.
let block = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 30.0, 20.0)).unwrap();
let boss = crate::cylinder(Vec3::new(15.0, 15.0, 20.0), Vec3::new(0.0, 0.0, 1.0), 8.0, 10.0).unwrap();
let b = crate::boolean(&block, &boss, crate::BoolOp::Union).unwrap().unwrap();
let mesh = b.mesh(&crate::MeshTolerance::chord(0.01)).unwrap();
let seams = crate::topo::seam_flags(&b, &mesh);
let record = b.record();
let one_face: Vec<usize> = crate::history::edge_faces(record)
.iter()
.zip(&record.edges)
.enumerate()
.filter(|(_, (faces, e))| faces.len() == 1 && e.uses.len() == 2)
.map(|(i, _)| i)
.collect();
assert_eq!(one_face.len(), 1, "the boss's side meets itself along its seam");
for &i in &one_face {
assert!(seams[i], "edge {i}, the boss's side on both of its sides, is a seam");
assert!(!mesh.edge_pickable(i), "edge {i} is drawn and picked as a seam");
// Its end on the base circle, where a pick takes the circle.
let line = &mesh.edges[i];
let foot = [line[0], line[line.len() - 1]].into_iter().find(|p| (p.z - 20.0).abs() < 1e-6).unwrap();
let picked = imp::resolve(&b, &[foot]).unwrap();
let [_, _, z] = picked.points[0];
assert!((z - 20.0).abs() < 1e-9, "the pick at {foot:?} took an edge through z = {z}");
}
}
#[test]
fn each_edge_takes_its_own_size() {
let b = cube();
+4 -7
View File
@@ -224,13 +224,10 @@ struct EdgeSetup {
}
impl EdgeSetup {
/// The edge of `body` nearest `at`.
/// The edge of `body` a pick at `at` takes, as the blend takes it.
fn new(body: &Body, at: Vec3) -> Result<EdgeSetup> {
let size = body.size();
let (edge, dist) = body.nearest_edge(at, (size * 2e-3).max(1e-3))?.ok_or_else(|| KernelError::Invalid(format!("no edge at {at:?}")))?;
if dist > size * 0.05 + 1e-3 {
return Err(KernelError::Invalid(format!("no edge at {at:?}")));
}
let (picked, _) = crate::blend_smlib::picked_edges(body, &[at])?;
let edge = *picked.first().ok_or_else(|| KernelError::Invalid(format!("no edge at {at:?}")))?;
let rec = Faces::of(body)?;
if !rec.straight(edge) {
return Err(unsupported("only straight edges take a variable radius"));
@@ -239,7 +236,7 @@ impl EdgeSetup {
let [v0, v1] = rec.rec.edges.get(edge).ok_or_else(|| KernelError::Failed("edge not found".into()))?.vertices;
let (p0, p1) = rec.ends(edge).ok_or_else(|| KernelError::Failed("edge not found".into()))?;
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
Ok(EdgeSetup { rec, fr, edge, v0, v1, p0, p1, d, tol: (size * 1e-7).max(1e-9) })
Ok(EdgeSetup { rec, fr, edge, v0, v1, p0, p1, d, tol: (body.size() * 1e-7).max(1e-9) })
}
/// How far a blend of radius `r` reaches into each face from the edge.
+14 -3
View File
@@ -84,11 +84,22 @@ impl<'a> Faces<'a> {
}
}
/// Per edge (in `Body::edges` order): is it a seam between two pieces of one curved surface?
/// Per edge (in `Body::edges` order): is it a seam of a curved surface, between two pieces of it
/// or where one face of it meets itself (a whole turn of a cylinder, its seam used twice)?
/// `mesh` is the body's tessellation, whose edges are the record's.
pub fn seam_flags(b: &Body, _mesh: &Mesh) -> Vec<bool> {
let faces = Faces::new(b.record(), (b.size() * 1e-4).max(1e-6) * 10.0);
edge_faces(b.record()).iter().map(|fs| matches!(fs[..], [x, y] if !faces.planar(x) && faces.same(x, y))).collect()
let record = b.record();
let faces = Faces::new(record, (b.size() * 1e-4).max(1e-6) * 10.0);
record
.edges
.iter()
.zip(edge_faces(record))
.map(|(e, fs)| match fs[..] {
[x, y] => !faces.planar(x) && faces.same(x, y),
[x] => e.uses.len() == 2 && !faces.planar(x),
_ => false,
})
.collect()
}
/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.mesh(..)`: faces of no