feat: strengthen sketches, feature editing, and independent assemblies
This commit is contained in:
1 parent
07932d0f42
commit
0398a813c8
65 files changed
+7082
-268
No files matched your search
@@ -0,0 +1,473 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
|
||||
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 = w - a * w.dot(a);
|
||||
if (radial.len() - r).abs() > tol * 10.0 || plane.normal().dot(radial / r) < 1.0 - 1e-8 {
|
||||
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 mut angular = None;
|
||||
for e in f.edge_iter() {
|
||||
if let Some(Some(mid)) = crate::offset::arc_mid(&e, tol * 10.0) {
|
||||
let am = angle(mid, o, a, radial, tangent);
|
||||
let aa = unwrap(angle(from_p3(e.front().point()), o, a, radial, tangent), am);
|
||||
let ab = unwrap(angle(from_p3(e.back().point()), o, a, radial, tangent), am);
|
||||
angular = Some((aa.min(ab), aa.max(ab)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((alo, ahi)) = angular 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"))?;
|
||||
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);
|
||||
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 map2 = |p: Vec2| Vec2::new(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(®.outer).chain(®.holes) {
|
||||
count = count.saturating_add(lp.segs.len());
|
||||
if count > 4096 || 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)?;
|
||||
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(®.outer).chain(®.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(result)
|
||||
})
|
||||
}
|
||||
|
||||
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 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(), ®ions, 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);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod build;
|
||||
mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
mod emboss;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
mod heal;
|
||||
@@ -54,6 +55,7 @@ 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 delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at};
|
||||
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;
|
||||
|
||||
+85
-15
@@ -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)]
|
||||
@@ -530,16 +530,93 @@ 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.
|
||||
Body::new(placed_topology(&body.solid, mat)?).map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()))
|
||||
})
|
||||
}
|
||||
|
||||
// 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());
|
||||
@@ -556,19 +633,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
|
||||
@@ -604,3 +670,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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user