feat: stabilize face attachments and closed spline workflows
This commit is contained in:
1 parent
c1d819f235
commit
a6bb8fc924
83 files changed
+6271
-385
No files matched your search
+145
-4
@@ -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
@@ -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).
|
||||
|
||||
@@ -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(®ion), 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, ®ion, Vec2::default(), Vec2::Y).unwrap();
|
||||
assert_eq!(axis, crate::canonical_revolve_axis(&Plane::XY, ®ion, 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user