From 74c725982517e4f4b8b5b46ffdd25a3f9f1e5142 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 18:20:16 +0000 Subject: [PATCH] Face provenance (kernel/src/provenance.rs): trace(result, inputs) tells per result face which input faces it lies on (surface and trimmed region, either side) and whether it was kept, modified, split, merged or generated, and per edge its faces and the input edge it lies on; extrude_origins and revolve_origins name a tool's caps and the profile segment each side sweeps, compose carries them through the boolean, blend_origins gives fillet/chamfer faces their edge and offset_origins gives shell walls the face they offset Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/kernel/src/lib.rs | 1 + crates/kernel/src/provenance.rs | 499 ++++++++++++++++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100644 crates/kernel/src/provenance.rs diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 5af9e4f9..aa28a611 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -31,6 +31,7 @@ mod ops; mod polybool; mod polyhedron; mod prism; +pub mod provenance; mod sew; mod splitface; mod step; diff --git a/crates/kernel/src/provenance.rs b/crates/kernel/src/provenance.rs new file mode 100644 index 00000000..86c34db3 --- /dev/null +++ b/crates/kernel/src/provenance.rs @@ -0,0 +1,499 @@ +//! Face and edge provenance: where each face (and edge) of an operation's result came from. +//! +//! [`trace`] works for any operation: a result face that lies on an input face (on its surface, +//! inside its trimmed region) comes from that face; one that lies on none is generated by the +//! operation. Distinct input faces on one surface (a target's and a tool's coplanar tops) are +//! told apart by their trimmed regions, so a merged face names both and a split face's pieces +//! name the same one. Operations that know more (which profile segment a side face sweeps, +//! which edge a blend replaces) fill in generated faces' origins themselves. + +use std::collections::BTreeSet; + +use solvecraft_geom::{Mesh, Plane, Region2, Seg2, Vec2, Vec3}; + +use crate::Result; +use crate::body::Body; + +/// Where a result face came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FaceOrigin { + /// A face of input `operand` (its index in the operand's face order). + Face { operand: usize, face: usize }, + /// Swept from segment `segment` of the profile's loops (in order: outer, then holes). + Profile { segment: usize }, + /// The start or end cap of a sweep (the profile itself, or where it ends up). + Cap { end: bool }, + /// Made in place of edge `edge` of input `operand` (a fillet or chamfer). + Edge { operand: usize, edge: usize }, + /// An offset of face `face` of input `operand` (a shell's inner wall). + Offset { operand: usize, face: usize }, +} + +/// How a result face relates to its origins. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Change { + /// The same face as its origin (same region). + Kept, + /// Its origin's surface, trimmed differently. + Modified, + /// One of several result faces made from one input face. + Split, + /// Made from several input faces (coplanar or cosurface faces joined). + Merged, + /// New to the operation (no input face under it). + Generated, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FaceProv { + pub origins: Vec, + pub change: Change, +} + +/// Where a result edge came from: the result faces it separates and, when it lies on an edge +/// of an input body, that edge. +#[derive(Clone, Debug, PartialEq)] +pub struct EdgeProv { + pub faces: Vec, + pub origin: Option<(usize, usize)>, +} + +/// Provenance of an operation's result: one entry per face (in the result's face order) and +/// per edge (in its mesh's edge order). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Provenance { + pub faces: Vec, + pub edges: Vec, +} + +/// Up to this many sample points per result face. +const SAMPLES: usize = 24; + +/// A face's triangles with their unit normals, and its box. +struct FaceTris { + tris: Vec<([Vec3; 3], Vec3)>, + lo: Vec3, + hi: Vec3, + area: f64, +} + +fn face_tris(m: &Mesh) -> Vec { + let n = m.tri_face.iter().copied().max().map(|x| x as usize + 1).unwrap_or(0); + let mut out: Vec = (0..n) + .map(|_| FaceTris { + tris: Vec::new(), + lo: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), + hi: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), + area: 0.0, + }) + .collect(); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let (Some(slot), Some(p)) = (out.get_mut(*f as usize), m.tri(t)) else { continue }; + let c = (p[1] - p[0]).cross(p[2] - p[0]); + let Some(nrm) = c.normalized() else { continue }; + slot.area += c.len() * 0.5; + for q in p { + slot.lo = slot.lo.min(q); + slot.hi = slot.hi.max(q); + } + slot.tris.push((p, nrm)); + } + out +} + +fn closest_on_tri(p: Vec3, [a, b, c]: [Vec3; 3]) -> Vec3 { + let (ab, ac, ap) = (b - a, c - a, p - a); + let (d1, d2) = (ab.dot(ap), ac.dot(ap)); + if d1 <= 0.0 && d2 <= 0.0 { + return a; + } + let bp = p - b; + let (d3, d4) = (ab.dot(bp), ac.dot(bp)); + if d3 >= 0.0 && d4 <= d3 { + return b; + } + let vc = d1 * d4 - d3 * d2; + if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { + return a + ab * (d1 / (d1 - d3)); + } + let cp = p - c; + let (d5, d6) = (ab.dot(cp), ac.dot(cp)); + if d6 >= 0.0 && d5 <= d6 { + return c; + } + let vb = d5 * d2 - d1 * d6; + if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { + return a + ac * (d2 / (d2 - d6)); + } + let va = d3 * d6 - d5 * d4; + if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { + return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6))); + } + let den = 1.0 / (va + vb + vc); + a + ab * (vb * den) + ac * (vc * den) +} + +/// Does point `p` with normal `n` lie on the face (within `tol`, normals along each other)? +fn on_face(f: &FaceTris, p: Vec3, n: Vec3, tol: f64) -> bool { + if p.x < f.lo.x - tol || p.y < f.lo.y - tol || p.z < f.lo.z - tol || p.x > f.hi.x + tol || p.y > f.hi.y + tol || p.z > f.hi.z + tol { + return false; + } + // Either side: a cut's tool faces come out turned inside out. + f.tris.iter().any(|(t, tn)| tn.dot(n).abs() > 0.95 && closest_on_tri(p, *t).dist(p) <= tol) +} + +/// Sample points (triangle centroids, spread out) with normals of a result face. +fn face_samples(f: &FaceTris) -> Vec<(Vec3, Vec3)> { + let all: Vec<(Vec3, Vec3)> = f.tris.iter().map(|(t, n)| ((t[0] + t[1] + t[2]) * (1.0 / 3.0), *n)).collect(); + if all.len() <= SAMPLES { + return all; + } + let step = all.len() as f64 / SAMPLES as f64; + (0..SAMPLES).filter_map(|i| all.get((i as f64 * step) as usize).copied()).collect() +} + +/// Provenance of `result` from the bodies it was made of (in operand order), by geometry. +pub fn trace(result: &Body, inputs: &[&Body]) -> Result { + let size = inputs.iter().map(|b| b.size()).fold(result.size(), f64::max).max(1e-9); + let mesh_tol = size * 2e-4; + let tol = mesh_tol * 2.0; + let rm = result.tessellate(mesh_tol)?; + let rfaces = face_tris(&rm); + let mut imeshes = Vec::with_capacity(inputs.len()); + for b in inputs { + imeshes.push(b.tessellate(mesh_tol)?); + } + let ifaces: Vec> = imeshes.iter().map(face_tris).collect(); + // Each result face: the input faces most of its samples lie on. + let mut origins: Vec> = Vec::with_capacity(rfaces.len()); + for rf in &rfaces { + let samples = face_samples(rf); + let mut found: BTreeSet = BTreeSet::new(); + for (op, faces) in ifaces.iter().enumerate() { + for (fi, f) in faces.iter().enumerate() { + let hits = samples.iter().filter(|(p, n)| on_face(f, *p, *n, tol)).count(); + // A face on the input face's surface and region: most of its samples land on it + // (a merged face's samples split between its origins). + if hits * 4 >= samples.len().max(1) || (hits > 0 && samples.len() <= 2) { + found.insert(FaceOrigin::Face { operand: op, face: fi }); + } + } + } + origins.push(found.into_iter().collect()); + } + // How each relates: several result faces on one origin are pieces of a split; several + // origins are a merge; one-to-one with the same area is kept. + let mut uses: std::collections::HashMap = std::collections::HashMap::new(); + for o in origins.iter().flatten() { + *uses.entry(*o).or_insert(0) += 1; + } + let mut faces = Vec::with_capacity(origins.len()); + for (ri, os) in origins.into_iter().enumerate() { + let change = match os.as_slice() { + [] => Change::Generated, + [o] if uses.get(o).copied().unwrap_or(0) > 1 => Change::Split, + [FaceOrigin::Face { operand, face }] => { + let a = rfaces.get(ri).map(|f| f.area).unwrap_or(0.0); + let b = ifaces.get(*operand).and_then(|v| v.get(*face)).map(|f| f.area).unwrap_or(0.0); + if (a - b).abs() <= 1e-4 * a.max(b).max(1e-12) { Change::Kept } else { Change::Modified } + } + [_] => Change::Modified, + _ => Change::Merged, + }; + faces.push(FaceProv { origins: os, change }); + } + // Edges: the faces they separate, and the input edge they lie on. + let mut edges = Vec::with_capacity(rm.edges.len()); + for (ei, pts) in rm.edges.iter().enumerate() { + let fs: Vec = rm.edge_faces.get(ei).map(|v| v.iter().map(|x| *x as usize).collect()).unwrap_or_default(); + let probe: Vec = if pts.len() >= 2 { + let k = pts.len(); + vec![pts[k / 2], (pts[0] + pts[1]) * 0.5, (pts[k - 2] + pts[k - 1]) * 0.5] + } else { + pts.clone() + }; + let mut origin = None; + 'search: for (op, m) in imeshes.iter().enumerate() { + for (ie, ipts) in m.edges.iter().enumerate() { + if probe.iter().all(|p| ipts.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= tol)) { + origin = Some((op, ie)); + break 'search; + } + } + } + edges.push(EdgeProv { faces: fs, origin }); + } + Ok(Provenance { faces, edges }) +} + +/// The profile segments of the loops, in [`FaceOrigin::Profile`] order (outer, then holes). +fn segments(regions: &[Region2]) -> Vec { + regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(r.holes.iter())).flat_map(|l| l.segs.iter().copied()).collect() +} + +/// The segment nearest the 2D points (by mean distance). +fn nearest_segment(segs: &[Seg2], pts: &[Vec2]) -> Option { + let dist = |s: &Seg2, q: Vec2| { + let t = s.closest_param(q); + s.point_at(t).dist(q) + }; + (0..segs.len()).min_by(|a, b| { + let m = |i: usize| segs.get(i).map(|s| pts.iter().map(|q| dist(s, *q)).sum::()).unwrap_or(f64::INFINITY); + m(*a).total_cmp(&m(*b)) + }) +} + +/// Origins of an extrusion's faces (`tool` from [`crate::extrude`] of `regions` on `plane`): +/// caps on the start and end planes, sides by the profile segment they sweep. +pub fn extrude_origins(tool: &Body, plane: &Plane, regions: &[Region2]) -> Result> { + let size = tool.size().max(1e-9); + let m = tool.tessellate(size * 2e-4)?; + let faces = face_tris(&m); + let n = plane.normal(); + let segs = segments(regions); + let heights: Vec = m.positions.iter().map(|p| (*p - plane.origin).dot(n)).collect(); + let (lo, hi) = heights.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| (a.min(*h), b.max(*h))); + let mut out = Vec::with_capacity(faces.len()); + for f in &faces { + let samples = face_samples(f); + let cap = samples.iter().all(|(_, fn_)| fn_.dot(n).abs() > 1.0 - 1e-6); + if cap { + let h = samples.first().map(|(p, _)| (*p - plane.origin).dot(n)).unwrap_or(lo); + // The start cap is the one at the low end along the plane's normal. + out.push(FaceOrigin::Cap { end: (h - lo).abs() > (h - hi).abs() }); + continue; + } + let pts: Vec = samples.iter().map(|(p, _)| plane.to_local(*p - n * (*p - plane.origin).dot(n))).collect(); + match nearest_segment(&segs, &pts) { + Some(k) => out.push(FaceOrigin::Profile { segment: k }), + None => out.push(FaceOrigin::Cap { end: false }), + } + } + Ok(out) +} + +/// Origins of a revolution's faces (`tool` from [`crate::revolve`] of `regions` about the axis +/// through `axis_origin` along `axis_dir`, in the plane): the angle caps, and each swept face +/// by the profile segment it turns. +pub fn revolve_origins(tool: &Body, plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2) -> Result> { + let size = tool.size().max(1e-9); + let m = tool.tessellate(size * 2e-4)?; + let faces = face_tris(&m); + let segs = segments(regions); + let o = plane.to_world(axis_origin); + let a = plane.dir_to_world(axis_dir).normalized().ok_or_else(|| crate::KernelError::Invalid("axis".into()))?; + // The profile's side of the axis, in the plane. + let side = { + let c = regions.first().map(|r| plane.to_world(r.outer.centroid())).unwrap_or(o); + let w = c - o; + (w - a * w.dot(a)).normalized().unwrap_or_else(|| plane.normal().cross(a)) + }; + let mut out = Vec::with_capacity(faces.len()); + for f in &faces { + let samples = face_samples(f); + // An angle cap is planar and contains the axis. + let planar = samples.windows(2).all(|w| w[0].1.dot(w[1].1) > 1.0 - 1e-9); + let n0 = samples.first().map(|x| x.1).unwrap_or(Vec3::Z); + if planar && n0.dot(a).abs() < 1e-6 && samples.iter().all(|(p, _)| (*p - o).dot(n0).abs() < size * 1e-6) { + // The start cap lies in the profile's plane. + let start = samples.iter().all(|(p, _)| plane.height(*p).abs() < size * 1e-6); + out.push(FaceOrigin::Cap { end: !start }); + continue; + } + // Turn each sample back into the profile's half-plane. + let pts: Vec = samples + .iter() + .map(|(p, _)| { + let w = *p - o; + let h = w.dot(a); + let r = (w - a * h).len(); + plane.to_local(o + a * h + side * r) + }) + .collect(); + match nearest_segment(&segs, &pts) { + Some(k) => out.push(FaceOrigin::Profile { segment: k }), + None => out.push(FaceOrigin::Cap { end: false }), + } + } + Ok(out) +} + +/// Fill in generated faces of a fillet or chamfer (`result` from `input` with `edges` blended, +/// edge indices in `input`'s mesh order): each blend face comes from the edge it lies along. +pub fn blend_origins(result: &Body, input: &Body, edges: &[usize], prov: &mut Provenance) -> Result<()> { + let size = input.size().max(1e-9); + let rm = result.tessellate(size * 2e-4)?; + let im = input.tessellate(size * 2e-4)?; + let rfaces = face_tris(&rm); + for (fi, fp) in prov.faces.iter_mut().enumerate() { + if fp.change != Change::Generated { + continue; + } + let Some(rf) = rfaces.get(fi) else { continue }; + let samples = face_samples(rf); + let mean = |e: usize| { + let Some(pts) = im.edges.get(e) else { return f64::INFINITY }; + samples.iter().map(|(p, _)| pts.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)).sum::() + / samples.len().max(1) as f64 + }; + if let Some(e) = edges.iter().copied().min_by(|a, b| mean(*a).total_cmp(&mean(*b))) { + fp.origins = vec![FaceOrigin::Edge { operand: 0, edge: e }]; + } + } + Ok(()) +} + +/// Fill in generated faces of a shell or offset (`result` from `input`, walls `thickness` +/// inside): an inner face whose points, moved back out by the thickness, land on an input face +/// comes from that face ([`FaceOrigin::Offset`]). +pub fn offset_origins(result: &Body, input: &Body, thickness: f64, prov: &mut Provenance) -> Result<()> { + let size = input.size().max(1e-9); + let tol = size * 4e-4; + let rm = result.tessellate(size * 2e-4)?; + let im = input.tessellate(size * 2e-4)?; + let rfaces = face_tris(&rm); + let ifaces = face_tris(&im); + for (fi, fp) in prov.faces.iter_mut().enumerate() { + if fp.change != Change::Generated { + continue; + } + let Some(rf) = rfaces.get(fi) else { continue }; + let samples = face_samples(rf); + // Inner walls face into the cavity: their normal points back toward the input face. + let found = ifaces.iter().position(|f| samples.iter().all(|(p, n)| on_face(f, *p - *n * thickness, *n, tol))); + if let Some(face) = found { + fp.origins = vec![FaceOrigin::Offset { operand: 0, face }]; + } + } + Ok(()) +} + +/// Replace origins on operand `operand` (a tool made by the operation) with that tool's own +/// origins (`tool[k]` for its face k): a result face from the tool's side face k comes from the +/// profile segment that swept it. +pub fn compose(prov: &mut Provenance, operand: usize, tool: &[FaceOrigin]) { + for fp in &mut prov.faces { + for o in &mut fp.origins { + if let FaceOrigin::Face { operand: op, face } = *o + && op == operand + && let Some(t) = tool.get(face) + { + *o = *t; + } + } + } +} + +#[cfg(test)] +mod tests { + use solvecraft_geom::Vec3; + + use super::{Change, FaceOrigin, trace}; + use crate::{BoolOp, boolean, box_solid, cylinder}; + + fn face_at(b: &crate::Body, p: Vec3) -> usize { + let m = b.tessellate(1e-3).unwrap(); + let mut best = (f64::INFINITY, 0); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let q = m.tri(t).unwrap(); + let d = super::closest_on_tri(p, q).dist(p); + if d < best.0 { + best = (d, *f as usize); + } + } + best.1 + } + + #[test] + fn provenance_through_booleans() { + let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(); + // A through hole: the plate's top and bottom are modified, the sides kept, the hole's + // wall comes from the tool's side. + let pin = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 4.0, 12.0).unwrap(); + let cut = boolean(&a, &pin, BoolOp::Cut).unwrap().unwrap(); + let p = trace(&cut, &[&a, &pin]).unwrap(); + let top = face_at(&cut, Vec3::new(5.0, 5.0, 10.0)); + let fp = &p.faces[top]; + assert_eq!(fp.origins, vec![FaceOrigin::Face { operand: 0, face: face_at(&a, Vec3::new(5.0, 5.0, 10.0)) }]); + assert_eq!(fp.change, Change::Modified); + let side = face_at(&cut, Vec3::new(0.0, 15.0, 5.0)); + assert_eq!(p.faces[side].change, Change::Kept); + let wall = face_at(&cut, Vec3::new(24.0, 15.0, 5.0)); + assert!(matches!(p.faces[wall].origins[..], [FaceOrigin::Face { operand: 1, .. }]), "{:?}", p.faces[wall]); + // A slot cut across the plate splits its top in two pieces of one origin. + let slot = box_solid(Vec3::new(18.0, -1.0, 5.0), Vec3::new(22.0, 31.0, 11.0)).unwrap(); + let split = boolean(&a, &slot, BoolOp::Cut).unwrap().unwrap(); + let p = trace(&split, &[&a, &slot]).unwrap(); + let (l, r) = (face_at(&split, Vec3::new(5.0, 5.0, 10.0)), face_at(&split, Vec3::new(35.0, 5.0, 10.0))); + assert_ne!(l, r); + assert_eq!(p.faces[l].origins, p.faces[r].origins); + assert_eq!(p.faces[l].change, Change::Split); + // Two boxes side by side, flush on top: the union's top merges both tops. + let b = box_solid(Vec3::new(40.0, 0.0, 0.0), Vec3::new(60.0, 30.0, 10.0)).unwrap(); + let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap(); + let p = trace(&u, &[&a, &b]).unwrap(); + let top = face_at(&u, Vec3::new(50.0, 15.0, 10.0)); + if u.face_count() == 6 { + assert_eq!(p.faces[top].change, Change::Merged, "{:?}", p.faces[top]); + assert_eq!(p.faces[top].origins.len(), 2); + } + // Edges: the plate's untouched vertical edges come from the plate's edges. + let m = cut.tessellate(1e-3).unwrap(); + let p = trace(&cut, &[&a, &pin]).unwrap(); + let corner = (0..m.edges.len()).find(|i| m.edges[*i].iter().all(|q| q.x.abs() < 1e-9 && q.y.abs() < 1e-9)).unwrap(); + assert!(matches!(p.edges[corner].origin, Some((0, _)))); + assert_eq!(p.edges[corner].faces.len(), 2); + } + + #[test] + fn provenance_of_sweeps_blends_and_shells() { + use solvecraft_geom::{Loop2, Plane, Region2, Vec2}; + + use super::{Provenance, blend_origins, compose, extrude_origins, offset_origins, revolve_origins}; + let plane = Plane::named("XY").unwrap(); + // A 40 × 30 rectangle (segments 0: bottom, 1: right, 2: top, 3: left) with a hole. + let rect = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]); + let region = Region2 { outer: rect, holes: vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.0).reversed()] }; + let tool = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 10.0).unwrap().remove(0); + let o = extrude_origins(&tool, &plane, std::slice::from_ref(®ion)).unwrap(); + assert_eq!(o[face_at(&tool, Vec3::new(40.0, 15.0, 5.0))], FaceOrigin::Profile { segment: 1 }); + assert_eq!(o[face_at(&tool, Vec3::new(20.0, 0.0, 5.0))], FaceOrigin::Profile { segment: 0 }); + assert!(matches!(o[face_at(&tool, Vec3::new(25.0, 15.0, 5.0))], FaceOrigin::Profile { segment: 4 | 5 })); + assert_eq!(o[face_at(&tool, Vec3::new(5.0, 5.0, 0.0))], FaceOrigin::Cap { end: false }); + assert_eq!(o[face_at(&tool, Vec3::new(5.0, 5.0, 10.0))], FaceOrigin::Cap { end: true }); + // Joined to a base plate: the tool's faces keep their profile origins through the union. + let base = box_solid(Vec3::new(-10.0, -10.0, -5.0), Vec3::new(60.0, 50.0, 0.0)).unwrap(); + let joined = boolean(&base, &tool, BoolOp::Union).unwrap().unwrap(); + let mut p = trace(&joined, &[&base, &tool]).unwrap(); + compose(&mut p, 1, &o); + let right = face_at(&joined, Vec3::new(40.0, 15.0, 5.0)); + assert_eq!(p.faces[right].origins, vec![FaceOrigin::Profile { segment: 1 }]); + // A revolve about the Y axis: the rectangle's right side sweeps the outer wall. + let rv = Loop2::polygon(&[Vec2::new(10.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 30.0), Vec2::new(10.0, 30.0)]); + let rr = Region2 { outer: rv, holes: vec![] }; + let rt = crate::revolve(&plane, std::slice::from_ref(&rr), Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::FRAC_PI_2).unwrap().remove(0); + let ro = revolve_origins(&rt, &plane, std::slice::from_ref(&rr), Vec2::ZERO, Vec2::new(0.0, 1.0)).unwrap(); + let p45 = |r: f64, y: f64| Vec3::new(r * std::f64::consts::FRAC_1_SQRT_2, y, r * std::f64::consts::FRAC_1_SQRT_2); + let outer = face_at(&rt, p45(20.0, 15.0)); + assert_eq!(ro[outer], FaceOrigin::Profile { segment: 1 }, "{ro:?}"); + assert_eq!(ro[face_at(&rt, p45(15.0, 30.0))], FaceOrigin::Profile { segment: 2 }); + assert_eq!(ro[face_at(&rt, Vec3::new(15.0, 15.0, 0.0))], FaceOrigin::Cap { end: false }); + // A fillet: the blend face comes from the edge it replaced. + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + let bm = b.tessellate(1e-3).unwrap(); + let e = (0..bm.edges.len()).find(|i| bm.edges[*i].iter().all(|q| (q.y - 30.0).abs() < 1e-9 && (q.z - 20.0).abs() < 1e-9)).unwrap(); + let f = crate::fillet(&b, &[Vec3::new(20.0, 30.0, 20.0)], 5.0).unwrap(); + let mut p: Provenance = trace(&f, &[&b]).unwrap(); + blend_origins(&f, &b, &[e], &mut p).unwrap(); + let blend = face_at(&f, Vec3::new(20.0, 25.0 + 5.0 * std::f64::consts::FRAC_1_SQRT_2, 15.0 + 5.0 * std::f64::consts::FRAC_1_SQRT_2)); + assert_eq!(p.faces[blend].origins, vec![FaceOrigin::Edge { operand: 0, edge: e }]); + assert_eq!(p.faces[blend].change, Change::Generated); + // A shell: the inner walls are offsets of the outer ones. + let sh = crate::shell(&b, &[Vec3::new(20.0, 15.0, 20.0)], 2.0).unwrap(); + let mut p = trace(&sh, &[&b]).unwrap(); + offset_origins(&sh, &b, 2.0, &mut p).unwrap(); + let inner = face_at(&sh, Vec3::new(2.0, 15.0, 10.0)); + let outer = face_at(&b, Vec3::new(0.0, 15.0, 10.0)); + assert_eq!(p.faces[inner].origins, vec![FaceOrigin::Offset { operand: 0, face: outer }]); + } +}