From 8c81bc60d000a3c5ee756461b2091d545305f304 Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Sun, 11 Oct 2026 10:43:24 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8 --- crates/geom/src/profile.rs | 6 +- crates/kernel/src/build.rs | 249 +++++++++++--- crates/kernel/src/emboss.rs | 25 +- crates/kernel/src/helix.rs | 29 +- crates/kernel/src/history_tests.rs | 3 +- crates/kernel/src/lib.rs | 1 + crates/kernel/src/lip.rs | 26 +- crates/kernel/src/record.rs | 273 ++++++++++++++++ crates/kernel/src/record_tests.rs | 499 +++++++++++++++++++++++++++++ crates/kernel/src/surfaces.rs | 30 +- crates/kernel/src/sweep_path.rs | 18 +- 11 files changed, 1088 insertions(+), 71 deletions(-) create mode 100644 crates/kernel/src/record.rs create mode 100644 crates/kernel/src/record_tests.rs diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 348c9076..80fe248e 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -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 { let mut out = Vec::new(); diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 7834358a..7619f47b 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -18,7 +18,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { 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>], +) -> Result<(Vec, Vec<(mt::Edge, Seg2)>, Vec<(mt::EdgeID, Vec)>)> { + 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 { - 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 { + 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 = 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 = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect(); let loops1: Vec = loops0.iter().map(|l| offset_loop(l, d)).collect::>()?; + let origins = crate::record::loop_origins(std::slice::from_ref(region)); guard("tapered extrude", || { - let w0: Vec = loops0.iter().map(|l| wire(plane, l)).collect::>()?; + let (w0, _, seeds) = region_wires(plane, region, &origins)?; let w1: Vec = loops1.iter().map(|l| wire(&top_plane, l)).collect::>()?; let mut faces: Vec = 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::>()) .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 = (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 = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect(); + let mut chain_src: Vec = (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 { + 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> = (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::>>()) + .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| 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, 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>) { 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 = (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> = (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 { 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)> = 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 = lead.1.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect(); let mut wires: Vec = 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::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 = 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 { }); 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 = Vec::new(); @@ -854,8 +983,19 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result { 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> = + wires.iter().zip(&edge_sources).flat_map(|(w, src)| w.edge_iter().map(|e| e.id()).zip(src)).collect(); + let history: Option> = faces + .iter() + .map(|f| { + let held: Vec<&Vec> = 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 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 ends.push(cur); } let faces: Vec = 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 { /// [`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 { + // 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 { 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]) }) } diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs index 806af16f..245f7267 100644 --- a/crates/kernel/src/emboss.rs +++ b/crates/kernel/src/emboss.rs @@ -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, 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, 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, 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, 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, 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, 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)?)) }) } diff --git a/crates/kernel/src/helix.rs b/crates/kernel/src/helix.rs index ecae8385..8aef6877 100644 --- a/crates/kernel/src/helix.rs +++ b/crates/kernel/src/helix.rs @@ -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)> = 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 { 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 { let base: Vec = (0..m).filter_map(|i| Some(builder::line(verts.get(i)?, verts.get((i + 1) % m)?))).collect(); let sides: Vec = 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) }) } diff --git a/crates/kernel/src/history_tests.rs b/crates/kernel/src/history_tests.rs index 03d807c0..637715c1 100644 --- a/crates/kernel/src/history_tests.rs +++ b/crates/kernel/src/history_tests.rs @@ -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] diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index bb71c63b..2f912546 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -47,6 +47,7 @@ mod prism; mod prism_curve; mod prism_measure; pub mod provenance; +mod record; mod sew; mod smooth_edges; mod splitface; diff --git a/crates/kernel/src/lip.rs b/crates/kernel/src/lip.rs index edef8544..9418eac3 100644 --- a/crates/kernel/src/lip.rs +++ b/crates/kernel/src/lip.rs @@ -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 = 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::>().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> = (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("; "); } diff --git a/crates/kernel/src/record.rs b/crates/kernel/src/record.rs new file mode 100644 index 00000000..232e2183 --- /dev/null +++ b/crates/kernel/src/record.rs @@ -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 { + 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>> { + 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> { + 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) -> FaceProv { + let numbers: std::collections::BTreeSet = 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>) -> Vec { + 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 { + 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) -> 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) -> Result { + 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, copies: HashMap) -> Result { + 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, + seeds: &[mt::EdgeID], + skip: Option, + run: Column, +) -> Option>> { + let faces: Vec<&mt::Face> = faces.into_iter().collect(); + let mut by_edge: HashMap> = 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> = 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 = 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 = 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)], closed: bool) -> Option> { + 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, + seeds: &[(mt::EdgeID, Vec)], + skip: Option, + run: Column, +) -> Option>> { + let ids: Vec = 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, + seeds: &[(mt::EdgeID, Vec)], + run: Column, +) -> Option> { + let mut out: Vec = columns(faces, seeds, None, run)?.into_iter().collect::>()?; + 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>) -> Result { + match faces { + Some(faces) => attach(body, operands, faces), + None => Ok(body), + } +} + +#[cfg(test)] +#[path = "record_tests.rs"] +mod tests; diff --git a/crates/kernel/src/record_tests.rs b/crates/kernel/src/record_tests.rs new file mode 100644 index 00000000..c8d4c2d0 --- /dev/null +++ b/crates/kernel/src/record_tests.rs @@ -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::>(), crate::history::edge_faces(&b.solid)); + h +} + +/// Each face's origins. +fn origins(b: &Body) -> Vec> { + sketch_made(b).faces.iter().map(|f| f.origins.clone()).collect() +} + +/// Mesh vertices of each face. +fn points(b: &Body) -> Vec> { + 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>) { + 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> { + let (corners, centres) = mesh_points(b); + let flat = |pts: &Vec| pts.iter().map(|p| to_2d(*p)).collect::>(); + 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 { + let segs: Vec = 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> { + 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 = got.into_iter().flatten().collect(); + all.sort(); + assert_eq!(all, [(0..8).map(P).collect::>(), 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> { + 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> = points(&b) + .iter() + .map(|pts| { + let foot: Vec = 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> { + let bases: Vec = 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 = 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 = 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::>(); + 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) { + 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 = 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 = 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 = (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 = (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 = (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 = + 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 = 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]); + } + } +} diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index f2eff4e4..39d5f4a1 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -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 { 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 { 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 { 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) }) } diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index 60c82097..df644163 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -139,6 +139,7 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result 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 Result> { @@ -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 { +/// 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>]) -> Result { let pts: Vec = 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 { (*pts.get(i + 1)? - *pts.get(i)?).normalized() }; @@ -212,7 +216,11 @@ fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3]) -> Result { } } 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)> = + 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) }) }