Merge upstream and update curved-cut regression for fallback diagnostics

This commit is contained in:
CypherNaugh_0x committed 2026-10-11 13:25:06 +02:00
commit 79753040d5
188 files changed
+30308 -1635

No files matched your search

+279 -27
View File
@@ -5,6 +5,9 @@ use truck_modeling as mt;
use crate::{KernelError, Result, guard};
#[path = "shell_groups.rs"]
mod shell_groups;
pub(crate) type Solid = mt::Solid;
/// A solid body (one or more closed shells), or a mesh body (triangles only, like an imported
@@ -24,6 +27,31 @@ pub struct Body {
/// Lines the user split faces along (Split Face), as polylines: export keeps the faces on
/// either side apart where it would otherwise join pieces of one surface.
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
/// Exact construction/import source identities, in this body's face order.
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum FaceSource {
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane> },
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solvecraft_geom::Plane> },
ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane },
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solvecraft_geom::Plane },
Box { producer: Option<u64>, axis: u8, high: bool, plane: solvecraft_geom::Plane },
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
}
pub type CapSignature = (Vec<String>, Vec<Vec<String>>);
type FaceSources = std::sync::Arc<Vec<Option<FaceSource>>>;
impl FaceSource {
fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> {
match self {
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane),
Self::Imported { .. } => None,
}
}
}
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
@@ -284,6 +312,7 @@ impl Body {
&& self.color == other.color
&& self.paint == other.paint
&& self.splits == other.splits
&& self.face_sources == other.face_sources
}
pub(crate) fn new(solid: Solid) -> Result<Body> {
@@ -321,9 +350,186 @@ impl Body {
color: None,
paint: None,
splits: None,
face_sources: None,
})
}
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
self.face_sources.as_deref().map(Vec::as_slice)
}
/// An exact construction/import plane; never inferred from tessellation.
pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> {
if index >= 50_000 {
return None;
}
let source = self.face_sources()?.get(index)?.as_ref()?;
match source {
FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane,
FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane),
FaceSource::Imported { .. } => self.planar_face(index),
}
}
/// Exact current planar surface for an explicitly fixed snapshot, without source lineage.
pub fn planar_face(&self, index: usize) -> Option<solvecraft_geom::Plane> {
if index >= 50_000 {
return None;
}
guard("exact planar face", || {
let plane = self.solid.face_iter().nth(index).and_then(|face| match face.oriented_surface() {
mt::Surface::Plane(plane) => {
let u = plane.u_axis();
let v = plane.v_axis();
solvecraft_geom::Plane::new(from_p3(plane.origin()), Vec3::new(u.x, u.y, u.z), Vec3::new(v.x, v.y, v.z))
}
_ => None,
});
Ok(plane)
})
.ok()
.flatten()
}
pub fn with_box_certificate(mut self, producer: u64) -> Result<Self> {
if producer == 0 {
return Err(KernelError::Invalid("invalid box producer".into()));
}
if let Some(sources) = &self.face_sources {
let mut sources = sources.as_ref().clone();
for source in sources.iter_mut().flatten() {
if let FaceSource::Box { producer: id, .. } = source {
*id = Some(producer);
}
}
self.face_sources = Some(std::sync::Arc::new(sources));
}
Ok(self)
}
/// Cap signatures are in face order; unknown or ambiguous source curves stay uncertified.
pub fn with_extrude_cap_certificates(mut self, producer: u64, sketch: u64, signatures: &[Option<CapSignature>]) -> Result<Self> {
if producer == 0 || sketch == 0 {
return Err(KernelError::Invalid("invalid cap producer".into()));
}
if let Some(sources) = &self.face_sources {
if sources.len() != signatures.len() {
return Err(KernelError::Invalid("cap certificate count mismatch".into()));
}
let mut sources = sources.as_ref().clone();
for (source, signature) in sources.iter_mut().zip(signatures) {
if let Some(FaceSource::ExtrudeCap { high, plane, .. }) = source {
let Some((outer, holes)) = signature else {
*source = None;
continue;
};
if outer.len() > 4096 || holes.len() > 4096 || holes.iter().any(|hole| hole.len() > 4096) {
return Err(KernelError::Invalid("oversized cap curve signature".into()));
}
let count = outer.len() + holes.iter().map(Vec::len).sum::<usize>();
if outer.is_empty()
|| count > 4096
|| holes.len() > 4096
|| outer.iter().chain(holes.iter().flatten()).map(String::len).sum::<usize>() > 65_536
|| outer.iter().chain(holes.iter().flatten()).any(|id| id.is_empty() || id.len() > 128)
{
return Err(KernelError::Invalid("invalid cap curve signature".into()));
}
let mut outer = outer.clone();
outer.sort();
outer.dedup();
let mut holes = holes.clone();
for hole in &mut holes {
if hole.is_empty() {
return Err(KernelError::Invalid("empty cap hole signature".into()));
}
hole.sort();
hole.dedup();
}
holes.sort();
if holes.windows(2).any(|pair| pair[0] == pair[1]) || holes.iter().flatten().any(|id| outer.binary_search(id).is_ok()) {
return Err(KernelError::Invalid("ambiguous cap hole signature".into()));
}
*source = Some(FaceSource::CertifiedExtrudeCap { producer, sketch, high: *high, outer, holes, plane: *plane });
}
}
self.face_sources = Some(std::sync::Arc::new(sources));
}
Ok(self)
}
pub(crate) fn transformed_sources(&self, m: [[f64; 4]; 4]) -> Result<Option<FaceSources>> {
let Some(sources) = &self.face_sources else { return Ok(None) };
let vector = |p: Vec3| {
Vec3::new(
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z,
m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z,
m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z,
)
};
let translation = Vec3::new(m[3][0], m[3][1], m[3][2]);
let reflected = vector(Vec3::X).dot(vector(Vec3::Y).cross(vector(Vec3::Z))) < 0.;
let mut sources = sources.as_ref().clone();
for source in sources.iter_mut().flatten() {
if let Some(plane) = source.plane_mut() {
*plane = solvecraft_geom::Plane::new(
vector(plane.origin) + translation,
vector(plane.x),
vector(plane.y) * if reflected { -1. } else { 1. },
)
.ok_or_else(|| KernelError::Invalid("degenerate transformed face plane".into()))?;
}
}
Ok(Some(std::sync::Arc::new(sources)))
}
/// Bind constructed edges to uniquely proven source curves without changing face names.
pub fn with_extrude_certificates(mut self, producer: u64, sketch: u64, curves: &[Option<String>]) -> Result<Self> {
if producer == 0 || sketch == 0 || curves.iter().flatten().any(|curve| curve.is_empty() || curve.len() > 128) {
return Err(KernelError::Invalid("invalid constructed side certificate".into()));
}
if let Some(sources) = &self.face_sources {
if sources.len() != curves.len() {
return Err(KernelError::Invalid("side certificate count mismatch".into()));
}
let sources = sources
.iter()
.zip(curves)
.map(|(source, curve)| match (source, curve) {
(Some(FaceSource::ExtrudeSide { plane, .. }), Some(curve)) => {
Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone(), plane: *plane })
}
(Some(FaceSource::ExtrudeSide { .. }), None) => None,
_ => source.clone(),
})
.collect();
self.face_sources = Some(std::sync::Arc::new(sources));
}
Ok(self)
}
pub fn with_import_certificate(mut self, digest: &str, producer: u64) -> Result<Self> {
if producer == 0 || digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) {
return Err(KernelError::Invalid("invalid immutable import certificate".into()));
}
if let Some(sources) = &self.face_sources {
let mut sources = sources.as_ref().clone();
for source in sources.iter_mut().flatten() {
if let FaceSource::Imported { payload_sha256, producer: origin, .. } = source {
*payload_sha256 = Some(digest.into());
*origin = Some(producer);
}
}
self.face_sources = Some(std::sync::Arc::new(sources));
}
Ok(self)
}
pub(crate) fn with_face_sources(mut self, sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>) -> Self {
self.face_sources = sources;
self
}
/// A deep copy of the solid that can be mutated without affecting other bodies.
pub(crate) fn deep_copy(&self) -> Solid {
mt::builder::clone(&*self.solid)
@@ -674,51 +880,97 @@ impl Body {
/// The separate pieces of a body: each outer shell with the voids inside it. A body in one
/// piece comes back as itself.
pub fn lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(0.0)
}
/// A boolean shortcut needs clearance larger than both shell mesh deflections.
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
}
fn lumps_with_clearance(&self, clearance: f64) -> Result<Vec<Body>> {
if self.mesh.is_some() || self.solid.boundaries().len() < 2 {
return Ok(vec![self.clone()]);
}
let tol = (self.size() * 1e-3).max(1e-3);
guard("lumps", || {
let shells: Vec<mt::Shell> = self.solid.boundaries().clone();
let meshes: Vec<(f64, Option<Mesh>)> = shells
if shells.len() > shell_groups::MAX_SHELLS
|| shells.iter().flat_map(mt::Shell::vertex_iter).take(shell_groups::MAX_POINTS + 1).count() > shell_groups::MAX_POINTS
|| shells.iter().flat_map(mt::Shell::face_iter).take(shell_groups::MAX_TRIANGLES + 1).count() > shell_groups::MAX_TRIANGLES
{
return Err(KernelError::Failed("lumps: topology exceeds the shell grouping limit".into()));
}
let (mut point_count, mut triangle_count) = (0usize, 0usize);
let data: Vec<(f64, Mesh)> = shells
.iter()
.map(|sh| {
let pm = sh.robust_triangulation(tol).to_polygon();
let v = pm.volume();
let meshed = sh.robust_triangulation(tol);
if meshed.face_iter().any(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())) {
return Err(KernelError::Failed("lumps: cannot mesh a complete shell boundary".into()));
}
let pm = meshed.to_polygon();
point_count = point_count
.checked_add(pm.positions().len())
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
triangle_count = triangle_count
.checked_add(pm.faces().triangle_iter().take(shell_groups::MAX_TRIANGLES + 1).count())
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
if point_count > shell_groups::MAX_POINTS || triangle_count > shell_groups::MAX_TRIANGLES {
return Err(KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()));
}
let mut m = Mesh::default();
for p in pm.positions() {
m.positions.push(Vec3::new(p.x, p.y, p.z));
}
for t in pm.faces().triangle_iter() {
let idx = |k: usize| t.get(k).map(|x| u32::try_from(x.pos).unwrap_or(0)).unwrap_or(0);
m.triangles.push([idx(0), idx(1), idx(2)]);
let idx = |k: usize| {
t.get(k).and_then(|x| u32::try_from(x.pos).ok()).ok_or_else(|| KernelError::Failed("lumps: invalid mesh index".into()))
};
let indices = [idx(0)?, idx(1)?, idx(2)?];
let [a, b, c] = m.tri(&indices).ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))?;
// STEP sphere poles can produce zero-area duplicate triangles.
// They have no boundary area or volume and are not ray crossings.
if (b - a).cross(c - a) == Vec3::ZERO {
continue;
}
m.triangles.push(indices);
m.tri_face.push(0);
}
(v, Some(m))
let origin = m.positions.first().copied().ok_or_else(|| KernelError::Failed("lumps: empty mesh".into()))?;
let v = m
.triangles
.iter()
.map(|t| {
m.tri(t)
.map(|[a, b, c]| (a - origin).dot((b - origin).cross(c - origin)) / 6.0)
.ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.sum();
Ok((v, m))
})
.collect();
let outer: Vec<usize> = (0..shells.len()).filter(|i| meshes.get(*i).is_some_and(|m| m.0 > 0.0)).collect();
if outer.len() < 2 {
.collect::<Result<_>>()?;
let (volumes, meshes): (Vec<_>, Vec<_>) = data.into_iter().unzip();
let groups = shell_groups::groups(&meshes, &volumes, (self.size() * 1e-8).max(1e-9).max(clearance))?;
if groups.len() == 1 {
return Ok(vec![self.clone()]);
}
let mut groups: Vec<Vec<mt::Shell>> = outer.iter().filter_map(|i| shells.get(*i).cloned()).map(|s| vec![s]).collect();
for (i, sh) in shells.iter().enumerate() {
if outer.contains(&i) {
continue;
}
// A void goes with the outer shell around one of its points.
let probe = sh.vertex_iter().next().map(|v| from_p3(v.point()));
let owner = outer.iter().position(|o| {
let (Some(p), Some((_, Some(m)))) = (probe, meshes.get(*o)) else { return false };
m.contains(p)
});
if let Some(k) = owner
&& let Some(g) = groups.get_mut(k)
{
g.push(sh.clone());
}
}
groups.into_iter().map(|g| Solid::try_new(g).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)).collect()
groups
.into_iter()
.map(|g| {
let shells = g
.into_iter()
.map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into())))
.collect::<Result<Vec<_>>>()?;
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)
})
.collect()
})
}
}
#[cfg(test)]
#[path = "body_lump_tests.rs"]
mod lump_tests;
+224
View File
@@ -0,0 +1,224 @@
use super::*;
use crate::{BoolOp, boolean, box_solid, measure, sphere, step_export, step_import};
fn cube(half: f64) -> Body {
box_solid(Vec3::new(-half, -half, -half), Vec3::new(half, half, half)).unwrap()
}
fn nested_island() -> Body {
let hollow = boolean(&cube(50.0), &cube(40.0), BoolOp::Cut).unwrap().unwrap();
// This order puts the containing outer shell ahead of the disconnected island.
let joined = boolean(&cube(20.0), &hollow, BoolOp::Union).unwrap().unwrap();
boolean(&joined, &cube(10.0), BoolOp::Cut).unwrap().unwrap()
}
fn assert_material(body: &Body, expected: f64, probes: &[(Vec3, bool)]) {
assert!(body.validity().is_empty(), "{:?}", body.validity());
let mesh = body.tessellate_with(0.01, true).unwrap();
assert!((mesh.measure().volume - expected).abs() < expected * 1e-6, "{} vs {expected}", mesh.measure().volume);
for (p, want) in probes {
assert_eq!(mesh.contains(*p), *want, "material at {p:?}");
}
}
fn check_nested(body: &Body) {
let probes = [(Vec3::ZERO, false), (Vec3::X * 15.0, true), (Vec3::X * 30.0, false), (Vec3::X * 45.0, true)];
assert_material(body, 544_000.0, &probes);
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) {
assert!((got - want).abs() < 1e-6, "{volumes:?}");
}
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in probes {
assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), usize::from(want), "lump material at {p:?}");
}
}
#[test]
fn nested_cavities_stay_with_their_material_islands_for_every_shell_order() {
let body = nested_island();
check_nested(&body);
let before = step_export(&[&body], "nested cavities").unwrap();
let shells = body.deep_copy().boundaries().clone();
assert_eq!(shells.len(), 4);
for a in 0..4 {
for b in 0..4 {
for c in 0..4 {
for d in 0..4 {
let order = [a, b, c, d];
if order.iter().enumerate().any(|(i, x)| order.iter().skip(i + 1).any(|y| x == y)) {
continue;
}
let permuted = guard("permuted cavity shells", || {
Body::new(Solid::try_new(order.into_iter().map(|i| shells[i].clone()).collect()).unwrap())
})
.unwrap();
check_nested(&permuted);
}
}
}
}
assert_eq!(step_export(&[&body], "nested cavities").unwrap(), before);
}
#[test]
fn nested_voids_survive_step_round_trip_and_further_cuts() {
let body = nested_island();
let text = step_export(&[&body], "nested cavities").unwrap();
let imported = step_import(&text).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let mut lumps = Vec::new();
for imported in imported.bodies {
lumps.extend(imported.body.lumps().unwrap());
}
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) {
assert!((got - want).abs() < 1e-3, "{volumes:?}");
}
let opening = box_solid(Vec3::new(5.0, -2.0, -2.0), Vec3::new(25.0, 2.0, 2.0)).unwrap();
let mut cut_volume = 0.0;
for lump in &lumps {
if let Some(cut) = boolean(lump, &opening, BoolOp::Cut).unwrap() {
cut_volume += measure(&cut).unwrap().volume;
assert!(!cut.tessellate_with(0.01, true).unwrap().contains(Vec3::X * 15.0));
}
}
// Only x=10..20 is material along this 4×4 opening: 160 mm³ removed.
assert!((cut_volume - 543_840.0).abs() < 1e-3, "{cut_volume}");
}
#[test]
fn curved_internal_void_and_disconnected_material_keep_analytic_volume() {
let hollow = boolean(&cube(20.0), &sphere(Vec3::ZERO, 10.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
let island = sphere(Vec3::ZERO, 4.0).unwrap();
let joined = boolean(&island, &hollow, BoolOp::Union).unwrap().unwrap();
let reverse = boolean(&hollow, &island, BoolOp::Union).unwrap().unwrap();
assert!((measure(&joined).unwrap().volume - measure(&reverse).unwrap().volume).abs() < 1e-4);
let body = boolean(&joined, &sphere(Vec3::ZERO, 2.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
let pi = std::f64::consts::PI;
let want = [4.0 / 3.0 * pi * (64.0 - 8.0), 64_000.0 - 4.0 / 3.0 * pi * 1000.0];
for (got, want) in volumes.iter().zip(want) {
assert!((got - want).abs() < want * 0.002, "{got} vs {want}");
}
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), want);
}
let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect();
assert_eq!(imported.len(), 2);
let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
assert_eq!(imported_meshes.iter().filter(|m| m.contains(p)).count(), want);
}
}
fn joined_shells(shells: Vec<mt::Shell>) -> Body {
guard("invalid ownership fixture", || Body::new(Solid::try_new(shells).unwrap())).unwrap()
}
#[test]
fn unowned_overlapping_and_touching_cavities_are_errors_not_dropped_geometry() {
let outer = cube(50.0);
for (min, max) in [
(Vec3::new(60.0, 60.0, 60.0), Vec3::new(70.0, 70.0, 70.0)),
(Vec3::new(45.0, -5.0, -5.0), Vec3::new(55.0, 5.0, 5.0)),
(Vec3::new(40.0, -5.0, -5.0), Vec3::new(50.0, 5.0, 5.0)),
] {
let mut inner = box_solid(min, max).unwrap().deep_copy();
inner.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(inner.boundaries()).cloned().collect());
let before = step_export(&[&body], "invalid ownership").unwrap();
assert!(body.lumps().is_err());
assert_eq!(step_export(&[&body], "invalid ownership").unwrap(), before);
}
let mut a = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0)).unwrap().deep_copy();
a.not();
let mut b = box_solid(Vec3::new(0.0, -10.0, -10.0), Vec3::new(20.0, 10.0, 10.0)).unwrap().deep_copy();
b.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(a.boundaries()).chain(b.boundaries()).cloned().collect());
assert!(body.lumps().is_err());
}
#[test]
fn shell_grouping_preserves_large_disconnected_patterns_and_bounds_hostile_work() {
let mesh = cube(1.0).tessellate_with(0.01, true).unwrap();
let copies: Vec<_> = (0..512)
.map(|i| {
let mut m = mesh.clone();
for p in &mut m.positions {
p.x += f64::from(i) * 4.0;
}
m
})
.collect();
assert_eq!(shell_groups::groups(&copies, &vec![8.0; 512], 1e-8).unwrap().len(), 512);
let excessive = vec![Mesh::default(); shell_groups::MAX_SHELLS + 1];
assert!(shell_groups::groups(&excessive, &vec![1.0; excessive.len()], 1e-8).unwrap_err().to_string().contains("shell count"));
let mut oversized = mesh.clone();
oversized.positions.resize(shell_groups::MAX_POINTS + 1, Vec3::ZERO);
assert!(shell_groups::groups(&[oversized], &[8.0], 1e-8).unwrap_err().to_string().contains("grouping limit"));
let mut repeated = mesh.clone();
repeated.triangles = mesh.triangles.iter().copied().cycle().take(7000).collect();
assert!(shell_groups::groups(&[repeated.clone(), repeated], &[8.0, 8.0], 1e-8).unwrap_err().to_string().contains("boundary work"));
let mut outer = cube(2.0).tessellate_with(0.01, true).unwrap();
let mut inner = mesh;
outer.triangles = outer.triangles.iter().copied().cycle().take(4000).collect();
inner.triangles = inner.triangles.iter().copied().cycle().take(4000).collect();
inner.positions.resize(4000, Vec3::ZERO);
assert!(shell_groups::groups(&[outer, inner], &[64.0, -8.0], 1e-8).unwrap_err().to_string().contains("containment work"));
}
#[test]
fn translated_and_scaled_nested_voids_keep_material_membership() {
let body = nested_island();
for scale in [0.01, 0.25, 2.0] {
let origin = Vec3::new(1234.0, -987.0, 765.0);
let transformed = crate::transform_matrix(
&body,
[[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, scale, 0.0], [origin.x, origin.y, origin.z, 1.0]],
)
.unwrap();
let lumps = transformed
.lumps()
.unwrap_or_else(|e| panic!("scale={scale}: {e}; bounds={:?}", transformed.tessellate_with(1e-4, true).unwrap().bounds()));
assert_eq!(lumps.len(), 2);
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with((scale * 0.01).max(1e-5), true).unwrap()).collect();
for (x, want) in [(0.0, 0), (15.0, 1), (30.0, 0), (45.0, 1)] {
assert_eq!(meshes.iter().filter(|m| m.contains(origin + Vec3::X * (x * scale))).count(), want);
}
}
}
#[test]
fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
let a = sphere(Vec3::ZERO, 10.0).unwrap();
for distance in [19.999, 20.001] {
let b = sphere(direction * distance, 10.0).unwrap();
for shells in [
a.deep_copy().boundaries().iter().chain(b.deep_copy().boundaries()).cloned().collect(),
b.deep_copy().boundaries().iter().chain(a.deep_copy().boundaries()).cloned().collect(),
] {
assert!(joined_shells(shells).separated_lumps().is_err(), "distance {distance}");
}
}
let far = sphere(direction * 21.0, 10.0).unwrap();
let safe = crate::ops::join_pieces(vec![a, far]).unwrap().unwrap();
assert_eq!(safe.separated_lumps().unwrap().len(), 2);
let outer = sphere(Vec3::ZERO, 10.0).unwrap();
let mut protruding = sphere(direction * 9.001, 1.0).unwrap().deep_copy();
protruding.not();
let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect());
assert!(invalid.separated_lumps().is_err());
}
+143 -7
View File
@@ -15,6 +15,10 @@ fn finite(x: f64, what: &str) -> Result<f64> {
/// A closed wire in 3D from a sketch loop.
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)>)> {
let n = lp.segs.len();
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid(format!("loop with {n} segments")));
@@ -24,6 +28,7 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
}
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
let mut edges = Vec::with_capacity(n);
let mut sources = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
let e: mt::Edge = match *s {
@@ -35,7 +40,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
// Split big arcs so every piece is well conditioned.
if sweep.abs() > std::f64::consts::PI * 1.5 {
let vm = builder::vertex(p3(plane.to_world(s.mid())));
edges.push(builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25)))));
let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))));
sources.push((first.clone(), *s));
edges.push(first);
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
} else {
builder::circle_arc(a, b, p3(plane.to_world(s.mid())))
@@ -45,9 +52,10 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?
}
};
sources.push((e.clone(), *s));
edges.push(e);
}
Ok(edges.into())
Ok((edges.into(), sources))
}
/// A planar face from a region (outer loop with holes).
@@ -82,6 +90,15 @@ fn region_ok(r: &Region2) -> Result<()> {
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
|| (plane.x.len2() - 1.).abs() > 1e-8
|| (plane.y.len2() - 1.).abs() > 1e-8
|| plane.x.dot(plane.y).abs() > 1e-8
{
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
if hi - lo < 1e-6 {
@@ -95,15 +112,105 @@ 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 f = face(&base, r)?;
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 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)))))?;
out.push(Body::new(solid)?);
let upper = guard("extrude cap identities", || {
let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect();
let mut visits = 0usize;
let mut candidate = None;
for (index, generated) in solid.face_iter().enumerate() {
if index >= 50_000 {
return Ok(None);
}
let mut side = false;
for edge in generated.absolute_boundaries().iter().flatten() {
visits += 1;
if visits > 200_000 {
return Ok(None);
}
side |= inputs.contains(&edge.id());
}
if generated.id() != f.id() && !side {
if candidate.is_some() {
return Ok(None);
}
candidate = Some(generated.id());
}
}
Ok(candidate)
})?;
let body = Body::new(solid)?;
// tsweep retains each actual input boundary edge on its generated side.
// Match topology identity, never face order, surface samples or centroids.
let provenance = guard("extrude source identities", || {
let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect();
let mut result = Vec::new();
for generated in body.solid.face_iter() {
let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect();
let source =
if generated.id() == f.id() {
upper.and_then(|_| {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096).then(|| {
crate::FaceSource::ExtrudeCap {
high: false,
region: r.clone(),
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
}
})
})
} else if Some(generated.id()) == upper {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096)
.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) })
} else if let [segment] = matching.as_slice() {
let basis = match **segment {
Seg2::Line { a, b } => {
let d = b - a;
d.normalized().and_then(|direction| {
let foot = a - direction * a.dot(direction);
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
})
}
_ => None,
};
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis })
} else {
None
};
result.push(source);
}
Ok(result)
})?;
out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance))));
}
Ok(out)
}
/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction
/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution).
/// Choose the per-profile axis sign whose positive rotation follows the sketch normal.
pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result<Vec3> {
if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() {
return Err(KernelError::Invalid("non-finite revolve frame".into()));
}
if (plane.x.len2() - 1.).abs() > 1e-8 || (plane.y.len2() - 1.).abs() > 1e-8 || plane.x.dot(plane.y).abs() > 1e-8 {
return Err(KernelError::Invalid("revolve frame is not orthonormal".into()));
}
region_ok(region)?;
let direction = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?;
let axis = plane.dir_to_world(direction).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?;
let origin = plane.to_world(axis_origin);
let center = plane.to_world(region.centroid());
let radial = center - origin - axis * (center - origin).dot(axis);
Ok(if axis.cross(radial).dot(plane.normal()) < 0. { -axis } else { axis })
}
/// Revolve each region about the sketch axis by `angle` radians.
/// This legacy API preserves its historical sweep sign behavior.
pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result<Vec<Body>> {
let angle = finite(angle, "angle")?;
if angle.abs() < 1e-6 {
@@ -230,7 +337,36 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
holes: vec![],
};
let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?;
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
let body = v.pop().ok_or_else(|| KernelError::Failed("box".into()))?;
let sources = body.face_sources().ok_or_else(|| KernelError::Failed("box construction source".into()))?;
let mut roles = std::collections::HashSet::new();
let mut certified = Vec::new();
for source in sources {
let role = match source {
Some(crate::FaceSource::ExtrudeCap { high, .. }) => Some((2u8, *high)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == lo.x => Some((0, false)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == hi.x => Some((0, true)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == lo.y => Some((1, false)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == hi.y => Some((1, true)),
_ => None,
}
.ok_or_else(|| KernelError::Failed("box role correspondence".into()))?;
if !roles.insert(role) {
return Err(KernelError::Failed("duplicate box role".into()));
}
let (axis, high) = role;
let sign = if high { 1. } else { -1. };
let plane = match axis {
0 => Plane { origin: Vec3::X * if high { hi.x } else { lo.x }, x: Vec3::Y, y: Vec3::Z * sign },
1 => Plane { origin: Vec3::Y * if high { hi.y } else { lo.y }, x: Vec3::X, y: -Vec3::Z * sign },
_ => Plane { origin: Vec3::Z * if high { hi.z } else { lo.z }, x: Vec3::X, y: Vec3::Y * sign },
};
certified.push(Some(crate::FaceSource::Box { producer: None, axis, high, plane }));
}
if roles.len() != 6 {
return Err(KernelError::Failed("box role count".into()));
}
Ok(body.with_face_sources(Some(std::sync::Arc::new(certified))))
}
/// Where primitives start their circles (radians from the frame's x axis).
+236 -44
View File
@@ -19,6 +19,144 @@ fn unsupported(msg: &str) -> KernelError {
/// Samples per edge for the contact curves and the blend surface.
const SAMPLES: usize = 24;
const MAX_SAMPLES: usize = 369;
const MAX_CHAIN_SAMPLES: usize = 100_000;
struct Fit {
surface: mt::Surface,
contacts: [mt::Curve; 2],
/// Parameter range belonging to this edge (the fit includes neighbouring sections).
range: (f64, f64),
}
struct Guide {
sections: Vec<Section>,
lengths: Vec<f64>,
}
impl Guide {
fn new(sections: Vec<Section>) -> Result<Self> {
let mut lengths = vec![0.0];
let mut total = 0.0;
for pair in sections.windows(2) {
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
total += a.c.dist(b.c);
lengths.push(total);
}
if !total.is_finite() || total <= 1e-12 {
return Err(unsupported("degenerate rolling-ball spine"));
}
Ok(Self { sections, lengths })
}
fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> {
if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) {
return None;
}
let distance = self.lengths.last()? * along;
let hi = self.lengths.partition_point(|length| *length < distance).clamp(1, self.lengths.len().checked_sub(1)?);
let (a, b) = (self.sections.get(hi - 1)?, self.sections.get(hi)?);
let (lo, hi_length) = (self.lengths.get(hi - 1)?, self.lengths.get(hi)?);
let span = hi_length - lo;
if span <= 1e-12 {
return None;
}
let fraction = (distance - lo) / span;
Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?))
}
}
/// One interpolation for both the face and its boundary curves. Fitting the contacts
/// independently gives different end conditions and leaves gaps between the same faces.
fn fit_sections(sections: &[Vec<Section>], k: usize, closed: bool, round: bool) -> Result<Fit> {
use mt::Cut;
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
let n = sections.len();
let list = sections.get(k).ok_or_else(|| fail("section"))?;
let before = usize::from(closed || k > 0) * 2;
let mut ext = Vec::with_capacity(list.len() + 4);
if before > 0 {
let prev = sections.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
}
ext.extend(list.iter().copied());
if closed || k + 1 < n {
let next = sections.get((k + 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(next.iter().skip(1).take(2).copied());
}
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
let mut rows = Vec::with_capacity(3);
let mut vknots = None;
for j in 0..3 {
let xyz: Vec<Vec3> = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.x, c.y, c.z)).collect();
let ww: Vec<Vec3> = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.w, 0.0, 0.0)).collect();
let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
vknots = Some(bx.knot_vec().clone());
rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect::<Vec<_>>());
}
let vk = vknots.ok_or_else(|| fail("knots"))?;
let den = ext.len().saturating_sub(1) as f64;
if den <= 0.0 {
return Err(fail("empty sections"));
}
let range = (before as f64 / den, (before + list.len().saturating_sub(1)) as f64 / den);
let contact = |row: usize| -> Result<mt::Curve> {
let points = rows.get(row).cloned().ok_or_else(|| fail("contact row"))?;
let bs = mt::BSplineCurve::try_new(vk.clone(), points).map_err(|e| fail(&e.to_string()))?;
let mut curve = mt::NurbsCurve::new(bs);
if range.0 > 0.0 {
curve = curve.cut(range.0);
}
if range.1 < 1.0 {
let _ = curve.cut(range.1);
}
Ok(mt::Curve::NurbsCurve(curve))
};
let contacts = [contact(0)?, contact(2)?];
let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?;
Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range })
}
/// Compare the fitted strip with a solved circular section between interpolation nodes.
/// This checks the supporting contacts as well as the middle of the fillet band.
fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f64, tolerance: f64) -> bool {
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let Some(ctrl) = section_controls(section, round) else { return false };
let v = fit.range.0 + (fit.range.1 - fit.range.0) * along;
for u in [0.0, 0.25, 0.5, 0.75, 1.0] {
let [a, b, c] = ctrl;
let h = a * (1.0 - u) * (1.0 - u) + b * (2.0 * u * (1.0 - u)) + c * u * u;
if !h.w.is_finite() || h.w <= 1e-12 {
return false;
}
let exact = Vec3::new(h.x / h.w, h.y / h.w, h.z / h.w);
// The input intersection edge may use piecewise leader-curve parameters. A
// parameter-speed change is not a geometric error: compare nearest points, with
// contacts constrained to the corresponding boundary and all probes to the strip.
let (probe_u, probe_v) = if u == 0.0 || u == 1.0 {
let Some(contact) = fit.contacts.get(usize::from(u == 1.0)) else { return false };
let Some(parameter) = contact.search_nearest_parameter(p3(exact), v, 32) else { return false };
(u, parameter.clamp(fit.range.0, fit.range.1))
} else {
let Some((pu, pv)) = fit.surface.search_nearest_parameter(p3(exact), Some((u, v)), 32) else { return false };
(pu.clamp(0.0, 1.0), pv.clamp(fit.range.0, fit.range.1))
};
let actual = from_p3(fit.surface.subs(probe_u, probe_v));
if !actual.is_finite() || actual.dist(exact) > tolerance {
return false;
}
if round && (u == 0.0 || u == 1.0) {
let Some(want) = ((section.c - exact) * sigma).normalized() else { return false };
let normal = fit.surface.normal(probe_u, probe_v);
let Some(normal) = Vec3::new(normal.x, normal.y, normal.z).normalized() else { return false };
if normal.dot(want).abs() < 0.5f64.to_radians().cos() {
return false;
}
}
}
true
}
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
#[derive(Clone, Copy, Debug)]
@@ -102,6 +240,16 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
/// Blend the closed chain of edges (ids in any order) with radius `r`.
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<Solid> {
curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES)
}
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<Solid> {
if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
}
if !(4..=MAX_SAMPLES).contains(&max_samples) {
return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into()));
}
let tol = (size * 1e-7).max(1e-9);
let shells = solid.boundaries();
let (si, shell) = shells
@@ -303,16 +451,85 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
vsec.push(s);
}
let mut esec: Vec<Vec<Section>> = Vec::with_capacity(n);
for (k, e) in chain.iter().enumerate() {
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
for j in 1..SAMPLES - 1 {
let (p, t) = tangent_at(e, j as f64 / (SAMPLES - 1) as f64).ok_or_else(|| KernelError::Failed("tangent".into()))?;
list.push(solve(&s1, &s2, p, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?);
// Uniform refinement preserves a common spacing across neighbouring patches. Work is
// capped: a difficult strip returns an error instead of accepting an inaccurate fit.
let fit_tolerance = (size * 1e-5).clamp(1e-5, 0.001);
// At a seam the contact lies on the seam plane, which need not be square to the
// original edge. Its ball centre therefore need not lie in the edge's normal plane.
// Interpolate that plane's displacement so the interior sections tend to the exact
// seam sections, rather than leaving a finite jump as sampling gets finer.
let offsets: Vec<(f64, f64)> = chain
.iter()
.enumerate()
.map(|(k, edge)| {
let (p0, t0) = tangent_at(edge, 0.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let (p1, t1) = tangent_at(edge, 1.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let start = vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?;
let end = vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?;
Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1)))
})
.collect::<Result<_>>()?;
let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<Section> {
let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?;
let anchor = p + t * (start * (1.0 - along) + end * along);
solve(&s1, &s2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
};
let mut samples = SAMPLES.min(max_samples);
let (esec, fits) = loop {
if n.checked_mul(samples).is_none_or(|count| count > MAX_CHAIN_SAMPLES) {
return Err(unsupported("curve blend exceeds the section fitting limit"));
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
esec.push(list);
}
let mut guides = Vec::with_capacity(n);
for (k, e) in chain.iter().enumerate() {
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
for j in 1..samples - 1 {
list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
guides.push(Guide::new(list)?);
}
// Intersection curves inherit their leader's piecewise parameter speed. Fitting
// those parameters creates oscillations even on a geometrically smooth spine.
// Resample by distance along the rolling centres, projecting each interpolated
// centre back onto the exact two offset surfaces before it becomes a section.
let uniform_section = |k: usize, along: f64| -> Result<Section> {
let guide = guides.get(k).ok_or_else(|| KernelError::Failed("spine guide".into()))?;
if along == 0.0 {
return guide.sections.first().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
if along == 1.0 {
return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
solve(&s1, &s2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
};
let esec: Vec<Vec<Section>> = (0..n)
.map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>())
.collect::<Result<_>>()?;
let fits = crate::guard("fit curve blend", || (0..n).map(|k| fit_sections(&esec, k, closed, round)).collect::<Result<Vec<_>>>())?;
let mut accurate = true;
'edges: for k in 0..n {
let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
for j in 0..samples - 1 {
for fraction in [0.25, 0.5, 0.75] {
let along = (j as f64 + fraction) / (samples - 1) as f64;
let section = uniform_section(k, along)?;
if !crate::guard("check curve blend fit", || Ok(section_fits(fit, &section, along, round, sigma, fit_tolerance)))? {
accurate = false;
break 'edges;
}
}
}
}
if accurate {
break (esec, fits);
}
if samples >= max_samples {
return Err(KernelError::Failed("curve blend: could not fit the rolling-ball surface within the accuracy and section limits".into()));
}
samples = (2 * samples - 1).min(max_samples);
};
crate::guard("curve blend", || {
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
// Vertices and arcs at the chain's vertices.
@@ -334,47 +551,18 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut c2: Vec<mt::Edge> = Vec::with_capacity(n);
let mut blends: Vec<mt::Surface> = Vec::with_capacity(n);
for (k, list) in esec.iter().enumerate() {
let q1: Vec<Vec3> = list.iter().map(|s| s.q1).collect();
let q2: Vec<Vec3> = list.iter().map(|s| s.q2).collect();
let k1 = (k + 1) % nv;
let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1));
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
let cv1 = crate::build::interpolate_cubic(&q1).ok_or_else(|| fail("contact curve"))?;
let cv2 = crate::build::interpolate_cubic(&q2).ok_or_else(|| fail("contact curve"))?;
let e1 = mt::Edge::new(a1, b1, mt::Curve::BSplineCurve(cv1));
let e2 = mt::Edge::new(a2, b2, mt::Curve::BSplineCurve(cv2));
// The blend surface: each control row interpolated along the edge (homogeneous),
// through two sections of each neighbouring edge too, so that neighbouring blend
// faces meet smoothly.
let mut ext: Vec<Section> = Vec::with_capacity(list.len() + 4);
if closed || k > 0 {
let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
}
ext.extend(list.iter().copied());
if closed || k + 1 < n {
let next = esec.get((k + 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(next.iter().skip(1).take(2).copied());
}
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
let mut rows: Vec<Vec<mt::Vector4>> = Vec::with_capacity(3);
let mut vknots = None;
for j in 0..3 {
let xyz: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].x, c[j].y, c[j].z)).collect();
let ww: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].w, 0.0, 0.0)).collect();
let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
vknots = Some(bx.knot_vec().clone());
rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect());
}
let vk = vknots.ok_or_else(|| fail("knots"))?;
let uk = mt::KnotVec::bezier_knot(2);
let bs = mt::BSplineSurface::try_new((uk, vk), rows).map_err(|e| fail(&e.to_string()))?;
let mut surface = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs));
let fit = fits.get(k).ok_or_else(|| fail("fit"))?;
let [cv1, cv2] = &fit.contacts;
let e1 = mt::Edge::new(a1, b1, cv1.clone());
let e2 = mt::Edge::new(a2, b2, cv2.clone());
let mut surface = fit.surface.clone();
// Outward: toward the ball centre on a concave edge, away on a convex one.
{
use mt::{ParametricSurface3D, SearchNearestParameter};
let s = list.get(SAMPLES / 2).ok_or_else(|| fail("section"))?;
let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?;
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r;
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v);
@@ -527,3 +715,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))
})
}
#[cfg(test)]
#[path = "curveblend_tests.rs"]
mod tests;
+194
View File
@@ -0,0 +1,194 @@
//! Independent analytic reference: the centre of a ball tangent to two perpendicular
//! cylinders lies on the intersection of their radius-offset cylinders.
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use super::*;
use crate::{Body, BoolOp, boolean, cylinder, fillet};
fn tee(scale: f64, origin: Vec3) -> Body {
let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap();
let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();
boolean(&main, &branch, BoolOp::Union).unwrap().unwrap()
}
fn analytic_section(theta: f64, scale: f64, origin: Vec3) -> (Vec3, Vec3, Vec3) {
let centre = Vec3::new(13.0 * theta.cos(), 13.0 * theta.sin(), (324.0 - 169.0 * theta.sin().powi(2)).sqrt());
let main = Vec3::new(centre.x, centre.y * 15.0 / 18.0, centre.z * 15.0 / 18.0);
let branch = Vec3::new(centre.x * 10.0 / 13.0, centre.y * 10.0 / 13.0, centre.z);
(origin + centre * scale, origin + main * scale, origin + branch * scale)
}
fn nearest_blend(surfaces: &[mt::Surface], point: Vec3) -> (f64, Vec3) {
surfaces
.iter()
.filter_map(|s| {
let (u, v) = s.search_nearest_parameter(p3(point), None, 100)?;
let actual = from_p3(s.subs(u, v));
let n = s.normal(u, v);
Some((actual.dist(point), Vec3::new(n.x, n.y, n.z).normalized()?))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.unwrap()
}
fn distance_to_analytic_arc(theta: f64, point: Vec3) -> f64 {
let (centre, main, branch) = analytic_section(theta, 1.0, Vec3::ZERO);
let a = (main - centre).normalized().unwrap();
let b = (branch - centre).normalized().unwrap();
let plane = a.cross(b).normalized().unwrap();
let radial = point - centre;
let projected = radial - plane * radial.dot(plane);
let direction = projected.normalized().unwrap();
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
let from_main = a.dot(direction).clamp(-1.0, 1.0).acos();
let from_branch = b.dot(direction).clamp(-1.0, 1.0).acos();
if (from_main + from_branch - angle).abs() < 1e-7 { point.dist(centre + direction * 3.0) } else { point.dist(main).min(point.dist(branch)) }
}
fn distance_to_analytic_band(point: Vec3) -> f64 {
// The arc's XY azimuth stays between its two contacts' azimuths. Their difference
// for this tee is less than 0.1 rad; bracket generously around the query azimuth.
let azimuth = point.y.atan2(point.x);
let (mut lo, mut hi) = (azimuth - 0.3, azimuth + 0.3);
for _ in 0..55 {
let a = lo + (hi - lo) * 0.38196601125;
let b = lo + (hi - lo) * 0.61803398875;
if distance_to_analytic_arc(a, point) < distance_to_analytic_arc(b, point) {
hi = b;
} else {
lo = a;
}
}
distance_to_analytic_arc((lo + hi) * 0.5, point)
}
#[test]
fn fitted_contact_edges_coincide_with_their_surface_boundaries() {
let sections: Vec<Vec<Section>> = (0..4)
.map(|k| {
(0..SAMPLES)
.map(|i| {
let theta = std::f64::consts::FRAC_PI_2 * (k as f64 + i as f64 / (SAMPLES - 1) as f64);
let (c, q1, q2) = analytic_section(theta, 1.0, Vec3::ZERO);
Section { c, q1, q2 }
})
.collect()
})
.collect();
for k in 0..sections.len() {
let fit = crate::guard("contact consistency", || fit_sections(&sections, k, true, true)).unwrap();
for (side, curve) in fit.contacts.iter().enumerate() {
let (lo, hi) = curve.range_tuple();
for i in 0..=200 {
let parameter = lo + (hi - lo) * i as f64 / 200.0;
let edge = from_p3(curve.subs(parameter));
let surface = from_p3(fit.surface.subs(side as f64, parameter));
assert!(edge.dist(surface) < 1e-9, "contact-to-surface gap {}", edge.dist(surface));
}
}
}
}
#[test]
fn rolling_ball_matches_analytic_tee_and_contacts() {
for (scale, origin) in [(1.0, Vec3::ZERO), (0.25, Vec3::new(23.0, -17.0, 9.0))] {
let body = tee(scale, origin);
let point = origin + Vec3::new(0.0, 10.0, 125f64.sqrt()) * scale;
let blend = fillet(&body, &[point], 3.0 * scale).unwrap();
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
assert!(!surfaces.is_empty());
let mut distance: f64 = 0.0;
let mut tangent_angle: f64 = 0.0;
for i in 0..96 {
// Offset the angular samples from patch seams; contact endpoints are tested below.
let theta = std::f64::consts::TAU * (i as f64 + 0.37) / 96.0;
let (centre, main, branch) = analytic_section(theta, scale, origin);
let a = (main - centre).normalized().unwrap();
let b = (branch - centre).normalized().unwrap();
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
// Spherical linear interpolation of the radial unit vectors, independent of
// the production rational control-point construction and fitting parameters.
let radial = (a * ((1.0 - fraction) * angle).sin() + b * (fraction * angle).sin()) / angle.sin();
let exact = centre + radial * (3.0 * scale);
let (error, normal) = nearest_blend(&surfaces, exact);
distance = distance.max(error);
if fraction == 0.0 || fraction == 1.0 {
let want = (centre - exact).normalized().unwrap();
tangent_angle = tangent_angle.max(normal.dot(want).abs().min(1.0).acos().to_degrees());
}
}
}
assert!(distance <= 0.005, "scale {scale}: analytic surface deviation {distance} mm");
assert!(tangent_angle <= 0.5, "scale {scale}: contact normals differ by {tangent_angle} degrees");
// The reverse direction uses points on the actual trimmed face mesh, so an
// untrimmed neighbouring surface cannot conceal an overshooting blend patch.
let mesh = blend.tessellate_with(0.002, true).unwrap();
let mut reverse_distance: f64 = 0.0;
let mut probes = 0;
for point in mesh.positions.iter().step_by(19) {
let p = (*point - origin) / scale;
if (p.x * p.x + p.y * p.y).sqrt() <= 10.000001
|| (p.y * p.y + p.z * p.z).sqrt() <= 15.000001
|| !(10.0..20.0).contains(&p.z)
|| p.x.abs() > 16.0
{
continue;
}
probes += 1;
reverse_distance = reverse_distance.max(distance_to_analytic_band(p) * scale);
}
assert!(probes > 300, "only {probes} trimmed surface points checked");
assert!(reverse_distance <= 0.005, "scale {scale}: reverse analytic deviation {reverse_distance} mm");
eprintln!("scale {scale}: surface {distance:.9} mm, reverse {reverse_distance:.9} mm, contact normals {tangent_angle:.6} degrees");
let mut contact_count = 0;
for edge in Body::unique_edges(&blend.solid) {
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
let endpoints = [from_p3(curve.subs(lo)), from_p3(curve.subs(hi))].map(|p| (p - origin) / scale);
if endpoints.iter().any(|p| !(9.0..20.0).contains(&p.z) || p.x.abs() > 16.0) {
continue;
}
let branch = endpoints.iter().all(|p| ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 1e-5);
let main = endpoints.iter().all(|p| ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 1e-5);
if !branch && !main {
continue;
}
contact_count += 1;
for i in 0..=200 {
let p = (from_p3(curve.subs(lo + (hi - lo) * i as f64 / 200.0)) - origin) / scale;
let radius_error = if branch { ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() } else { ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() };
assert!(radius_error * scale <= 0.002, "supporting cylinder deviation {} mm", radius_error * scale);
}
}
assert!(contact_count >= 4, "only {contact_count} contacts checked");
}
}
#[test]
fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
let body = tee(1.0, Vec3::ZERO);
let solid = body.deep_copy();
let ids: Vec<_> = Body::unique_edges(&solid)
.iter()
.filter(|edge| {
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
let p = from_p3(curve.subs((lo + hi) * 0.5));
p.z > 10.0 && ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 0.01 && ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 0.01
})
.map(mt::Edge::id)
.collect();
assert!(!ids.is_empty());
let before = crate::step_export(&[&body], "unchanged").unwrap();
let input_before = crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap();
// Four sections deliberately undersample the saddle; production starts at 24.
let error = crate::guard("limited rolling ball", || curve_blend_with_limit(&solid, &ids, body.size(), 3.0, true, 4)).unwrap_err();
assert!(error.to_string().contains("accuracy and section limits"), "{error}");
assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
}
+730
View File
@@ -0,0 +1,730 @@
//! Exact radial embossing of orthogonal profiles on complete cylindrical walls.
use crate::body::{Body, Solid, from_p3, p3};
use crate::offset::{Surf, surf_of};
use crate::{KernelError, Result, guard};
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling::{self as mt, Transformed, builder};
fn fail(s: &str) -> KernelError {
KernelError::Invalid(format!("cylinder emboss: {s}"))
}
fn angle(p: Vec3, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3) -> f64 {
let w = p - o;
let w = w - axis * w.dot(axis);
w.dot(tangent).atan2(w.dot(radial))
}
fn unwrap(a: f64, around: f64) -> f64 {
around + (a - around + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI
}
fn wall_angles(face: &mt::Face, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3, tol: f64) -> Option<(f64, f64)> {
if face.boundaries().len() != 1 || face.edge_iter().count() != 4 {
return None;
}
for edge in face.edge_iter() {
if let Some(Some(mid)) = crate::offset::arc_mid(&edge, tol * 10.) {
let am = angle(mid, o, axis, radial, tangent);
let aa = unwrap(angle(from_p3(edge.front().point()), o, axis, radial, tangent), am);
let ab = unwrap(angle(from_p3(edge.back().point()), o, axis, radial, tangent), am);
return Some((aa.min(ab), aa.max(ab)));
}
}
None
}
/// Certify a tessellated pick and project it onto the cylinder used by emboss.
pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result<Vec3> {
if !at.is_finite() || at.len() > 1e9 {
return Err(fail("invalid selected face point"));
}
guard("cylinder emboss anchor", || {
let f = body.solid.face_iter().nth(face).ok_or_else(|| fail("selected face"))?;
let tol = (body.size() * 1e-8).max(1e-8);
let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(f, tol) else {
return Err(fail("selected face is not an exterior cylinder"));
};
let w = at - o;
let radial = w - a * w.dot(a);
if (radial.len() - r).abs() > (body.size() * 1e-3).max(1e-3).min(r * 0.05) {
return Err(fail("selected point is too far from cylinder"));
}
Ok(o + a * w.dot(a) + radial.normalized().ok_or_else(|| fail("radial point"))? * r)
})
}
/// Wrap sketch arc length around an exterior cylinder, retaining exact radial depth.
/// Profiles must contain orthogonal lines and lie inside one uninterrupted wall face.
pub fn emboss_cylinder(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
emboss_impl(body, None, plane, regions, depth, false).and_then(EmbossOutcome::body)
}
/// As [`emboss_cylinder`], requiring the saved selected point to identify that wall.
pub fn emboss_cylinder_at(body: &Body, at: Vec3, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
if !at.is_finite() || at.len() > 1e9 {
return Err(fail("invalid selected face point"));
}
emboss_impl(body, Some(at), plane, regions, depth, false).and_then(EmbossOutcome::body)
}
enum EmbossOutcome {
Body(Body),
Anchor(usize, Vec3),
NoMatch,
}
impl EmbossOutcome {
fn body(self) -> Result<Body> {
match self {
Self::Body(body) => Ok(body),
Self::Anchor(..) | Self::NoMatch => Err(fail("unexpected qualification result")),
}
}
}
/// Qualify a tangent cylindrical target without changing it. A tangent exterior
/// cylinder with an unsupported profile or depth returns an explicit error;
/// a body without such a tangent wall returns `None` for planar compatibility.
/// The anchor lies on a validated profile boundary, clear of seams and endcaps.
pub fn infer_cylinder_emboss(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Option<(usize, Vec3)>> {
if body.is_mesh() {
return Ok(None);
}
guard("cylinder emboss inference", || {
let tol = (body.size() * 1e-8).max(1e-8);
let mut tangent = false;
for (index, face) in body.solid.face_iter().enumerate() {
if index >= 50_000 {
return Err(fail("target search exceeds face work limit"));
}
if let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(face, tol) {
let w = plane.origin - o;
let normal = plane.normal();
let signed = w.dot(normal);
if normal.dot(a).abs() < 1e-8 && (signed.abs() - r).abs() <= tol * 10.0 {
let radial = if signed < 0.0 { -normal } else { normal };
// Every supported uninterrupted wall uses one angle interval
// around this tangent normal, contained in (-pi,pi). A remote
// footprint entirely outside even that global interval cannot
// select this body, regardless of other unsupported faces.
if !profile_interval_overlaps(plane, regions, o, a.cross(radial), -std::f64::consts::PI * r, std::f64::consts::PI * r)? {
continue;
}
if let Some((lo, hi)) = wall_angles(face, o, a, radial, a.cross(radial), tol) {
if lo >= -tol / r || hi <= tol / r {
continue;
}
if !profile_interval_overlaps(plane, regions, o, a.cross(radial), lo * r, hi * r)? {
continue;
}
}
let relevant = match face_axial_bounds(face, o, a)? {
Some((lo, hi)) => profile_interval_overlaps(plane, regions, o, a, lo, hi)?,
None => true,
};
if relevant && signed < 0.0 {
return Err(fail("sketch normal must point outwards from the exterior wall"));
}
tangent |= relevant;
}
}
}
if !tangent {
return Ok(None);
}
match emboss_impl(body, None, plane, regions, depth, true)? {
EmbossOutcome::Anchor(index, at) => Ok(Some((index, at))),
EmbossOutcome::NoMatch => Ok(None),
EmbossOutcome::Body(_) => Err(fail("unexpected construction result")),
}
})
}
/// Whether a body contains an analytic exterior cylindrical wall. This bounded
/// preflight avoids resolving cylinder placement context for ordinary planar targets.
pub fn has_cylinder_emboss_wall(body: &Body) -> Result<bool> {
if body.is_mesh() {
return Ok(false);
}
guard("cylinder emboss target", || {
let tol = (body.size() * 1e-8).max(1e-8);
for (index, face) in body.solid.face_iter().enumerate() {
if index >= 50_000 {
return Err(fail("target search exceeds face work limit"));
}
if matches!(surf_of(face, tol), Some(Surf::Cylinder { convex: true, .. })) {
return Ok(true);
}
}
Ok(false)
})
}
fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Region2], depth: f64, qualify: bool) -> Result<EmbossOutcome> {
body.require_brep("cylinder emboss")?;
if !depth.is_finite() || depth.abs() < 1e-6 || depth.abs() > 1e6 || regions.is_empty() || regions.len() > 64 {
return Err(fail("needs finite nonzero depth and 1..64 profiles"));
}
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
|| (plane.x.len() - 1.0).abs() > 1e-8
|| (plane.y.len() - 1.0).abs() > 1e-8
|| plane.x.dot(plane.y).abs() > 1e-8
{
return Err(fail("invalid sketch frame"));
}
guard("cylinder emboss", || {
if body.solid.boundaries().len() != 1 || body.solid.face_iter().take(17).count() > 16 || body.solid.edge_iter().take(257).count() > 256 {
return Err(fail("requires a complete cylinder or coaxial sleeve"));
}
let faces: Vec<_> = body.solid.face_iter().cloned().collect();
let tol = (body.size() * 1e-8).max(1e-8);
let surfaces: Vec<_> =
faces.iter().map(|f| surf_of(f, tol).ok_or_else(|| fail("requires only cylindrical walls and planar endcaps"))).collect::<Result<_>>()?;
let mut chosen = None;
for (fi, s) in surfaces.iter().enumerate() {
let Surf::Cylinder { o, a, r, convex: true } = *s else { continue };
let w = plane.origin - o;
let radial = plane.normal();
if radial.dot(a).abs() > 1e-8 || (w.dot(radial) - r).abs() > tol * 10.0 {
continue;
}
let radial = radial.normalized().ok_or_else(|| fail("radial frame"))?;
let tangent = a.cross(radial);
let f = faces.get(fi).ok_or_else(|| fail("face"))?;
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
continue;
}
let Some((alo, ahi)) = wall_angles(f, o, a, radial, tangent, tol) else {
continue;
};
if alo >= -tol / r || ahi <= tol / r {
continue;
}
if chosen.is_some() {
return Err(fail("ambiguous cylindrical face"));
}
chosen = Some((fi, o, a, r, radial, tangent, alo, ahi));
}
let (fi, o, axis, r, radial, tangent, alo, ahi) =
chosen.ok_or_else(|| fail("sketch must be tangent to one complete exterior cylindrical wall, clear of its seam"))?;
if qualify && !profile_interval_overlaps(plane, regions, o, tangent, alo * r, ahi * r)? {
return Ok(EmbossOutcome::NoMatch);
}
let mut caps = Vec::new();
let mut inner: f64 = 0.0;
for s in &surfaces {
match *s {
Surf::Plane { n, d } if n.dot(axis).abs() > 1.0 - 1e-8 => caps.push((d - n.dot(o)) / n.dot(axis)),
Surf::Cylinder { o: co, a: ca, r: cr, convex }
if ca.dot(axis).abs() > 1.0 - 1e-8 && (co - o - axis * (co - o).dot(axis)).len() < tol * 10.0 =>
{
if convex && (cr - r).abs() > tol * 10.0 {
return Err(fail("interrupted or stepped cylinder"));
}
if !convex {
inner = inner.max(cr);
}
}
_ => return Err(fail("requires a complete cylinder or coaxial sleeve")),
}
}
if caps.len() != 2 {
return Err(fail("requires two planar endcaps"));
}
let lo = caps.iter().copied().fold(f64::INFINITY, f64::min);
let hi = caps.iter().copied().fold(f64::NEG_INFINITY, f64::max);
// An infinite cylinder can be tangent while its finite wall is far from
// the selected footprint. Conservative analytic/control-polygon bounds
// exclude that body before unsupported-profile or depth diagnostics.
if qualify && !profile_interval_overlaps(plane, regions, o, axis, lo, hi)? {
return Ok(EmbossOutcome::NoMatch);
}
let rr = r + depth;
if rr <= inner + tol * 10.0 {
return Err(fail("depth reaches the axis or inner wall"));
}
if let Some(at) = anchor {
let h = (at - o).dot(axis);
let v = at - o - axis * h;
let theta = angle(at, o, axis, radial, tangent);
if (v.len() - r).abs() > tol * 10.0 || theta <= alo + tol / r || theta >= ahi - tol / r || h <= lo + tol || h >= hi - tol {
return Err(fail("saved selection no longer identifies the sketch's cylindrical face"));
}
}
let z0 = (plane.origin - o).dot(axis);
let s0 = (plane.origin - o).dot(tangent);
let map2 = |p: Vec2| Vec2::new(s0 + plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis));
let mut count = 0usize;
let mut mapped = Vec::new();
for reg in regions {
let mut loops = Vec::new();
for lp in std::iter::once(&reg.outer).chain(&reg.holes) {
count = count.saturating_add(lp.segs.len());
if count > 4096 {
return Err(fail("profile geometry budget"));
}
if lp.segs.iter().any(|segment| !matches!(segment, Seg2::Line { .. })) {
return Err(fail("only orthogonal line profiles are supported on cylinders"));
}
if lp.segs.len() < 4 || !lp.is_closed(tol) {
return Err(fail("profile geometry budget or open loop"));
}
let mut segs = Vec::new();
for s in &lp.segs {
let Seg2::Line { a, b } = *s else {
return Err(fail("only orthogonal line profiles are supported on cylinders"));
};
if !a.is_finite() || !b.is_finite() || a.len() > 1e7 || b.len() > 1e7 {
return Err(fail("nonfinite or excessive profile coordinates"));
}
let (a, b) = (map2(a), map2(b));
let d = b - a;
if d.len() < tol || (d.x.abs() > tol && d.y.abs() > tol) {
return Err(fail("profile edges must follow the cylinder axis or circumference"));
}
for p in [a, b] {
if p.x / r <= alo + tol / r || p.x / r >= ahi - tol / r || p.y <= lo + tol || p.y >= hi - tol {
return Err(fail("profile crosses a cylinder seam or endcap boundary"));
}
}
segs.push(Seg2::Line { a, b });
}
loops.push(Loop2 { segs });
}
let outer = loops.first().ok_or_else(|| fail("profile"))?.ccw();
let holes = loops.iter().skip(1).map(|l| l.ccw().reversed()).collect();
mapped.push(Region2 { outer, holes });
}
validate_profiles(&mapped, tol)?;
if qualify {
let segment = mapped.first().and_then(|region| region.outer.segs.first()).ok_or_else(|| fail("empty profile"))?;
let p = (segment.start() + segment.end()) * 0.5;
let at = o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * r;
return Ok(EmbossOutcome::Anchor(fi, at));
}
let at = |p: Vec2, radius: f64| o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * radius;
let selected = faces.get(fi).ok_or_else(|| fail("face"))?;
let mut base_patches = vec![selected.absolute_boundaries().clone()];
let mut hole_patches = Vec::new();
let mut footprints = Vec::new();
let mut added = Vec::new();
let k = rr / r;
let coord = |v: Vec3| [v.x, v.y, v.z];
let av = coord(axis);
let ov = coord(o);
let mut m = [[0.0; 4]; 4];
for (j, col) in m.iter_mut().take(3).enumerate() {
for (i, slot) in col.iter_mut().take(3).enumerate() {
*slot = if i == j { k } else { 0.0 } + (1.0 - k) * av.get(i).copied().unwrap_or(0.0) * av.get(j).copied().unwrap_or(0.0);
}
}
for (i, oi) in ov.iter().enumerate() {
let translation =
*oi - (0..3).map(|j| m.get(j).and_then(|row| row.get(i)).copied().unwrap_or(0.0) * ov.get(j).copied().unwrap_or(0.0)).sum::<f64>();
if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(i)) {
*slot = translation;
}
}
if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(3)) {
*slot = 1.0;
}
let [c0, c1, c2, c3] = m;
let [a, b, c, d] = c0;
let [e, f, g, h] = c1;
let [i, j, l, n] = c2;
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);
for reg in &mapped {
let mut cap = Vec::new();
for (li, lp) in std::iter::once(&reg.outer).chain(&reg.holes).enumerate() {
let (base, top, walls) = wrapped_loop(lp, r, rr, &at)?;
if li == 0 {
footprints.push((lp, base.inverse()));
} else {
let index = base_patches.len();
base_patches.push(vec![base.inverse()]);
hole_patches.push((lp, index));
}
cap.push(top);
added.extend(walls);
}
added.push(mt::Face::try_new(cap, raised.clone()).map_err(|e| fail(&e.to_string()))?);
}
// An island inside a profile hole cuts that hole's original-radius patch,
// rather than adding another hole to the already-cut exterior patch.
for (lp, wire) in footprints {
let probe = lp.segs.first().ok_or_else(|| fail("empty profile"))?.start();
let owner = hole_patches
.iter()
.filter(|(hole, _)| hole.contains(probe))
.min_by(|(a, _), (b, _)| a.signed_area().abs().total_cmp(&b.signed_area().abs()))
.map(|(_, index)| *index)
.unwrap_or(0);
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();
for patch in base_patches {
all.push(mt::Face::try_new(patch, selected.oriented_surface()).map_err(|e| fail(&e.to_string()))?);
}
all.extend(added);
let solid = Solid::try_new(vec![all.into()]).map_err(|e| fail(&e.to_string()))?;
let result = Body::new(solid)?;
if !result.validity().is_empty() {
return Err(fail("rebuilt cylinder is not a closed manifold"));
}
Ok(EmbossOutcome::Body(result))
})
}
fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis: Vec3, lo: f64, hi: f64) -> Result<bool> {
let z0 = (plane.origin - o).dot(axis);
let direction = Vec2::new(plane.x.dot(axis), plane.y.dot(axis));
let mut count = 0usize;
let mut overlaps = false;
for region in regions {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for segment in &region.outer.segs {
count = count.saturating_add(1);
if count > 4096 {
return Err(fail("profile geometry budget"));
}
let mut add = |p: Vec2| -> Result<()> {
if !p.is_finite() || p.len() > 1e7 {
return Err(fail("nonfinite or excessive profile coordinates"));
}
let z = z0 + p.dot(direction);
min = min.min(z);
max = max.max(z);
Ok(())
};
match *segment {
Seg2::Line { a, b } => {
add(a)?;
add(b)?;
}
Seg2::Arc { center, radius, .. } => {
add(center)?;
if !radius.is_finite() || radius <= 0.0 || radius > 1e7 {
return Err(fail("invalid profile radius"));
}
let z = z0 + center.dot(direction);
min = min.min(z - radius * direction.len());
max = max.max(z + radius * direction.len());
}
Seg2::Cubic { p0, p1, p2, p3 } => {
for p in [p0, p1, p2, p3] {
add(p)?;
}
}
Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => {
for p in [a, apex, b] {
add(p)?;
}
}
Seg2::Bezier { n, p } if (1..=7).contains(&n) => {
for p in p.iter().take(usize::from(n) + 1) {
add(*p)?;
}
}
_ => return Err(fail("unsupported profile geometry")),
}
}
overlaps |= min < hi && max > lo;
}
Ok(overlaps)
}
fn face_axial_bounds(face: &mt::Face, o: Vec3, axis: Vec3) -> Result<Option<(f64, f64)>> {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
let mut count = 0usize;
let mut add = |point: Vec3| -> Result<()> {
count = count.saturating_add(1);
if count > 100_000 || !point.is_finite() {
return Err(fail("target boundary work limit or nonfinite geometry"));
}
let z = (point - o).dot(axis);
lo = lo.min(z);
hi = hi.max(z);
Ok(())
};
for edge in face.edge_iter() {
match edge.curve() {
mt::Curve::Line(_) => {
add(from_p3(edge.front().point()))?;
add(from_p3(edge.back().point()))?;
}
mt::Curve::BSplineCurve(curve) => {
for point in curve.control_points() {
add(from_p3(*point))?;
}
}
mt::Curve::NurbsCurve(curve) => {
for point in curve.control_points() {
if !point.w.is_finite() || point.w <= 0.0 {
return Ok(None);
}
add(Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w))?;
}
}
mt::Curve::IntersectionCurve(_) => return Ok(None),
}
}
Ok((lo.is_finite() && hi.is_finite()).then_some((lo, hi)))
}
fn wrapped_loop(lp: &Loop2, r: f64, rr: f64, at: &impl Fn(Vec2, f64) -> Vec3) -> Result<(mt::Wire, mt::Wire, Vec<mt::Face>)> {
let base: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), r)))).collect();
let top: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), rr)))).collect();
let radial: Vec<_> = base.iter().zip(&top).map(|(a, b)| builder::line(a, b)).collect();
let mut be = Vec::new();
let mut te = Vec::new();
let mut walls = Vec::new();
for (i, s) in lp.segs.iter().enumerate() {
let next = (i + 1) % lp.segs.len();
let (Some(a), Some(b), Some(c), Some(d), Some(ra), Some(rb)) =
(base.get(i), base.get(next), top.get(i), top.get(next), radial.get(i), radial.get(next))
else {
return Err(fail("loop topology"));
};
let circular = (s.end().x - s.start().x).abs() > 1e-10;
let eb = if circular { builder::circle_arc(a, b, p3(at(s.mid(), r))) } else { builder::line(a, b) };
let et = if circular { builder::circle_arc(c, d, p3(at(s.mid(), rr))) } else { builder::line(c, d) };
let w: mt::Wire = vec![eb.clone(), rb.clone(), et.inverse(), ra.inverse()].into();
walls.push(builder::try_attach_plane(&[w]).map_err(|e| fail(&e.to_string()))?);
be.push(eb);
te.push(et);
}
Ok((be.into(), te.into(), walls))
}
fn validate_profiles(regions: &[Region2], tol: f64) -> Result<()> {
let loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).collect();
let edges: Vec<_> = loops.iter().enumerate().flat_map(|(l, lp)| lp.segs.iter().enumerate().map(move |(i, s)| (l, i, *s))).collect();
for (k, (l, i, s)) in edges.iter().enumerate() {
for (ll, j, t) in edges.iter().skip(k + 1) {
let len = loops.get(*l).map(|lp| lp.segs.len()).unwrap_or(0);
if l == ll && ((*i + 1) % len == *j || (*j + 1) % len == *i) {
continue;
}
let (a, b, c, d) = (s.start(), s.end(), t.start(), t.end());
let ab = b - a;
let cd = d - c;
let cross = ab.cross(cd);
let intersects = if cross.abs() > tol * tol {
let u = (c - a).cross(cd) / cross;
let v = (c - a).cross(ab) / cross;
(0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)
} else {
false
};
if intersects
|| [a, b].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) < tol)
|| [c, d].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) < tol)
{
return Err(fail("profile boundaries intersect or touch"));
}
}
}
for (i, r) in regions.iter().enumerate() {
if r.area() <= tol * tol {
return Err(fail("profile has no area"));
}
for (h, lp) in r.holes.iter().enumerate() {
if !r.outer.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start()) {
return Err(fail("hole outside profile"));
}
for other in r.holes.iter().skip(h + 1) {
if lp.contains(other.segs.first().ok_or_else(|| fail("empty hole"))?.start())
|| other.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start())
{
return Err(fail("nested profile holes"));
}
}
}
for other in regions.iter().skip(i + 1) {
if r.contains(other.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start())
|| other.contains(r.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start())
{
return Err(fail("overlapping profiles"));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn profile() -> Region2 {
Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.)]), holes: vec![] }
}
fn plane() -> Plane {
Plane::new(Vec3::Y * 10., -Vec3::X, Vec3::Z).unwrap()
}
#[test]
fn inference_uses_finite_wall_footprint_and_an_interior_profile_anchor() {
let near = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
let far = crate::cylinder(Vec3::Z * 100., Vec3::Z, 10., 20.).unwrap();
let other = crate::cylinder(Vec3::Z * 120., Vec3::Z, 8., 10.).unwrap();
let unsupported = crate::ops::join_pieces(vec![far.clone(), other]).unwrap().unwrap();
let regions = [profile()];
for body in [&far, &unsupported] {
assert!(infer_cylinder_emboss(body, &plane(), &regions, 1.).unwrap().is_none());
}
for x in [20., 80.] {
let base = crate::cylinder(Vec3::X * x, Vec3::Z, 10., 20.).unwrap();
let step = crate::cylinder(Vec3::X * x + Vec3::Z * 20., Vec3::Z, 8., 10.).unwrap();
let remote = crate::ops::join_pieces(vec![base, step]).unwrap().unwrap();
let inferred = infer_cylinder_emboss(&remote, &plane(), &regions, 1.);
if x == 20. {
// The offset maps this footprint across the remote wall's seam;
// partial relevance must retain conservative unsupported refusal.
assert!(inferred.is_err());
} else {
assert!(inferred.unwrap().is_none());
}
}
let (index, anchor) = infer_cylinder_emboss(&near, &plane(), &regions, 1.).unwrap().unwrap();
assert!(anchor.z > 0. && anchor.z < 20.);
assert!((anchor.x.hypot(anchor.y) - 10.).abs() < 1e-9);
assert!(cylinder_emboss_anchor(&near, index, anchor).unwrap().dist(anchor) < 1e-10);
let shifted = Plane::new(Vec3::new(-3., 10., 0.), -Vec3::X, Vec3::Z).unwrap();
let shifted_profile =
Region2 { outer: Loop2::polygon(&[Vec2::new(-5., 5.), Vec2::new(-1., 5.), Vec2::new(-1., 10.), Vec2::new(-5., 10.)]), holes: vec![] };
let (_, other_anchor) = infer_cylinder_emboss(&near, &shifted, std::slice::from_ref(&shifted_profile), 1.).unwrap().unwrap();
assert!(other_anchor.dist(anchor) < 1e-10);
let before = near.tessellate_with(1e-4, true).unwrap().measure().volume;
let result = emboss_cylinder_at(&near, other_anchor, &shifted, &[shifted_profile], 1.).unwrap();
assert!((result.tessellate_with(1e-4, true).unwrap().measure().volume - before - 21.).abs() < 0.02);
assert!(infer_cylinder_emboss(&near, &plane(), &regions, -10.).is_err());
let inward = Plane::new(Vec3::Y * 10., Vec3::X, Vec3::Z).unwrap();
assert!(infer_cylinder_emboss(&near, &inward, &regions, 1.).unwrap_err().to_string().contains("outwards"));
}
#[test]
fn wrapped_nested_island_uses_its_enclosing_hole_patch() {
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
let mut ring = profile();
ring.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)]));
let island =
Region2 { outer: Loop2::polygon(&[Vec2::new(-0.5, 6.5), Vec2::new(0.5, 6.5), Vec2::new(0.5, 8.5), Vec2::new(-0.5, 8.5)]), holes: vec![] };
let before = body.tessellate_with(1e-4, true).unwrap().measure().volume;
for depth in [1., -1.] {
for regions in [vec![ring.clone(), island.clone()], vec![island.clone(), ring.clone()]] {
let result = emboss_cylinder(&body, &plane(), &regions, depth).unwrap();
assert_eq!(result.shell_count(), 1);
assert!(result.validity().is_empty());
let after = result.tessellate_with(1e-4, true).unwrap().measure().volume;
let expected = 16. * (depth + depth * depth / 20.);
assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before);
let mesh = result.tessellate_with(0.001, true).unwrap();
for (s, raised) in [(0., true), (0.75, false), (1.5, true)] {
let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos();
let radius = if depth > 0. { 10.5 } else { 9.5 };
assert_eq!(mesh.contains(radial * radius + Vec3::Z * 7.5), if depth > 0. { raised } else { !raised });
}
}
}
}
#[test]
fn exact_wrapped_rectangle_is_connected_and_has_radial_depth() {
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
for depth in [1., -1.] {
let result = emboss_cylinder(&body, &plane(), &[profile()], depth).unwrap();
assert_eq!(result.shell_count(), 1);
assert!(result.validity().is_empty());
let before = body.tessellate_with(1e-4, true).unwrap().measure().volume;
let after = result.tessellate_with(1e-4, true).unwrap().measure().volume;
let expected = if depth > 0. { 21. } else { -19. };
assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before);
let mesh = result.tessellate_with(0.001, true).unwrap();
for s in [-1.5, 0., 1.5] {
let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos();
assert_eq!(mesh.contains(radial * (if depth > 0. { 10.5 } else { 9.5 }) + Vec3::Z * 7.5), depth > 0.);
}
}
}
#[test]
fn wrapped_holes_and_concave_profiles_preserve_material_on_rotated_axes() {
let origin = Vec3::new(123.0, -456.0, 789.0);
let axis = Vec3::X;
let radial = Vec3::Y;
let tangent = Vec3::Z;
let body = crate::cylinder(origin, axis, 10.0, 20.0).unwrap();
let plane = Plane::new(origin + radial * 10.0, tangent, axis).unwrap();
let mut holed = profile();
holed.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)]));
let concave = Region2 {
outer: Loop2::polygon(&[
Vec2::new(-2., 5.),
Vec2::new(2., 5.),
Vec2::new(2., 7.),
Vec2::new(0., 7.),
Vec2::new(0., 10.),
Vec2::new(-2., 10.),
]),
holes: vec![],
};
for profile in [holed, concave] {
for depth in [1., -1.] {
let result = emboss_cylinder_at(&body, origin + radial * 10.0 + axis * 7.5, &plane, std::slice::from_ref(&profile), depth).unwrap();
assert_eq!(result.shell_count(), 1);
let before = body.tessellate_with(1e-4, true).unwrap().measure().volume;
let after = result.tessellate_with(1e-4, true).unwrap().measure().volume;
let want = profile.area().abs() * (depth + depth * depth / 20.0);
assert!((after - before - want).abs() < 0.02, "{} vs {want}", after - before);
let point = |s: f64, z: f64, r: f64| origin + axis * z + (radial * (s / 10.0).cos() + tangent * (s / 10.0).sin()) * r;
let mesh = result.tessellate_with(0.001, true).unwrap();
for (s, z) in [(-1.5, 7.5), (1.5, 7.5), (0.5, 6.5)] {
let material = profile.contains(Vec2::new(s, z));
assert_eq!(mesh.contains(point(s, z, if depth > 0. { 10.5 } else { 9.5 })), if depth > 0. { material } else { !material });
}
let imported = crate::step_import(&crate::step_export(&[&result], "wrapped cylinder").unwrap()).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
assert_eq!(imported.bodies.len(), 1);
let round = imported.bodies.first().unwrap();
assert!(round.body.validity().is_empty());
let roundmesh = round.body.tessellate_with(0.001, true).unwrap();
assert_eq!(roundmesh.contains(point(-1.5, 7.5, if depth > 0. { 10.5 } else { 9.5 })), depth > 0.);
let cut = crate::boolean(
&round.body,
&crate::box_solid(origin + Vec3::new(5., -20., -20.), origin + Vec3::new(10., 20., 20.)).unwrap(),
crate::BoolOp::Cut,
)
.unwrap()
.unwrap();
assert!(!cut.tessellate(0.01).unwrap().contains(point(-1.5, 7.5, 9.0)));
}
}
}
#[test]
fn sleeve_depth_and_hostile_profiles_refuse_changes_atomically() {
let outer = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
let inner = crate::cylinder(-Vec3::Z, Vec3::Z, 8., 22.).unwrap();
let sleeve = crate::boolean(&outer, &inner, crate::BoolOp::Cut).unwrap().unwrap();
let good = emboss_cylinder(&sleeve, &plane(), &[profile()], -1.).unwrap();
assert_eq!(good.shell_count(), 1);
let before = crate::step_export(&[&sleeve], "unchanged").unwrap();
let mut cap = profile();
cap.outer = Loop2::polygon(&[Vec2::new(-2., 19.), Vec2::new(2., 19.), Vec2::new(2., 21.), Vec2::new(-2., 21.)]);
let circle = Region2 { outer: Loop2::circle(Vec2::new(0., 7.5), 1.), holes: vec![] };
let mut crossing = profile();
crossing.outer = Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.), Vec2::new(2., 5.)]);
for (profiles, d) in [
(vec![profile()], -2.),
(vec![cap], 1.),
(vec![circle], 1.),
(vec![crossing], 1.),
(vec![profile()], f64::NAN),
(vec![profile(); 65], 1.),
] {
assert!(emboss_cylinder(&sleeve, &plane(), &profiles, d).is_err());
}
assert!(emboss_cylinder_at(&sleeve, -Vec3::Y * 10. + Vec3::Z * 7.5, &plane(), &[profile()], 1.).is_err());
assert_eq!(crate::step_export(&[&sleeve], "unchanged").unwrap(), before);
}
}
+297
View File
@@ -0,0 +1,297 @@
use crate::{FaceSource, extrude, transform_matrix};
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
fn assert_exact_planes(body: &crate::Body) {
let mesh = body.tessellate(0.001).unwrap();
for (index, _) in body.face_sources().unwrap().iter().enumerate() {
let Some(plane) = body.face_plane(index) else { continue };
let mut count = 0;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if *face as usize != index {
continue;
}
for point in mesh.tri(triangle).unwrap() {
assert!((point - plane.origin).dot(plane.normal()).abs() < 1e-7);
count += 1;
}
}
assert!(count > 0);
let normal = body.faces(0.001).unwrap()[index].plane_normal.unwrap();
assert!(normal.dot(plane.normal()) > 0.999999);
}
}
#[test]
fn box_certificates_are_six_construction_roles_with_dimension_stable_axes() {
for (a, b) in [
(Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)),
(Vec3::new(12., 23., 34.), Vec3::new(2., 3., 4.)),
(Vec3::new(2., -8., 4.), Vec3::new(22., 43., 54.)),
] {
let body = crate::box_solid(a, b).unwrap().with_box_certificate(42).unwrap();
let mut roles = std::collections::HashSet::new();
for (index, source) in body.face_sources().unwrap().iter().enumerate() {
let Some(FaceSource::Box { producer, axis, high, plane }) = source else { panic!("uncertified box face") };
assert_eq!(*producer, Some(42));
assert!(roles.insert((*axis, *high)));
let unit = [Vec3::X, Vec3::Y, Vec3::Z][*axis as usize];
let bound = if *high { a.max(b) } else { a.min(b) };
assert_eq!(plane.origin, unit * bound.dot(unit));
assert!(plane.normal().dist(unit * if *high { 1. } else { -1. }) < 1e-12);
assert_eq!(body.face_plane(index), Some(*plane));
}
assert_eq!(roles.len(), 6);
assert_exact_planes(&body);
}
}
#[test]
fn cap_roles_follow_exact_offset_planes_and_canonical_profile_memberships() {
let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]);
let h1 = Loop2::circle(Vec2::new(-4., 0.), 1.);
let h2 = Loop2::circle(Vec2::new(4., 0.), 2.);
let plane = Plane::new(Vec3::new(30., 40., 50.), Vec3::Y, Vec3::Z).unwrap();
for (start, end) in [(-9., -2.), (7., -3.), (2., 11.)] {
for holes in [vec![h1.clone(), h2.clone()], vec![h2.clone().reversed(), h1.clone().reversed()]] {
let region = Region2 { outer: outer.clone().reversed(), holes };
let raw = extrude(&plane, std::slice::from_ref(&region), start, end).unwrap().remove(0);
let signatures: Vec<_> = raw
.face_sources()
.unwrap()
.iter()
.map(|source| match source {
Some(FaceSource::ExtrudeCap { .. }) => {
Some((vec!["l4".into(), "l1".into(), "l3".into(), "l2".into()], vec![vec!["c2".into()], vec!["c1".into()]]))
}
_ => None,
})
.collect();
let body = raw.with_extrude_cap_certificates(42, 11, &signatures).unwrap();
let mut caps = std::collections::HashSet::new();
for source in body.face_sources().unwrap().iter().flatten() {
if let FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane: cap } = source {
assert_eq!((*producer, *sketch), (42, 11));
assert!(caps.insert(*high));
assert_eq!(outer, &["l1", "l2", "l3", "l4"]);
assert_eq!(holes, &[vec!["c1"], vec!["c2"]]);
assert!(cap.origin.dist(plane.origin + plane.normal() * if *high { start.max(end) } else { start.min(end) }) < 1e-10);
assert!(cap.normal().dot(plane.normal()) * if *high { 1. } else { -1. } > 0.999999);
}
}
assert_eq!(caps.len(), 2);
assert_exact_planes(&body);
let expected = (400. - 5. * std::f64::consts::PI) * (end - start).abs();
assert!((body.tessellate(1e-4).unwrap().measure().volume - expected).abs() < 0.03);
}
}
}
#[test]
fn construction_planes_follow_reflected_and_sheared_affine_images() {
let body = crate::box_solid(Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)).unwrap().with_box_certificate(42).unwrap();
for m in [
[[0., 2., 0., 0.], [-1., 0., 0., 0.], [0., 0., 3., 0.], [25., 30., 40., 1.]],
[[-1., 0., 0., 0.], [0.4, 2., 0., 0.], [0., 0.3, 1., 0.], [8., 9., 10., 1.]],
] {
let placed = transform_matrix(&body, m).unwrap();
for (original, transformed) in body.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) {
let (
Some(FaceSource::Box { producer: a, axis: aa, high: ah, plane: ap }),
Some(FaceSource::Box { producer: b, axis: ba, high: bh, plane: bp }),
) = (original, transformed)
else {
panic!("box identity changed")
};
assert_eq!((a, aa, ah), (b, ba, bh));
let p = ap.origin;
let expected = Vec3::new(
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0],
m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1],
m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2],
);
assert!(bp.origin.dist(expected) < 1e-10);
}
assert_exact_planes(&placed);
}
}
#[test]
fn material_changes_discard_producer_certificates() {
let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap();
let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap();
let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap();
assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8);
assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!(
source,
FaceSource::ExtrudeSide { .. }
| FaceSource::ExtrudeCap { .. }
| FaceSource::Box { producer: None, .. }
| FaceSource::Imported { producer: None, .. }
)));
}
#[test]
fn canonical_revolve_axis_is_per_profile_and_independent_of_input_axis_sign() {
for x in [-4., 4.] {
let region = Region2 {
outer: Loop2::polygon(&[Vec2::new(x - 1., 0.), Vec2::new(x + 1., 0.), Vec2::new(x + 1., 2.), Vec2::new(x - 1., 2.)]),
holes: vec![],
};
let axis = crate::canonical_revolve_axis(&Plane::XY, &region, Vec2::default(), Vec2::Y).unwrap();
assert_eq!(axis, crate::canonical_revolve_axis(&Plane::XY, &region, Vec2::default(), -Vec2::Y).unwrap());
assert!(axis.cross(Plane::XY.to_world(region.centroid())).dot(Vec3::Z) > 0.);
assert_eq!(axis, Vec3::Y * if x < 0. { 1. } else { -1. });
}
}
#[test]
fn linear_side_frame_stays_fixed_when_endpoints_and_depth_change() {
for (lo, hi, start, end) in [(3., 23., 0., 7.), (-9., 43., -3., 19.)] {
let region =
Region2 { outer: Loop2::polygon(&[Vec2::new(2., lo), Vec2::new(12., lo), Vec2::new(12., hi), Vec2::new(2., hi)]), holes: vec![] };
let body = extrude(&Plane::XY, &[region], start, end).unwrap().remove(0);
let plane = body
.face_sources()
.unwrap()
.iter()
.enumerate()
.find_map(|(index, source)| match source {
Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == 2. && b.x == 2. => body.face_plane(index),
_ => None,
})
.unwrap();
assert_eq!(plane.origin, Vec3::X * 2.);
assert_eq!(plane.x, -Vec3::Y);
assert_eq!(plane.normal(), -Vec3::X);
}
}
#[test]
fn reflected_hole_walls_and_low_caps_keep_exact_outward_planes() {
let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]);
let hole = Loop2::polygon(&[Vec2::new(-3., -2.), Vec2::new(3., -2.), Vec2::new(3., 2.), Vec2::new(-3., 2.)]);
let body = extrude(&Plane::XY, &[Region2 { outer, holes: vec![hole] }], -4., 7.).unwrap().remove(0);
let reflected = transform_matrix(&body, [[-1., 0., 0., 0.], [0., 2., 0., 0.], [0.2, 0., 1., 0.], [30., 40., 50., 1.]]).unwrap();
assert_exact_planes(&reflected);
assert_eq!(
reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { plane: Some(_), .. }))).count(),
8
);
assert_eq!(
reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { high: false, .. }))).count(),
1
);
}
#[test]
fn side_basis_and_attached_coordinates_stay_continuous_through_vertical() {
let mut previous = None;
for epsilon in [1e-6, -1e-6] {
let region =
Region2 { outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(epsilon, 10.), Vec2::new(-5., 10.), Vec2::new(-5., 0.)]), holes: vec![] };
let body = extrude(&Plane::XY, &[region], 0., 5.).unwrap().remove(0);
let plane = body
.face_sources()
.unwrap()
.iter()
.enumerate()
.find_map(|(index, source)| match source {
Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.dist(Vec2::ZERO) < 1e-12 && b.y == 10. && b.x == epsilon => {
body.face_plane(index)
}
_ => None,
})
.unwrap();
assert!(plane.x.dot(Vec3::Y) > 0.999999);
assert_eq!(plane.y, Vec3::Z);
let attached = plane.to_world(Vec2::new(3., 2.));
assert!(attached.dist(Vec3::new(epsilon * 0.3, 3., 2.)) < 1e-10);
if let Some(old) = previous {
assert!(attached.dist(old) < 1e-6);
}
previous = Some(attached);
assert_exact_planes(&body);
}
}
#[test]
fn constructed_annulus_sides_follow_exact_source_edges() {
let ring = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![Loop2::circle(Vec2::default(), 9.).reversed()] };
for (start, end) in [(0., 7.), (-7., 0.), (-7., 7.)] {
let bodies = extrude(&Plane::XY, std::slice::from_ref(&ring), start, end).unwrap();
let body = &bodies[0];
let sources = body.face_sources().unwrap();
assert_eq!(sources.len(), body.face_count());
let mesh = body.tessellate(0.001).unwrap();
let mut inner = 0;
let mut outer = 0;
for (index, source) in sources.iter().enumerate() {
let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. }, .. }) = source else { continue };
if (*radius - 10.).abs() < 1e-12 {
outer += 1;
} else {
assert!((*radius - 9.).abs() < 1e-12);
inner += 1;
}
let mut vertices = 0;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if *face as usize != index {
continue;
}
for point in mesh.tri(triangle).unwrap() {
assert!((point.x.hypot(point.y) - radius).abs() < 1e-8, "face {index}, source {radius}, {point:?}");
vertices += 1;
}
}
assert!(vertices > 0);
}
assert_eq!((outer, inner), (2, 2), "full circles split into two actual edges each");
assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { .. }))).count(), 2);
}
}
#[test]
fn constructed_side_identity_survives_affine_placement_without_recognition() {
let region = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![] };
let bodies = extrude(&Plane::named("XY").unwrap(), &[region], 0., 7.).unwrap();
let source = &bodies[0];
let placed = transform_matrix(source, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [25., 30., 40., 1.]]).unwrap();
for (original, placed) in source.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) {
match (original, placed) {
(Some(FaceSource::ExtrudeSide { segment: a, .. }), Some(FaceSource::ExtrudeSide { segment: b, .. })) => assert_eq!(a, b),
(Some(FaceSource::ExtrudeCap { high: a, region: ar, .. }), Some(FaceSource::ExtrudeCap { high: b, region: br, .. })) => {
assert_eq!(a, b);
assert_eq!(ar, br)
}
_ => panic!("source role changed"),
}
}
assert!((placed.size() - source.size()).abs() < 1e-8);
assert!(placed.faces(0.001).unwrap().iter().all(|face| face.centroid.dist(Vec3::new(25., 30., 43.5)) < 11.));
}
#[test]
fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() {
let arc = Seg2::Arc { center: Vec2::default(), radius: 10., start: 0., sweep: std::f64::consts::PI * 5. / 3. };
let outline = Loop2 { segs: vec![arc, Seg2::Line { a: arc.end(), b: arc.start() }] };
for outline in [outline.clone(), outline.reversed()] {
let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0., 7.).unwrap().remove(0);
let sources = body.face_sources().unwrap();
let arcs: Vec<_> = sources
.iter()
.filter_map(|source| match source {
Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. }, .. }) => Some((radius, sweep)),
_ => None,
})
.collect();
assert_eq!(arcs.len(), 2, "large input arc explicitly creates two actual boundary edges");
assert!(arcs.iter().all(|(radius, sweep)| (**radius - 10.).abs() < 1e-12 && (sweep.abs() - std::f64::consts::PI * 5. / 3.).abs() < 1e-12));
assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { segment: Seg2::Line { .. }, .. }))).count(), 1);
let mesh = body.tessellate(0.001).unwrap();
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if matches!(sources[*face as usize], Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { .. }, .. })) {
assert!(mesh.tri(triangle).unwrap().iter().all(|point| (point.x.hypot(point.y) - 10.).abs() < 1e-7));
}
}
}
}
+7 -2
View File
@@ -20,6 +20,9 @@ mod build;
mod coplanar;
mod curveblend;
mod delete_face;
mod emboss;
#[cfg(test)]
mod face_source_tests;
mod freeform;
pub mod fuzz;
mod heal;
@@ -51,9 +54,10 @@ mod topo;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use blend_corner::fillet_radii;
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use delete_face::delete_faces;
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies;
@@ -64,6 +68,7 @@ pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{extrude_planar_regions, planar_regions_boolean};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
pub use step::step_export;
+2
View File
@@ -108,6 +108,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
color: None,
paint: None,
splits: None,
face_sources: None,
})
}
@@ -199,6 +200,7 @@ impl Body {
color: self.color,
paint: self.paint.clone(),
splits: self.splits.clone(),
face_sources: None,
})
}
}
+222 -30
View File
@@ -2,8 +2,10 @@
//! faces). Each face moves along its outward normal by its own distance: a plane is translated,
//! a cylinder changes radius about its axis. Every vertex and edge goes to where its faces'
//! moved surfaces meet; edges are lines or circular arcs. This is exact when the moved
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls);
//! anything else is refused, so callers can fall back.
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls).
//! A corner where more than three planes meet, whose moved planes miss a common point (a
//! floor falling to a drain, a pyramid's apex), becomes several corners joined by short edges.
//! Anything else is refused, so callers can fall back.
use std::collections::HashMap;
@@ -422,6 +424,143 @@ fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
Some(v)
}
/// Most faces round one corner that [`split_corner`] takes on.
const MAX_SPLIT_FACES: usize = 48;
/// The corners in place of a corner where more than three planar faces meet and whose moved
/// planes miss a common point (a hip floor's corner, a pyramid's apex): each new corner with
/// the faces through it. Corners sharing two faces are joined by a new edge on those faces.
/// Only corners whose edges are all convex or all concave: the faces there bound a convex
/// region (the body's side or the outside's), and after the move its corners are the points on
/// three moved planes inside all the others.
fn split_corner(
v: &mt::Vertex,
at: &[usize],
faces: &[mt::Face],
efaces: &HashMap<mt::EdgeID, Vec<usize>>,
old: &[Surf],
new: &[Surf],
size: f64,
) -> Option<Vec<(Vec3, Vec<usize>)>> {
if at.len() < 4 || at.len() > MAX_SPLIT_FACES {
return None;
}
let p = from_p3(v.point());
// (face, outward normal, old offset, moved offset).
let planes: Vec<(usize, Vec3, f64, f64)> = at
.iter()
.map(|i| match (old.get(*i)?, new.get(*i)?) {
(Surf::Plane { n, d }, Surf::Plane { d: d2, .. }) => Some((*i, *n, *d, *d2)),
_ => None,
})
.collect::<Option<_>>()?;
let normal = |fi: usize| planes.iter().find(|x| x.0 == fi).map(|x| x.1);
// Edge kinds round the corner: a face's loop runs counter-clockwise about its outward
// normal, so its inside is to the left; the edge is convex when the face beyond it falls
// away from there.
let mut sign = 0.0f64;
let mut pairs: Vec<(usize, usize)> = Vec::new();
for &(fi, na, ..) in &planes {
for w in faces.get(fi)?.boundaries() {
for e in w.edge_iter() {
if e.front().id() != v.id() && e.back().id() != v.id() {
continue;
}
let fb = efaces.get(&e.id())?.iter().copied().find(|j| *j != fi)?;
pairs.push((fi, fb));
let dir = (from_p3(e.back().point()) - from_p3(e.front().point())).normalized()?;
let s = normal(fb)?.dot(na.cross(dir));
if s.abs() < 1e-9 {
continue;
}
if sign * s < 0.0 {
return None;
}
sign = s.signum();
}
}
}
if sign == 0.0 {
return None;
}
// Convex: the body's side, n·x ≤ d; concave: the outside's, n·x ≥ d.
let side = -sign;
let merge = size * 1e-6;
let mut nodes: Vec<(Vec3, Vec<usize>)> = Vec::new();
for (ia, a) in planes.iter().enumerate() {
for (ib, b) in planes.iter().enumerate().skip(ia + 1) {
for c in planes.iter().skip(ib + 1) {
let den = a.1.dot(b.1.cross(c.1));
if den.abs() < 1e-9 {
continue;
}
let x = (b.1.cross(c.1) * a.3 + c.1.cross(a.1) * b.3 + a.1.cross(b.1) * c.3) * (1.0 / den);
if !x.is_finite() || x.dist(p) > size || planes.iter().any(|q| side * (q.1.dot(x) - q.3) > merge) {
continue;
}
let fs = [a.0, b.0, c.0];
match nodes.iter_mut().find(|(q, _)| q.dist(x) < merge) {
Some((_, nf)) => {
for f in fs {
if !nf.contains(&f) {
nf.push(f);
}
}
}
None => nodes.push((x, fs.to_vec())),
}
}
}
}
// Every face keeps a corner here, and every edge an end.
if planes.iter().any(|q| !nodes.iter().any(|(_, nf)| nf.contains(&q.0)))
|| pairs.iter().any(|(x, y)| !nodes.iter().any(|(_, nf)| nf.contains(x) && nf.contains(y)))
{
return None;
}
// Each corner where its planes meet best (least squares, for planes meeting at one point
// to within the merge distance).
for (q, nf) in nodes.iter_mut() {
let (ns, ss): (Vec<Vec3>, Vec<f64>) = nf.iter().filter_map(|f| planes.iter().find(|x| x.0 == *f)).map(|x| (x.1, x.3 - x.2)).unzip();
*q = p + span_step(&ns, &ss)?;
}
Some(nodes)
}
/// The corners of a split corner (see [`split_corner`]) on face `face`, in order from corner
/// `from` to corner `to`, along the new edges between them.
fn split_path(nodes: &[(Vec3, Vec<usize>)], face: usize, from: usize, to: usize) -> Option<Vec<usize>> {
let joined = |x: usize, y: usize| {
let (Some((_, a)), Some((_, b))) = (nodes.get(x), nodes.get(y)) else { return false };
a.contains(&face) && b.contains(&face) && a.iter().filter(|f| b.contains(f)).count() >= 2
};
// Breadth first; `prev` leads back to `from`.
let mut prev: Vec<Option<usize>> = vec![None; nodes.len()];
let mut queue = std::collections::VecDeque::from([from]);
let mut seen = vec![false; nodes.len()];
*seen.get_mut(from)? = true;
while let Some(x) = queue.pop_front() {
if x == to {
let mut path = vec![to];
let mut cur = to;
while let Some(p) = prev.get(cur).copied().flatten() {
path.push(p);
cur = p;
}
path.reverse();
return Some(path);
}
for y in 0..nodes.len() {
if !seen.get(y).copied().unwrap_or(true) && joined(x, y) {
*seen.get_mut(y)? = true;
*prev.get_mut(y)? = Some(x);
queue.push_back(y);
}
}
}
None
}
/// A line or circular arc through an edge (by samples): `None` for other curves.
/// Maximum straight edges per face checked pairwise for crossings.
const MAX_CROSSING_EDGES: usize = 2000;
@@ -431,7 +570,7 @@ const MAX_CROSSING_EDGES: usize = 2000;
fn crossed_loops(
faces: &[mt::Face],
old: &[Surf],
newpos: &HashMap<mt::VertexID, Vec3>,
pos: &dyn Fn(&mt::Vertex, &mt::Edge) -> Option<Vec3>,
edge_collapsed: &dyn Fn(&mt::Edge) -> bool,
size: f64,
) -> Option<Vec3> {
@@ -443,32 +582,32 @@ fn crossed_loops(
}
let (u, w) = (n.any_perp(), n.cross(n.any_perp()));
let flat = |p: Vec3| (p.dot(u), p.dot(w));
// Straight edges as (vertex ids, old ends, new ends) in the plane.
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2])> = Vec::new();
// Straight edges as (vertex ids, new ends in the plane, the plane's new offset).
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2], f64)> = Vec::new();
for wire in f.absolute_boundaries() {
let (mut area_old, mut area_new, mut straight) = (0.0, 0.0, true);
for e in wire.edge_iter() {
let (v0, v1) = (e.front(), e.back());
let (Some(q0), Some(q1)) = (newpos.get(&v0.id()), newpos.get(&v1.id())) else { return None };
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(*q0), flat(*q1));
let (Some(q0), Some(q1)) = (pos(v0, e), pos(v1, e)) else { return None };
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(q0), flat(q1));
area_old += o0.0 * o1.1 - o1.0 * o0.1;
area_new += n0.0 * n1.1 - n1.0 * n0.1;
if arc_mid(e, size * 1e-6) == Some(None) {
segs.push(([v0.id(), v1.id()], [n0, n1]));
segs.push(([v0.id(), v1.id()], [n0, n1], n.dot(q0)));
} else {
straight = false;
}
}
if straight && area_old.abs() > eps && area_old * area_new <= 0.0 {
return wire.vertex_iter().next().and_then(|v| newpos.get(&v.id()).copied());
return wire.edge_iter().next().and_then(|e| pos(e.front(), e));
}
}
if segs.len() > MAX_CROSSING_EDGES {
continue;
}
let side = |a: (f64, f64), c: (f64, f64), p: (f64, f64)| (c.0 - a.0) * (p.1 - a.1) - (c.1 - a.1) * (p.0 - a.0);
for (i, (ia, [a0, a1])) in segs.iter().enumerate() {
for (ib, [b0, b1]) in segs.iter().skip(i + 1) {
for (i, (ia, [a0, a1], d)) in segs.iter().enumerate() {
for (ib, [b0, b1], _) in segs.iter().skip(i + 1) {
if ia.iter().any(|v| ib.contains(v)) {
continue;
}
@@ -478,8 +617,7 @@ fn crossed_loops(
if ((s0 > tol && s1 < -tol) || (s0 < -tol && s1 > tol)) && ((t0 > tol && t1 < -tol) || (t0 < -tol && t1 > tol)) {
let k = t0 / (t0 - t1);
let (x, y) = (a0.0 + (a1.0 - a0.0) * k, a0.1 + (a1.1 - a0.1) * k);
let d = n.dot(newpos.get(&ia[0]).copied().unwrap_or_default());
return Some(u * x + w * y + n * d);
return Some(u * x + w * y + n * *d);
}
}
}
@@ -618,6 +756,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
})
.collect();
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
// Corners whose moved faces meet at several points (see `split_corner`).
let mut splits: HashMap<mt::VertexID, Vec<(Vec3, Vec<usize>)>> = HashMap::new();
for v in b.solid.vertex_iter() {
let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
let p = from_p3(v.point());
@@ -672,10 +812,33 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
});
let q = match apex {
Some(q) if n.iter().all(|sf| sf.dist(q).abs() < size * 1e-6) => q,
_ => move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?,
_ => match move_point(p, &o, &s, &n, size * 1e-6) {
Some(q) => q,
None => {
let nodes = (fs == *at)
.then(|| split_corner(&v, at, &faces, &efaces, &old, &new, size))
.flatten()
.ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?;
let q = nodes.first().map(|x| x.0).ok_or_else(|| fail("corner"))?;
splits.insert(v.id(), nodes);
q
}
},
};
newpos.insert(v.id(), q);
}
// Which of a split corner's new corners an edge ends at.
let node_of = |v: mt::VertexID, e: mt::EdgeID| -> Option<usize> {
let fs = efaces.get(&e)?;
splits.get(&v)?.iter().position(|(_, nf)| fs.iter().all(|i| nf.contains(i)))
};
// Where an edge's end goes.
let end_pos = |v: &mt::Vertex, e: &mt::Edge| -> Option<Vec3> {
match splits.get(&v.id()) {
Some(nodes) => nodes.get(node_of(v.id(), e.id())?).map(|x| x.0),
None => newpos.get(&v.id()).copied(),
}
};
// An edge turned round: faces crossed over (a wall thinner than the move), which would
// leave the body inside out.
let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i)));
@@ -684,8 +847,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
continue;
}
let (f0, f1) = (e.absolute_front(), e.absolute_back());
let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue };
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0);
let (Some(q0), Some(q1)) = (end_pos(f0, &e), end_pos(f1, &e)) else { continue };
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), q1 - q0);
if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 {
return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)"));
}
@@ -693,7 +856,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
// A plane's loops crossing after the move: a rim or ledge narrower than the move (the hole
// in a top face sliding past its outer edge) turns part of the face inside out, though no
// edge turns round.
if let Some(at) = crossed_loops(&faces, &old, &newpos, &edge_collapsed, size) {
if let Some(at) = crossed_loops(&faces, &old, &end_pos, &edge_collapsed, size) {
return Err(fail(&format!(
"not supported yet: the move is larger than a rim or ledge near [{:.1}, {:.1}, {:.1}] is wide (a face would turn inside out)",
at.x, at.y, at.z
@@ -725,6 +888,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
};
verts.insert(v.id(), nv);
}
let node_verts: HashMap<mt::VertexID, Vec<mt::Vertex>> =
splits.iter().map(|(id, nodes)| (*id, nodes.iter().map(|(q, _)| builder::vertex(p3(*q))).collect())).collect();
let vert_at = |v: &mt::Vertex, e: &mt::Edge| -> Option<&mt::Vertex> {
match node_verts.get(&v.id()) {
Some(nv) => nv.get(node_of(v.id(), e.id())?),
None => verts.get(&v.id()),
}
};
// New edges between a split corner's corners (stored from the lower index).
let mut links: HashMap<(mt::VertexID, usize, usize), mt::Edge> = HashMap::new();
// Old edge → new edge, and whether it runs the other way (merged with its twin).
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
@@ -732,7 +905,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
if edges.contains_key(&e.id()) {
continue;
}
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
let (Some(a), Some(c)) = (vert_at(e.absolute_front(), &e), vert_at(e.absolute_back(), &e)) else { return Err(fail("edge")) };
let touches = edge_collapsed(&e);
if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
if touches {
@@ -796,18 +969,37 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
if is_collapsed(i) {
continue;
}
let wires: Vec<mt::Wire> = f
.absolute_boundaries()
.iter()
.map(|w| {
w.edge_iter()
.filter_map(|e| {
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
})
.collect::<Vec<_>>()
.into()
})
.collect();
let mut wires: Vec<mt::Wire> = Vec::new();
for w in f.absolute_boundaries() {
let old_edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let mut wire: Vec<mt::Edge> = Vec::new();
for (j, e) in old_edges.iter().enumerate() {
if let Some((ne, flip)) = edges.get(&e.id()) {
wire.push(if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() });
}
// At a split corner, along the new edges from this edge's end to the next's.
let v = e.back();
let (Some(nodes), Some(next)) = (splits.get(&v.id()), old_edges.get((j + 1) % old_edges.len())) else { continue };
let (Some(from), Some(to)) = (node_of(v.id(), e.id()), node_of(v.id(), next.id())) else { return Err(fail("split corner")) };
let path = split_path(nodes, i, from, to).ok_or_else(|| fail("split corner"))?;
for pair in path.windows(2) {
let (Some(&x), Some(&y)) = (pair.first(), pair.get(1)) else { continue };
let key = (v.id(), x.min(y), x.max(y));
let link = match links.get(&key) {
Some(l) => l.clone(),
None => {
let nv = node_verts.get(&v.id()).ok_or_else(|| fail("split corner"))?;
let (Some(va), Some(vb)) = (nv.get(key.1), nv.get(key.2)) else { return Err(fail("split corner")) };
let l = builder::line(va, vb);
links.insert(key, l.clone());
l
}
};
wire.push(if x < y { link } else { link.inverse() });
}
}
wires.push(wire.into());
}
let (Some(o), Some(n)) = (old.get(i), new.get(i)) else { return Err(fail("face")) };
let surface = match (o.map_to(n), *o, *n) {
(Some(mat), _, _) => mt::Transformed::transformed(&f.surface(), mat),
+123 -38
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::body::{Body, Solid, from_p3};
use crate::{KernelError, Result, guard};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -114,6 +114,13 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv
const ALL: f64 = 0.997;
let disjoint = b_in_a < NONE && a_in_b < NONE;
match op {
// Sampling only selects this candidate. Validate every shell boundary and the
// complete material/void nesting before preserving a disconnected union.
BoolOp::Union if disjoint => {
let joined = join_pieces(vec![a.clone(), b.clone()]).ok().flatten()?;
joined.separated_lumps().ok()?;
Some(Some(joined))
}
BoolOp::Cut if disjoint => Some(Some(a.clone())),
BoolOp::Cut if a_in_b > ALL => Some(None),
BoolOp::Intersect if disjoint => Some(None),
@@ -122,31 +129,34 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv
BoolOp::Union if b_in_a > ALL => Some(Some(a.clone())),
BoolOp::Union if a_in_b > ALL => Some(Some(b.clone())),
// The tool wholly inside, clear of the outside: a void.
BoolOp::Cut if b_in_a > ALL && clear_inside(ma, mb, a.size().max(b.size()) * 1e-6) => void(a, b).ok(),
BoolOp::Cut if b_in_a > ALL => {
let candidate = void(a, b).ok().flatten()?;
candidate.separated_lumps().ok()?;
Some(Some(candidate))
}
_ => None,
}
}
/// Does every vertex of `inner` lie inside `outer` and farther than `gap` from its surface?
fn clear_inside(outer: &solvecraft_geom::Mesh, inner: &solvecraft_geom::Mesh, gap: f64) -> bool {
if outer.triangles.len() * inner.positions.len() > 40_000_000 {
return false;
#[cfg(test)]
mod shortcut_tests {
use super::*;
#[test]
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
let outer = crate::sphere(Vec3::ZERO, 10.0).unwrap();
let mesh = outer.tessellate(0.03).unwrap();
for distance in [19.999, 20.001] {
let other = crate::sphere(direction * distance, 10.0).unwrap();
let other_mesh = other.tessellate(0.03).unwrap();
assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh).is_none());
assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh).is_none());
}
let protruding = crate::sphere(direction * 9.001, 1.0).unwrap();
let protruding_mesh = protruding.tessellate(0.03).unwrap();
assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh).is_none());
}
let tris: Vec<[Vec3; 3]> = outer.triangles.iter().filter_map(|t| outer.tri(t)).collect();
inner.positions.iter().all(|p| {
outer.contains(*p)
&& tris.iter().all(|[a, b, c]| {
// Distance to the triangle's plane bounds the distance to the triangle from below.
let n = (*b - *a).cross(*c - *a);
let Some(nn) = n.normalized() else { return true };
let h = (*p - *a).dot(nn).abs();
h > gap || {
let q = *p - nn * (*p - *a).dot(nn);
let inside = [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
!inside && [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| p.dist_to_segment(*u, *v) > gap)
}
})
})
}
/// `a` with the inside of `b` (wholly within it) taken out: a second, inward-facing shell.
@@ -249,9 +259,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let size = a.size().max(b.size());
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
// thin part trimmed by a long one), a step from their size would facet the small faces
// there coarsely enough to fail the membership test on a correct result.
// there coarsely enough to fail the membership test on a correct result. The meshes are
// refined: a round side trimmed to a thin band (a short boss crossing a gently sloping
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
// material.
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
let (va, vb) = match &meshes {
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
_ => (volume(a), volume(b)),
@@ -260,7 +273,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Volume bounds, then membership against the operands (catches misclassified pieces), on
// one mesh of the result. Returns (plausible, volume).
let check = |r: &Body| -> (bool, f64) {
let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) };
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
let v = mr.measure().volume;
let bounds = v > 0.0
&& match op {
@@ -520,16 +533,95 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2],
m[3][3],
);
Body::new(mt::builder::transformed(&body.deep_copy(), mat))
// An affine placement retains face order and appearance metadata.
let sources = body.transformed_sources(m)?;
Body::new(placed_topology(&body.solid, mat)?)
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources))
})
}
// A STEP periodic face may traverse a seam edge twice. Generic topology builders
// reject that wire while cloning, even though placement preserves its topology.
pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result<Solid> {
use mt::{Transform, Transformed};
use std::collections::HashMap;
let mut vertices = HashMap::new();
let mut edges = HashMap::new();
let mut shells = Vec::new();
let mut face_count = 0usize;
let mut edge_visits = 0usize;
for shell in source.boundaries() {
let mut faces = Vec::new();
for face in shell {
face_count = face_count.saturating_add(1);
if face_count > 500_000 {
return Err(KernelError::Invalid("placement exceeds face work limit".into()));
}
let mut wires = Vec::new();
for wire in face.absolute_boundaries() {
let mut mapped = Vec::new();
for edge in wire {
edge_visits = edge_visits.saturating_add(1);
if edge_visits > 4_000_000 {
return Err(KernelError::Invalid("placement exceeds edge work limit".into()));
}
if let std::collections::hash_map::Entry::Vacant(entry) = edges.entry(edge.id()) {
for vertex in [edge.absolute_front(), edge.absolute_back()] {
if let std::collections::hash_map::Entry::Vacant(entry) = vertices.entry(vertex.id()) {
let point = matrix.transform_point(vertex.point());
if !from_p3(point).is_finite() {
return Err(KernelError::Invalid("placement produced nonfinite geometry".into()));
}
entry.insert(mt::Vertex::new(point));
}
}
let front =
vertices.get(&edge.absolute_front().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?;
let back = vertices.get(&edge.absolute_back().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?;
entry.insert(mt::Edge::new_unchecked(front, back, edge.curve().transformed(matrix)));
}
let copied = edges.get(&edge.id()).ok_or_else(|| KernelError::Failed("placement edge missing".into()))?;
mapped.push(if edge.orientation() { copied.clone() } else { copied.inverse() });
}
wires.push(mt::Wire::from(mapped));
}
// Wire connectivity, seam sharing and orientation are copied exactly;
// no new topology is introduced by this geometry-only operation.
let mut copied = mt::Face::new_unchecked(wires, face.surface().transformed(matrix));
if !face.orientation() {
copied.invert();
}
faces.push(copied);
}
shells.push(mt::Shell::from(faces));
}
Ok(Solid::new_unchecked(shells))
}
#[cfg(test)]
#[test]
fn affine_placement_keeps_body_and_face_paint_attached() {
let paint = crate::Paint { opacity: 0.4, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.2, 0.1], opacity: 0.3 }] };
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap().with_color(Some([0.2, 0.6, 0.8])).with_paint(Some(paint.clone()));
let source_faces = body.faces(0.01).unwrap();
let transform = [[0.0, 2.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [30.0, 40.0, 50.0, 1.0]];
let placed = transform_matrix(&body, transform).unwrap();
assert_eq!(placed.color(), body.color());
assert_eq!(placed.paint(), Some(&paint));
let faces = placed.faces(0.01).unwrap();
for source in source_faces {
let target = faces.iter().find(|f| f.index == source.index).unwrap();
let p = source.centroid;
assert!(target.centroid.dist(Vec3::new(30.0 - p.y, 40.0 + 2.0 * p.x, 50.0 + 0.5 * p.z)) < 1e-6);
}
}
/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate.
pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) {
return Err(KernelError::Invalid("non-finite transform".into()));
}
if body.is_mesh() {
{
// Same motion as a matrix: rotate about the line through `origin`, then translate.
let ax = if angle.abs() > 1e-12 { axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))? } else { Vec3::Z };
let (c, s) = (angle.cos(), angle.sin());
@@ -546,19 +638,8 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
);
let tr = origin - ro + translate;
let m = [[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]];
return body.mesh_transformed(&m).ok_or_else(|| KernelError::Failed("mesh transform".into()));
transform_matrix(body, m)
}
guard("transform", || {
let mut s = body.deep_copy();
if angle.abs() > 1e-12 {
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))?;
s = mt::builder::rotated(&s, p3(origin), v3(ax), mt::Rad(angle));
}
if translate.len() > 0.0 {
s = mt::builder::translated(&s, v3(translate));
}
Body::new(s)
})
}
/// Split a body with a plane: the parts on the positive and the negative side of the plane
@@ -594,3 +675,7 @@ pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive:
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
boolean(body, &half, BoolOp::Intersect)
}
#[cfg(test)]
#[path = "placement_tests.rs"]
mod placement_tests;
+95
View File
@@ -0,0 +1,95 @@
use super::*;
use crate::body::{p3, v3};
use mt::builder;
fn assert_topology(source: &Body, placed: &Body, matrix: [[f64; 4]; 4]) {
use std::collections::HashMap;
let mut vertices = HashMap::new();
let mut edges = HashMap::new();
for (a, b) in source.solid.face_iter().zip(placed.solid.face_iter()) {
assert_eq!(a.orientation(), b.orientation());
let aw = a.absolute_boundaries();
let bw = b.absolute_boundaries();
assert_eq!(aw.len(), bw.len());
for (a, b) in aw.iter().zip(bw.iter()) {
assert_eq!(a.len(), b.len());
for (a, b) in a.edge_iter().zip(b.edge_iter()) {
assert_eq!(a.orientation(), b.orientation());
assert_ne!(a.id(), b.id());
assert_eq!(*edges.entry(a.id()).or_insert(b.id()), b.id());
for (a, b) in [(a.absolute_front(), b.absolute_front()), (a.absolute_back(), b.absolute_back())] {
assert_ne!(a.id(), b.id());
assert_eq!(*vertices.entry(a.id()).or_insert(b.id()), b.id());
let p = from_p3(a.point());
let expected = Vec3::new(
matrix[0][0] * p.x + matrix[1][0] * p.y + matrix[2][0] * p.z + matrix[3][0],
matrix[0][1] * p.x + matrix[1][1] * p.y + matrix[2][1] * p.z + matrix[3][1],
matrix[0][2] * p.x + matrix[1][2] * p.y + matrix[2][2] * p.z + matrix[3][2],
);
assert!(from_p3(b.point()).dist(expected) < 1e-9);
}
}
}
}
assert_eq!(edges.len(), source.solid.edge_iter().map(|e| e.id()).collect::<std::collections::HashSet<_>>().len());
}
fn periodic_cylinder() -> Body {
let b0 = builder::vertex(p3(Vec3::new(10., 0., 0.)));
let b1 = builder::vertex(p3(Vec3::new(-10., 0., 0.)));
let t0 = builder::vertex(p3(Vec3::new(10., 0., 20.)));
let t1 = builder::vertex(p3(Vec3::new(-10., 0., 20.)));
let bottom: mt::Wire =
vec![builder::circle_arc(&b0, &b1, p3(Vec3::new(0., 10., 0.))), builder::circle_arc(&b1, &b0, p3(Vec3::new(0., -10., 0.)))].into();
let top: mt::Wire =
vec![builder::circle_arc(&t0, &t1, p3(Vec3::new(0., 10., 20.))), builder::circle_arc(&t1, &t0, p3(Vec3::new(0., -10., 20.)))].into();
let seam = builder::line(&b0, &t0);
let mut boundary: Vec<_> = bottom.edge_iter().cloned().collect();
boundary.push(seam.clone());
boundary.extend(top.inverse().edge_iter().cloned());
boundary.push(seam.inverse());
let wall = mt::Face::new_unchecked(
vec![boundary.into()],
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(
seam.inverse().oriented_curve(),
p3(Vec3::ZERO),
v3(Vec3::Z),
))),
);
let lower = builder::try_attach_plane(&[bottom.inverse()]).unwrap();
let upper = builder::try_attach_plane(&[top]).unwrap();
Body::new(Solid::new_unchecked(vec![vec![wall, lower, upper].into()])).unwrap()
}
#[test]
fn periodic_step_seam_survives_identity_rotation_and_translation() {
let source = periodic_cylinder();
let bytes = crate::step_export(&[&source], "placement regression").unwrap();
let imported = crate::step_import(&bytes).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let body = &imported.bodies.first().unwrap().body;
assert!(body.solid.face_iter().any(|f| f.absolute_boundaries().iter().any(|w| !w.is_simple())));
let source_vertices: Vec<_> = body.solid.vertex_iter().map(|v| from_p3(v.point())).collect();
let source_faces = body.solid.face_iter().count();
let unchanged = crate::step_export(&[body], "source snapshot").unwrap();
let before = body.tessellate_with(1e-3, true).unwrap().measure().volume;
assert!((before - 2000. * std::f64::consts::PI).abs() < 1.);
for matrix in [
[[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]],
[[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [10., 10., 0., 1.]],
[[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]],
] {
let moved = transform_matrix(body, matrix).unwrap();
assert_topology(body, &moved, matrix);
assert_eq!(moved.solid.face_iter().count(), source_faces);
assert_eq!(moved.solid.vertex_iter().count(), source_vertices.len());
let after = moved.tessellate_with(1e-3, true).unwrap().measure().volume;
assert!((after - before).abs() < 1e-5, "{after} vs {before}");
let roundtrip = crate::step_import(&crate::step_export(&[&moved], "placement regression").unwrap()).unwrap();
assert!(roundtrip.warnings.is_empty(), "{:?}", roundtrip.warnings);
assert!((roundtrip.bodies.first().unwrap().body.tessellate_with(1e-3, true).unwrap().measure().volume - before).abs() < 1e-4);
}
let rigid = transform(body, Vec3::new(10., 10., 30.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2).unwrap();
assert_topology(body, &rigid, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]]);
assert_eq!(crate::step_export(&[body], "source snapshot").unwrap(), unchanged);
}
+367 -15
View File
@@ -10,7 +10,108 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3};
use crate::{BoolOp, Result};
use crate::{BoolOp, KernelError, Result};
const MAX_PLANAR_SEGMENTS: usize = 8192;
const MAX_PLANAR_EVENTS: usize = 131_072;
const MAX_PLANAR_PAIRS: usize = 16_000_000;
/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay
/// analytic; disjoint results and retained islands are returned as separate regions.
pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result<Vec<Region2>> {
crate::guard("planar region boolean", || {
if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) {
return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into()));
}
let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes));
let mut edges = 0usize;
let mut samples = 0usize;
for lp in loops() {
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_PLANAR_SEGMENTS || lp.segs.is_empty() || !lp.is_closed(tolerance * 10.0) {
return Err(KernelError::Invalid("planar boolean needs closed loops within its geometry budget".into()));
}
for s in &lp.segs {
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
let valid = match *s {
Seg2::Line { a, b } => finite(a) && finite(b) && a.dist(b) > 1e-12,
Seg2::Arc { center, radius, start, sweep } => {
finite(center)
&& radius.is_finite()
&& radius > 0.0
&& radius < 1e9
&& start.is_finite()
&& sweep.is_finite()
&& sweep.abs() > 1e-12
&& sweep.abs() <= std::f64::consts::TAU + 1e-9
}
_ => false,
};
if !valid {
return Err(KernelError::Invalid("planar boolean supports finite nondegenerate lines and circular arcs".into()));
}
samples = samples.saturating_add(s.polyline(1e-3).len());
if samples > 65_536 {
return Err(KernelError::Invalid("planar boolean sampled geometry exceeds its budget".into()));
}
}
}
boolean2d(a, b, op, tolerance)
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))
})
}
/// Extrude normalized planar material as one logical compound, retaining every disjoint
/// region and its holes. Planar arrangement certifies separation before shells are joined.
pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Body> {
crate::guard("planar material extrusion", || {
// Validate aggregate inputs first; an arrangement compares opposite operands,
// so regions in the same operand must be normalized through balanced unions.
planar_regions_boolean(regions, &[], BoolOp::Union, 1e-7)?;
let mut groups: Vec<Vec<Region2>> = regions.iter().cloned().map(|r| vec![r]).collect();
let mut remaining_pairs = 32_000_000usize;
while groups.len() > 1 {
let mut next = Vec::new();
let mut iter = groups.into_iter();
while let Some(a) = iter.next() {
if let Some(b) = iter.next() {
let count = |rs: &[Region2]| {
rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, l| n.saturating_add(l.segs.len()))
};
remaining_pairs = remaining_pairs
.checked_sub(count(&a).saturating_mul(count(&b)))
.ok_or_else(|| KernelError::Invalid("planar extrusion arrangement exceeds its work budget".into()))?;
next.push(planar_regions_boolean(&a, &b, BoolOp::Union, 1e-7)?);
} else {
next.push(a);
}
}
groups = next;
}
let normalized = groups.pop().unwrap_or_default();
if normalized.is_empty() {
return Err(KernelError::Invalid("empty planar material extrusion".into()));
}
for (i, a) in normalized.iter().enumerate() {
for b in normalized.iter().skip(i + 1) {
for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) {
for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) {
remaining_pairs = remaining_pairs
.checked_sub(1)
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7)
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7);
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
}
}
}
}
}
let pieces = crate::extrude(plane, &normalized, start, end)?;
crate::ops::join_pieces(pieces)?.ok_or_else(|| KernelError::Failed("empty planar material extrusion".into()))
})
}
/// A prism: the plane of its bottom cap (normal along the walls), its height and section.
struct Prism {
@@ -191,9 +292,83 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
out
}
/// Exact point-in-loop by winding (arcs as fine polylines are enough away from the boundary).
/// Ray parity against analytic lines/arcs. Fragment classification must not depend on a
/// display chord tolerance: a tiny tool can sit between a circular boundary and its chords.
fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
let mut odd = false;
for seg in &lp.segs {
match *seg {
Seg2::Line { a, b } => {
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y);
if x > p.x {
odd = !odd;
}
}
}
Seg2::Arc { center, radius, start, sweep } => {
let y = (p.y - center.y) / radius;
if !(-1.0..=1.0).contains(&y) {
continue;
}
let a = y.asin();
let root = (1.0 - y * y).max(0.0).sqrt();
let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-12;
for (i, (angle, x)) in [(a, center.x + radius * root), (std::f64::consts::PI - a, center.x - radius * root)].into_iter().enumerate() {
// A tangent has one geometric root. Interior tangencies contribute zero
// parity; a minimum at an endpoint follows the same half-open rule as lines.
if root == 0.0 && i == 1 {
continue;
}
if x <= p.x {
continue;
}
let mut delta = if sweep > 0.0 { angle - start } else { start - angle };
delta = delta.rem_euclid(std::f64::consts::TAU);
if (delta - std::f64::consts::TAU).abs() < 1e-12 {
delta = 0.0;
}
if delta > sweep.abs() + 1e-12 {
continue;
}
let dy = angle.cos() * sweep;
let tangent = dy.abs() < sweep.abs() * 1e-12;
let at_start = delta < 1e-12;
let at_end = (delta - sweep.abs()).abs() < 1e-12;
let crosses = if full {
!tangent
} else if at_start {
dy > 0.0 && !tangent || tangent && y < 0.0
} else if at_end {
dy < 0.0 && !tangent || tangent && y < 0.0
} else {
!tangent
};
if crosses {
odd = !odd;
}
}
}
_ => {
// Private prism callers only supply lines/arcs. Keep a controlled fallback
// for future internal callers; the public helper rejects other segment kinds.
let points = seg.polyline(1e-7);
for pair in points.windows(2) {
if let [a, b] = pair
&& (a.y > p.y) != (b.y > p.y)
&& a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x
{
odd = !odd;
}
}
}
}
}
odd
}
fn inside(regions: &[Region2], p: Vec2) -> bool {
regions.iter().any(|r| r.contains(p))
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
}
fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
@@ -233,19 +408,30 @@ fn oriented(regions: &[Region2]) -> Vec<Region2> {
/// 2D boolean of regions bounded by lines and arcs.
fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<Region2>> {
let count =
|rs: &[Region2]| rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, lp| n.saturating_add(lp.segs.len()));
let (na, nb) = (count(a), count(b));
if na.saturating_add(nb) > MAX_PLANAR_SEGMENTS || na.saturating_mul(nb) > MAX_PLANAR_PAIRS {
return None;
}
let (a, b) = (oriented(a), oriented(b));
let segs =
|rs: &[Region2]| -> Vec<Seg2> { rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| l.segs.clone()).collect() };
let (sa, sb) = (segs(&a), segs(&b));
if sa.len() * sb.len() > 4_000_000 || sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return None;
}
// Split every segment where it crosses the other side's boundary.
let mut cut_a: Vec<Vec<f64>> = vec![Vec::new(); sa.len()];
let mut cut_b: Vec<Vec<f64>> = vec![Vec::new(); sb.len()];
let mut events = 0usize;
for (i, s) in sa.iter().enumerate() {
for (j, t) in sb.iter().enumerate() {
for (u, v) in crossings(s, t, tol) {
events += 2;
if events > MAX_PLANAR_EVENTS {
return None;
}
cut_a.get_mut(i)?.push(u);
cut_b.get_mut(j)?.push(v);
}
@@ -257,6 +443,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
for p in [t.start(), t.end()] {
let u = s.closest_param(p);
if s.point_at(u).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
return None;
}
cut_a.get_mut(i)?.push(u);
}
}
@@ -267,6 +457,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
for p in [s.start(), s.end()] {
let v = t.closest_param(p);
if t.point_at(v).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
return None;
}
cut_b.get_mut(j)?.push(v);
}
}
@@ -295,6 +489,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
let mut kept: Vec<Seg2> = Vec::new();
// A's pieces.
for (i, s) in sa.iter().enumerate() {
if cut_a.get(i)?.len() > MAX_PLANAR_SEGMENTS {
return None;
}
for p in pieces(s, cut_a.get_mut(i)?) {
let m = p.point_at(0.5);
let on = dist_to(&b, m) < tol;
@@ -312,18 +509,27 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
}
};
if keep {
if kept.len() >= MAX_PLANAR_SEGMENTS {
return None;
}
kept.push(p);
}
}
}
// B's pieces (coincident ones were decided with A's).
for (j, t) in sb.iter().enumerate() {
if cut_b.get(j)?.len() > MAX_PLANAR_SEGMENTS {
return None;
}
for p in pieces(t, cut_b.get_mut(j)?) {
let m = p.point_at(0.5);
if dist_to(&a, m) < tol {
continue;
}
let ins = inside(&a, m);
if kept.len() >= MAX_PLANAR_SEGMENTS {
return None;
}
match op {
BoolOp::Union if !ins => kept.push(p),
BoolOp::Intersect if ins => kept.push(p),
@@ -351,7 +557,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
return None;
}
let end = segs.last()?.end();
if end.dist(first) < tol * 10.0 && segs.len() > 1 {
let full_circle =
segs.first().is_some_and(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9));
if end.dist(first) < tol * 10.0 && (segs.len() > 1 || full_circle) {
break;
}
let next = (0..kept.len())
@@ -379,7 +587,7 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
let mut regions: Vec<Region2> = outers.into_iter().map(|o| Region2 { outer: o, holes: Vec::new() }).collect();
for h in holes {
let p = h.segs.first()?.point_at(0.5);
let r = regions.iter_mut().find(|r| r.outer.contains(p) && r.outer.signed_area().abs() > h.signed_area().abs())?;
let r = regions.iter_mut().find(|r| inside_loop(&r.outer, p) && r.outer.signed_area().abs() > h.signed_area().abs())?;
r.holes.push(h);
}
Some(regions)
@@ -654,9 +862,13 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
}
fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
// This path repairs joins with a common outer cap (a boss flush with a rim).
// Other extents and subtractive operations retain their existing B-rep path.
if op != BoolOp::Union {
// Common-cap joins and partial-depth cuts of aligned stepped prisms can
// retain their exact section boundaries instead of intersecting meshes.
// Other extents retain their existing B-rep path.
if !matches!(op, BoolOp::Union | BoolOp::Cut) {
return None;
}
if op == BoolOp::Cut && a.face_count().saturating_add(b.face_count()) > 2048 {
return None;
}
fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> &[Region2] {
@@ -674,28 +886,46 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
}
}
}
let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi)));
let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| {
let plane = Plane::from_normal(Vec3::ZERO, d)?;
Some((d, plane, stack_of(a, d, &plane, tol)?, stack_of(b, d, &plane, tol)?))
let pa = stack_of(a, d, &plane, tol)?;
let pb = stack_of(b, d, &plane, tol)?;
let (ea, eb) = (ends(&pa), ends(&pb));
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol {
return None;
}
Some((d, plane, pa, pb))
})?;
let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi)));
let (ea, eb) = (ends(&pa), ends(&pb));
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol {
return None;
}
let mut levels: Vec<f64> = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect();
levels.sort_by(f64::total_cmp);
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
let mut slabs = Vec::new();
let mut work = 0usize;
let mut output_edges = 0usize;
let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::<usize>();
for w in levels.windows(2) {
let (lo, hi) = (*w.first()?, *w.get(1)?);
let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol));
if op == BoolOp::Cut {
let count = edges(x).saturating_add(edges(y));
work = work.saturating_add(count.saturating_mul(count));
if work > 32_000_000 {
return None;
}
}
let s = match (x.is_empty(), y.is_empty(), op) {
(true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(),
(true, _, BoolOp::Union) => y.to_vec(),
(_, true, _) => x.to_vec(),
_ => boolean2d(x, y, op, tol)?,
};
if op == BoolOp::Cut {
output_edges = output_edges.saturating_add(edges(&s));
if output_edges > 8192 {
return None;
}
}
slabs.push(oriented(&s));
}
if slabs.iter().all(Vec::is_empty) {
@@ -707,3 +937,125 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten()
}
#[cfg(test)]
mod planar_tests {
use super::*;
fn rectangle(a: Vec2, b: Vec2) -> Region2 {
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
}
#[test]
fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() {
let plane = Plane::named("XY").unwrap();
let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.));
plate.holes.push(rectangle(Vec2::new(25., 20.), Vec2::new(30., 30.)).outer);
let stock = crate::extrude(&plane, &[plate], 0., 2.5).unwrap().pop().unwrap();
let low = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.))], 0., 1.5).unwrap().pop().unwrap();
let high = crate::extrude(&plane, &[rectangle(Vec2::new(25., 20.), Vec2::new(35., 30.))], 1., 2.5).unwrap().pop().unwrap();
for tools in [[&low, &high], [&high, &low]] {
let mut result = stock.clone();
for tool in tools {
result = stack_boolean(&result, tool, BoolOp::Cut).expect("exact stack path").unwrap().unwrap();
}
assert!(result.validity().is_empty());
assert_eq!(result.shell_count(), 1);
assert!((crate::measure(&result).unwrap().volume - 14_725.).abs() < 1e-6);
let mesh = result.tessellate(1e-4).unwrap();
for (point, material) in [
(Vec3::new(22.5, 25., 0.5), false),
(Vec3::new(22.5, 25., 2.), true),
(Vec3::new(32.5, 25., 0.5), true),
(Vec3::new(32.5, 25., 2.), false),
(Vec3::new(27.5, 25., 1.25), false),
(Vec3::new(10., 10., 1.25), true),
] {
assert_eq!(mesh.contains(point), material, "{point:?}");
}
let imported = crate::step_import(&crate::step_export(&[&result], "layered cut").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
assert!((crate::measure(&imported.bodies.first().unwrap().body).unwrap().volume - 14_725.).abs() < 1e-6);
let next = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(25., 30.))], 1.5, 2.5).unwrap().pop().unwrap();
let opened = crate::boolean(&imported.bodies.first().unwrap().body, &next, BoolOp::Cut).unwrap().unwrap();
assert!((crate::measure(&opened).unwrap().volume - 14_675.).abs() < 1e-6);
assert!(opened.validity().is_empty());
}
}
#[test]
fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() {
let plane = Plane::named("XY").unwrap();
let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
let b = rectangle(Vec2::new(5.0, 0.0), Vec2::new(15.0, 10.0));
let body = extrude_planar_regions(&plane, &[a.clone(), b], 0.0, 2.0).unwrap();
assert!((crate::measure(&body).unwrap().volume - 300.0).abs() < 1e-6);
let inner = Region2 { outer: Loop2::circle(Vec2::new(5.0, 5.0), 2.0), holes: vec![] };
let body = extrude_planar_regions(&plane, &[a.clone(), inner.clone()], 0.0, 2.0).unwrap();
assert!((crate::measure(&body).unwrap().volume - 200.0).abs() < 1e-6);
let annulus = Region2 { outer: a.outer, holes: vec![Loop2::circle(Vec2::new(5.0, 5.0), 3.0)] };
let body = extrude_planar_regions(&plane, &[annulus, inner], 0.0, 2.0).unwrap();
let expected = (100.0 - std::f64::consts::PI * 5.0) * 2.0;
assert!((crate::measure(&body).unwrap().volume - expected).abs() < 0.1);
assert_eq!(body.solid.boundaries().len(), 2);
let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
let b = rectangle(Vec2::new(10.0, 10.0), Vec2::new(20.0, 20.0));
assert!(extrude_planar_regions(&plane, &[a, b], 0.0, 2.0).is_err());
let circles = [Vec2::ZERO, Vec2::new(10.0, 0.0)].map(|center| Region2 { outer: Loop2::circle(center, 5.0), holes: vec![] });
assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err());
}
#[test]
fn analytic_circle_classification_respects_signed_sweeps_and_seams() {
for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] {
for reversed in [false, true] {
let lp = Loop2::circle_from(Vec2::ZERO, 10.0, seam);
let lp = if reversed { lp.reversed() } else { lp };
for p in [Vec2::ZERO, Vec2::new(9.9998, 0.0), Vec2::new(0.0, 9.9998), Vec2::new(0.0, -9.9998)] {
assert!(inside_loop(&lp, p), "{seam} {reversed} {p:?}");
}
for p in [Vec2::new(11.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(0.0, -10.0)] {
assert!(!inside_loop(&lp, p), "{seam} {reversed} {p:?}");
}
}
}
let half = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, -10.0) },
],
};
for lp in [half.clone(), half.reversed()] {
assert!(inside_loop(&lp, Vec2::new(5.0, 0.0)));
assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0)));
assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0)));
}
}
#[test]
fn tiny_tool_between_circle_and_display_chord_is_not_lost() {
let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] };
let sampled = circle.outer.polyline(1e-3);
let direction = (sampled[0] + sampled[1]).normalized().unwrap();
let at = direction * 9.9998;
let half = Vec2::new(0.000025, 0.000025);
let square = rectangle(at - half, at + half);
assert!(square.outer.segs.iter().all(|s| s.start().len() < 10.0));
assert!(!circle.contains(at), "fixture must lie outside the display chord");
let cut = planar_regions_boolean(std::slice::from_ref(&circle), std::slice::from_ref(&square), BoolOp::Cut, 1e-7).unwrap();
assert_eq!(cut.len(), 1);
assert_eq!(cut[0].holes.len(), 1);
assert!((cut[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13);
let intersect = planar_regions_boolean(&[circle], &[square], BoolOp::Intersect, 1e-7).unwrap();
assert_eq!(intersect.len(), 1);
assert!((intersect[0].area() - 2.5e-9).abs() < 1e-13);
}
#[test]
fn planar_region_api_rejects_hostile_values_and_budgets() {
let plate = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
for tolerance in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
assert!(planar_regions_boolean(std::slice::from_ref(&plate), &[], BoolOp::Union, tolerance).is_err());
}
let mut bad = plate.clone();
bad.outer.segs[0] = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X };
assert!(planar_regions_boolean(&[bad], &[], BoolOp::Union, 1e-7).is_err());
let many = vec![plate; MAX_PLANAR_SEGMENTS / 4 + 1];
assert!(planar_regions_boolean(&many, &[], BoolOp::Union, 1e-7).is_err());
}
}
+132 -2
View File
@@ -193,6 +193,72 @@ fn faces_connected(faces: &[mt::Face]) -> bool {
seen.len() == faces.len()
}
/// Keep disconnected closed boundaries as distinct shells, preserving orientation.
fn closed_components(faces: Vec<mt::Face>) -> Result<Vec<mt::Shell>> {
const MAX_EDGE_USES: usize = 500_000;
const MAX_COMPONENTS: usize = 1024;
let fail = |s: &str| KernelError::Invalid(format!("sew: {s}"));
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
let mut edge_uses = 0usize;
for (i, face) in faces.iter().enumerate() {
for edge in face.edge_iter() {
edge_uses = edge_uses.saturating_add(1);
if edge_uses > MAX_EDGE_USES {
return Err(fail("edge budget exceeded"));
}
let uses = by_edge.entry(edge.id()).or_default();
if uses.len() >= 2 {
return Err(fail("nonmanifold edge contact"));
}
uses.push(i);
}
}
if by_edge.values().any(|uses| uses.len() != 2) {
return Err(fail("the faces do not close"));
}
let mut seen = vec![false; faces.len()];
let mut vertices = HashMap::new();
let mut components = Vec::new();
for first in 0..faces.len() {
if seen.get(first).copied().unwrap_or(true) {
continue;
}
if components.len() >= MAX_COMPONENTS {
return Err(fail("component budget exceeded"));
}
let component = components.len();
let mut todo = vec![first];
if let Some(slot) = seen.get_mut(first) {
*slot = true;
}
let mut indices = Vec::new();
while let Some(i) = todo.pop() {
let face = faces.get(i).ok_or_else(|| fail("missing component face"))?;
indices.push(i);
for vertex in face.vertex_iter() {
if vertices.insert(vertex.id(), component).is_some_and(|previous| previous != component) {
return Err(fail("bodies touch only at a vertex"));
}
}
for edge in face.edge_iter() {
let adjacent = by_edge.get(&edge.id()).ok_or_else(|| fail("missing component edge"))?;
for j in adjacent {
let slot = seen.get_mut(*j).ok_or_else(|| fail("missing adjacent face"))?;
if !*slot {
*slot = true;
todo.push(*j);
}
}
}
}
indices.sort_unstable();
let component_faces =
indices.into_iter().map(|i| faces.get(i).cloned().ok_or_else(|| fail("missing component face"))).collect::<Result<Vec<_>>>()?;
components.push(component_faces.into());
}
Ok(components)
}
/// A contact's edge sheets can terminate in separate vertex fans. Give each fan
/// its own vertex at the same coordinates so the boundary remains a manifold.
fn split_vertex_fans(faces: Vec<mt::Face>) -> Result<Vec<mt::Face>> {
@@ -285,8 +351,13 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
if faces.len() < 4 || faces.len() > 200_000 {
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into()));
}
let mut edge_uses = 0usize;
for f in faces {
for l in &f.loops {
edge_uses = edge_uses.saturating_add(l.len().max(1));
if edge_uses > 500_000 {
return Err(KernelError::Invalid("sew: edge budget exceeded".into()));
}
for e in l {
let (a, b) = e.ends();
if !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 {
@@ -331,8 +402,8 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
if !sheets.is_empty() {
out = split_vertex_fans(out)?;
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
let shells = closed_components(out)?;
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
Body::new(solid)
})
}
@@ -363,6 +434,65 @@ mod tests {
}
}
fn box_faces(origin: Vec3, size: Vec3) -> Vec<FaceSpec> {
let p = |x: f64, y: f64, z: f64| origin + Vec3::new(x * size.x, y * size.y, z * size.z);
let (a, b, c, d) = (p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.));
let (e, f, g, h) = (p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.));
vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)]
}
#[test]
fn disconnected_closed_faces_keep_every_material_component() {
let mut faces = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0));
faces.extend(box_faces(Vec3::X * 10.0, Vec3::new(2.0, 3.0, 4.0)));
for reverse in [false, true] {
if reverse {
faces.reverse();
}
let body = sew(&faces).unwrap();
assert_eq!(body.shell_count(), 2);
assert!(body.validity().is_empty());
assert!((crate::measure(&body).unwrap().volume - 48.0).abs() < 1e-9);
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
assert!(lumps.iter().all(|b| (crate::measure(b).unwrap().volume - 24.0).abs() < 1e-9));
let mesh = body.tessellate(0.01).unwrap();
assert!(mesh.contains(Vec3::new(1.0, 1.0, 1.0)));
assert!(mesh.contains(Vec3::new(11.0, 1.0, 1.0)));
assert!(!mesh.contains(Vec3::new(5.0, 1.0, 1.0)));
let imported = crate::step_import(&crate::step_export(&[&body], "sewn pieces").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let volume: f64 = imported.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum();
assert!((volume - 48.0).abs() < 1e-6);
}
}
#[test]
fn sewing_preserves_inward_cavity_orientation_and_refuses_vertex_contact() {
let outer = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0));
let mut inner = box_faces(Vec3::new(0.5, 0.75, 1.0), Vec3::new(1.0, 1.5, 2.0));
for face in &mut inner {
for lp in &mut face.loops {
lp.reverse();
for edge in lp {
if let EdgeSpec::Line { a, b } = edge {
std::mem::swap(a, b);
}
}
}
}
let cavity = sew(&outer.iter().chain(&inner).cloned().collect::<Vec<_>>()).unwrap();
assert_eq!(cavity.shell_count(), 2);
assert_eq!(cavity.lumps().unwrap().len(), 1);
assert!((crate::measure(&cavity).unwrap().volume - 21.0).abs() < 1e-9);
assert!(!cavity.tessellate(0.01).unwrap().contains(Vec3::new(1.0, 1.5, 2.0)));
let touching = box_faces(Vec3::new(2.0, 3.0, 4.0), Vec3::new(2.0, 3.0, 4.0));
assert!(sew(&outer.iter().chain(&touching).cloned().collect::<Vec<_>>()).unwrap_err().to_string().contains("vertex"));
let mut hostile = outer.clone();
hostile[0].loops = vec![Vec::new(); 500_001];
assert!(sew(&hostile).unwrap_err().to_string().contains("budget"));
}
#[test]
fn a_sewn_box() {
let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z);
+222
View File
@@ -0,0 +1,222 @@
//! Group disconnected material regions without moving a cavity to another enclosing shell.
//! A valid shell hierarchy alternates material and void at every nesting level.
use solvecraft_geom::{Aabb3, Mesh, Vec3};
use crate::{KernelError, Result};
pub(super) const MAX_SHELLS: usize = 1024;
pub(super) const MAX_POINTS: usize = 100_000;
pub(super) const MAX_TRIANGLES: usize = 100_000;
const MAX_PAIR_WORK: usize = 40_000_000;
const MAX_PROBES: usize = 2_000_000;
const MAX_CONTAINMENT_WORK: usize = 40_000_000;
fn failed(detail: &str) -> KernelError {
KernelError::Failed(format!("lumps: {detail}"))
}
fn box_of(points: &[Vec3]) -> Aabb3 {
let mut bounds = Aabb3::EMPTY;
for p in points {
bounds.add(*p);
}
bounds
}
fn boxes_separate(a: Aabb3, b: Aabb3, gap: f64) -> bool {
a.max.x < b.min.x - gap
|| b.max.x < a.min.x - gap
|| a.max.y < b.min.y - gap
|| b.max.y < a.min.y - gap
|| a.max.z < b.min.z - gap
|| b.max.z < a.min.z - gap
}
fn unit(v: Vec3) -> Option<Vec3> {
let scale = v.x.abs().max(v.y.abs()).max(v.z.abs());
if !scale.is_finite() || scale == 0.0 {
return None;
}
(v / scale).normalized()
}
/// Separating axes include the in-plane edge normals for coplanar triangles.
fn triangles_meet(a: &[Vec3; 3], b: &[Vec3; 3], gap: f64) -> bool {
let [a0, a1, a2] = *a;
let [b0, b1, b2] = *b;
let ea = [a1 - a0, a2 - a1, a0 - a2];
let eb = [b1 - b0, b2 - b1, b0 - b2];
let na = (a1 - a0).cross(a2 - a0);
let nb = (b1 - b0).cross(b2 - b0);
let separated = |axis: Vec3| {
let Some(n) = unit(axis) else { return false };
// Translate projections first so a far-away body does not lose its clearance.
let range = |points: &[Vec3; 3]| {
points.iter().map(|p| (*p - a0).dot(n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x), hi.max(x)))
};
let ((alo, ahi), (blo, bhi)) = (range(a), range(b));
ahi < blo - gap || bhi < alo - gap
};
if separated(na) || separated(nb) {
return false;
}
for e in ea {
if separated(na.cross(e)) {
return false;
}
for f in eb {
if separated(e.cross(f)) {
return false;
}
}
}
for e in eb {
if separated(nb.cross(e)) {
return false;
}
}
true
}
pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result<Vec<Vec<usize>>> {
if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS {
return Err(failed("shell count exceeds the grouping limit or is invalid"));
}
if !gap.is_finite() || gap <= 0.0 {
return Err(failed("invalid shell clearance"));
}
let mut points = 0usize;
let mut triangle_count = 0usize;
let mut triangles = Vec::with_capacity(meshes.len());
let mut triangle_bounds = Vec::with_capacity(meshes.len());
let mut bounds = Vec::with_capacity(meshes.len());
for (mesh, volume) in meshes.iter().zip(volumes) {
points = points.checked_add(mesh.positions.len()).ok_or_else(|| failed("point count exceeds the grouping limit"))?;
triangle_count = triangle_count.checked_add(mesh.triangles.len()).ok_or_else(|| failed("triangle count exceeds the grouping limit"))?;
if points > MAX_POINTS || triangle_count > MAX_TRIANGLES {
return Err(failed("mesh exceeds the shell grouping limit"));
}
if !volume.is_finite()
|| *volume == 0.0
|| mesh.positions.is_empty()
|| mesh.triangles.is_empty()
|| mesh.positions.iter().any(|p| !p.is_finite())
{
return Err(failed("cannot determine a closed shell's orientation"));
}
let tris: Vec<_> = mesh.triangles.iter().map(|t| mesh.tri(t).ok_or_else(|| failed("invalid shell triangle"))).collect::<Result<_>>()?;
if tris.iter().any(|[a, b, c]| unit((*b - *a).cross(*c - *a)).is_none()) {
return Err(failed("degenerate shell triangle"));
}
triangle_bounds.push(tris.iter().map(|t| box_of(t)).collect::<Vec<_>>());
triangles.push(tris);
bounds.push(mesh.bounds());
}
let mut inside = vec![vec![false; meshes.len()]; meshes.len()];
let mut pair_work = 0usize;
let mut probes = 0usize;
let mut containment_work = 0usize;
for (i, mesh) in meshes.iter().enumerate() {
for j in i + 1..meshes.len() {
let (Some(a_bounds), Some(b_bounds)) = (bounds.get(i), bounds.get(j)) else { return Err(failed("missing shell bounds")) };
if boxes_separate(*a_bounds, *b_bounds, gap) {
continue;
}
let (Some(a), Some(b)) = (triangles.get(i), triangles.get(j)) else { return Err(failed("missing shell triangles")) };
let work = a.len().checked_mul(b.len()).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?;
pair_work = pair_work.checked_add(work).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?;
if pair_work > MAX_PAIR_WORK {
return Err(failed("shell boundary work exceeds the grouping limit"));
}
for (ai, ta) in a.iter().enumerate() {
for (bi, tb) in b.iter().enumerate() {
let (Some(ba), Some(bb)) = (triangle_bounds.get(i).and_then(|x| x.get(ai)), triangle_bounds.get(j).and_then(|x| x.get(bi)))
else {
return Err(failed("missing triangle bounds"));
};
if !boxes_separate(*ba, *bb, gap) && triangles_meet(ta, tb, gap) {
return Err(failed("shell boundaries intersect or touch; cavity ownership is ambiguous"));
}
}
}
for (inner, outer) in [(i, j), (j, i)] {
let ib = bounds.get(inner).ok_or_else(|| failed("missing shell bounds"))?;
let ob = bounds.get(outer).ok_or_else(|| failed("missing shell bounds"))?;
// With boundary intersections already excluded, a connected shell
// extending beyond another's bounds cannot be wholly contained.
if ib.min.x < ob.min.x - gap
|| ib.min.y < ob.min.y - gap
|| ib.min.z < ob.min.z - gap
|| ib.max.x > ob.max.x + gap
|| ib.max.y > ob.max.y + gap
|| ib.max.z > ob.max.z + gap
{
continue;
}
let inner_mesh = if inner == i { mesh } else { meshes.get(inner).ok_or_else(|| failed("missing shell"))? };
probes = probes.checked_add(inner_mesh.positions.len()).ok_or_else(|| failed("shell probes exceed the grouping limit"))?;
if probes > MAX_PROBES {
return Err(failed("shell probes exceed the grouping limit"));
}
let outer_mesh = meshes.get(outer).ok_or_else(|| failed("missing shell"))?;
let probe_count = inner_mesh.positions.len();
let work = probe_count
.checked_mul(outer_mesh.triangles.len())
.and_then(|n| n.checked_mul(3))
.ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?;
containment_work = containment_work.checked_add(work).ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?;
if containment_work > MAX_CONTAINMENT_WORK {
return Err(failed("shell containment work exceeds the grouping limit"));
}
let mut contained = None;
for p in inner_mesh.positions.iter().copied() {
let hit = outer_mesh.contains(p);
if contained.is_some_and(|previous| previous != hit) {
return Err(failed("shell containment is inconsistent; cavity ownership is ambiguous"));
}
contained = Some(hit);
}
if let Some(slot) = inside.get_mut(inner).and_then(|row| row.get_mut(outer)) {
*slot = contained == Some(true);
}
}
}
}
let mut parent = Vec::with_capacity(meshes.len());
for i in 0..meshes.len() {
let row = inside.get(i).ok_or_else(|| failed("missing containment"))?;
let candidates: Vec<_> = row.iter().enumerate().filter_map(|(j, hit)| hit.then_some(j)).collect();
for (k, a) in candidates.iter().enumerate() {
for b in candidates.iter().skip(k + 1) {
if !inside.get(*a).and_then(|r| r.get(*b)).copied().unwrap_or(false)
&& !inside.get(*b).and_then(|r| r.get(*a)).copied().unwrap_or(false)
{
return Err(failed("multiple overlapping shell owners"));
}
}
}
let owner = candidates.into_iter().min_by(|a, b| {
volumes.get(*a).copied().unwrap_or(f64::INFINITY).abs().total_cmp(&volumes.get(*b).copied().unwrap_or(f64::INFINITY).abs())
});
let outward = volumes.get(i).is_some_and(|v| *v > 0.0);
match owner {
None if !outward => return Err(failed("inward shell has no enclosing material shell")),
Some(p) if volumes.get(p).is_some_and(|v| (*v > 0.0) == outward) => {
return Err(failed("nested shells do not alternate material and void"));
}
_ => {}
}
parent.push(owner);
}
let mut groups = Vec::new();
for (i, v) in volumes.iter().enumerate() {
if *v <= 0.0 {
continue;
}
let mut group = vec![i];
group.extend(parent.iter().enumerate().filter_map(|(j, p)| (*p == Some(i)).then_some(j)));
groups.push(group);
}
Ok(groups)
}
+9 -1
View File
@@ -878,7 +878,15 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
let shells = std::mem::take(&mut imp.shells);
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
Ok((
Body { solid: std::sync::Arc::new(solid), coarse_missing: None, mesh: None, color: body.color, paint: None, splits: body.splits.clone() },
Body {
solid: std::sync::Arc::new(solid),
coarse_missing: None,
mesh: None,
color: body.color,
paint: None,
splits: body.splits.clone(),
face_sources: None,
},
imp,
))
})
+4 -4
View File
@@ -7,8 +7,8 @@ use crate::{KernelError, Result, guard};
/// Intersection-curve edges (from booleans) as cubic B-splines through the exact curve: STEP
/// readers get plain 3D geometry that lies on both faces, and the file does not repeat the two
/// surfaces for every such edge.
fn plain_curves(b: &Body) -> mt::Solid {
let s = b.deep_copy();
fn plain_curves(b: &Body) -> Result<mt::Solid> {
let s = crate::ops::placed_topology(&b.solid, mt::Matrix4::from_scale(1.0))?;
let size = b.size();
let mut seen = std::collections::HashSet::new();
for e in s.edge_iter() {
@@ -21,7 +21,7 @@ fn plain_curves(b: &Body) -> mt::Solid {
e.set_curve(mt::Curve::BSplineCurve(c));
}
}
s
Ok(s)
}
/// A C¹ cubic B-spline through exact points of `c` spaced so that chords deviate at most `tol`
@@ -71,7 +71,7 @@ pub(crate) fn truck_step(bodies: &[&Body], system: &str) -> Result<String> {
b.require_brep("STEP export")?;
}
guard("step export", || {
let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).compress()).collect();
let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).map(|solid| solid.compress())).collect::<Result<_>>()?;
let models: out::StepModels<_, _, _> = cs.iter().collect();
let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() };
Ok(out::CompleteStepDisplay::new(models, header).to_string())
+64 -6
View File
@@ -1,7 +1,9 @@
//! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces.
//!
//! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and
//! swept surfaces. Edge curves are trimmed to their vertices here, so every kernel edge runs
//! swept surfaces, with exact offsets of planes, cylinders, cones, spheres and regular tori.
//! Free-form offsets and offsets through collapsed radii remain explicit import warnings.
//! Edge curves are trimmed to their vertices here, so every kernel edge runs
//! exactly from its start vertex to its end vertex. Lengths are scaled to millimetres while
//! reading.
@@ -761,6 +763,59 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R<mt::Surface> {
}
}
/// Resolve exact analytic offsets. Each intermediate offset is checked: an invalid inner
/// offset must not become valid merely because an outer offset cancels its distance.
/// Entity attributes follow ISO 10303-42 (`basis_surface`, `distance`, `self_intersect`).
fn offset_basis(cx: &Ctx, id: u64, depth: usize) -> R<(u64, f64)> {
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
let e = entity(cx, id)?;
if e.name() != "OFFSET_SURFACE" {
return Ok((id, 0.0));
}
let p = e.params();
let basis = p.get(1).and_then(Param::as_ref_id).ok_or("offset surface without basis")?;
let distance = length(cx, p.get(2), "offset distance")?;
if !matches!(p.get(3).and_then(Param::as_enum), Some("T" | "F" | "U")) {
return Err("offset surface without a logical self_intersect flag".into());
}
let (basis, inner) = offset_basis(cx, basis, depth + 1)?;
let offset = inner + distance;
if !offset.is_finite() || offset.abs() >= MAX_COORD {
return Err("offset distance out of range after unit conversion".into());
}
let e = entity(cx, basis)?;
let p = e.params();
match e.name() {
"PLANE" => {}
"CYLINDRICAL_SURFACE" | "SPHERICAL_SURFACE" => {
let radius = length(cx, p.get(2), "radius")?;
if radius <= 0.0 || !(1e-9..MAX_COORD).contains(&(radius + offset)) {
return Err("offset surface collapses or reverses its radius".into());
}
}
"CONICAL_SURFACE" => {
let radius = length(cx, p.get(2), "radius")?;
let angle = num(p.get(3), "semi angle")? * cx.ang;
if radius < 0.0 || !(angle.abs() > 1e-9 && angle.abs() < FRAC_PI_2 - 1e-9) || !(0.0..MAX_COORD).contains(&(radius + offset / angle.cos()))
{
return Err("offset cone has an invalid reference radius or angle".into());
}
}
"TOROIDAL_SURFACE" => {
let major = length(cx, p.get(2), "major radius")?;
let minor = length(cx, p.get(3), "minor radius")?;
if minor <= 0.0 || major <= minor || major >= MAX_COORD || !(1e-9..major).contains(&(minor + offset)) {
return Err("offset torus collapses or becomes self-intersecting".into());
}
}
_ if offset == 0.0 => {}
n => return Err(format!("unsupported OFFSET_SURFACE basis surface {n}")),
}
Ok((basis, offset))
}
/// A STEP surface entity as a kernel surface whose normal is the STEP surface normal.
/// `extent` holds points of the face boundary (bounds the analytic surfaces' parameter ranges).
/// `sense` false gives the opposite normal (a face with `same_sense = .F.`).
@@ -776,6 +831,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
let (id, offset) = offset_basis(cx, id, depth)?;
let e = entity(cx, id)?;
if e.is_complex() {
return bspline_surface(cx, e).map(flip);
@@ -785,11 +841,12 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
match e.name() {
"PLANE" => {
let f = placement()?;
Ok(flip(mt::Surface::Plane(mt::Plane::new(f.o, f.o + f.x, f.o + f.y))))
let o = f.o + f.z * offset;
Ok(flip(mt::Surface::Plane(mt::Plane::new(o, o + f.x, o + f.y))))
}
"CYLINDRICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("cylinder radius must be positive".into());
}
@@ -799,8 +856,9 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"CONICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
let basis_r = length(cx, p.get(2), "radius")?;
let a = num(p.get(3), "semi angle")? * cx.ang;
let r = basis_r + offset / a.cos();
if r < 0.0 || !(a.abs() > 1e-9 && a.abs() < FRAC_PI_2 - 1e-9) {
return Err("bad cone".into());
}
@@ -821,7 +879,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"SPHERICAL_SURFACE" => {
let f = placement()?;
let r = length(cx, p.get(2), "radius")?;
let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("sphere radius must be positive".into());
}
@@ -844,7 +902,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"TOROIDAL_SURFACE" => {
let f = placement()?;
let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")?);
let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")? + offset);
if small <= 0.0 || big < 0.0 {
return Err("bad torus radii".into());
}
+19 -1
View File
@@ -34,6 +34,10 @@ const MAX_VISITS: usize = 100_000;
/// A body read from a STEP file.
#[derive(Clone, Debug)]
pub struct ImportedBody {
/// Immutable STEP source item, scoped to its embedded payload.
pub source_item: u64,
/// Original STEP face entity of each kernel face, including seam pieces.
pub face_origin: Vec<u64>,
/// Solid name (the brep's label), else the product name.
pub name: String,
pub body: Body,
@@ -510,7 +514,21 @@ impl<'a> Reader<'a> {
.collect();
let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces };
let body = body.with_color(color).with_paint(Some(paint));
self.bodies.push(ImportedBody { name, body, color, path: path.to_vec(), closed, file_faces });
let identities = face_origin
.iter()
.map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None }))
.collect();
let body = body.with_face_sources(Some(std::sync::Arc::new(identities)));
self.bodies.push(ImportedBody {
name,
body,
color,
path: path.to_vec(),
closed,
file_faces,
source_item: item,
face_origin,
});
}
Err(err) => {
let label = self.label(item);
+100
View File
@@ -76,6 +76,106 @@ fn imported_bodies_take_features() {
const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n";
const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n";
/// Original fixtures: elementary basis surfaces plus offsets, with no external CAD files.
fn offset_geometry(definition: &str, offset: &str, len: f64, sense: bool, points: &[mt::Point3]) -> crate::Result<mt::Surface> {
let text = format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=AXIS2_PLACEMENT_3D('',#1,$,$);\n#3={definition};\n#4=OFFSET_SURFACE('',#3,{offset},.U.);\n{TAIL}"
);
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6 };
crate::guard("test offset surface", || geom::surface(&cx, 4, points, &[], sense, 0).map_err(KernelError::Failed))
}
#[test]
fn offset_surface_analytic_geometry_and_orientation() {
use mt::{InnerSpace, MetricSpace, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let angle: f64 = 0.4;
let cases = [
("PLANE('',#2)".to_string(), mt::Point3::new(2.0, 3.0, 1.0), mt::Vector3::unit_z()),
("CYLINDRICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 2.0), mt::Vector3::unit_x()),
("SPHERICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 0.0), mt::Vector3::unit_x()),
("TOROIDAL_SURFACE('',#2,10.,2.)".to_string(), mt::Point3::new(13.0, 0.0, 0.0), mt::Vector3::unit_x()),
(
format!("CONICAL_SURFACE('',#2,5.,{angle})"),
mt::Point3::new(5.0 + 1.0 / angle.cos() + 2.0 * angle.tan(), 0.0, 2.0),
mt::Vector3::new(angle.cos(), 0.0, -angle.sin()),
),
];
for (definition, point, normal) in cases {
for sense in [true, false] {
let extent = [point, point + mt::Vector3::unit_z()];
let surface = offset_geometry(&definition, "1.", 1.0, sense, &extent).unwrap();
let uv = surface.search_nearest_parameter(point, None, 100).unwrap();
assert!(surface.subs(uv.0, uv.1).distance(point) < 1e-6, "{definition}");
assert!(surface.normal(uv.0, uv.1).dot(normal) * if sense { 1.0 } else { -1.0 } > 0.99, "{definition}, sense {sense}");
}
}
let p = mt::Point3::new(40.0, 0.0, 2.0);
let surface = offset_geometry("CYLINDRICAL_SURFACE('',#2,5.)", "-1.", 10.0, true, &[p, p + mt::Vector3::unit_z()]).unwrap();
let uv = surface.search_nearest_parameter(p, None, 100).unwrap();
assert!(surface.subs(uv.0, uv.1).distance(p) < 1e-6);
}
#[test]
fn offset_surfaces_nested_and_hostile() {
let points = [mt::Point3::new(0.0, 0.0, 0.0), mt::Point3::new(0.0, 0.0, 1.0)];
for (basis, offset) in [
("CYLINDRICAL_SURFACE('',#2,5.)", "-5."),
("SPHERICAL_SURFACE('',#2,5.)", "-6."),
("TOROIDAL_SURFACE('',#2,10.,2.)", "8."),
("TOROIDAL_SURFACE('',#2,10.,2.)", "-2."),
("CONICAL_SURFACE('',#2,5.,0.4)", "-10."),
("PLANE('',#2)", "1.E100"),
("PLANE('',#2)", "$"),
] {
assert!(offset_geometry(basis, offset, 1.0, true, &points).is_err(), "{basis}, {offset}");
}
let text = format!("{HEAD}#1=OFFSET_SURFACE('',#1,1.,.F.);{TAIL}");
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
assert!(geom::surface(&cx, 1, &points, &[], true, 0).unwrap_err().contains("nested too deeply"));
let text = format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));#2=AXIS2_PLACEMENT_3D('',#1,$,$);#3=CYLINDRICAL_SURFACE('',#2,5.);#4=OFFSET_SURFACE('',#3,-6.,.F.);#5=OFFSET_SURFACE('',#4,6.,.F.);{TAIL}"
);
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
assert!(geom::surface(&cx, 5, &points, &[], true, 0).is_err());
}
#[test]
fn offset_surface_cube_import_is_closed_and_editable() {
// Move each basis plane half a file unit inward; nested quarter-unit offsets
// bring its supporting surface back to the original unit cube boundary.
let mut entities = cube_entities();
let origins = [(0.0, 0.0, 0.5), (0.0, 0.0, 0.5), (0.0, 0.5, 0.0), (0.5, 0.0, 0.0), (1.0, 0.5, 0.0), (0.5, 1.0, 0.0)];
for (f, (x, y, z)) in origins.into_iter().enumerate() {
let base = 400 + f * 20;
let extra = 1000 + f * 10;
entities = entities
.replace(&format!("#{}=PLANE('',#{});", base + 10, base + 9), &format!("#{}=OFFSET_SURFACE('',#{},0.25,.F.);", base + 10, extra + 3));
entities += &format!(
"#{extra}=CARTESIAN_POINT('',({x},{y},{z}));\n#{}=AXIS2_PLACEMENT_3D('',#{extra},#{},$);\n#{}=PLANE('',#{});\n#{}=OFFSET_SURFACE('',#{},0.25,.F.);\n",
extra + 1,
base + 8,
extra + 2,
extra + 1,
extra + 3,
extra + 2
);
}
entities += &context(10, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
entities += "#9=ADVANCED_BREP_SHAPE_REPRESENTATION('Offsets',(#101),#10);\n";
let import = step_import(&format!("{HEAD}{entities}{TAIL}")).unwrap();
assert!(import.warnings.is_empty(), "{:?}", import.warnings);
assert!(import.bodies[0].closed);
assert_eq!(import.bodies[0].file_faces, 6);
let body = &import.bodies[0].body;
assert!(rel(measure(body).unwrap().volume, 1000.0) < 1e-6);
let cut = cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
let result = boolean(body, &cut, BoolOp::Cut).unwrap().unwrap();
assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
}
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
+2
View File
@@ -29,6 +29,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
color: None,
paint: None,
splits: None,
face_sources: None,
}
}
@@ -45,6 +46,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
color: None,
paint: None,
splits: None,
face_sources: None,
})
}
}
+160
View File
@@ -290,6 +290,65 @@ fn moving_a_body_with_an_intersection_curve_edge_ends() {
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
}
/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads
/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed
/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain
/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing
/// material, rejected the result, and the planar fallback refused the round pad.
#[test]
fn round_pads_crossing_a_shallow_fall() {
let hs = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let walls = |floor: &[HalfSpace]| {
let mut h = vec![
hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)),
hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)),
hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)),
hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)),
hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)),
];
h.extend_from_slice(floor);
convex_polyhedron(&h).unwrap()
};
let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap();
// One fall along x at 1°: floor z = 8 + t (x − 10).
let t = 1f64.to_radians().tan();
let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap();
let v0 = measure(&fall).unwrap().volume;
for x in [60.0, 100.0] {
// A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above).
let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap();
let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap();
// The part of the pad above the floor: the floor under the disc is planar, so its mean
// height is the height at the centre (where the floor is below z = 9 everywhere).
let floor = |x: f64| 8.0 + t * (x - 10.0);
let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN };
let v = measure(&u).unwrap().volume;
if above.is_finite() {
assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above);
} else {
assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}");
}
}
// Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall.
let t2 = 2f64.to_radians().tan();
let d = Vec3::new(240.0, 110.0, 8.0);
let hopper = walls(&[
hs(Vec3::new(t2, 0.0, -1.0), d),
hs(Vec3::new(-t2, 0.0, -1.0), d),
hs(Vec3::new(0.0, t2, -1.0), d),
hs(Vec3::new(0.0, -t2, -1.0), d),
]);
let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap();
let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap();
let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap();
// Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor.
let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume;
assert!((gain - 6517.0).abs() < 10.0, "{gain}");
}
#[test]
fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.
@@ -1670,3 +1729,104 @@ fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() {
}
}
}
/// A trough whose floor falls about 1° toward a central drain, the fall starting right at the
/// trough edges: four faces meet at each bottom corner (two walls, two fall facets) and at the
/// drain (four facets). A tab on one end makes the body non-convex. The moved planes of four
/// faces don't meet at one point, so each such corner of the cavity becomes two corners joined
/// by a short edge. Checked against the volume integrated by hand.
#[test]
fn shell_where_four_faces_meet_at_a_corner() {
let (w, l, ht, t) = (100.0, 60.0, 40.0, 2.0);
// Drain depth: the long facets fall 1°.
let h = 30.0 * 1f64.to_radians().tan();
let facet = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let facets = [
facet(Vec3::new(0.0, -h, -30.0), Vec3::ZERO),
facet(Vec3::new(0.0, h, -30.0), Vec3::new(0.0, l, 0.0)),
facet(Vec3::new(-h, 0.0, -50.0), Vec3::ZERO),
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(w, 0.0, 0.0)),
];
let mut hs = vec![
HalfSpace { n: -Vec3::X, d: 0.0 },
HalfSpace { n: Vec3::X, d: w },
HalfSpace { n: -Vec3::Y, d: 0.0 },
HalfSpace { n: Vec3::Y, d: l },
HalfSpace { n: Vec3::Z, d: ht },
];
hs.extend(facets);
let trough = convex_polyhedron(&hs).unwrap();
let pyramid = w * l * h / 3.0;
assert!(rel(measure(&trough).unwrap().volume, w * l * ht + pyramid) < 1e-9);
let tab = box_solid(Vec3::new(w, 20.0, 20.0), Vec3::new(w + 20.0, 40.0, ht)).unwrap();
let body = boolean(&trough, &tab, BoolOp::Union).unwrap().unwrap();
let s = shell(&body, &[Vec3::new(50.0, 30.0, ht)], t).unwrap();
// The cavity: the walls in by t, above the floor facets moved in by t (midpoint rule; the
// floor is linear between its creases).
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d - t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
let (nx, ny) = (960, 560);
let (dx, dy) = ((w - 2.0 * t) / nx as f64, (l - 2.0 * t) / ny as f64);
let mut cavity = 0.0;
for i in 0..nx {
for j in 0..ny {
cavity += (ht - floor(t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy)) * dx * dy;
}
}
let tab_cavity = 20.0 * (20.0 - 2.0 * t) * (20.0 - t);
let want = w * l * ht + pyramid + 8000.0 - cavity - tab_cavity;
let m = measure(&s).unwrap();
assert!(rel(m.volume, want) < 1e-5, "{} vs {want}", m.volume);
}
/// The same trough cut as a pocket into a slab (four faces meet at each concave corner of the
/// pocket floor), shelled with its top open.
#[test]
fn shell_of_a_pocket_whose_floor_falls_to_a_drain() {
let (px, py, pw, pl, top, depth, t) = (20.0, 15.0, 100.0, 60.0, 50.0, 30.0, 3.0);
let h = 30.0 * 1f64.to_radians().tan();
let z0 = top - depth;
let facet = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let facets = [
facet(Vec3::new(0.0, -h, -30.0), Vec3::new(px, py, z0)),
facet(Vec3::new(0.0, h, -30.0), Vec3::new(px, py + pl, z0)),
facet(Vec3::new(-h, 0.0, -50.0), Vec3::new(px, py, z0)),
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(px + pw, py, z0)),
];
let mut hs = vec![
HalfSpace { n: -Vec3::X, d: -px },
HalfSpace { n: Vec3::X, d: px + pw },
HalfSpace { n: -Vec3::Y, d: -py },
HalfSpace { n: Vec3::Y, d: py + pl },
HalfSpace { n: Vec3::Z, d: top + 10.0 },
];
hs.extend(facets);
let pocket = convex_polyhedron(&hs).unwrap();
let slab = box_solid(Vec3::ZERO, Vec3::new(140.0, 90.0, top)).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let pocket_vol = pw * pl * depth + pw * pl * h / 3.0;
assert!(rel(measure(&basin).unwrap().volume, 140.0 * 90.0 * top - pocket_vol) < 1e-9);
let s = shell(&basin, &[Vec3::new(5.0, 5.0, top)], t).unwrap();
// The cavity's columns: from t up to the top, or up to the pocket floor moved down by t
// inside the pocket's walls moved out by t.
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d + t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
let (nx, ny) = (1340, 840);
let (dx, dy) = ((140.0 - 2.0 * t) / nx as f64, (90.0 - 2.0 * t) / ny as f64);
let mut cavity = 0.0;
for i in 0..nx {
for j in 0..ny {
let (x, y) = (t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy);
let inside = x > px - t && x < px + pw + t && y > py - t && y < py + pl + t;
let roof = if inside { floor(x, y) } else { top };
cavity += (roof - t) * dx * dy;
}
}
let want = 140.0 * 90.0 * top - pocket_vol - cavity;
let m = measure(&s).unwrap();
assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume);
}