feat(kernel): creation builders report exact face history
Extrude, revolve (partial and full turn), tapered extrude, loft (incl. to a point), sweep, path sweep, coil, primitives, lip, emboss and thicken attach the exact history of the bodies they build: each face's profile segment, cap or operand face, and each edge's operand edge, recorded by topology identity inside the building call. Nothing is sampled; a body whose faces do not follow the expected construction carries no history. Face sources are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
This commit is contained in:
1 parent
cc4955a8da
commit
8c81bc60d0
11 files changed
+1088
-71
No files matched your search
@@ -397,9 +397,13 @@ impl Loop2 {
|
||||
pub fn reversed(&self) -> Loop2 {
|
||||
Loop2 { segs: self.segs.iter().rev().map(Seg2::reversed).collect() }
|
||||
}
|
||||
/// Whether the loop runs counter-clockwise ([`Loop2::ccw`] keeps it as it is).
|
||||
pub fn is_ccw(&self) -> bool {
|
||||
self.signed_area() >= 0.0
|
||||
}
|
||||
/// Counter-clockwise copy.
|
||||
pub fn ccw(&self) -> Loop2 {
|
||||
if self.signed_area() < 0.0 { self.reversed() } else { self.clone() }
|
||||
if self.is_ccw() { self.clone() } else { self.reversed() }
|
||||
}
|
||||
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
+197
-52
@@ -18,7 +18,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
wire_sources(plane, lp).map(|(wire, _)| wire)
|
||||
}
|
||||
|
||||
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2)>)> {
|
||||
/// A closed wire in 3D from a sketch loop, with each edge's segment and that segment's
|
||||
/// position in the loop (a big arc makes two edges).
|
||||
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
|
||||
let n = lp.segs.len();
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid(format!("loop with {n} segments")));
|
||||
@@ -41,7 +43,7 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
|
||||
if sweep.abs() > std::f64::consts::PI * 1.5 {
|
||||
let vm = builder::vertex(p3(plane.to_world(s.mid())));
|
||||
let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))));
|
||||
sources.push((first.clone(), *s));
|
||||
sources.push((first.clone(), *s, i));
|
||||
edges.push(first);
|
||||
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
|
||||
} else {
|
||||
@@ -52,19 +54,44 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
|
||||
crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?
|
||||
}
|
||||
};
|
||||
sources.push((e.clone(), *s));
|
||||
sources.push((e.clone(), *s, i));
|
||||
edges.push(e);
|
||||
}
|
||||
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);
|
||||
}
|
||||
Ok((wires, sources, seeds))
|
||||
}
|
||||
|
||||
/// A planar face from a region (outer loop with holes).
|
||||
pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
|
||||
let mut wires = vec![wire(plane, &r.outer.ccw())?];
|
||||
for h in &r.holes {
|
||||
wires.push(wire(plane, &h.ccw().reversed())?);
|
||||
}
|
||||
guard("face", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||||
let (wires, ..) = region_wires(plane, r, &[])?;
|
||||
attach_profile(&wires)
|
||||
}
|
||||
|
||||
fn attach_profile(wires: &[mt::Wire]) -> Result<mt::Face> {
|
||||
guard("face", || builder::try_attach_plane(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||||
}
|
||||
|
||||
fn region_ok(r: &Region2) -> Result<()> {
|
||||
@@ -112,15 +139,10 @@ 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 (outer, mut sources) = wire_sources(&base, &r.outer.ccw())?;
|
||||
let mut wires = vec![outer];
|
||||
for hole in &r.holes {
|
||||
let (wire, mut hole_sources) = wire_sources(&base, &hole.ccw().reversed())?;
|
||||
wires.push(wire);
|
||||
sources.append(&mut hole_sources);
|
||||
}
|
||||
let (wires, sources, seeds) = region_wires(&base, r, &crate::record::loop_origins(std::slice::from_ref(r)))?;
|
||||
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 mut visits = 0usize;
|
||||
@@ -187,7 +209,8 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
}
|
||||
Ok(result)
|
||||
})?;
|
||||
out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance))));
|
||||
let body = body.with_face_sources(Some(std::sync::Arc::new(provenance)));
|
||||
out.push(crate::record::attach_if(body, &[], history)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -253,8 +276,9 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
.collect(),
|
||||
};
|
||||
// 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.
|
||||
let reseam = |l: Loop2| -> Loop2 {
|
||||
// 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) {
|
||||
let c = match l.segs.first() {
|
||||
Some(Seg2::Arc { center, radius, .. }) => Some((*center, *radius)),
|
||||
_ => None,
|
||||
@@ -282,18 +306,46 @@ 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() }
|
||||
(if ccw { n } else { n.reversed() }, true)
|
||||
}
|
||||
_ => l,
|
||||
_ => (l, false),
|
||||
}
|
||||
};
|
||||
let r = &Region2 { outer: reseam(snap_loop(&r.outer)), holes: r.holes.iter().map(|h| reseam(snap_loop(h))).collect() };
|
||||
// A half disc on the axis revolved fully is a sphere: build it without poles.
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
{
|
||||
out.push(sphere_patches(plane.to_world(c), rad)?);
|
||||
let arcs: Vec<usize> = r.outer.segs.iter().enumerate().filter(|(_, s)| matches!(s, Seg2::Arc { .. })).map(|(i, _)| i).collect();
|
||||
out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?);
|
||||
continue;
|
||||
}
|
||||
// A full revolution of a profile with one edge on the axis: sweep the rest of the loop
|
||||
@@ -305,7 +357,8 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
out.push(b);
|
||||
continue;
|
||||
}
|
||||
let f = face(plane, r)?;
|
||||
let (wires, _, seeds) = region_wires(plane, r, &origins)?;
|
||||
let f = attach_profile(&wires)?;
|
||||
// Partial revolves sweep toward the side the sketch normal points to (as features
|
||||
// extrude toward it).
|
||||
let c = plane.to_world(r.centroid());
|
||||
@@ -320,7 +373,9 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
continue;
|
||||
}
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
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)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -469,14 +524,17 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
|
||||
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));
|
||||
guard("tapered extrude", || {
|
||||
let w0: Vec<mt::Wire> = loops0.iter().map(|l| wire(plane, l)).collect::<Result<_>>()?;
|
||||
let (w0, _, seeds) = region_wires(plane, region, &origins)?;
|
||||
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());
|
||||
}
|
||||
// The sides, then the bottom and top as pushed below.
|
||||
let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::Steps(1));
|
||||
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}")))?;
|
||||
@@ -489,7 +547,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 Body::new(s),
|
||||
Ok(s) => return crate::record::attach_if(Body::new(s)?, &[], history),
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -632,7 +690,9 @@ 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 };
|
||||
return Body::new(solid).map(Some);
|
||||
// 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);
|
||||
}
|
||||
Err(er) => last_err = er.to_string(),
|
||||
}
|
||||
@@ -647,8 +707,9 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
|
||||
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.
|
||||
// The open chain after the axis segment, back around to it, with each piece's segment.
|
||||
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();
|
||||
// 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[..] {
|
||||
@@ -657,6 +718,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];
|
||||
}
|
||||
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);
|
||||
@@ -669,11 +731,18 @@ 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;
|
||||
// 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())) {
|
||||
mid = mid.get(1..).unwrap_or(&[]);
|
||||
start_cap = mid_src.first().copied();
|
||||
mid_src = mid_src.get(1..).unwrap_or(&[]);
|
||||
}
|
||||
if mid.last().is_some_and(|l| radial(l) && on_axis(l.end())) {
|
||||
mid = mid.get(..mid.len().saturating_sub(1)).unwrap_or(&[]);
|
||||
end_cap = mid_src.last().copied();
|
||||
mid_src = mid_src.get(..mid_src.len().saturating_sub(1)).unwrap_or(&[]);
|
||||
}
|
||||
if mid.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -710,7 +779,39 @@ 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();
|
||||
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));
|
||||
let src = if at(first_vertex) {
|
||||
start_cap
|
||||
} else if at(last_vertex) {
|
||||
end_cap
|
||||
} else {
|
||||
None
|
||||
};
|
||||
history = history.zip(src).map(|(mut h, seg)| {
|
||||
h.push(crate::record::profile(&[seg]));
|
||||
h
|
||||
});
|
||||
let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?;
|
||||
shell.push(cap);
|
||||
}
|
||||
@@ -724,7 +825,7 @@ 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 };
|
||||
Body::new(solid).map(Some)
|
||||
crate::record::attach_if(Body::new(solid)?, &[], history).map(Some)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -750,8 +851,9 @@ pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt:
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 {
|
||||
/// 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,
|
||||
@@ -765,24 +867,29 @@ fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 {
|
||||
a[i] += std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
let segs = (0..n)
|
||||
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();
|
||||
return Loop2 { segs };
|
||||
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 }
|
||||
(Loop2 { segs }, src)
|
||||
}
|
||||
|
||||
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
|
||||
@@ -804,27 +911,39 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) };
|
||||
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 ordered: Vec<&(Plane, Loop2)> = if big < 0.0 { sections.iter().rev().collect() } else { sections.iter().collect() };
|
||||
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();
|
||||
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);
|
||||
let oriented: Vec<(Plane, Loop2)> = ordered
|
||||
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
|
||||
.iter()
|
||||
.map(|(p, l)| {
|
||||
let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() };
|
||||
(*p, l)
|
||||
.map(|(base, (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)
|
||||
})
|
||||
.collect();
|
||||
let lead = oriented.iter().max_by_key(|(_, l)| l.segs.len()).cloned().ok_or_else(|| KernelError::Invalid("no sections".into()))?;
|
||||
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 mut wires: Vec<mt::Wire> = Vec::new();
|
||||
for (p, l) in &oriented {
|
||||
// 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();
|
||||
// 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 = resample(l, n, &angles);
|
||||
let (mut r, mut src) = resample(l, 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();
|
||||
@@ -838,15 +957,25 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
});
|
||||
if let Some(k) = best {
|
||||
r.segs.rotate_left(k);
|
||||
src.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)?);
|
||||
}
|
||||
// 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)];
|
||||
// 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))
|
||||
{
|
||||
return Ok(b);
|
||||
// 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);
|
||||
}
|
||||
guard("loft", || {
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
@@ -854,8 +983,19 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
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 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())) };
|
||||
cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))
|
||||
crate::record::attach_if(cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))?, &[], history)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1006,10 +1146,7 @@ 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 mut starts = vec![wire(plane, ®ion.outer.ccw())?];
|
||||
for h in ®ion.holes {
|
||||
starts.push(wire(plane, &h.ccw().reversed())?);
|
||||
}
|
||||
let (starts, _, seeds) = region_wires(plane, region, &crate::record::loop_origins(std::slice::from_ref(region)))?;
|
||||
guard("sweep", || {
|
||||
let mut shell = mt::Shell::new();
|
||||
let mut ends = Vec::new();
|
||||
@@ -1034,7 +1171,9 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
ends.push(cur);
|
||||
}
|
||||
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
cap_and_close(faces, &starts, &ends)
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1136,6 +1275,12 @@ pub fn sphere_patches(center: Vec3, radius: f64) -> Result<Body> {
|
||||
|
||||
/// [`sphere_patches`] with the patch layout turned to the frame `rot` (orthonormal columns).
|
||||
pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Result<Body> {
|
||||
// Every patch is swept from the arc (segment 0) of the half disc a sphere revolves.
|
||||
sphere_solid(center, radius, rot, &[0])
|
||||
}
|
||||
|
||||
/// [`sphere_patches_rotated`], every patch swept from profile segments `arcs`.
|
||||
fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Result<Body> {
|
||||
let radius = finite(radius, "radius")?;
|
||||
let turn = |v: Vec3| rot[0] * v.x + rot[1] * v.y + rot[2] * v.z;
|
||||
if radius <= 1e-6 || !center.is_finite() {
|
||||
@@ -1197,6 +1342,6 @@ pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Resu
|
||||
}
|
||||
let shell: mt::Shell = out.into();
|
||||
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
|
||||
Body::new(solid)
|
||||
crate::record::attach(Body::new(solid)?, &[], vec![crate::record::profile(arcs); 6])
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Exact radial embossing of orthogonal profiles on complete cylindrical walls.
|
||||
use crate::body::{Body, Solid, from_p3, p3};
|
||||
use crate::history::Change;
|
||||
use crate::offset::{Surf, surf_of};
|
||||
use crate::{KernelError, Result, guard};
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
@@ -255,6 +256,9 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
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();
|
||||
for reg in regions {
|
||||
let mut loops = Vec::new();
|
||||
for lp in std::iter::once(®.outer).chain(®.holes) {
|
||||
@@ -293,6 +297,8 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
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() });
|
||||
}
|
||||
validate_profiles(&mapped, tol)?;
|
||||
if qualify {
|
||||
@@ -334,10 +340,19 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
let [p, q, s, t] = c3;
|
||||
let mat = mt::Matrix4::new(a, b, c, d, e, f, g, h, i, j, l, n, p, q, s, t);
|
||||
let raised = selected.oriented_surface().transformed(mat);
|
||||
// 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 {
|
||||
let mut cap = Vec::new();
|
||||
for (li, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() {
|
||||
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)));
|
||||
if li == 0 {
|
||||
footprints.push((lp, base.inverse()));
|
||||
} else {
|
||||
@@ -349,6 +364,7 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
added.extend(walls);
|
||||
}
|
||||
added.push(mt::Face::try_new(cap, raised.clone()).map_err(|e| fail(&e.to_string()))?);
|
||||
added_history.push(crate::record::cap(true));
|
||||
}
|
||||
// An island inside a profile hole cuts that hole's original-radius patch,
|
||||
// rather than adding another hole to the already-cut exterior patch.
|
||||
@@ -363,6 +379,13 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
base_patches.get_mut(owner).ok_or_else(|| fail("profile ownership"))?.push(wire);
|
||||
}
|
||||
let mut all: Vec<_> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, f)| f.clone()).collect();
|
||||
// History (one operand, the body; profile numbering over `regions`), face by face as
|
||||
// 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();
|
||||
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 {
|
||||
all.push(mt::Face::try_new(patch, selected.oriented_surface()).map_err(|e| fail(&e.to_string()))?);
|
||||
}
|
||||
@@ -372,7 +395,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(result))
|
||||
Ok(EmbossOutcome::Body(crate::record::attach(result, &[body], history)?))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,12 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
|
||||
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) {
|
||||
return Ok(b);
|
||||
// Its faces as built: one wall per segment, then the caps.
|
||||
return crate::record::attach(b, &[], crate::record::walls_then_caps(origins));
|
||||
}
|
||||
let n = ((turns * 120.0).ceil() as usize).clamp(4, 24_000);
|
||||
let total = std::f64::consts::TAU * turns;
|
||||
@@ -55,7 +58,9 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
|
||||
faces.extend(sides.face_iter().cloned());
|
||||
}
|
||||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("helix".into())) };
|
||||
cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,8 +210,10 @@ pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
|
||||
return Err(KernelError::Invalid("not supported yet: lofting curved sections to a point".into()));
|
||||
}
|
||||
// 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 (apex - plane.origin).dot(plane.normal()) > 0.0 {
|
||||
if above {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
guard("loft to point", || {
|
||||
@@ -216,22 +223,28 @@ pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
|
||||
let base: 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();
|
||||
faces.push(builder::try_attach_plane(&[bw]).map_err(|e| KernelError::Failed(format!("loft base: {e}")))?);
|
||||
history.push(crate::record::cap(false));
|
||||
for i in 0..m {
|
||||
let (Some(e), Some(up), Some(down)) = (base.get(i), sides.get((i + 1) % m), sides.get(i)) else { continue };
|
||||
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 };
|
||||
// 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]));
|
||||
}
|
||||
let shell: mt::Shell = faces.into();
|
||||
match Solid::try_new(vec![shell.clone()]) {
|
||||
Ok(s) => Body::new(s),
|
||||
let body = match Solid::try_new(vec![shell.clone()]) {
|
||||
Ok(s) => Body::new(s)?,
|
||||
Err(_) => {
|
||||
let inv: mt::Shell = shell.face_iter().map(|f| f.inverse()).collect();
|
||||
Body::new(Solid::try_new(vec![inv]).map_err(|e| KernelError::Failed(format!("loft to point: {e}")))?)
|
||||
Body::new(Solid::try_new(vec![inv]).map_err(|e| KernelError::Failed(format!("loft to point: {e}")))?)?
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::record::attach(body, &[], history)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ fn substitute_a_body_made_from_a_sketch() {
|
||||
#[test]
|
||||
fn bodies_without_reported_history_have_none() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(4.0, 3.0, 1.0)).unwrap();
|
||||
assert!(a.history().is_none());
|
||||
let bare = Body::new(a.deep_copy()).unwrap();
|
||||
assert!(bare.history().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,6 +47,7 @@ mod prism;
|
||||
mod prism_curve;
|
||||
mod prism_measure;
|
||||
pub mod provenance;
|
||||
mod record;
|
||||
mod sew;
|
||||
mod smooth_edges;
|
||||
mod splitface;
|
||||
|
||||
@@ -7,6 +7,7 @@ use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::history::{Change, FaceOrigin, FaceProv};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
fn fail(m: &str) -> KernelError {
|
||||
@@ -175,6 +176,15 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool)
|
||||
Some(m) => builder::circle_arc(a, b, p3(m)),
|
||||
});
|
||||
}
|
||||
// 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 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()) {
|
||||
wall_edge.insert(e.id(), *i);
|
||||
wall_edge.insert(ce.id(), *i);
|
||||
}
|
||||
}
|
||||
let c: mt::Wire = cedges.into();
|
||||
// The rest of the face: its other loop, and the band's edge as a loop of its own.
|
||||
// (The band's edge runs the face's way round: a hole when the band follows the inner
|
||||
@@ -190,6 +200,20 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool)
|
||||
let flipped = |sh: &mt::Shell, yes: bool| -> mt::Shell {
|
||||
if yes { sh.face_iter().map(|x| x.inverse()).collect::<Vec<_>>().into() } else { sh.clone() }
|
||||
};
|
||||
// History (one operand, the body), face by face as gathered below: the other faces
|
||||
// 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)))
|
||||
.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()))?;
|
||||
Some(crate::record::generated(vec![FaceOrigin::Edge { operand: 0, edge: *edge }]))
|
||||
}))
|
||||
.chain([Some(crate::record::generated(vec![FaceOrigin::Offset { operand: 0, face: fi }]))])
|
||||
.collect();
|
||||
let mut last = String::new();
|
||||
for flip in 0..8u32 {
|
||||
let (wa, wc) = (flipped(&wall_along, flip & 1 == 1), flipped(&wall_c, flip & 2 == 2));
|
||||
@@ -211,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 Ok(b);
|
||||
return crate::record::attach_if(b, &[body], history.clone());
|
||||
}
|
||||
last = b.validity().join("; ");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Recording the exact history of the bodies the creation builders make (extrude, revolve,
|
||||
//! sweeps, lofts, primitives, and the builders that rebuild a body around new faces), by
|
||||
//! 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.
|
||||
|
||||
use std::collections::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()
|
||||
}
|
||||
|
||||
/// 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();
|
||||
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 }
|
||||
}
|
||||
|
||||
/// A start (`end == false`) or end cap.
|
||||
pub(crate) fn cap(end: bool) -> FaceProv {
|
||||
generated(vec![FaceOrigin::Cap { end }])
|
||||
}
|
||||
|
||||
/// A face new to the operation, made from `origins`.
|
||||
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())
|
||||
}
|
||||
|
||||
/// [`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> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// How far a column of swept faces runs.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Column {
|
||||
/// Until no face lies beyond (an open sweep whose caps are not among the faces) or the
|
||||
/// column comes back to its first face (a full turn).
|
||||
ToEnd,
|
||||
/// Exactly this many faces (the sweep's caps are among the faces).
|
||||
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).
|
||||
pub(crate) fn follow<'a>(
|
||||
faces: impl IntoIterator<Item = &'a mt::Face>,
|
||||
seeds: &[mt::EdgeID],
|
||||
skip: Option<usize>,
|
||||
run: Column,
|
||||
) -> Option<Vec<Option<usize>>> {
|
||||
let faces: Vec<&mt::Face> = faces.into_iter().collect();
|
||||
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
if skip == Some(i) {
|
||||
continue;
|
||||
}
|
||||
for e in f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
let slot = by_edge.entry(e.id()).or_default();
|
||||
if !slot.contains(&i) {
|
||||
slot.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut owner: Vec<Option<usize>> = vec![None; faces.len()];
|
||||
let limit = match run {
|
||||
Column::Steps(n) => n,
|
||||
Column::ToEnd => faces.len(),
|
||||
};
|
||||
for (seed, start) in seeds.iter().enumerate() {
|
||||
let (mut edge, mut prev, mut first) = (*start, None, None);
|
||||
let mut count = 0;
|
||||
while count < limit {
|
||||
let next: Vec<usize> = by_edge.get(&edge).map(|v| v.iter().copied().filter(|f| Some(*f) != prev).collect()).unwrap_or_default();
|
||||
let next = match (next.as_slice(), first) {
|
||||
([], _) => break,
|
||||
([f], _) => *f,
|
||||
// A full turn: the profile edge bounds the first face and the last.
|
||||
([f, _], None) if run == Column::ToEnd => *f,
|
||||
_ => return None,
|
||||
};
|
||||
if Some(next) == first {
|
||||
break;
|
||||
}
|
||||
let slot = owner.get_mut(next)?;
|
||||
if slot.is_some() {
|
||||
return None;
|
||||
}
|
||||
*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)?;
|
||||
prev = Some(next);
|
||||
first.get_or_insert(next);
|
||||
count += 1;
|
||||
}
|
||||
if matches!(run, Column::Steps(n) if count != n) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
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
|
||||
/// (`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.
|
||||
pub(crate) fn swept_face(solid: &crate::body::Solid, profile: &mt::Face, seeds: &[(mt::EdgeID, Vec<usize>)], closed: bool) -> Option<Vec<FaceProv>> {
|
||||
let faces: Vec<&mt::Face> = solid.face_iter().collect();
|
||||
let start = faces.iter().position(|f| f.id() == profile.id());
|
||||
let run = match (closed, start) {
|
||||
(true, None) => Column::ToEnd,
|
||||
(false, Some(_)) => {
|
||||
// Two caps, and as many faces for every edge.
|
||||
let steps = faces.len().checked_sub(2)?.checked_div(seeds.len())?;
|
||||
if steps == 0 || steps * seeds.len() + 2 != faces.len() {
|
||||
return None;
|
||||
}
|
||||
Column::Steps(steps)
|
||||
}
|
||||
_ => 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)?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| match p {
|
||||
Some(p) => Some(p),
|
||||
None if Some(i) == start => Some(cap(false)),
|
||||
None => (!closed).then(|| cap(true)),
|
||||
})
|
||||
.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;
|
||||
@@ -0,0 +1,499 @@
|
||||
//! The creation builders' histories: every face's origin checked against the geometry the
|
||||
//! builder made it from.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::build::PathSeg;
|
||||
use crate::history::{Change, FaceOrigin, History};
|
||||
|
||||
const P: fn(usize) -> FaceOrigin = |segment| FaceOrigin::Profile { segment };
|
||||
const START: FaceOrigin = FaceOrigin::Cap { end: false };
|
||||
const END: FaceOrigin = FaceOrigin::Cap { end: true };
|
||||
|
||||
/// The body's history, checked against it; a body made from a sketch has generated faces only
|
||||
/// and its edges, all new, separate the faces the body's edge order says.
|
||||
fn sketch_made(b: &Body) -> &History {
|
||||
let h = b.history().expect("the builder reports its history");
|
||||
h.check(b, &[]).unwrap();
|
||||
assert!(h.faces.iter().all(|f| f.change == Change::Generated && !f.origins.is_empty()));
|
||||
assert!(h.edges.iter().all(|e| e.origins.is_empty()));
|
||||
assert_eq!(h.edges.iter().map(|e| e.faces.clone()).collect::<Vec<_>>(), crate::history::edge_faces(&b.solid));
|
||||
h
|
||||
}
|
||||
|
||||
/// Each face's origins.
|
||||
fn origins(b: &Body) -> Vec<Vec<FaceOrigin>> {
|
||||
sketch_made(b).faces.iter().map(|f| f.origins.clone()).collect()
|
||||
}
|
||||
|
||||
/// Mesh vertices of each face.
|
||||
fn points(b: &Body) -> Vec<Vec<Vec3>> {
|
||||
mesh_points(b).0
|
||||
}
|
||||
|
||||
/// Mesh vertices of each face, and its triangles' centres (off a curved face by up to the
|
||||
/// mesh's tolerance).
|
||||
fn mesh_points(b: &Body) -> (Vec<Vec<Vec3>>, Vec<Vec<Vec3>>) {
|
||||
let m = b.tessellate(b.size() * 1e-3).unwrap();
|
||||
let (mut corners, mut centres) = (vec![Vec::new(); b.face_count()], vec![Vec::new(); b.face_count()]);
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let c = t.map(|i| m.positions[i as usize]);
|
||||
corners[*f as usize].extend(c);
|
||||
centres[*f as usize].push((c[0] + c[1] + c[2]) * (1.0 / 3.0));
|
||||
}
|
||||
(corners, centres)
|
||||
}
|
||||
|
||||
/// The segments a face lies over: its vertices on them within `tol`, its triangles' centres
|
||||
/// near them (`to_2d` takes a point into the profile's plane).
|
||||
fn swept_from(b: &Body, loops: &[&Loop2], to_2d: impl Fn(Vec3) -> Vec2, tol: f64) -> Vec<Vec<usize>> {
|
||||
let (corners, centres) = mesh_points(b);
|
||||
let flat = |pts: &Vec<Vec3>| pts.iter().map(|p| to_2d(*p)).collect::<Vec<_>>();
|
||||
corners
|
||||
.iter()
|
||||
.zip(¢res)
|
||||
.map(|(v, c)| {
|
||||
let near = under(loops, &flat(c), b.size() * 3e-3);
|
||||
under(loops, &flat(v), tol).into_iter().filter(|k| near.contains(k)).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The segments (of all `loops`, numbered in order) every point lies on.
|
||||
fn under(loops: &[&Loop2], pts: &[Vec2], tol: f64) -> Vec<usize> {
|
||||
let segs: Vec<Seg2> = loops.iter().flat_map(|l| l.segs.iter().copied()).collect();
|
||||
(0..segs.len()).filter(|k| !pts.is_empty() && pts.iter().all(|q| segs[*k].point_at(segs[*k].closest_param(*q)).dist(*q) < tol)).collect()
|
||||
}
|
||||
|
||||
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Loop2 {
|
||||
Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)])
|
||||
}
|
||||
|
||||
fn rotated(l: &Loop2, k: usize) -> Loop2 {
|
||||
let mut segs = l.segs.clone();
|
||||
segs.rotate_left(k);
|
||||
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>> {
|
||||
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);
|
||||
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()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let outer = rect(0.0, 0.0, 40.0, 30.0);
|
||||
let hole = rect(10.0, 10.0, 20.0, 20.0).reversed();
|
||||
let variants = [
|
||||
Region2 { outer: outer.clone(), holes: vec![hole.clone()] },
|
||||
Region2 { outer: outer.reversed(), holes: vec![hole.clone()] },
|
||||
Region2 { outer: rotated(&outer, 2), holes: vec![rotated(&hole.reversed(), 1)] },
|
||||
Region2 { outer: rotated(&outer.reversed(), 3), holes: vec![rotated(&hole, 3)] },
|
||||
];
|
||||
for r in &variants {
|
||||
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));
|
||||
// One face per segment, two caps.
|
||||
let mut all: Vec<FaceOrigin> = got.into_iter().flatten().collect();
|
||||
all.sort();
|
||||
assert_eq!(all, [(0..8).map(P).collect::<Vec<_>>(), vec![START, END]].concat());
|
||||
// The certificates are produced as before.
|
||||
assert!(b.face_sources().unwrap().iter().all(Option::is_some));
|
||||
}
|
||||
// The +x side is whichever segment of the given loop runs along x = 40.
|
||||
let r = &variants[2];
|
||||
let b = crate::extrude(&Plane::XY, std::slice::from_ref(r), 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let k = r.outer.segs.iter().position(|s| s.start().x == 40.0 && s.end().x == 40.0).unwrap();
|
||||
let side = b.faces(1e-3).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dist(Vec3::X) < 1e-9)).unwrap();
|
||||
assert_eq!(origins(&b)[side], [P(k)]);
|
||||
}
|
||||
|
||||
/// A big arc is built as two edges: both faces come from it.
|
||||
#[test]
|
||||
fn extrude_a_big_arc_gives_two_faces_of_one_segment() {
|
||||
let (a, b) = (Vec2::new(10.0 * (0.3f64).cos(), -10.0 * (0.3f64).sin()), Vec2::new(10.0 * (0.3f64).cos(), 10.0 * (0.3f64).sin()));
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.3, sweep: TAU - 0.6 };
|
||||
let r = Region2 { outer: Loop2 { segs: vec![arc, Seg2::Line { a, b }] }, holes: vec![] };
|
||||
let body = crate::extrude(&Plane::XY, std::slice::from_ref(&r), 0.0, 5.0).unwrap().pop().unwrap();
|
||||
let got = origins(&body);
|
||||
assert_eq!(got, expected_extrusion(&body, &Plane::XY, &r, 5.0));
|
||||
assert_eq!(got.iter().filter(|o| **o == [P(0)]).count(), 2);
|
||||
assert_eq!(got.iter().filter(|o| **o == [P(1)]).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitives_report_their_construction() {
|
||||
// Box: the rectangle's sides (−y, +x, +y, −x) and the caps (−z, +z).
|
||||
let b = crate::box_solid(Vec3::ZERO, Vec3::new(4.0, 3.0, 2.0)).unwrap();
|
||||
let got = origins(&b);
|
||||
for (f, o) in b.faces(1e-3).unwrap().iter().zip(&got) {
|
||||
let n = f.plane_normal.unwrap();
|
||||
let want = [(-Vec3::Y, P(0)), (Vec3::X, P(1)), (Vec3::Y, P(2)), (-Vec3::X, P(3)), (-Vec3::Z, START), (Vec3::Z, END)]
|
||||
.into_iter()
|
||||
.find(|(d, _)| d.dist(n) < 1e-9)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(o, &[want]);
|
||||
}
|
||||
assert!(b.face_sources().unwrap().iter().all(|s| matches!(s, Some(crate::FaceSource::Box { .. }))));
|
||||
// Cylinder: a circle of two arcs, extruded.
|
||||
let c = crate::cylinder(Vec3::ZERO, Vec3::Z, 5.0, 8.0).unwrap();
|
||||
let r = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 5.0, crate::build::SEAM_ANGLE), holes: vec![] };
|
||||
let frame = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap();
|
||||
let want = expected_extrusion(&c, &frame, &r, 8.0);
|
||||
assert_eq!(origins(&c), want);
|
||||
// Sphere: six patches, all from the arc of the half disc it revolves.
|
||||
let s = crate::sphere(Vec3::ZERO, 5.0).unwrap();
|
||||
assert_eq!(origins(&s), vec![vec![P(0)]; 6]);
|
||||
// Torus: a circle re-split for its seam, turned in three: each face from both arcs.
|
||||
let t = crate::torus(Vec3::ZERO, 10.0, 2.0).unwrap();
|
||||
assert_eq!(origins(&t), vec![vec![P(0), P(1)]; 6]);
|
||||
}
|
||||
|
||||
/// The plane the revolve tests sketch in (x across, y up the world z axis, the revolve axis).
|
||||
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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_partial_and_full() {
|
||||
let r = Region2 { outer: rect(10.0, 0.0, 20.0, 5.0), holes: vec![rect(12.0, 1.0, 14.0, 2.0).reversed()] };
|
||||
// Under half a turn truck sweeps in two parts, beyond it in three; a full turn has no caps.
|
||||
for (angle, parts, caps) in [(FRAC_PI_2, 2, 2), (1.5 * PI, 3, 2), (TAU, 3, 0)] {
|
||||
let b = crate::revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap();
|
||||
let got = origins(&b);
|
||||
assert_eq!(got, expected_revolution(&b, &r), "angle {angle}");
|
||||
for k in 0..8 {
|
||||
assert_eq!(got.iter().filter(|o| **o == [P(k)]).count(), parts, "segment {k} at angle {angle}");
|
||||
}
|
||||
assert_eq!(got.iter().filter(|o| **o == [START] || **o == [END]).count(), caps);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_with_an_edge_on_the_axis() {
|
||||
// Segment 3 runs down the axis: it makes no face.
|
||||
let r = Region2 { outer: rect(0.0, 0.0, 10.0, 5.0), holes: vec![] };
|
||||
let partial = crate::revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, FRAC_PI_2).unwrap().pop().unwrap();
|
||||
let got = origins(&partial);
|
||||
assert_eq!(got, expected_revolution(&partial, &r));
|
||||
assert_eq!(got.len(), 5);
|
||||
// A full turn: the radial segments 0 and 2 sweep discs, segment 1 the cylinder (three parts).
|
||||
let full = crate::revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap();
|
||||
let got = origins(&full);
|
||||
assert_eq!(got, expected_revolution(&full, &r));
|
||||
assert_eq!(got.iter().filter(|o| **o == [P(1)]).count(), 3);
|
||||
assert_eq!(got.iter().filter(|o| **o == [P(0)] || **o == [P(2)]).count(), 2);
|
||||
// A cone: the slanted segment ends on the axis (the apex), the base disc.
|
||||
let tri = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 8.0)]), holes: vec![] };
|
||||
let cone = crate::revolve(&SIDE, std::slice::from_ref(&tri), Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap();
|
||||
assert_eq!(origins(&cone), expected_revolution(&cone, &tri));
|
||||
// The same cone from a loop that starts elsewhere and runs the other way.
|
||||
let turned = Region2 { outer: rotated(&tri.outer.reversed(), 1), holes: vec![] };
|
||||
let cone = crate::revolve(&SIDE, std::slice::from_ref(&turned), Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap();
|
||||
assert_eq!(origins(&cone), expected_revolution(&cone, &turned));
|
||||
// A half disc: a sphere, every patch from the arc (segment 1 here).
|
||||
let half = Region2 {
|
||||
outer: Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(0.0, 5.0), b: Vec2::new(0.0, -5.0) },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 5.0, start: -FRAC_PI_2, sweep: PI },
|
||||
],
|
||||
},
|
||||
holes: vec![],
|
||||
};
|
||||
let ball = crate::revolve(&SIDE, std::slice::from_ref(&half), Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap();
|
||||
assert_eq!(origins(&ball), vec![vec![P(1)]; 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_extrusion() {
|
||||
let r = Region2 { outer: rotated(&rect(0.0, 0.0, 20.0, 10.0).reversed(), 1), holes: vec![] };
|
||||
let b = crate::extrude_tapered(&Plane::XY, &r, 10.0, 1.0, 0.1).unwrap();
|
||||
let want: Vec<Vec<FaceOrigin>> = points(&b)
|
||||
.iter()
|
||||
.map(|pts| {
|
||||
let foot: Vec<Vec2> = pts.iter().filter(|p| p.z.abs() < 1e-9).map(|p| Vec2::new(p.x, p.y)).collect();
|
||||
if foot.len() == pts.len() {
|
||||
vec![START]
|
||||
} else if foot.is_empty() {
|
||||
vec![END]
|
||||
} else {
|
||||
under(&[&r.outer], &foot, 1e-7).into_iter().map(P).collect()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(origins(&b), want);
|
||||
}
|
||||
|
||||
/// Each loft face from the section segments its edges in each section's plane lie on.
|
||||
fn expected_loft(b: &Body, sections: &[(Plane, Loop2)]) -> Vec<Vec<FaceOrigin>> {
|
||||
let bases: Vec<usize> = sections.iter().scan(0, |acc, (_, l)| Some(std::mem::replace(acc, *acc + l.segs.len()))).collect();
|
||||
points(b)
|
||||
.iter()
|
||||
.map(|pts| {
|
||||
let mut out = Vec::new();
|
||||
let mut whole = None;
|
||||
for (i, (p, l)) in sections.iter().enumerate() {
|
||||
let near: Vec<Vec2> = pts.iter().filter(|q| p.height(**q).abs() < 1e-9).map(|q| p.to_local(*q)).collect();
|
||||
if near.len() == pts.len() {
|
||||
whole = Some(i);
|
||||
}
|
||||
if near.len() >= 2 {
|
||||
out.extend(under(&[l], &near, 1e-3).into_iter().map(|k| P(bases[i] + k)));
|
||||
}
|
||||
}
|
||||
match whole {
|
||||
Some(0) => vec![START],
|
||||
Some(i) if i + 1 == sections.len() => vec![END],
|
||||
_ => {
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_sections() {
|
||||
let at = |z: f64| Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
|
||||
let low = (at(0.0), rect(-10.0, -10.0, 10.0, 10.0));
|
||||
let high = (at(10.0), rotated(&rect(-5.0, -5.0, 5.0, 5.0).reversed(), 2));
|
||||
// Two sections, either way round: ruled faces, each between a segment of each section.
|
||||
for sections in [vec![low.clone(), high.clone()], vec![high.clone(), low.clone()]] {
|
||||
let b = crate::loft(§ions).unwrap();
|
||||
let got = origins(&b);
|
||||
assert_eq!(got, expected_loft(&b, §ions));
|
||||
assert!(got.iter().filter(|o| o.len() == 2).all(|o| o[0] < P(4) && o[1] >= P(4)));
|
||||
}
|
||||
// Three sections: smooth faces through all of them (the middle section leaves no edge).
|
||||
let mid = (at(5.0), rect(-7.0, -7.0, 7.0, 7.0));
|
||||
let sections = [low.clone(), mid, high.clone()];
|
||||
let b = crate::loft(§ions).unwrap();
|
||||
for o in origins(&b).iter().filter(|o| o.len() > 1) {
|
||||
assert_eq!(o.len(), 3, "{o:?}");
|
||||
assert!(o[0] < P(4) && o[1] >= P(4) && o[1] < P(8) && o[2] >= P(8));
|
||||
}
|
||||
let got = origins(&b);
|
||||
let want = expected_loft(&b, §ions);
|
||||
// (The smooth faces' middle-section segment is checked above.)
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!(w.iter().all(|x| g.contains(x)), "{g:?} vs {w:?}");
|
||||
}
|
||||
// A circle and a square: the circle is re-split at the square's corners, each piece from
|
||||
// the arcs it runs over.
|
||||
let round = (at(10.0), Loop2::circle(Vec2::ZERO, 6.0));
|
||||
let b = crate::loft(&[low, round]).unwrap();
|
||||
let got = origins(&b);
|
||||
assert_eq!(got.iter().filter(|o| o.len() > 1).count(), 4);
|
||||
for o in got.iter().filter(|o| o.len() > 1) {
|
||||
assert!(o[0] < P(4) && o[1..].iter().all(|x| *x == P(4) || *x == P(5)), "{o:?}");
|
||||
}
|
||||
// To a point: the base is the start cap, each triangle from its base segment.
|
||||
let base = rotated(&rect(-10.0, -10.0, 10.0, 10.0).reversed(), 1);
|
||||
let b = crate::loft_to_point(&Plane::XY, &base, Vec3::new(0.0, 0.0, 15.0)).unwrap();
|
||||
let want = points(&b)
|
||||
.iter()
|
||||
.map(|pts| {
|
||||
let foot: Vec<Vec2> = pts.iter().filter(|p| p.z.abs() < 1e-9).map(|p| Vec2::new(p.x, p.y)).collect();
|
||||
if foot.len() == pts.len() { vec![START] } else { under(&[&base], &foot, 1e-7).into_iter().map(P).collect() }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(origins(&b), want);
|
||||
}
|
||||
|
||||
/// Faces of a sweep: the start cap in the profile's plane; each segment's faces run the whole
|
||||
/// path, the first one standing on that segment.
|
||||
fn check_sweep(b: &Body, plane: &Plane, r: &Region2, faces_per_segment: Option<usize>) {
|
||||
let loops: Vec<&Loop2> = std::iter::once(&r.outer).chain(&r.holes).collect();
|
||||
let n: usize = loops.iter().map(|l| l.segs.len()).sum();
|
||||
let got = origins(b);
|
||||
let pts = points(b);
|
||||
assert_eq!(got.iter().filter(|o| **o == [START]).count(), 1);
|
||||
assert_eq!(got.iter().filter(|o| **o == [END]).count(), 1);
|
||||
for (o, p) in got.iter().zip(&pts) {
|
||||
let in_plane: Vec<Vec2> = p.iter().filter(|q| plane.height(**q).abs() < 1e-6).map(|q| plane.to_local(*q)).collect();
|
||||
// (Near the profile: a coil crosses its plane again further round.)
|
||||
let foot: Vec<Vec2> = in_plane
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|q| loops.iter().flat_map(|l| &l.segs).any(|s| s.point_at(s.closest_param(*q)).dist(*q) < 1e-3))
|
||||
.collect();
|
||||
if *o == [START] {
|
||||
assert_eq!(in_plane.len(), p.len());
|
||||
} else if let [FaceOrigin::Profile { segment }] = o.as_slice()
|
||||
&& foot.len() >= 2
|
||||
{
|
||||
assert_eq!(under(&loops, &foot, 1e-3), [*segment], "{foot:?}");
|
||||
}
|
||||
}
|
||||
let counts: Vec<usize> = (0..n).map(|k| got.iter().filter(|o| **o == [P(k)]).count()).collect();
|
||||
match faces_per_segment {
|
||||
Some(c) => assert!(counts.iter().all(|x| *x == c), "{counts:?}"),
|
||||
None => assert!(counts.windows(2).all(|w| w[0] == w[1] && w[0] > 0), "{counts:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeps_along_paths() {
|
||||
let r = Region2 { outer: rotated(&rect(-2.0, -2.0, 2.0, 2.0).reversed(), 1), holes: vec![] };
|
||||
// A line then a quarter turn (two parts): three faces per segment.
|
||||
let path = [
|
||||
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) },
|
||||
PathSeg::Arc { a: Vec3::new(0.0, 0.0, 10.0), center: Vec3::new(20.0, 0.0, 10.0), axis: Vec3::Y, angle: FRAC_PI_2 },
|
||||
];
|
||||
let b = crate::sweep(&Plane::XY, &r, &path).unwrap();
|
||||
check_sweep(&b, &Plane::XY, &r, Some(3));
|
||||
// Along a smooth 3D path: one wall per segment.
|
||||
let curve: Vec<Vec3> = (0..=40)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 40.0 * FRAC_PI_2;
|
||||
Vec3::new(30.0 * t.sin(), 30.0 * (1.0 - t.cos()), 10.0 * t)
|
||||
})
|
||||
.collect();
|
||||
let plane = Plane::from_normal(curve[0], (curve[1] - curve[0]).normalized().unwrap()).unwrap();
|
||||
let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 2.0), holes: vec![] };
|
||||
let b = crate::sweep_path(&plane, &circle, &curve).unwrap();
|
||||
check_sweep(&b, &plane, &circle, Some(1));
|
||||
// A path with a corner, a holed profile: ruled walls station to station.
|
||||
let holed = Region2 { outer: rect(-3.0, -3.0, 3.0, 3.0), holes: vec![rect(-1.0, -1.0, 1.0, 1.0).reversed()] };
|
||||
let b = crate::sweep_path(&Plane::XY, &holed, &[Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::new(20.0, 0.0, 20.0)]).unwrap();
|
||||
check_sweep(&b, &Plane::XY, &holed, Some(2));
|
||||
// A coil: a smooth wall per segment.
|
||||
let side = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
||||
let ring = Region2 { outer: Loop2::circle(Vec2::new(20.0, 0.0), 2.0), holes: vec![] };
|
||||
let b = crate::sweep_helix(&side, &ring, Vec3::ZERO, Vec3::Z, 8.0, 3.0).unwrap();
|
||||
check_sweep(&b, &side, &ring, Some(1));
|
||||
}
|
||||
|
||||
/// Kept faces are their origins, unchanged; each edge an operand still owns is that edge.
|
||||
fn check_kept(result: &Body, input: &Body) {
|
||||
let h = result.history().unwrap();
|
||||
h.check(result, &[input]).unwrap();
|
||||
let (rf, inf) = (result.faces(1e-3).unwrap(), input.faces(1e-3).unwrap());
|
||||
for (f, p) in rf.iter().zip(&h.faces) {
|
||||
if let (Change::Kept, [FaceOrigin::Face { operand: 0, face }]) = (p.change, p.origins.as_slice()) {
|
||||
assert!(f.centroid.dist(inf[*face].centroid) < 1e-6, "face {}", f.index);
|
||||
}
|
||||
}
|
||||
let (re, ie) = (result.edges(1e-3).unwrap(), input.edges(1e-3).unwrap());
|
||||
let mut owned = 0;
|
||||
for (e, p) in re.iter().zip(&h.edges) {
|
||||
for o in &p.origins {
|
||||
let crate::history::EdgeOrigin::Edge { operand: 0, edge } = o else { panic!("{o:?}") };
|
||||
assert!(e.mid.dist(ie[*edge].mid) < 1e-6, "edge {}", e.index);
|
||||
owned += 1;
|
||||
}
|
||||
}
|
||||
assert!(owned > 0);
|
||||
}
|
||||
|
||||
/// The input edge (among `edges`) nearest the face: the edge a wall stands on.
|
||||
fn nearest_edge(result: &Body, face: usize, input: &Body, edges: &[usize]) -> usize {
|
||||
let c = result.faces(1e-3).unwrap()[face].centroid;
|
||||
let mids = input.edges(1e-3).unwrap();
|
||||
*edges.iter().min_by(|a, b| c.dist(mids[**a].mid).total_cmp(&c.dist(mids[**b].mid))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thicken_reports_copies_offsets_and_walls() {
|
||||
let c = crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
let half = crate::copy_faces(&c, &[Vec3::new(7.0, 7.0, 10.0)]).unwrap();
|
||||
let t = crate::thicken(&half, 1.0, false).unwrap();
|
||||
check_kept(&t, &half);
|
||||
let h = t.history().unwrap();
|
||||
assert_eq!(h.faces[0].origins, [FaceOrigin::Face { operand: 0, face: 0 }]);
|
||||
assert_eq!(h.faces[1].origins, [FaceOrigin::Offset { operand: 0, face: 0 }]);
|
||||
let free: Vec<usize> = (0..half.edge_count()).collect();
|
||||
for (i, f) in h.faces.iter().enumerate().skip(2) {
|
||||
let [FaceOrigin::Edge { operand: 0, edge }] = f.origins.as_slice() else { panic!("{f:?}") };
|
||||
assert_eq!(*edge, nearest_edge(&t, i, &half, &free));
|
||||
}
|
||||
assert_eq!(h.faces.len(), 2 + half.edge_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raised_band_reports_kept_trimmed_and_new_faces() {
|
||||
let b = crate::box_solid(Vec3::new(-40.0, -30.0, 0.0), Vec3::new(40.0, 30.0, 30.0)).unwrap();
|
||||
let sh = crate::shell(&b, &[Vec3::new(0.0, 0.0, 30.0)], 2.0).unwrap();
|
||||
let lip = crate::raise_band(&sh, Vec3::new(0.0, 29.0, 30.0), 0.5, 1.5, false).unwrap();
|
||||
check_kept(&lip, &sh);
|
||||
let h = lip.history().unwrap();
|
||||
let rim = h.faces.iter().find(|f| f.change == Change::Modified).unwrap();
|
||||
let [FaceOrigin::Face { operand: 0, face: rim }] = rim.origins.as_slice() else { panic!() };
|
||||
// The rim's inner loop: the edges the walls stand on.
|
||||
let loop_edges: Vec<usize> =
|
||||
crate::history::edge_faces(&sh.solid).iter().enumerate().filter(|(_, fs)| fs.contains(rim)).map(|(i, _)| i).collect();
|
||||
let mut walls = 0;
|
||||
for (i, f) in h.faces.iter().enumerate() {
|
||||
if let [FaceOrigin::Edge { operand: 0, edge }] = f.origins.as_slice() {
|
||||
assert_eq!(*edge, nearest_edge(&lip, i, &sh, &loop_edges));
|
||||
walls += 1;
|
||||
}
|
||||
}
|
||||
// Two walls per inner edge (the face's and the band's), and the band's top.
|
||||
assert_eq!(walls, 8);
|
||||
assert_eq!(h.faces.iter().filter(|f| f.origins == [FaceOrigin::Offset { operand: 0, face: *rim }]).count(), 1);
|
||||
assert_eq!(h.faces.iter().filter(|f| f.change == Change::Kept).count(), sh.face_count() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_reports_kept_trimmed_and_profile_faces() {
|
||||
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
let plane = Plane::new(Vec3::Y * 10.0, -Vec3::X, Vec3::Z).unwrap();
|
||||
let profile = Region2 { outer: rotated(&rect(-2.0, 5.0, 2.0, 10.0), 1), holes: vec![] };
|
||||
for depth in [1.0, -1.0] {
|
||||
let e = crate::emboss_cylinder(&body, &plane, std::slice::from_ref(&profile), depth).unwrap();
|
||||
check_kept(&e, &body);
|
||||
let h = e.history().unwrap();
|
||||
assert_eq!(h.faces.iter().filter(|f| f.change == Change::Kept).count(), body.face_count() - 1);
|
||||
assert_eq!(h.faces.iter().filter(|f| f.change == Change::Modified).count(), 1);
|
||||
assert_eq!(h.faces.iter().filter(|f| f.origins == [END]).count(), 1);
|
||||
// Each wall over its segment: the sketch's y is the cylinder's z, its x the arc length.
|
||||
for (pts, f) in points(&e).iter().zip(&h.faces) {
|
||||
let [FaceOrigin::Profile { segment }] = f.origins.as_slice() else { continue };
|
||||
let back: Vec<Vec2> = pts.iter().map(|p| Vec2::new(10.0 * (-p.x).atan2(p.y), p.z)).collect();
|
||||
assert_eq!(under(&[&profile.outer], &back, 1e-6), [*segment]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use solvecraft_geom::{Plane, Region2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::history::{Change, EdgeOrigin, FaceOrigin};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
impl Body {
|
||||
@@ -430,10 +431,22 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
|
||||
Ok(nf)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (f, off) in faces.iter().zip(&offs) {
|
||||
// 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 mut history = Vec::new();
|
||||
let copy = |i: usize, d: f64| {
|
||||
if d == 0.0 {
|
||||
crate::record::from_face(0, i, Change::Kept)
|
||||
} else {
|
||||
crate::record::generated(vec![FaceOrigin::Offset { operand: 0, face: i }])
|
||||
}
|
||||
};
|
||||
for (i, (f, off)) in faces.iter().zip(&offs).enumerate() {
|
||||
// The lower copy faces back, the upper one forward.
|
||||
out.push(moved_face(f, off, 0, lo)?.inverse());
|
||||
out.push(moved_face(f, off, 1, hi)?);
|
||||
history.extend([copy(i, lo), copy(i, hi)]);
|
||||
// Walls along free edges, outward across the edge.
|
||||
for w in f.boundaries() {
|
||||
for e in w.edge_iter() {
|
||||
@@ -466,6 +479,8 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
|
||||
x
|
||||
};
|
||||
out.push(wall);
|
||||
let edge = edge_index.get(&abs_id).copied().ok_or_else(|| KernelError::Failed("thicken: an edge outside the surface".into()))?;
|
||||
history.push(crate::record::generated(vec![FaceOrigin::Edge { operand: 0, edge }]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,7 +488,18 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
|
||||
if shell.shell_condition() != mt::ShellCondition::Closed {
|
||||
return Err(KernelError::Failed("the thickened surface does not close".into()));
|
||||
}
|
||||
Body::new(mt::Solid::new_unchecked(vec![shell]))
|
||||
// The edges copied at no distance are the surface's own edges, rebuilt.
|
||||
let mut copies = HashMap::new();
|
||||
for (id, (low, high)) in &emoved {
|
||||
for (copy, d) in [(low, lo), (high, hi)] {
|
||||
if let Some(&edge) = edge_index.get(id)
|
||||
&& d == 0.0
|
||||
{
|
||||
copies.insert(copy.id(), EdgeOrigin::Edge { operand: 0, edge });
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::record::attach_edges(Body::new(mt::Solid::new_unchecked(vec![shell]))?, &[body], history, copies)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ 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));
|
||||
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,10 +149,11 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
|
||||
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) {
|
||||
return Ok(b);
|
||||
// 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()));
|
||||
}
|
||||
}
|
||||
ruled(plane, region, &pts)
|
||||
ruled(plane, region, &pts, &origins)
|
||||
}
|
||||
|
||||
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
@@ -170,8 +172,10 @@ 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.
|
||||
fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3]) -> Result<Body> {
|
||||
/// 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> {
|
||||
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() };
|
||||
@@ -212,7 +216,11 @@ fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3]) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("path sweep".into())) };
|
||||
cap_and_close(faces, first, last)
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user