Selection feedback and origin: hover and selected highlights (theme tokens), origin planes/axes/point at a fixed screen size with browser eye toggles, click-to-pick sketch planes and faces with camera animation (Finish Sketch returns), multi-select dialog inputs with pre-selection, tangent chains and face-to-edges, window/crossing box selection, shell/draft/mirror/hole dialogs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 20:00:17 +00:00
1 parent 059909fb05
commit 97e73b0935
20 files changed
+1907 -518

No files matched your search

+50
View File
@@ -64,6 +64,9 @@ pub struct Mesh {
/// Per edge: a seam between two pieces of the same smooth surface (not drawn).
#[serde(default)]
pub seams: Vec<bool>,
/// Per edge: the B-rep faces it bounds (one or two).
#[serde(default)]
pub edge_faces: Vec<Vec<u32>>,
}
/// Mass properties of a closed mesh.
@@ -75,6 +78,53 @@ pub struct MeshMeasure {
}
impl Mesh {
/// Edges bounding face `f`.
pub fn face_edges(&self, f: u32) -> Vec<usize> {
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
}
/// The edges reached from `e` through vertices where the next edge continues smoothly
/// (angle between the end tangents below `max_angle`), including `e` itself.
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
// (end point, direction leaving the edge there). A polyline's end chord leans inward
// on a curve by half the turn to the next chord; extrapolating the two end chords
// recovers the curve's end tangent.
let end_dir = |p0: Vec3, p1: Vec3, p2: Option<Vec3>| -> Option<Vec3> {
let u1 = (p0 - p1).normalized()?;
match p2.and_then(|p2| (p1 - p2).normalized()) {
Some(u2) => (u1 * 1.5 - u2 * 0.5).normalized(),
None => Some(u1),
}
};
let ends = |i: usize| -> Option<[(Vec3, Vec3); 2]> {
let p = self.edges.get(i)?;
let n = p.len();
let (a, a1, a2) = (*p.first()?, *p.get(1)?, p.get(2).copied().filter(|_| n > 2));
let (b, b1) = (*p.last()?, *p.get(n.checked_sub(2)?)?);
let b2 = n.checked_sub(3).and_then(|k| p.get(k)).copied();
Some([(a, end_dir(a, a1, a2)?), (b, end_dir(b, b1, b2)?)])
};
let size = self.bounds().diagonal().max(1e-9);
let tol = size * 1e-7;
let cos = max_angle.cos();
let mut chain = vec![e];
let mut todo = vec![e];
while let Some(cur) = todo.pop() {
let Some(ce) = ends(cur) else { continue };
for (i, _) in self.edges.iter().enumerate() {
if chain.contains(&i) || self.seams.get(i).copied().unwrap_or(false) {
continue;
}
let Some(ne) = ends(i) else { continue };
// Smooth: leaving `cur` at a shared end continues into `i` (opposite directions).
let smooth = ce.iter().any(|(p, d)| ne.iter().any(|(q, dn)| p.dist(*q) < tol && d.dot(-*dn) > cos));
if smooth && chain.len() < 10_000 {
chain.push(i);
todo.push(i);
}
}
}
chain
}
pub fn tri(&self, t: &[u32; 3]) -> Option<[Vec3; 3]> {
Some([*self.positions.get(t[0] as usize)?, *self.positions.get(t[1] as usize)?, *self.positions.get(t[2] as usize)?])
}
+52
View File
@@ -233,6 +233,44 @@ impl Region2 {
pub fn contains(&self, p: Vec2) -> bool {
self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p))
}
/// Triangles covering the region (for filled display), by slabs between the vertex
/// heights: inside each slab the boundary edges don't cross, so consecutive crossings pair
/// up into trapezoids.
pub fn triangulate(&self, tol: f64) -> Vec<[Vec2; 3]> {
let mut edges: Vec<(Vec2, Vec2)> = Vec::new();
for l in std::iter::once(&self.outer).chain(&self.holes) {
let pl = l.polyline(tol);
let n = pl.len();
for i in 0..n {
let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue };
if (a.y - b.y).abs() > 1e-12 {
edges.push(if a.y < b.y { (*a, *b) } else { (*b, *a) });
}
}
}
if edges.len() > 20_000 {
return Vec::new();
}
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b)| [a.y, b.y]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
let mut out = Vec::new();
for w in ys.windows(2) {
let (y0, y1) = (w[0], w[1]);
let ym = (y0 + y1) * 0.5;
let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
for pair in span.chunks(2) {
let [l, r] = pair else { continue };
let (a, b) = (Vec2::new(x_at(l, y0), y0), Vec2::new(x_at(r, y0), y0));
let (c, d) = (Vec2::new(x_at(r, y1), y1), Vec2::new(x_at(l, y1), y1));
out.push([a, b, c]);
out.push([a, c, d]);
}
}
out
}
pub fn centroid(&self) -> Vec2 {
let ao = self.outer.signed_area().abs();
let mut c = self.outer.centroid() * ao;
@@ -250,6 +288,20 @@ impl Region2 {
mod tests {
use super::*;
#[test]
fn triangulated_area() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
let r = Region2 { outer: sq, holes: vec![Loop2::circle(Vec2::new(3.0, 1.5), 1.0).reversed()] };
let tris = r.triangulate(1e-3);
let a: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum();
// The circle is a polyline within the tolerance, so the areas agree to about that.
assert!((a - r.area()).abs() < 1e-3 * r.area(), "{a} vs {}", r.area());
for [a, b, c] in &tris {
let m = (*a + *b + *c) / 3.0;
assert!((*b - *a).cross(*c - *a).abs() < 1e-12 || r.contains(m), "{m:?}");
}
}
#[test]
fn areas() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]);
+22 -1
View File
@@ -6,7 +6,7 @@ fn cube_mesh(s: f64) -> Mesh {
let triangles =
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
let n = triangles.len();
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![] }
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![], edge_faces: vec![] }
}
#[test]
@@ -62,3 +62,24 @@ fn aabb() {
b.add(Vec3::new(-1.0, 0.0, 0.0));
assert_eq!(b.size(), Vec3::new(2.0, 2.0, 3.0));
}
#[test]
fn tangent_chains_follow_smooth_edges() {
// A line, then an arc leaving it tangentially, then a sharp corner.
let mut m = Mesh { positions: vec![Vec3::ZERO, Vec3::new(30.0, 30.0, 0.0)], ..Default::default() };
m.edges.push(vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0)]);
let arc: Vec<Vec3> = (0..=16)
.map(|k| {
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::FRAC_PI_2 * k as f64 / 16.0;
Vec3::new(10.0 + 5.0 * a.cos(), 5.0 + 5.0 * a.sin(), 0.0)
})
.collect();
m.edges.push(arc);
m.edges.push(vec![Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 0.0)]);
m.edge_faces = vec![vec![0], vec![0, 1], vec![1]];
let mut c = m.tangent_chain(0, 5f64.to_radians());
c.sort();
assert_eq!(c, vec![0, 1]);
assert_eq!(m.tangent_chain(2, 5f64.to_radians()), vec![2]);
assert_eq!(m.face_edges(1), vec![1, 2]);
}