refactor(kernel): number profile loops once and record face history as faces are built

One numbered loop type (crate::numbered) carries each segment's profile
numbers through orientation, snapping, circle re-seaming, re-sampling and
rotation; every builder orients its regions through it once. Builders that
make their own side faces pair each face with its provenance as it is made
(ruled walls by the wire edges each face holds, smooth lofts, coils and path
sweeps by segment), and cap_and_close appends the caps itself. Column
following is kept only for faces truck sweeps internally (rsweep, tsweep
along paths, cones, where an apex vertex stays put). Extrude derives face
sources and history in one topology pass. One attach entry point takes
operands with their edges already indexed.

Geometry, face order, face sources and recorded origins are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
This commit is contained in:
Iraklis KourtisandClaude Opus 5.5 authored and bherbruck committed 2026-10-12 11:52:53 -04:00
1 parent 8c81bc60d0
commit d69ebbee1b
13 files changed
+705 -527

No files matched your search

+7
View File
@@ -458,6 +458,13 @@ impl Body {
Ok(self)
}
/// [`Body::with_history`], given the operands' face and edge counts.
pub(crate) fn with_history_counts(mut self, history: crate::history::History, operands: &[(usize, usize)]) -> Result<Self> {
history.check_counts((self.face_count(), self.edge_count()), operands)?;
self.history = Some(std::sync::Arc::new(history));
Ok(self)
}
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
self.face_sources.as_deref().map(Vec::as_slice)
}
+242 -285
View File
@@ -5,6 +5,9 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::history::FaceProv;
use crate::numbered::{self, NumberedLoop, NumberedRegion};
use crate::record::{self, Side};
use crate::{KernelError, Result, guard};
const MAX_SEGS: usize = 20_000;
@@ -60,34 +63,61 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
Ok((edges.into(), sources))
}
/// A region's wires, its loops oriented as the builders build them (outer counter-clockwise,
/// holes clockwise); each edge with its segment; and each edge with the profile segments under
/// it (`origins[k][i]`: loop `k`, segment `i` as oriented; see [`crate::record::loop_origins`]).
#[allow(clippy::type_complexity)]
fn region_wires(
plane: &Plane,
r: &Region2,
origins: &[Vec<Vec<usize>>],
) -> Result<(Vec<mt::Wire>, Vec<(mt::Edge, Seg2)>, Vec<(mt::EdgeID, Vec<usize>)>)> {
let mut wires = Vec::with_capacity(1 + r.holes.len());
let (mut sources, mut seeds) = (Vec::new(), Vec::new());
for (k, lp) in std::iter::once(r.outer.ccw()).chain(r.holes.iter().map(|h| h.ccw().reversed())).enumerate() {
let (w, s) = wire_sources(plane, &lp)?;
for (edge, segment, i) in s {
if let Some(o) = origins.get(k).and_then(|l| l.get(i)) {
seeds.push((edge.id(), o.clone()));
}
sources.push((edge, segment));
}
wires.push(w);
/// An edge of a profile wire: the segment it was built from and the profile segments under it.
pub(crate) struct ProfileEdge {
pub edge: mt::Edge,
pub segment: Seg2,
pub numbers: Vec<usize>,
}
/// A wire built from a profile loop, with its edges in wire order.
pub(crate) struct ProfileWire {
pub wire: mt::Wire,
pub edges: Vec<ProfileEdge>,
}
impl ProfileWire {
/// The profile segments under each edge, in wire order.
fn numbers(&self) -> Vec<Vec<usize>> {
self.edges.iter().map(|e| e.numbers.clone()).collect()
}
Ok((wires, sources, seeds))
}
/// The wires of a region oriented as the builders build it ([`NumberedRegion::oriented`]), each
/// with what its edges were built from.
fn region_wires(plane: &Plane, r: &NumberedRegion) -> Result<Vec<ProfileWire>> {
r.loops()
.map(|lp| {
let (wire, s) = wire_sources(plane, &lp.lp)?;
let edges = s.into_iter().map(|(edge, segment, i)| ProfileEdge { edge, segment, numbers: lp.numbers(i).to_vec() }).collect();
Ok(ProfileWire { wire, edges })
})
.collect()
}
/// The wires alone.
fn wires_of(profile: &[ProfileWire]) -> Vec<mt::Wire> {
profile.iter().map(|p| p.wire.clone()).collect()
}
/// The profile edges as sweep seeds: each edge with the profile segments under it.
fn seeds(profile: &[ProfileWire]) -> Vec<(mt::EdgeID, Vec<usize>)> {
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e.numbers.clone())).collect()
}
/// A region numbered on its own, as given.
fn numbered_region(r: &Region2) -> Result<NumberedRegion> {
numbered::number_regions(std::slice::from_ref(r)).pop().ok_or_else(|| KernelError::Invalid("profile".into()))
}
/// A region numbered on its own and oriented as the builders build it.
fn oriented(r: &Region2) -> Result<NumberedRegion> {
numbered_region(r).map(|n| n.oriented())
}
/// A planar face from a region (outer loop with holes).
pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
let (wires, ..) = region_wires(plane, r, &[])?;
attach_profile(&wires)
attach_profile(&wires_of(&region_wires(plane, &oriented(r)?)?))
}
fn attach_profile(wires: &[mt::Wire]) -> Result<mt::Face> {
@@ -139,78 +169,81 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
for r in regions {
region_ok(r)?;
let base = plane.offset(lo);
let (wires, sources, seeds) = region_wires(&base, r, &crate::record::loop_origins(std::slice::from_ref(r)))?;
let profile = region_wires(&base, &oriented(r)?)?;
let wires = wires_of(&profile);
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
let history = guard("extrude history", || Ok(crate::record::swept_face(&solid, &f, &seeds, false)))?;
let upper = guard("extrude cap identities", || {
let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect();
let body = Body::new(solid)?;
// tsweep retains each actual input boundary edge on its generated side: one pass over
// the faces matches topology identity (never face order, surface samples or
// centroids) for both the face sources and the history. The profile face is the start
// cap, the one face holding no profile edge the end cap.
let (sources, history) = guard("extrude source identities", || {
let by_edge: std::collections::HashMap<mt::EdgeID, &ProfileEdge> =
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e)).collect();
let mut faces: Vec<(mt::FaceID, Vec<&ProfileEdge>)> = Vec::new();
let mut visits = 0usize;
let mut candidate = None;
for (index, generated) in solid.face_iter().enumerate() {
if index >= 50_000 {
return Ok(None);
}
let mut side = false;
for generated in body.solid.face_iter() {
let mut matching = Vec::new();
for edge in generated.absolute_boundaries().iter().flatten() {
visits += 1;
if visits > 200_000 {
return Ok(None);
if let Some(e) = by_edge.get(&edge.id()) {
matching.push(*e);
}
side |= inputs.contains(&edge.id());
}
if generated.id() != f.id() && !side {
if candidate.is_some() {
return Ok(None);
}
candidate = Some(generated.id());
}
faces.push((generated.id(), matching));
}
Ok(candidate)
})?;
let body = Body::new(solid)?;
// tsweep retains each actual input boundary edge on its generated side.
// Match topology identity, never face order, surface samples or centroids.
let provenance = guard("extrude source identities", || {
let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect();
let mut result = Vec::new();
for generated in body.solid.face_iter() {
let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect();
let source =
if generated.id() == f.id() {
upper.and_then(|_| {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096).then(|| {
crate::FaceSource::ExtrudeCap {
high: false,
region: r.clone(),
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
}
})
let mut ends = faces.iter().filter(|(id, matching)| *id != f.id() && matching.is_empty()).map(|(id, _)| *id);
let end = match (ends.next(), ends.next()) {
(Some(id), None) => Some(id),
_ => None,
};
let upper = end.filter(|_| faces.len() <= 50_000 && visits <= 200_000);
let small = r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096;
let mut sources = Vec::with_capacity(faces.len());
let mut history = Some(Vec::with_capacity(faces.len()));
for (id, matching) in &faces {
let (source, prov) = if *id == f.id() {
let source = upper.and_then(|_| {
small.then(|| crate::FaceSource::ExtrudeCap {
high: false,
region: r.clone(),
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
})
} else if Some(generated.id()) == upper {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096)
.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) })
} else if let [segment] = matching.as_slice() {
let basis = match **segment {
Seg2::Line { a, b } => {
let d = b - a;
d.normalized().and_then(|direction| {
let foot = a - direction * a.dot(direction);
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
})
}
_ => None,
};
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
} else {
None
});
(source, Some(record::cap(false)))
} else if Some(*id) == upper {
(small.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }), Some(record::cap(true)))
} else if let [side] = matching.as_slice() {
let basis = match side.segment {
Seg2::Line { a, b } => {
let d = b - a;
d.normalized().and_then(|direction| {
let foot = a - direction * a.dot(direction);
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
})
}
_ => None,
};
result.push(source);
(
Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }),
Some(record::profile(&side.numbers)),
)
} else if Some(*id) == end {
(None, Some(record::cap(true)))
} else {
(None, None)
};
sources.push(source);
history = history.zip(prov).map(|(mut h, p)| {
h.push(p);
h
});
}
Ok(result)
Ok((sources, history))
})?;
let body = body.with_face_sources(Some(std::sync::Arc::new(provenance)));
out.push(crate::record::attach_if(body, &[], history)?);
let body = body.with_face_sources(Some(std::sync::Arc::new(sources)));
out.push(record::attach(body, &[], history, Default::default())?);
}
Ok(out)
}
@@ -265,20 +298,17 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
let q = axis_origin + d * along;
if p.dist(q) < 1e-7 { q } else { p }
};
let snap_loop = |l: &Loop2| Loop2 {
segs: l
.segs
.iter()
.map(|s| match *s {
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
arc => arc,
})
.collect(),
let snap_loop = |l: &NumberedLoop| {
l.map_segs(|s| match *s {
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
arc => arc,
})
};
// Full circles start at an odd angle: their seam points then don't lie in the planes
// through the profile's centre that other bodies tend to cut along. (Whether the loop
// was re-split comes with it.)
let reseam = |l: Loop2| -> (Loop2, bool) {
// through the profile's centre that other bodies tend to cut along. Each new arc comes
// from every given arc it overlaps.
let reseam = |nl: NumberedLoop| -> NumberedLoop {
let l = &nl.lp;
let c = match l.segs.first() {
Some(Seg2::Arc { center, radius, .. }) => Some((*center, *radius)),
_ => None,
@@ -306,45 +336,27 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
{
let ccw = l.signed_area() > 0.0;
let n = Loop2::circle_from(center, radius, 0.4142);
(if ccw { n } else { n.reversed() }, true)
nl.resplit_circle(if ccw { n } else { n.reversed() })
}
_ => (l, false),
_ => nl,
}
};
// Each loop's profile segments as oriented; a circle the revolve re-split comes from
// every input arc it overlaps.
let mut origins = crate::record::loop_origins(std::slice::from_ref(r));
let (outer, resplit) = reseam(snap_loop(&r.outer));
let mut resplit = vec![resplit];
let mut holes = Vec::with_capacity(r.holes.len());
for h in &r.holes {
let (l, again) = reseam(snap_loop(h));
holes.push(l);
resplit.push(again);
}
let given: Vec<&Loop2> = std::iter::once(&r.outer).chain(&r.holes).collect();
let r = &Region2 { outer, holes };
for (k, b) in std::iter::once(&r.outer).chain(&r.holes).enumerate() {
if resplit.get(k) == Some(&true)
&& let Some(g) = given.get(k)
{
let base: usize = origins.iter().take(k).map(Vec::len).sum();
let oriented = if k == 0 { b.ccw() } else { b.ccw().reversed() };
if let Some(slot) = origins.get_mut(k) {
*slot = crate::record::arc_overlaps(&oriented.segs, &g.segs)
.into_iter()
.map(|js| js.into_iter().map(|j| base + j).collect())
.collect();
}
}
}
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l)));
let r = &given.region();
// A half disc on the axis revolved fully is a sphere: build it without poles. Every
// patch is swept from the half disc's arcs.
if angle.abs() >= std::f64::consts::TAU - 1e-9
&& r.holes.is_empty()
&& let Some((c, rad)) = half_disc(&r.outer, axis_origin, d)
{
let arcs: Vec<usize> = r.outer.segs.iter().enumerate().filter(|(_, s)| matches!(s, Seg2::Arc { .. })).map(|(i, _)| i).collect();
let arcs: Vec<usize> = r
.outer
.segs
.iter()
.enumerate()
.filter(|(_, s)| matches!(s, Seg2::Arc { .. }))
.flat_map(|(i, _)| given.outer.numbers(i).to_vec())
.collect();
out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?);
continue;
}
@@ -352,13 +364,13 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
// as a cone-like shell (no zero-area faces, which booleans can't handle).
if angle.abs() >= std::f64::consts::TAU - 1e-9
&& r.holes.is_empty()
&& let Some(b) = revolve_touching_axis(plane, &r.outer, axis_origin, d)?
&& let Some(b) = revolve_touching_axis(plane, &given.outer, axis_origin, d)?
{
out.push(b);
continue;
}
let (wires, _, seeds) = region_wires(plane, r, &origins)?;
let f = attach_profile(&wires)?;
let profile = region_wires(plane, &given.oriented())?;
let f = attach_profile(&wires_of(&profile))?;
// Partial revolves sweep toward the side the sketch normal points to (as features
// extrude toward it).
let c = plane.to_world(r.centroid());
@@ -367,15 +379,15 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
let angle = if toward < 0.0 { -angle } else { angle };
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
if r.holes.is_empty()
&& let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)?
&& let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)?
{
out.push(b);
continue;
}
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
let closed = angle.abs() >= std::f64::consts::TAU;
let history = guard("revolve history", || Ok(crate::record::swept_face(&solid, &f, &seeds, closed)))?;
out.push(crate::record::attach_if(Body::new(solid)?, &[], history)?);
let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?;
out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?);
}
Ok(out)
}
@@ -522,19 +534,22 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
region_ok(region)?;
let d = length * taper.tan();
let top_plane = plane.offset(length * dir_sign.signum());
let loops0: Vec<Loop2> = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect();
let loops1: Vec<Loop2> = loops0.iter().map(|l| offset_loop(l, d)).collect::<Result<_>>()?;
let origins = crate::record::loop_origins(std::slice::from_ref(region));
let loops0 = oriented(region)?;
let loops1: Vec<Loop2> = loops0.loops().map(|l| offset_loop(&l.lp, d)).collect::<Result<_>>()?;
guard("tapered extrude", || {
let (w0, _, seeds) = region_wires(plane, region, &origins)?;
let p0 = region_wires(plane, &loops0)?;
let w0 = wires_of(&p0);
let w1: Vec<mt::Wire> = loops1.iter().map(|l| wire(&top_plane, l)).collect::<Result<_>>()?;
let mut faces: Vec<mt::Face> = Vec::new();
for (a, b) in w0.iter().zip(&w1) {
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
faces.extend(sides.face_iter().cloned());
let mut sides: Vec<Side> = Vec::new();
for (a, b) in p0.iter().zip(&w1) {
let shell = builder::try_wire_homotopy(&a.wire, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
// The offset loop keeps the loop's segments, edge for edge.
let numbers = a.numbers();
sides.extend(record::ruled(&shell, &a.wire, b, &numbers, &numbers));
}
let (faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
// The sides, then the bottom and top as pushed below.
let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::Steps(1));
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(false)), Some(record::cap(true))]).collect();
let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>())
.map_err(|e| KernelError::Failed(format!("taper bottom: {e}")))?;
let top = builder::try_attach_plane(&w1).map_err(|e| KernelError::Failed(format!("taper top: {e}")))?;
@@ -547,7 +562,7 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
all.push(t);
let shell: mt::Shell = all.into();
match Solid::try_new(vec![shell]) {
Ok(s) => return crate::record::attach_if(Body::new(s)?, &[], history),
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
Err(e) => last = e.to_string(),
}
}
@@ -576,8 +591,9 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
/// loop isn't like that.
fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
let lp = &numbered.lp;
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
let axis_segs: Vec<usize> =
@@ -632,8 +648,9 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
}
}
// Side faces: each edge's swept surface, oriented to agree with its boundary.
let mut faces: Vec<mt::Face> = Vec::new();
// Side faces: each edge's swept surface, oriented to agree with its boundary, from the
// profile segment the edge was built from.
let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new();
for (i, sg) in chain.iter().enumerate() {
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
@@ -673,7 +690,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
}
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
};
faces.push(f);
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
}
// Caps: the profile at the start and at the end, sharing the axis edge.
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
@@ -682,6 +699,8 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
history.extend([record::cap(false), record::cap(true)]);
let mut last_err = String::new();
for flip in 0..4 {
let mut all = faces.clone();
@@ -690,9 +709,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
match Solid::try_new(vec![all.into()]) {
Ok(solid) => {
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
// The faces as built: one per chain segment, then the start and end caps.
let history = crate::record::walls_then_caps((0..chain.len()).map(|i| vec![(k + 1 + i) % n]));
return crate::record::attach(Body::new(solid)?, &[], history).map(Some);
return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some);
}
Err(er) => last_err = er.to_string(),
}
@@ -701,15 +718,17 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
})
}
fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
fn revolve_touching_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
let lp = &numbered.lp;
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
let axis_segs: Vec<usize> =
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
let [k] = axis_segs[..] else { return Ok(None) };
// The open chain after the axis segment, back around to it, with each piece's segment.
// The open chain after the axis segment, back around to it, with the profile segments
// under each piece.
let mut chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
let mut chain_src: Vec<usize> = (1..n).map(|j| (k + j) % n).collect();
let mut chain_src: Vec<&[usize]> = (1..n).map(|j| numbered.numbers((k + j) % n)).collect();
// A single arc (a sphere) is split off-centre: a seam on the equator would lie in the very
// planes other bodies are often cut with.
if let [Seg2::Arc { center, radius, start, sweep }] = chain[..] {
@@ -718,7 +737,7 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
Seg2::Arc { center, radius, start, sweep: sweep * f },
Seg2::Arc { center, radius, start: start + sweep * f, sweep: sweep * (1.0 - f) },
];
chain_src = vec![(k + 1) % n; 2];
chain_src = vec![numbered.numbers((k + 1) % n); 2];
}
if chain.is_empty() || chain.iter().skip(1).take(chain.len().saturating_sub(2)).any(|s| on_axis(s.start()) && on_axis(s.end())) {
return Ok(None);
@@ -731,7 +750,7 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
// Radial lines at the ends become planar caps (no degenerate disc centres).
let radial = |sg: &Seg2| matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
let mut mid: &[Seg2] = &chain;
let mut mid_src: &[usize] = &chain_src;
let mut mid_src: &[&[usize]] = &chain_src;
// The segments of the radial lines that become caps.
let (mut start_cap, mut end_cap) = (None, None);
if mid.first().is_some_and(|f| radial(f) && on_axis(f.start())) {
@@ -767,6 +786,8 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
},
});
}
// Each edge with the profile segments under it, before the wire may be turned round.
let seeds: Vec<(mt::EdgeID, Vec<usize>)> = edges.iter().map(|e| e.id()).zip(mid_src.iter().map(|s| s.to_vec())).collect();
let mut w: mt::Wire = edges.into();
let axis = plane.dir_to_world(d);
let mut shell = if apex_start || apex_end {
@@ -779,25 +800,15 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
builder::rsweep(&w, p3(plane.to_world(axis_origin)), v3(axis), mt::Rad(std::f64::consts::TAU))
};
let sides = shell.len();
// History. truck's full turn makes its faces division by division (three), each
// division one face per wire edge in wire order; the wire runs backwards when it was
// turned for the cone. Each cap is the disc a trimmed radial line sweeps, found by the
// profile vertex on its rim.
let per = sides / 3;
let side_src = |i: usize| -> Option<usize> {
let j = i % per.max(1);
let j = if mid.len() == 1 {
0
} else if apex_end && !apex_start {
mid.len().checked_sub(j + 1)?
} else {
j
};
mid_src.get(j).copied()
};
let mut history: Option<Vec<crate::history::FaceProv>> = (sides % 3 == 0 && (per == mid.len() || (mid.len() == 1 && per == 2)))
.then(|| (0..sides).map(|i| side_src(i).map(|seg| crate::record::profile(&[seg]))).collect::<Option<Vec<_>>>())
.flatten();
// History. truck sweeps each wire edge round into a column of faces (triangles at an
// apex, which stays put); each cap is the disc a trimmed radial line sweeps, found by
// the profile vertex on its rim.
let fixed: Vec<mt::VertexID> =
[(apex_start, verts.first()), (apex_end, verts.last())].into_iter().filter_map(|(apex, v)| v.filter(|_| apex).map(|v| v.id())).collect();
let mut history: Option<Vec<FaceProv>> = (seeds.len() == mid.len())
.then(|| record::columns(shell.face_iter(), &seeds, None, record::Column::ToEnd, &fixed))
.flatten()
.and_then(|sides| sides.into_iter().collect());
let (first_vertex, last_vertex) = (verts.first().map(|v| v.id()), verts.last().map(|v| v.id()));
for b in shell.extract_boundaries() {
let at = |v: Option<mt::VertexID>| v.is_some_and(|v| b.vertex_iter().any(|x| x.id() == v));
@@ -808,8 +819,8 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
} else {
None
};
history = history.zip(src).map(|(mut h, seg)| {
h.push(crate::record::profile(&[seg]));
history = history.zip(src).map(|(mut h, numbers)| {
h.push(record::profile(numbers));
h
});
let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?;
@@ -825,12 +836,16 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
});
let solid = solid.map_err(|e| KernelError::Failed(format!("revolve: {e}")))?;
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
crate::record::attach_if(Body::new(solid)?, &[], history).map(Some)
record::attach(Body::new(solid)?, &[], history, Default::default()).map(Some)
})
}
/// Close a ruled shell (side faces between matching wires) with planar caps into a body.
pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt::Wire]) -> Result<Body> {
/// Close a ruled shell (side faces between matching wires, with their provenance as built)
/// with planar caps into a body, whose history is the sides' and the caps': the profile at
/// `w0` is the start cap and at `w1` the end cap (the other way round when `from_end`).
pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire], from_end: bool) -> Result<Body> {
let (mut faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(from_end)), Some(record::cap(!from_end))]).collect();
let bottom =
builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>()).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
let top = builder::try_attach_plane(w1).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
@@ -842,7 +857,7 @@ pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt:
all.push(t);
let shell: mt::Shell = all.into();
match Solid::try_new(vec![shell]) {
Ok(s) => return Body::new(s),
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
Err(e) => last = e.to_string(),
}
faces = keep;
@@ -850,48 +865,6 @@ pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt:
Err(KernelError::Failed(format!("closing the shell: {last}")))
}
/// Re-split a loop into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
/// other loops split their longest segments in half. With each new segment, the segments of
/// `lp` it runs over.
fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> (Loop2, Vec<Vec<usize>>) {
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
&& lp.segs.windows(2).all(|w| match (w[0], w[1]) {
(Seg2::Arc { center: c1, radius: r1, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => c1.dist(c2) < 1e-9 && (r1 - r2).abs() < 1e-9,
_ => false,
});
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
let mut a: Vec<f64> = if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
// Keep the given order but make them increase.
for i in 1..a.len() {
while a[i] <= a[i - 1] {
a[i] += std::f64::consts::TAU;
}
}
let segs: Vec<Seg2> = (0..n)
.map(|i| {
let s = a[i];
let e = if i + 1 < n { a[i + 1] } else { a[0] + std::f64::consts::TAU };
Seg2::Arc { center, radius, start: s, sweep: e - s }
})
.collect();
let src = crate::record::arc_overlaps(&segs, &lp.segs);
return (Loop2 { segs }, src);
}
let mut segs = lp.segs.clone();
let mut src: Vec<Vec<usize>> = (0..segs.len()).map(|i| vec![i]).collect();
while segs.len() < n {
let Some((k, _)) = segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())) else { break };
let s = segs.remove(k);
let (p, q) = s.split_half();
segs.insert(k, q);
segs.insert(k, p);
if let Some(from) = src.get(k).cloned() {
src.insert(k, from);
}
}
(Loop2 { segs }, src)
}
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
/// same sections in reverse order give the same body.
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
@@ -912,90 +885,66 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
let step = centre(last) - centre(first);
let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0);
let reversed = big < 0.0;
// (Each section with the profile number of its first segment: the sections' segments are
// numbered in the order given.)
let mut ordered: Vec<(usize, &(Plane, Loop2))> =
sections.iter().scan(0, |acc, s| Some((std::mem::replace(acc, *acc + s.1.segs.len()), s))).collect();
// The sections' segments are numbered in the order given.
let mut ordered: Vec<(Plane, NumberedLoop)> =
sections.iter().map(|(p, _)| *p).zip(numbered::number_loops(sections.iter().map(|(_, l)| l))).collect();
if reversed {
ordered.reverse();
}
// Orient every section counter-clockwise about the first section's normal (`Body::new`
// turns the solid outward), then match segment counts and starting points to the section
// with the most segments.
let n0 = ordered.first().map(|(_, (p, _))| p.normal()).unwrap_or(Vec3::Z);
// (With each section, the profile segment number of each of its segments as oriented.)
let oriented: Vec<(Plane, Loop2, Vec<usize>)> = ordered
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
let oriented: Vec<(Plane, NumberedLoop)> = ordered
.iter()
.map(|(base, (p, l))| {
.map(|(p, l)| {
let ccw = p.normal().dot(n0) >= 0.0;
let numbers = crate::record::ccw_numbers(l, !ccw, *base);
let l = if ccw { l.ccw() } else { l.ccw().reversed() };
(*p, l, numbers)
(*p, l.oriented(!ccw))
})
.collect();
let lead = oriented.iter().max_by_key(|(_, l, _)| l.segs.len()).cloned().ok_or_else(|| KernelError::Invalid("no sections".into()))?;
let lead_c = lead.0.to_world(lead.1.centroid());
let lead_dirs: Vec<Vec3> = lead.1.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
let lead_c = lead.0.to_world(lead.1.lp.centroid());
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
for (p, l, numbers) in &oriented {
let c = l.centroid();
for (p, l) in &oriented {
let c = l.lp.centroid();
// Break angles: the lead section's vertex directions seen in this plane.
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
let (mut r, mut src) = resample(l, n, &angles);
let mut r = l.resample(n, &angles);
// Start at the vertex closest in direction to the lead's first vertex.
if let Some(d0) = lead_dirs.first() {
let target = Vec2::new(d0.dot(p.x), d0.dot(p.y)).angle();
let best = (0..r.segs.len()).min_by(|a, b| {
let ang = |k: usize| {
let v = r.segs[k].start() - c;
let d = (v.angle() - target).rem_euclid(std::f64::consts::TAU);
d.min(std::f64::consts::TAU - d)
};
ang(*a).total_cmp(&ang(*b))
});
let ang = |s: &Seg2| {
let d = ((s.start() - c).angle() - target).rem_euclid(std::f64::consts::TAU);
d.min(std::f64::consts::TAU - d)
};
let best = r.lp.segs.iter().enumerate().min_by(|a, b| ang(a.1).total_cmp(&ang(b.1))).map(|(k, _)| k);
if let Some(k) = best {
r.segs.rotate_left(k);
src.rotate_left(k);
r.rotate_left(k);
}
}
edge_sources.push(src.iter().map(|js| js.iter().filter_map(|j| numbers.get(*j).copied()).collect()).collect());
wires.push(uniform_wire(p, &r)?);
wires.push(uniform_wire(p, &r.lp)?);
edge_sources.push(r.from);
}
// The caps: the wires run from the first section given, or (turned round) the last.
let caps = [crate::record::cap(reversed), crate::record::cap(!reversed)];
// The wires run from the first section given, or (turned round) the last.
// Three sections or more: one smooth surface through them all for each segment.
if wires.len() >= 3
&& let Ok(b) = guard("loft", || smooth_loft(&wires))
&& let Ok(b) = guard("loft", || smooth_loft(&wires, &edge_sources, reversed))
{
// Its faces as built: one per edge position k, through the k-th edge of every
// section, then the caps.
let faces = (0..n)
.map(|k| crate::record::profile_union(edge_sources.iter().filter_map(|s| s.get(k)).flatten().copied()))
.chain(caps.clone())
.collect();
return crate::record::attach(b, &[], faces);
return Ok(b);
}
guard("loft", || {
let mut faces: Vec<mt::Face> = Vec::new();
for w in wires.windows(2) {
let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
faces.extend(sides.face_iter().cloned());
// Each ruled face lies between two sections: it comes from the section edges it spans.
let mut sides: Vec<Side> = Vec::new();
for (w, src) in wires.windows(2).zip(edge_sources.windows(2)) {
let (Some(a), Some(b), Some(na), Some(nb)) = (w.first(), w.get(1), src.first(), src.get(1)) else { continue };
let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
sides.extend(record::ruled(&shell, a, b, na, nb));
}
// Each ruled face lies between two sections: it comes from the section edges it holds.
let under: std::collections::HashMap<mt::EdgeID, &Vec<usize>> =
wires.iter().zip(&edge_sources).flat_map(|(w, src)| w.edge_iter().map(|e| e.id()).zip(src)).collect();
let history: Option<Vec<crate::history::FaceProv>> = faces
.iter()
.map(|f| {
let held: Vec<&Vec<usize>> = f.absolute_boundaries().iter().flatten().filter_map(|e| under.get(&e.id()).copied()).collect();
(held.len() == 2).then(|| crate::record::profile_union(held.iter().flat_map(|s| s.iter().copied())))
})
.chain(caps.iter().cloned().map(Some))
.collect();
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) };
crate::record::attach_if(cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))?, &[], history)
cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed)
})
}
@@ -1013,8 +962,10 @@ fn across(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
/// A loft whose side faces are B-spline surfaces through every section (smooth across the
/// middle sections, which leave no edges). Every section wire comes from `uniform_wire`, so the
/// k-th edges of all sections share one knot vector and their control points pair up.
fn smooth_loft(wires: &[mt::Wire]) -> Result<Body> {
/// k-th edges of all sections share one knot vector and their control points pair up. Face k
/// comes from the profile segments under the k-th edge of every section (`numbers`: by
/// section, by edge); the caps as [`cap_and_close`] takes them (`from_end`).
fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], from_end: bool) -> Result<Body> {
let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}"));
let edges: Vec<Vec<mt::Edge>> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect();
let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?;
@@ -1061,10 +1012,12 @@ fn smooth_loft(wires: &[mt::Wire]) -> Result<Body> {
};
// u along the section edge, v across the sections: counter-clockwise in (u, v).
let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into();
faces.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?);
let face = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?;
let under: Vec<&usize> = numbers.iter().filter_map(|s| s.get(k)).flatten().collect();
faces.push((face, (under.len() >= numbers.len()).then(|| record::profile(under))));
}
let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) };
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1))
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), from_end)
}
/// A 3D path segment for sweeps.
@@ -1146,7 +1099,8 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
return crate::sweep_path::sweep_path(plane, region, &pts);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let (starts, _, seeds) = region_wires(plane, region, &crate::record::loop_origins(std::slice::from_ref(region)))?;
let profile = region_wires(plane, &oriented(region)?)?;
let starts = wires_of(&profile);
guard("sweep", || {
let mut shell = mt::Shell::new();
let mut ends = Vec::new();
@@ -1170,10 +1124,13 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
}
ends.push(cur);
}
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
// Each profile edge's faces run along the whole path, path segment after segment.
let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::ToEnd);
crate::record::attach_if(cap_and_close(faces, &starts, &ends)?, &[], history)
// truck sweeps the faces: each profile edge's run along the whole path, path segment
// after segment.
let sides: Vec<Side> = match record::columns(shell.face_iter(), &seeds(&profile), None, record::Column::ToEnd, &[]) {
Some(prov) => shell.face_iter().cloned().zip(prov).collect(),
None => shell.face_iter().map(|f| (f.clone(), None)).collect(),
};
cap_and_close(sides, &starts, &ends, false)
})
}
@@ -1342,6 +1299,6 @@ fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Re
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
crate::record::attach(Body::new(solid)?, &[], vec![crate::record::profile(arcs); 6])
record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default())
})
}
+11 -14
View File
@@ -1,7 +1,9 @@
//! Exact radial embossing of orthogonal profiles on complete cylindrical walls.
use crate::body::{Body, Solid, from_p3, p3};
use crate::history::Change;
use crate::numbered::NumberedRegion;
use crate::offset::{Surf, surf_of};
use crate::record::Operand;
use crate::{KernelError, Result, guard};
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling::{self as mt, Transformed, builder};
@@ -255,7 +257,6 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
let s0 = (plane.origin - o).dot(tangent);
let map2 = |p: Vec2| Vec2::new(s0 + plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis));
let mut count = 0usize;
let mut mapped = Vec::new();
// The regions mapped onto the wall as given (their loops' turning may differ from the
// sketch's): profile numbering follows them.
let mut unoriented = Vec::new();
@@ -294,12 +295,11 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
}
loops.push(Loop2 { segs });
}
let outer = loops.first().ok_or_else(|| fail("profile"))?.ccw();
let holes = loops.iter().skip(1).map(|l| l.ccw().reversed()).collect();
mapped.push(Region2 { outer, holes });
let mut given = loops.into_iter();
unoriented.push(Region2 { outer: given.next().ok_or_else(|| fail("profile"))?, holes: given.collect() });
}
let numbered: Vec<NumberedRegion> = crate::numbered::number_regions(&unoriented).iter().map(NumberedRegion::oriented).collect();
let mapped: Vec<Region2> = numbered.iter().map(NumberedRegion::region).collect();
validate_profiles(&mapped, tol)?;
if qualify {
let segment = mapped.first().and_then(|region| region.outer.segs.first()).ok_or_else(|| fail("empty profile"))?;
@@ -343,16 +343,13 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
// History of the faces added (walls from their profile segments, the raised or sunk
// region the profile's end cap), in the order added.
let mut added_history = Vec::new();
let mut origins = crate::record::loop_origins(&unoriented).into_iter();
for reg in &mapped {
for reg in &numbered {
let mut cap = Vec::new();
for (li, lp) in std::iter::once(&reg.outer).chain(&reg.holes).enumerate() {
for (li, numbered_loop) in reg.loops().enumerate() {
let lp = &numbered_loop.lp;
let (base, top, walls) = wrapped_loop(lp, r, rr, &at)?;
let loop_origins = origins.next().ok_or_else(|| fail("profile numbering"))?;
if loop_origins.len() != walls.len() {
return Err(fail("profile numbering"));
}
added_history.extend(loop_origins.iter().map(|o| crate::record::profile(o)));
// One wall per segment, in order.
added_history.extend((0..walls.len()).map(|j| crate::record::profile(numbered_loop.numbers(j))));
if li == 0 {
footprints.push((lp, base.inverse()));
} else {
@@ -383,7 +380,7 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
// gathered: the other faces kept, the wall's pieces left around the footprints (split
// when the profile's holes leave more than one), then the faces added.
let pieces = if base_patches.len() > 1 { Change::Split } else { Change::Modified };
let mut history: Vec<_> = (0..faces.len()).filter(|i| *i != fi).map(|i| crate::record::from_face(0, i, Change::Kept)).collect();
let mut history: Vec<_> = crate::record::kept_except(faces.len(), fi).collect();
history.extend(std::iter::repeat_n(crate::record::from_face(0, fi, pieces), base_patches.len()));
history.extend(added_history);
for patch in base_patches {
@@ -395,7 +392,7 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
if !result.validity().is_empty() {
return Err(fail("rebuilt cylinder is not a closed manifold"));
}
Ok(EmbossOutcome::Body(crate::record::attach(result, &[body], history)?))
Ok(EmbossOutcome::Body(crate::record::attach(result, &[Operand::new(body)], Some(history), Default::default())?))
})
}
+29 -29
View File
@@ -6,6 +6,8 @@ use truck_modeling as mt;
use crate::body::{Body, Solid, p3};
use crate::build::{cap_and_close, uniform_wire};
use crate::numbered::NumberedLoop;
use crate::record::{self, Side};
use crate::{KernelError, Result, guard};
/// Rotate `v` about the unit axis `k` by `ang` (Rodrigues).
@@ -26,20 +28,19 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
if region.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}
// The profile must clear itself after one turn.
let lp = region.outer.ccw();
// The profile must clear itself after one turn. (Only the outer loop is swept.)
let numbered = crate::numbered::number_regions(std::slice::from_ref(region)).pop().ok_or_else(|| KernelError::Invalid("profile".into()))?;
let profile = numbered.outer.oriented(false);
let lp = &profile.lp;
let along: Vec<f64> =
lp.segs.iter().flat_map(|s| (0..8).map(move |i| s.point_at(i as f64 / 8.0))).map(|q| (plane.to_world(q) - axis_origin).dot(k)).collect();
let extent = along.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - along.iter().cloned().fold(f64::INFINITY, f64::min);
if turns > 1.0 && pitch.abs() <= extent {
return Err(KernelError::Invalid(format!("the coil runs into itself: the pitch must exceed the section ({extent:.3} mm)")));
}
// Profile numbering (only the outer loop is swept).
let origins = crate::record::loop_origins(std::slice::from_ref(region)).into_iter().next().unwrap_or_default();
// One smooth B-spline wall per profile segment; the ruled walls below are the fallback.
if let Ok(b) = smooth_helix(plane, &lp, axis_origin, k, pitch, turns) {
// Its faces as built: one wall per segment, then the caps.
return crate::record::attach(b, &[], crate::record::walls_then_caps(origins));
if let Ok(b) = smooth_helix(plane, &profile, axis_origin, k, pitch, turns) {
return Ok(b);
}
let n = ((turns * 120.0).ceil() as usize).clamp(4, 24_000);
let total = std::f64::consts::TAU * turns;
@@ -49,18 +50,18 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
let lift = k * (pitch * a / std::f64::consts::TAU);
let o = axis_origin + rot(plane.origin - axis_origin, k, a) + lift;
let pl = Plane { origin: o, x: rot(plane.x, k, a), y: rot(plane.y, k, a) };
wires.push(uniform_wire(&pl, &lp)?);
wires.push(uniform_wire(&pl, lp)?);
}
guard("helix sweep", || {
let mut faces: Vec<mt::Face> = Vec::new();
// Every station's wire has the profile's segments, edge for edge.
let mut faces: Vec<Side> = Vec::new();
for w in wires.windows(2) {
let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("helix: {e}")))?;
faces.extend(sides.face_iter().cloned());
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("helix: {e}")))?;
faces.extend(record::ruled(&sides, a, b, &profile.from, &profile.from));
}
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("helix".into())) };
let seeds: Vec<(mt::EdgeID, Vec<usize>)> = first.edge_iter().map(|e| e.id()).zip(origins).collect();
let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::ToEnd);
crate::record::attach_if(cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))?, &[], history)
cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last), false)
})
}
@@ -109,7 +110,8 @@ const STATIONS_PER_TURN: f64 = 12.0;
/// Helical walls as B-spline surfaces: each profile segment, sampled at every station of the
/// screw motion, interpolated by cubics across the profile and then along the helix.
fn smooth_helix(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, pitch: f64, turns: f64) -> Result<Body> {
fn smooth_helix(plane: &Plane, profile: &NumberedLoop, o: Vec3, k: Vec3, pitch: f64, turns: f64) -> Result<Body> {
let lp = &profile.lp;
let nseg = lp.segs.len();
if nseg == 0 || nseg > 64 {
return Err(KernelError::Invalid("helix profile".into()));
@@ -171,7 +173,8 @@ fn smooth_helix(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, pitch: f64, turns:
};
// Counter-clockwise in the surface's (u along the helix, v along the profile) space.
let w: mt::Wire = vec![sa.clone(), b.clone(), sb.inverse(), a.inverse()].into();
walls.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?);
let wall = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?;
walls.push((wall, Some(record::profile(profile.numbers(j)))));
}
let w0: mt::Wire = e0.iter().cloned().collect();
let w1: mt::Wire = e1.iter().cloned().collect();
@@ -179,8 +182,8 @@ fn smooth_helix(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, pitch: f64, turns:
let expected = lp_area_path(plane, lp, o, k, turns);
// A coarse mesh (within ~10%) is enough to tell a proper solid from an empty or inside-out one.
let section_tol = (lp.signed_area().abs().sqrt() * 0.05).max(1e-3);
for ws in [walls.clone(), walls.iter().map(|f| f.inverse()).collect()] {
if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1))
for ws in [walls.clone(), walls.iter().map(|(f, prov)| (f.inverse(), prov.clone())).collect()] {
if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1), false)
&& b.tessellate(section_tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs())
{
return Ok(b);
@@ -211,30 +214,27 @@ pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
}
// Wind the base so its normal points away from the apex.
let above = (apex - plane.origin).dot(plane.normal()) > 0.0;
let numbers = crate::record::ccw_numbers(lp, above, 0);
let mut lp = lp.ccw();
if above {
lp = lp.reversed();
}
let base = crate::numbered::number_loops([lp]).pop().ok_or_else(|| KernelError::Invalid("loft to a point: bad section".into()))?.oriented(above);
let lp = &base.lp;
guard("loft to point", || {
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
let top = builder::vertex(p3(apex));
let m = verts.len();
let base: Vec<mt::Edge> = (0..m).filter_map(|i| Some(builder::line(verts.get(i)?, verts.get((i + 1) % m)?))).collect();
let edges: Vec<mt::Edge> = (0..m).filter_map(|i| Some(builder::line(verts.get(i)?, verts.get((i + 1) % m)?))).collect();
let sides: Vec<mt::Edge> = verts.iter().map(|v| builder::line(v, &top)).collect();
let mut faces = Vec::with_capacity(m + 1);
// History as the faces are made: the base is the section (the start cap), each
// triangle comes from its base segment.
let mut history = Vec::with_capacity(m + 1);
let bw: mt::Wire = base.iter().cloned().collect();
let bw: mt::Wire = edges.iter().cloned().collect();
faces.push(builder::try_attach_plane(&[bw]).map_err(|e| KernelError::Failed(format!("loft base: {e}")))?);
history.push(crate::record::cap(false));
history.push(record::cap(false));
for i in 0..m {
let (Some(e), Some(up), Some(down), Some(n)) = (base.get(i), sides.get((i + 1) % m), sides.get(i), numbers.get(i)) else { continue };
let (Some(e), Some(up), Some(down)) = (edges.get(i), sides.get((i + 1) % m), sides.get(i)) else { continue };
// Triangle i: along the base edge, up to the apex, back down — opposite to the base's winding.
let w: mt::Wire = vec![e.inverse(), down.clone(), up.inverse()].into();
faces.push(builder::try_attach_plane(&[w]).map_err(|e| KernelError::Failed(format!("loft side: {e}")))?);
history.push(crate::record::profile(&[*n]));
history.push(record::profile(base.numbers(i)));
}
let shell: mt::Shell = faces.into();
let body = match Solid::try_new(vec![shell.clone()]) {
@@ -244,7 +244,7 @@ pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
Body::new(Solid::try_new(vec![inv]).map_err(|e| KernelError::Failed(format!("loft to point: {e}")))?)?
}
};
crate::record::attach(body, &[], history)
record::attach(body, &[], Some(history), Default::default())
})
}
+8 -3
View File
@@ -117,13 +117,18 @@ impl History {
/// Check that this history describes `result` made from `operands`: one entry per face and
/// edge, and every origin within its operand's faces and edges.
pub fn check(&self, result: &Body, operands: &[&Body]) -> Result<()> {
if self.operands != operands.len() {
let counts: Vec<(usize, usize)> = operands.iter().map(|b| (b.face_count(), b.edge_count())).collect();
self.check_counts((result.face_count(), result.edge_count()), &counts)
}
/// [`History::check`] against the result's and each operand's face and edge counts.
pub(crate) fn check_counts(&self, result: (usize, usize), counts: &[(usize, usize)]) -> Result<()> {
if self.operands != counts.len() {
return Err(failed("operand count does not match"));
}
if self.faces.len() != result.face_count() || self.edges.len() != result.edge_count() {
if self.faces.len() != result.0 || self.edges.len() != result.1 {
return Err(failed("entries do not match the result's faces and edges"));
}
let counts: Vec<(usize, usize)> = operands.iter().map(|b| (b.face_count(), b.edge_count())).collect();
let face_ok = |o: &FaceOrigin| match *o {
FaceOrigin::Face { operand, face } | FaceOrigin::Offset { operand, face } => counts.get(operand).is_some_and(|c| face < c.0),
FaceOrigin::Edge { operand, edge } => counts.get(operand).is_some_and(|c| edge < c.1),
+1
View File
@@ -39,6 +39,7 @@ mod loopblend;
mod measure;
mod mesh_convert;
mod meshbody;
mod numbered;
mod offset;
mod ops;
mod polybool;
+6 -6
View File
@@ -8,6 +8,7 @@ use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::history::{Change, FaceOrigin, FaceProv};
use crate::record::Operand;
use crate::{KernelError, Result, guard};
fn fail(m: &str) -> KernelError {
@@ -177,10 +178,10 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool)
});
}
// The loop's edges and the band's edge beside each, by the loop edge's index in the body.
let edge_index = crate::record::edge_index(body);
let operand = Operand::new(body);
let mut wall_edge: std::collections::HashMap<mt::EdgeID, usize> = std::collections::HashMap::new();
for (e, ce) in along.edge_iter().zip(&cedges) {
if let Some(i) = edge_index.get(&e.id()) {
if let Some(i) = operand.edges.get(&e.id()) {
wall_edge.insert(e.id(), *i);
wall_edge.insert(ce.id(), *i);
}
@@ -204,9 +205,8 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool)
// kept, the face trimmed to what the band leaves, each wall made along the loop
// edge it rises from (the band's inner wall along the edge it runs beside), and the
// band's top an offset of the face. (Flipping a face keeps its edges.)
let history: Option<Vec<FaceProv>> = (0..faces.len())
.filter(|i| *i != fi)
.map(|i| Some(crate::record::from_face(0, i, Change::Kept)))
let history: Option<Vec<FaceProv>> = crate::record::kept_except(faces.len(), fi)
.map(Some)
.chain([Some(crate::record::from_face(0, fi, Change::Modified))])
.chain(wall_along.face_iter().chain(wall_c.face_iter()).map(|w| {
let edge = w.absolute_boundaries().iter().flatten().find_map(|e| wall_edge.get(&e.id()))?;
@@ -235,7 +235,7 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool)
Ok(s) => {
let b = Body::new(s)?;
if b.validity().is_empty() {
return crate::record::attach_if(b, &[body], history.clone());
return crate::record::attach(b, std::slice::from_ref(&operand), history.clone(), Default::default());
}
last = b.validity().join("; ");
}
+195
View File
@@ -0,0 +1,195 @@
//! Profile loops whose segments carry the profile segments they come from, through every way the
//! builders reshape a loop before building (turning it round, re-seaming a circle, re-splitting
//! it to match another section). [`FaceOrigin::Profile`](crate::history::FaceOrigin::Profile)
//! numbers are defined here: the segments of the caller's regions in order, each region its outer
//! loop and then its holes ([`profile_loops`]).
use std::collections::BTreeSet;
use solvecraft_geom::{Loop2, Region2, Seg2};
/// The loops of `regions` in profile order: each region's outer loop, then its holes.
pub(crate) fn profile_loops(regions: &[Region2]) -> impl Iterator<Item = &Loop2> {
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
}
/// A loop with, for each of its segments, the profile segments it runs over (one, until the loop
/// is re-split; a segment split in two leaves both pieces over it).
#[derive(Clone, Debug)]
pub(crate) struct NumberedLoop {
pub lp: Loop2,
pub from: Vec<Vec<usize>>,
}
/// A region of numbered loops (outer, then holes).
#[derive(Clone, Debug)]
pub(crate) struct NumberedRegion {
pub outer: NumberedLoop,
pub holes: Vec<NumberedLoop>,
}
/// `loops` numbered one after the other from 0, in the order given.
pub(crate) fn number_loops<'a>(loops: impl IntoIterator<Item = &'a Loop2>) -> Vec<NumberedLoop> {
let mut base = 0;
loops
.into_iter()
.map(|lp| {
let numbered = NumberedLoop { lp: lp.clone(), from: (base..base + lp.segs.len()).map(|n| vec![n]).collect() };
base += lp.segs.len();
numbered
})
.collect()
}
/// `regions` numbered in profile order ([`profile_loops`]), as given (not yet oriented).
pub(crate) fn number_regions(regions: &[Region2]) -> Vec<NumberedRegion> {
let mut loops = number_loops(profile_loops(regions)).into_iter();
regions.iter().filter_map(|r| Some(NumberedRegion { outer: loops.next()?, holes: loops.by_ref().take(r.holes.len()).collect() })).collect()
}
impl NumberedLoop {
/// The profile segments under segment `i`.
pub(crate) fn numbers(&self, i: usize) -> &[usize] {
self.from.get(i).map(Vec::as_slice).unwrap_or(&[])
}
/// Run the other way round.
pub(crate) fn reversed(&self) -> NumberedLoop {
NumberedLoop { lp: self.lp.reversed(), from: self.from.iter().rev().cloned().collect() }
}
/// Counter-clockwise (as [`Loop2::ccw`]); clockwise when `hole`. Every builder orients its
/// loops this way, through [`NumberedRegion::oriented`] or this.
pub(crate) fn oriented(&self, hole: bool) -> NumberedLoop {
let ccw = if self.lp.is_ccw() { self.clone() } else { self.reversed() };
if hole { ccw.reversed() } else { ccw }
}
/// Each segment replaced by `f` of it (the same segment, moved).
pub(crate) fn map_segs(&self, f: impl Fn(&Seg2) -> Seg2) -> NumberedLoop {
NumberedLoop { lp: Loop2 { segs: self.lp.segs.iter().map(f).collect() }, from: self.from.clone() }
}
/// Start at segment `k`.
pub(crate) fn rotate_left(&mut self, k: usize) {
if k < self.lp.segs.len() && self.from.len() == self.lp.segs.len() {
self.lp.segs.rotate_left(k);
self.from.rotate_left(k);
}
}
/// This loop (a full circle) re-split as `pieces` (arcs of the same circle): each piece runs
/// over every arc of this loop it overlaps.
pub(crate) fn resplit_circle(&self, pieces: Loop2) -> NumberedLoop {
let from = arc_overlaps(&pieces.segs, &self.lp.segs).into_iter().map(|js| union(js.iter().map(|j| self.numbers(*j)))).collect();
NumberedLoop { lp: pieces, from }
}
/// Re-split into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
/// other loops split their longest segments in half.
pub(crate) fn resample(&self, n: usize, angles: &[f64]) -> NumberedLoop {
let lp = &self.lp;
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
&& lp.segs.windows(2).all(|w| match (w.first(), w.get(1)) {
(Some(Seg2::Arc { center: c1, radius: r1, .. }), Some(Seg2::Arc { center: c2, radius: r2, .. })) => {
c1.dist(*c2) < 1e-9 && (r1 - r2).abs() < 1e-9
}
_ => false,
});
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
let mut a: Vec<f64> =
if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
// Keep the given order but make them increase.
for i in 1..a.len() {
let Some(prev) = a.get(i - 1).copied() else { continue };
if let Some(x) = a.get_mut(i) {
while *x <= prev {
*x += std::f64::consts::TAU;
}
}
}
let segs: Vec<Seg2> = (0..n)
.filter_map(|i| {
let s = *a.get(i)?;
let e = if i + 1 < n { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
Some(Seg2::Arc { center, radius, start: s, sweep: e - s })
})
.collect();
return self.resplit_circle(Loop2 { segs });
}
let mut out = self.clone();
while out.lp.segs.len() < n {
let Some((k, _)) = out.lp.segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())) else { break };
out.split_half(k);
}
out
}
/// Split segment `k` in half, both halves over what it ran over.
fn split_half(&mut self, k: usize) {
if k >= self.lp.segs.len() || k >= self.from.len() {
return;
}
let s = self.lp.segs.remove(k);
let (p, q) = s.split_half();
self.lp.segs.insert(k, q);
self.lp.segs.insert(k, p);
let from = self.from.get(k).cloned().unwrap_or_default();
self.from.insert(k, from);
}
}
impl NumberedRegion {
/// The region as the builders build it: outer loop counter-clockwise, holes clockwise.
pub(crate) fn oriented(&self) -> NumberedRegion {
NumberedRegion { outer: self.outer.oriented(false), holes: self.holes.iter().map(|h| h.oriented(true)).collect() }
}
/// Each loop replaced by `f` of it.
pub(crate) fn map_loops(&self, f: impl Fn(&NumberedLoop) -> NumberedLoop) -> NumberedRegion {
NumberedRegion { outer: f(&self.outer), holes: self.holes.iter().map(f).collect() }
}
/// The outer loop, then the holes.
pub(crate) fn loops(&self) -> impl Iterator<Item = &NumberedLoop> {
std::iter::once(&self.outer).chain(&self.holes)
}
/// The region's geometry.
pub(crate) fn region(&self) -> Region2 {
Region2 { outer: self.outer.lp.clone(), holes: self.holes.iter().map(|h| h.lp.clone()).collect() }
}
}
/// Every number of `sets`, once each, in order.
fn union<'a>(sets: impl IntoIterator<Item = &'a [usize]>) -> Vec<usize> {
sets.into_iter().flatten().copied().collect::<BTreeSet<usize>>().into_iter().collect()
}
/// Which arcs of `originals` (arcs of one circle) each arc of `pieces` (arcs of the same
/// circle) runs over: a circle re-split at other angles comes from every original arc it
/// overlaps.
fn arc_overlaps(pieces: &[Seg2], originals: &[Seg2]) -> Vec<Vec<usize>> {
use std::f64::consts::TAU;
// An arc as its counter-clockwise start angle and (positive) sweep.
let span = |s: &Seg2| match *s {
Seg2::Arc { start, sweep, .. } => Some(if sweep < 0.0 { (start + sweep, -sweep) } else { (start, sweep) }),
_ => None,
};
let eps = 1e-9;
pieces
.iter()
.map(|p| {
let Some((p0, pw)) = span(p) else { return Vec::new() };
(0..originals.len())
.filter(|&j| {
originals.get(j).and_then(span).is_some_and(|(o0, ow)| {
// The original's start, seen from the piece's start going round.
let d = (o0 - p0).rem_euclid(TAU);
d < pw - eps || d + ow > TAU + eps || ow >= TAU - eps
})
})
.collect()
})
.collect()
}
+1 -1
View File
@@ -178,7 +178,7 @@ pub fn trace(result: &Body, inputs: &[&Body]) -> Result<History> {
/// The profile segments of the loops, in [`FaceOrigin::Profile`] order (outer, then holes).
fn segments(regions: &[Region2]) -> Vec<Seg2> {
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(r.holes.iter())).flat_map(|l| l.segs.iter().copied()).collect()
crate::numbered::profile_loops(regions).flat_map(|l| l.segs.iter().copied()).collect()
}
/// The segment nearest the 2D points (by mean distance).
+93 -133
View File
@@ -3,97 +3,39 @@
//! topology identity inside the building call: which face a profile edge was swept into,
//! which edges an operand still owns. Nothing is sampled.
//!
//! Profile numbering: the segments of the builder's regions in the caller's order (each region
//! its outer loop, then its holes), as [`FaceOrigin::Profile`] counts them. Builders turn loops
//! counter-clockwise (holes clockwise) before building; [`loop_origins`] undoes that.
//! Faces our code builds are paired with their provenance as they are made (a [`Side`]); only
//! where truck makes the faces itself (`tsweep`, `rsweep`, `cone`) are they found afterwards,
//! by following each profile edge's column of swept faces ([`follow`]). Profile segments are
//! numbered by [`crate::numbered`].
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use solvecraft_geom::{Loop2, Region2, Seg2};
use truck_modeling as mt;
use crate::Result;
use crate::body::{Body, edge_order};
use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History};
/// The profile number of each segment of `lp` as `ccw()` orders it (as `ccw().reversed()` does
/// for a hole), the loop's own first segment being number `base`.
pub(crate) fn ccw_numbers(lp: &Loop2, hole: bool, base: usize) -> Vec<usize> {
let n = lp.segs.len();
let reversed = lp.is_ccw() == hole;
(0..n).map(|j| base + if reversed { n - 1 - j } else { j }).collect()
}
/// A face as built, with its provenance (`None` where the construction could not be followed).
pub(crate) type Side = (mt::Face, Option<FaceProv>);
/// For every loop of `regions` (each region's outer loop, then its holes), the profile
/// segments under each of its segments in the order the builders orient the loop (one each,
/// until a builder re-splits the loop).
pub(crate) fn loop_origins(regions: &[Region2]) -> Vec<Vec<Vec<usize>>> {
let mut base = 0;
let mut out = Vec::new();
for r in regions {
for (k, lp) in std::iter::once(&r.outer).chain(&r.holes).enumerate() {
out.push(ccw_numbers(lp, k > 0, base).into_iter().map(|n| vec![n]).collect());
base += lp.segs.len();
}
}
out
}
/// Which arcs of `originals` (arcs of one circle) each arc of `pieces` (arcs of the same
/// circle) runs over: a circle re-split at other angles comes from every original arc it
/// overlaps.
pub(crate) fn arc_overlaps(pieces: &[Seg2], originals: &[Seg2]) -> Vec<Vec<usize>> {
use std::f64::consts::TAU;
// An arc as its counter-clockwise start angle and (positive) sweep.
let span = |s: &Seg2| match *s {
Seg2::Arc { start, sweep, .. } => Some(if sweep < 0.0 { (start + sweep, -sweep) } else { (start, sweep) }),
_ => None,
};
let eps = 1e-9;
pieces
.iter()
.map(|p| {
let Some((p0, pw)) = span(p) else { return Vec::new() };
(0..originals.len())
.filter(|&j| {
originals.get(j).and_then(span).is_some_and(|(o0, ow)| {
// The original's start, seen from the piece's start going round.
let d = (o0 - p0).rem_euclid(TAU);
d < pw - eps || d + ow > TAU + eps || ow >= TAU - eps
})
})
.collect()
})
.collect()
}
/// A face made from profile segments `numbers`.
pub(crate) fn profile(numbers: &[usize]) -> FaceProv {
generated(numbers.iter().map(|&segment| FaceOrigin::Profile { segment }).collect())
}
/// A face made from all of `numbers` (in any order, repeats allowed).
pub(crate) fn profile_union(numbers: impl IntoIterator<Item = usize>) -> FaceProv {
let numbers: std::collections::BTreeSet<usize> = numbers.into_iter().collect();
/// A face made from all of profile segments `numbers` (in any order, repeats allowed).
pub(crate) fn profile<'a>(numbers: impl IntoIterator<Item = &'a usize>) -> FaceProv {
let numbers: BTreeSet<usize> = numbers.into_iter().copied().collect();
generated(numbers.into_iter().map(|segment| FaceOrigin::Profile { segment }).collect())
}
/// Walls from the given profile segments in order, then the start and end caps: the face
/// order of every builder that closes its walls with [`crate::build::cap_and_close`].
pub(crate) fn walls_then_caps(walls: impl IntoIterator<Item = Vec<usize>>) -> Vec<FaceProv> {
walls.into_iter().map(|w| profile(&w)).chain([cap(false), cap(true)]).collect()
}
/// The index of each of `body`'s edges by identity, in its edge order.
pub(crate) fn edge_index(body: &Body) -> HashMap<mt::EdgeID, usize> {
edge_order(body.solid.face_iter()).0.iter().enumerate().map(|(i, e)| (e.id(), i)).collect()
}
/// A face of operand `operand` and how this one relates to it.
pub(crate) fn from_face(operand: usize, face: usize, change: Change) -> FaceProv {
FaceProv { origins: vec![FaceOrigin::Face { operand, face }], change }
}
/// Every face of the one operand (with `count` faces) but `except`, kept as it is, in order:
/// the faces a builder carries over unchanged when it rebuilds a body around one face.
pub(crate) fn kept_except(count: usize, except: usize) -> impl Iterator<Item = FaceProv> {
(0..count).filter(move |i| *i != except).map(|i| from_face(0, i, Change::Kept))
}
/// A start (`end == false`) or end cap.
pub(crate) fn cap(end: bool) -> FaceProv {
generated(vec![FaceOrigin::Cap { end }])
@@ -104,26 +46,62 @@ pub(crate) fn generated(origins: Vec<FaceOrigin>) -> FaceProv {
FaceProv { origins, change: Change::Generated }
}
/// Attach to `body`, made from `operands`, the history whose faces are `faces` (in its face
/// order). Each edge's origin is the operand edge it is (the very edge, kept by the building
/// code), if any.
pub(crate) fn attach(body: Body, operands: &[&Body], faces: Vec<FaceProv>) -> Result<Body> {
attach_edges(body, operands, faces, HashMap::new())
/// The index of each of `body`'s edges by identity, in its edge order.
fn edge_index(body: &Body) -> HashMap<mt::EdgeID, usize> {
edge_order(body.solid.face_iter()).0.iter().enumerate().map(|(i, e)| (e.id(), i)).collect()
}
/// [`attach`], with `copies`: result edges the building code made as exact copies of operand
/// edges (the same curve, rebuilt), and which.
pub(crate) fn attach_edges(body: Body, operands: &[&Body], faces: Vec<FaceProv>, copies: HashMap<mt::EdgeID, EdgeOrigin>) -> Result<Body> {
/// An operation's operand, with its edges indexed by identity (in its edge order).
pub(crate) struct Operand<'a> {
pub body: &'a Body,
pub edges: HashMap<mt::EdgeID, usize>,
}
impl<'a> Operand<'a> {
pub(crate) fn new(body: &'a Body) -> Operand<'a> {
Operand { body, edges: edge_index(body) }
}
}
/// Attach to `body`, made from `operands`, the history whose faces are `faces` (in its face
/// order); a body whose faces could not be followed (`None`) has no history. Each edge's
/// origin is the operand edge it is (the very edge, kept by the building code), or what
/// `copies` says: result edges the building code made as exact copies of operand edges.
pub(crate) fn attach(body: Body, operands: &[Operand], faces: Option<Vec<FaceProv>>, copies: HashMap<mt::EdgeID, EdgeOrigin>) -> Result<Body> {
let Some(faces) = faces else { return Ok(body) };
let mut owned = copies;
for (operand, b) in operands.iter().enumerate() {
for (id, edge) in edge_index(b) {
owned.entry(id).or_insert(EdgeOrigin::Edge { operand, edge });
for (operand, o) in operands.iter().enumerate() {
for (id, edge) in &o.edges {
owned.entry(*id).or_insert(EdgeOrigin::Edge { operand, edge: *edge });
}
}
let (edges, bounds) = edge_order(body.solid.face_iter());
let edges = edges.iter().zip(bounds).map(|(e, faces)| EdgeProv { faces, origins: owned.get(&e.id()).copied().into_iter().collect() }).collect();
let history = History { operands: operands.len(), faces, edges };
body.with_history(history, operands)
let counts: Vec<(usize, usize)> = operands.iter().map(|o| (o.body.face_count(), o.edges.len())).collect();
body.with_history_counts(history, &counts)
}
/// Ruled faces (truck's `try_wire_homotopy` of wires `a` and `b`), each with the provenance of
/// the edges it spans: every face holds one edge of each wire, and comes from the profile
/// segments under both (`a_numbers`, `b_numbers`: by edge, in wire order).
pub(crate) fn ruled(faces: &mt::Shell, a: &mt::Wire, b: &mt::Wire, a_numbers: &[Vec<usize>], b_numbers: &[Vec<usize>]) -> Vec<Side> {
let under = |w: &mt::Wire, numbers: &[Vec<usize>]| -> HashMap<mt::EdgeID, Vec<usize>> {
w.edge_iter().map(|e| e.id()).zip(numbers.iter().cloned()).collect()
};
let (ua, ub) = (under(a, a_numbers), under(b, b_numbers));
faces
.face_iter()
.map(|f| {
let held = |u: &HashMap<mt::EdgeID, Vec<usize>>| -> Option<Vec<usize>> {
let mut found = f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).filter_map(|e| u.get(&e.id()));
let one = found.next()?.clone();
found.next().is_none().then_some(one)
};
let prov = held(&ua).zip(held(&ub)).map(|(x, y)| profile(x.iter().chain(&y)));
(f.clone(), prov)
})
.collect()
}
/// How far a column of swept faces runs.
@@ -136,17 +114,19 @@ pub(crate) enum Column {
Steps(usize),
}
/// Follow swept faces from the profile edges (`seeds`) they start at. A face swept from an
/// edge is bounded by one loop of four edges with the edge and its moved copy opposite each
/// other, and the copy is where the next step of the sweep starts (truck builds every swept,
/// revolved and ruled side face that way). The face at `skip` is never entered. Returns, for each
/// face, the seed whose column it lies in; `None` when the faces don't follow that pattern
/// (a face reached twice, a column with a fork or of the wrong length).
/// Follow the faces truck sweeps from the profile edges (`seeds`) they start at. A face swept
/// from an edge is bounded by one loop: the edge, its moved copy, and the paths its ends sweep
/// (none where an end stays put: a cone's apex, `fixed`). The copy is the loop's one other edge
/// that shares no moving vertex with the edge, and is where the next step of the sweep starts.
/// The face at `skip` is never entered. Returns, for each face, the seed whose column it lies
/// in; `None` when the faces don't follow that pattern (a face reached twice, a column with a
/// fork or of the wrong length).
pub(crate) fn follow<'a>(
faces: impl IntoIterator<Item = &'a mt::Face>,
seeds: &[mt::EdgeID],
skip: Option<usize>,
run: Column,
fixed: &[mt::VertexID],
) -> Option<Vec<Option<usize>>> {
let faces: Vec<&mt::Face> = faces.into_iter().collect();
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
@@ -187,12 +167,11 @@ pub(crate) fn follow<'a>(
}
*slot = Some(seed);
let [wire] = faces.get(next)?.absolute_boundaries().as_slice() else { return None };
let ids: Vec<mt::EdgeID> = wire.edge_iter().map(|e| e.id()).collect();
if ids.len() != 4 {
return None;
}
let at = ids.iter().position(|id| *id == edge)?;
edge = *ids.get((at + 2) % 4)?;
let here = wire.edge_iter().find(|e| e.id() == edge)?;
let moving: Vec<mt::VertexID> = [here.front().id(), here.back().id()].into_iter().filter(|v| !fixed.contains(v)).collect();
let mut copies = wire.edge_iter().filter(|e| e.id() != edge && !moving.contains(&e.front().id()) && !moving.contains(&e.back().id()));
let (Some(copy), None) = (copies.next(), copies.next()) else { return None };
edge = copy.id();
prev = Some(next);
first.get_or_insert(next);
count += 1;
@@ -204,8 +183,21 @@ pub(crate) fn follow<'a>(
Some(owner)
}
/// The history of a solid swept from the planar face `profile` (truck's `tsweep` or `rsweep`
/// of it), whose boundary edges `seeds` come from the profile segments given with them. Open
/// Each face's provenance by the column it lies in ([`follow`]), from the profile segments
/// under each seed edge.
pub(crate) fn columns<'a>(
faces: impl IntoIterator<Item = &'a mt::Face>,
seeds: &[(mt::EdgeID, Vec<usize>)],
skip: Option<usize>,
run: Column,
fixed: &[mt::VertexID],
) -> Option<Vec<Option<FaceProv>>> {
let ids: Vec<mt::EdgeID> = seeds.iter().map(|(id, _)| *id).collect();
Some(follow(faces, &ids, skip, run, fixed)?.into_iter().map(|o| o.and_then(|s| seeds.get(s)).map(|(_, numbers)| profile(numbers))).collect())
}
/// The history of a solid truck swept from the planar face `profile` (`rsweep` of it), whose
/// boundary edges `seeds` come from the profile segments given with them. Open
/// (`closed == false`): the profile face is the start cap, each edge's faces follow from it to
/// the end cap (the one face left), the same number of faces for every edge. Closed (a full
/// turn): no caps, each edge's faces go round.
@@ -225,7 +217,7 @@ pub(crate) fn swept_face(solid: &crate::body::Solid, profile: &mt::Face, seeds:
_ => return None,
};
// Every face but the caps lies in a column, so the one face left open is the end cap.
columns(faces, seeds, start, run)?
columns(faces, seeds, start, run, &[])?
.into_iter()
.enumerate()
.map(|(i, p)| match p {
@@ -236,38 +228,6 @@ pub(crate) fn swept_face(solid: &crate::body::Solid, profile: &mt::Face, seeds:
.collect()
}
/// Each face's provenance by the column it lies in ([`follow`]).
fn columns<'a>(
faces: impl IntoIterator<Item = &'a mt::Face>,
seeds: &[(mt::EdgeID, Vec<usize>)],
skip: Option<usize>,
run: Column,
) -> Option<Vec<Option<FaceProv>>> {
let ids: Vec<mt::EdgeID> = seeds.iter().map(|(id, _)| *id).collect();
Some(follow(faces, &ids, skip, run)?.into_iter().map(|o| o.and_then(|s| seeds.get(s)).map(|(_, numbers)| profile(numbers))).collect())
}
/// Side faces swept from `seeds` (edges with the profile segments under them; see [`follow`]),
/// then the start and end caps, the order [`crate::build::cap_and_close`] leaves them in.
/// `None` unless every side lies in some edge's column.
pub(crate) fn capped_sides<'a>(
faces: impl IntoIterator<Item = &'a mt::Face>,
seeds: &[(mt::EdgeID, Vec<usize>)],
run: Column,
) -> Option<Vec<FaceProv>> {
let mut out: Vec<FaceProv> = columns(faces, seeds, None, run)?.into_iter().collect::<Option<_>>()?;
out.extend([cap(false), cap(true)]);
Some(out)
}
/// Attach `faces` when recorded; a body whose faces could not be followed has no history.
pub(crate) fn attach_if(body: Body, operands: &[&Body], faces: Option<Vec<FaceProv>>) -> Result<Body> {
match faces {
Some(faces) => attach(body, operands, faces),
None => Ok(body),
}
}
#[cfg(test)]
#[path = "record_tests.rs"]
mod tests;
+82 -29
View File
@@ -78,26 +78,46 @@ fn rotated(l: &Loop2, k: usize) -> Loop2 {
Loop2 { segs }
}
/// An extrusion (`h` up from `plane`): its caps by height, its sides by the profile segment
/// under them.
fn expected_extrusion(b: &Body, plane: &Plane, r: &Region2, h: f64) -> Vec<Vec<FaceOrigin>> {
/// A body swept from `r`: each face from the profile segments it lies over (`to_2d` takes a
/// point back into the profile's plane, within `tol`), or else the cap `cap` says its points
/// lie on (if any).
fn expected_swept(
b: &Body,
r: &Region2,
to_2d: impl Fn(Vec3) -> Vec2,
tol: f64,
cap: impl Fn(&[Vec3]) -> Option<FaceOrigin>,
) -> Vec<Vec<FaceOrigin>> {
let loops: Vec<&Loop2> = std::iter::once(&r.outer).chain(&r.holes).collect();
let sides = swept_from(b, &loops, |p| plane.to_local(p), 1e-7);
let swept = swept_from(b, &loops, to_2d, tol);
points(b)
.iter()
.zip(sides)
.map(|(pts, segs)| {
if pts.iter().all(|p| plane.height(*p).abs() < 1e-9) {
vec![START]
} else if pts.iter().all(|p| (plane.height(*p) - h).abs() < 1e-9) {
vec![END]
} else {
segs.into_iter().map(P).collect()
}
})
.zip(swept)
.map(|(pts, segs)| if segs.is_empty() { cap(pts).into_iter().collect() } else { segs.into_iter().map(P).collect() })
.collect()
}
/// An extrusion (`h` up from `plane`): its sides by the profile segment under them, its caps
/// by height.
fn expected_extrusion(b: &Body, plane: &Plane, r: &Region2, h: f64) -> Vec<Vec<FaceOrigin>> {
let at = |pts: &[Vec3], z: f64| pts.iter().all(|p| (plane.height(*p) - z).abs() < 1e-9);
expected_swept(
b,
r,
|p| plane.to_local(p),
1e-7,
|pts| {
if at(pts, 0.0) {
Some(START)
} else if at(pts, h) {
Some(END)
} else {
None
}
},
)
}
/// Side k comes from segment k whatever order and direction the loops are given in.
#[test]
fn extrude_sides_follow_profile_segments_in_any_order() {
@@ -176,21 +196,13 @@ const SIDE: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
/// A revolution's faces by the profile segment under each point turned back into the sketch,
/// the angle caps by where they lie (the start in the sketch plane).
fn expected_revolution(b: &Body, r: &Region2) -> Vec<Vec<FaceOrigin>> {
let loops: Vec<&Loop2> = std::iter::once(&r.outer).chain(&r.holes).collect();
let swept = swept_from(b, &loops, |p| Vec2::new(p.x.hypot(p.y), p.z), 1e-6);
points(b)
.iter()
.zip(swept)
.map(|(pts, segs)| {
if !segs.is_empty() {
segs.into_iter().map(P).collect()
} else if pts.iter().all(|p| p.y.abs() < 1e-9 && p.x > -1e-9) {
vec![START]
} else {
vec![END]
}
})
.collect()
expected_swept(
b,
r,
|p| Vec2::new(p.x.hypot(p.y), p.z),
1e-6,
|pts| Some(if pts.iter().all(|p| p.y.abs() < 1e-9 && p.x > -1e-9) { START } else { END }),
)
}
#[test]
@@ -208,6 +220,47 @@ fn revolve_partial_and_full() {
}
}
/// Loops given clockwise, a hole that is a full circle of three arcs: extrude numbers every
/// face by the segment as given; revolve re-seams the circle into two half circles, each from
/// every given arc it runs over.
#[test]
fn clockwise_loops_and_a_reseamed_circle_keep_their_numbers() {
let (c, rad) = (Vec2::new(20.0, 10.0), 4.0);
let arcs = [(0.3, 2.0), (2.3, 2.5), (4.8, TAU - 4.5)];
let ccw = Loop2 { segs: arcs.iter().map(|&(start, sweep)| Seg2::Arc { center: c, radius: rad, start, sweep }).collect() };
// Clockwise: segment 4 is the third arc above, 5 the second, 6 the first.
let r = Region2 { outer: rect(10.0, 0.0, 30.0, 20.0).reversed(), holes: vec![ccw.reversed()] };
let b = crate::extrude(&Plane::XY, std::slice::from_ref(&r), 0.0, 10.0).unwrap().pop().unwrap();
let got = origins(&b);
assert_eq!(got, expected_extrusion(&b, &Plane::XY, &r, 10.0));
for k in 0..7 {
assert_eq!(got.iter().filter(|o| **o == [P(k)]).count(), 1, "segment {k}");
}
// Revolved, the circle restarts at 0.4142 rad: the half from there runs over the first two
// arcs (6, 5), the other half over all three (it ends past the first arc's start).
let first_half = |q: Vec2| (q - c).angle().rem_euclid(TAU);
for angle in [FRAC_PI_2, TAU] {
let b = crate::revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap();
let got = origins(&b);
let want = expected_revolution(&b, &r);
let (_, centres) = mesh_points(&b);
let mut halves = [0, 0];
for ((g, w), centres) in got.iter().zip(&want).zip(&centres) {
let flat: Vec<Vec2> = centres.iter().map(|p| Vec2::new(p.x.hypot(p.y), p.z)).collect();
if flat.iter().all(|q| (q.dist(c) - rad).abs() < 0.1) {
let first = flat.iter().all(|q| (0.4142 - 1e-3..=0.4142 + PI + 1e-3).contains(&first_half(*q)));
assert_eq!(g, &if first { vec![P(5), P(6)] } else { vec![P(4), P(5), P(6)] }, "angle {angle}");
halves[usize::from(!first)] += 1;
} else {
assert_eq!(g, w, "angle {angle}");
}
}
// Truck turns each half in as many parts as each rectangle side.
let parts = got.iter().filter(|o| **o == [P(0)]).count();
assert_eq!(halves, [parts, parts], "angle {angle}");
}
}
#[test]
fn revolve_with_an_edge_on_the_axis() {
// Segment 3 runs down the axis: it makes no face.
+3 -2
View File
@@ -433,7 +433,8 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
let mut out = Vec::new();
// History (one operand, the surface): a copy at no distance is the face itself, a copy
// further out its offset, a wall is made along its free edge.
let edge_index = crate::record::edge_index(body);
let operand = crate::record::Operand::new(body);
let edge_index = &operand.edges;
let mut history = Vec::new();
let copy = |i: usize, d: f64| {
if d == 0.0 {
@@ -499,7 +500,7 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
}
}
}
crate::record::attach_edges(Body::new(mt::Solid::new_unchecked(vec![shell]))?, &[body], history, copies)
crate::record::attach(Body::new(mt::Solid::new_unchecked(vec![shell]))?, &[operand], Some(history), copies)
})
}
+27 -25
View File
@@ -4,11 +4,13 @@
//! between stations, mitred at the corners.
use mt::builder;
use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use solvecraft_geom::{Plane, Region2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, p3};
use crate::build::{cap_and_close, interpolate_cubic, uniform_wire};
use crate::numbered::{NumberedLoop, NumberedRegion};
use crate::record::{self, Side};
use crate::{KernelError, Result, guard};
/// Most path points accepted.
@@ -138,8 +140,10 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
}
let pts = clean(path)?;
let corner = pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER);
let lp = region.outer.ccw();
let origins = crate::record::loop_origins(std::slice::from_ref(region));
let profile = crate::numbered::number_regions(std::slice::from_ref(region))
.pop()
.map(|r| r.oriented())
.ok_or_else(|| KernelError::Invalid("profile".into()))?;
if !corner && region.holes.is_empty() {
// Smooth: evenly spaced stations, tangents from neighbours.
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
@@ -148,12 +152,11 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
let tangents = central_tangents(&sp)?;
let st = stations(&sp, &tangents, &[])?;
let expected = region.area().abs() * len * start_cos(plane, &tangents);
if let Ok(b) = smooth_walls(plane, &lp, &st, expected) {
// Its faces as built: one wall per segment, then the caps.
return crate::record::attach(b, &[], crate::record::walls_then_caps(origins.first().cloned().unwrap_or_default()));
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
return Ok(b);
}
}
ruled(plane, region, &pts, &origins)
ruled(plane, &profile, &pts)
}
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
@@ -172,10 +175,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
tangents.first().map(|t| t.dot(plane.normal()).abs()).unwrap_or(1.0).max(1e-3)
}
/// Ruled walls between stations at the path's points (mitred at corners), every loop
/// (`origins`: the profile segments under the loops' segments, from
/// [`crate::record::loop_origins`]).
fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3], origins: &[Vec<Vec<usize>>]) -> Result<Body> {
/// Ruled walls between stations at the path's points (mitred at corners), every loop of the
/// (oriented) profile.
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body> {
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() };
let n = pts.len();
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
@@ -201,31 +203,30 @@ fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3], origins: &[Vec<Vec<usize
}
let st = stations(&pts, &tangents, &mitre)?;
let s0 = pts.first().copied().unwrap_or_default();
let loops: Vec<Loop2> = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect();
let loops: Vec<&NumberedLoop> = profile.loops().collect();
let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len());
for s in &st {
let pl = station_plane(plane, s0, s);
wires.push(loops.iter().map(|l| uniform_wire(&pl, l)).collect::<Result<Vec<_>>>()?);
wires.push(loops.iter().map(|l| uniform_wire(&pl, &l.lp)).collect::<Result<Vec<_>>>()?);
}
guard("path sweep", || {
let mut faces: Vec<mt::Face> = Vec::new();
// Every station's wires have the loops' segments, edge for edge.
let mut faces: Vec<Side> = Vec::new();
for w in wires.windows(2) {
for (a, b) in w[0].iter().zip(&w[1]) {
let (Some(w0), Some(w1)) = (w.first(), w.get(1)) else { continue };
for ((a, b), l) in w0.iter().zip(w1).zip(&loops) {
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("path sweep: {e}")))?;
faces.extend(sides.face_iter().cloned());
faces.extend(record::ruled(&sides, a, b, &l.from, &l.from));
}
}
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("path sweep".into())) };
// Each edge of the first station's loops starts a column of walls along the path.
let seeds: Vec<(mt::EdgeID, Vec<usize>)> =
first.iter().zip(origins).flat_map(|(w, os)| w.edge_iter().map(|e| e.id()).zip(os.iter().cloned())).collect();
let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::ToEnd);
crate::record::attach_if(cap_and_close(faces, first, last)?, &[], history)
cap_and_close(faces, first, last, false)
})
}
/// One B-spline wall per profile segment through the stations, capped; checked by volume.
fn smooth_walls(plane: &Plane, lp: &Loop2, st: &[Station], expected: f64) -> Result<Body> {
fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected: f64) -> Result<Body> {
let lp = &profile.lp;
let nseg = lp.segs.len();
let fail = |w: &str| KernelError::Failed(format!("path sweep: {w}"));
if nseg == 0 || nseg > 64 || st.len() < 4 {
@@ -278,13 +279,14 @@ fn smooth_walls(plane: &Plane, lp: &Loop2, st: &[Station], expected: f64) -> Res
return Err(fail("edges"));
};
let w: mt::Wire = vec![sa.clone(), b.clone(), sb.inverse(), a.inverse()].into();
walls.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?);
let wall = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?;
walls.push((wall, Some(record::profile(profile.numbers(j)))));
}
let w0: mt::Wire = e0.iter().cloned().collect();
let w1: mt::Wire = e1.iter().cloned().collect();
let tol = (lp.signed_area().abs().sqrt() * 0.05).max(1e-3);
for ws in [walls.clone(), walls.iter().map(|f| f.inverse()).collect()] {
if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1))
for ws in [walls.clone(), walls.iter().map(|(f, prov)| (f.inverse(), prov.clone())).collect()] {
if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1), false)
&& b.tessellate(tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs())
{
return Ok(b);