feat: stabilize face attachments and closed spline workflows

This commit is contained in:
bherbruck committed 2026-10-11 05:32:46 -04:00
1 parent c1d819f235
commit a6bb8fc924
83 files changed
+6271 -385

No files matched your search

+145 -4
View File
@@ -33,11 +33,27 @@ pub struct Body {
#[derive(Clone, Debug, PartialEq)]
pub enum FaceSource {
ExtrudeSide { segment: solvecraft_geom::Seg2 },
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String },
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.
#[derive(Clone, Debug, PartialEq)]
pub struct Paint {
@@ -342,6 +358,131 @@ impl Body {
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) {
@@ -355,8 +496,8 @@ impl Body {
.iter()
.zip(curves)
.map(|(source, curve)| match (source, curve) {
(Some(FaceSource::ExtrudeSide { .. }), Some(curve)) => {
Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone() })
(Some(FaceSource::ExtrudeSide { plane, .. }), Some(curve)) => {
Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone(), plane: *plane })
}
(Some(FaceSource::ExtrudeSide { .. }), None) => None,
_ => source.clone(),
+112 -10
View File
@@ -90,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 {
@@ -112,6 +121,31 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
}
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
let 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.
@@ -120,13 +154,35 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
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() {
None
} else if let [segment] = matching.as_slice() {
Some(crate::FaceSource::ExtrudeSide { segment: **segment })
} else {
None
};
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)
@@ -136,8 +192,25 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
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 {
@@ -264,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).
+228 -6
View File
@@ -1,6 +1,219 @@
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()] };
@@ -13,7 +226,7 @@ fn constructed_annulus_sides_follow_exact_source_edges() {
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 };
let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. }, .. }) = source else { continue };
if (*radius - 10.).abs() < 1e-12 {
outer += 1;
} else {
@@ -33,7 +246,7 @@ fn constructed_annulus_sides_follow_exact_source_edges() {
assert!(vertices > 0);
}
assert_eq!((outer, inner), (2, 2), "full circles split into two actual edges each");
assert_eq!(sources.iter().filter(|source| source.is_none()).count(), 2);
assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { .. }))).count(), 2);
}
}
@@ -43,7 +256,16 @@ fn constructed_side_identity_survives_affine_placement_without_recognition() {
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();
assert_eq!(placed.face_sources(), source.face_sources());
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.));
}
@@ -58,16 +280,16 @@ fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() {
let arcs: Vec<_> = sources
.iter()
.filter_map(|source| match source {
Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. } }) => Some((radius, sweep)),
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);
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 { .. } })) {
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));
}
}
+2 -2
View File
@@ -54,8 +54,8 @@ 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, FaceSource, 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};
+2 -1
View File
@@ -531,8 +531,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
m[3][3],
);
// 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(body.face_sources.clone()))
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources))
})
}