M1: oracle recipe coverage (arcs, refs-based constraints/dimensions, extents), taper and through-all extrudes, curve splitting in profiles, region merging, angle dimensions by sector, smoother tangency solve, merged topology for revolves
Oracle: 13/29 parts match Fusion. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0f3e7701a7
commit
b85ccd62cf
17 files changed
+1067
-164
No files matched your search
@@ -202,3 +202,97 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
|
||||
let mut v = revolve(&plane, &[r], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("torus".into()))
|
||||
}
|
||||
|
||||
/// Offset every segment of a loop to its right by `d` (outward for a region's outer loop and
|
||||
/// holes alike). Loops of lines (mitred corners) and loops of arcs on one circle are supported.
|
||||
fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
|
||||
if d == 0.0 {
|
||||
return Ok(lp.clone());
|
||||
}
|
||||
let all_arcs = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }));
|
||||
if all_arcs {
|
||||
let segs = lp
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let r = radius + sweep.signum() * d;
|
||||
if r > 1e-6 {
|
||||
Ok(Seg2::Arc { center, radius: r, start, sweep })
|
||||
} else {
|
||||
Err(KernelError::Invalid("the taper closes the profile".into()))
|
||||
}
|
||||
}
|
||||
Seg2::Line { .. } => Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
return Ok(Loop2 { segs });
|
||||
}
|
||||
let n = lp.segs.len();
|
||||
let mut lines: Vec<(Vec2, Vec2)> = Vec::with_capacity(n);
|
||||
for s in &lp.segs {
|
||||
let Seg2::Line { a, b } = *s else { return Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())) };
|
||||
let dir = (b - a).normalized().ok_or_else(|| KernelError::Invalid("zero-length edge".into()))?;
|
||||
let right = Vec2::new(dir.y, -dir.x);
|
||||
lines.push((a + right * d, dir));
|
||||
}
|
||||
let mut pts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let (Some(&(p0, d0)), Some(&(p1, d1))) = (lines.get((i + n - 1) % n), lines.get(i)) else { continue };
|
||||
let den = d0.cross(d1);
|
||||
if den.abs() < 1e-12 {
|
||||
pts.push(p1);
|
||||
continue;
|
||||
}
|
||||
let t = (p1 - p0).cross(d1) / den;
|
||||
pts.push(p0 + d0 * t);
|
||||
}
|
||||
let out = Loop2::polygon(&pts);
|
||||
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("the taper closes the profile".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Extrude one region by `length` along `dir_sign` × the plane normal with a taper angle
|
||||
/// (radians; positive grows the profile).
|
||||
pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f64, taper: f64) -> Result<Body> {
|
||||
let (length, taper) = (finite(length, "distance")?, finite(taper, "taper")?);
|
||||
if length <= 1e-6 {
|
||||
return Err(KernelError::Invalid("extrude distance is zero".into()));
|
||||
}
|
||||
if taper.abs() >= std::f64::consts::FRAC_PI_2 - 1e-3 {
|
||||
return Err(KernelError::Invalid("taper must be between −90° and 90°".into()));
|
||||
}
|
||||
region_ok(region)?;
|
||||
let d = length * taper.tan();
|
||||
let top_plane = plane.offset(length * dir_sign.signum());
|
||||
let loops0: Vec<Loop2> = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect();
|
||||
let loops1: Vec<Loop2> = loops0.iter().map(|l| offset_loop(l, d)).collect::<Result<_>>()?;
|
||||
guard("tapered extrude", || {
|
||||
let w0: Vec<mt::Wire> = loops0.iter().map(|l| wire(plane, l)).collect::<Result<_>>()?;
|
||||
let w1: Vec<mt::Wire> = loops1.iter().map(|l| wire(&top_plane, l)).collect::<Result<_>>()?;
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
for (a, b) in w0.iter().zip(&w1) {
|
||||
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
|
||||
faces.extend(sides.face_iter().cloned());
|
||||
}
|
||||
let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>())
|
||||
.map_err(|e| KernelError::Failed(format!("taper bottom: {e}")))?;
|
||||
let top = builder::try_attach_plane(&w1).map_err(|e| KernelError::Failed(format!("taper top: {e}")))?;
|
||||
let mut tries: Vec<(mt::Face, mt::Face)> = vec![(bottom.clone(), top.clone())];
|
||||
tries.push((bottom.inverse(), top.inverse()));
|
||||
let mut last = String::new();
|
||||
for (b, t) in tries {
|
||||
let mut all = faces.clone();
|
||||
all.push(b);
|
||||
all.push(t);
|
||||
let shell: mt::Shell = all.into();
|
||||
match Solid::try_new(vec![shell]) {
|
||||
Ok(s) => return Body::new(s),
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(KernelError::Failed(format!("tapered extrude: {last}")))
|
||||
})
|
||||
}
|
||||
@@ -22,7 +22,7 @@ mod topo;
|
||||
|
||||
pub use blend::{chamfer, fillet};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo};
|
||||
pub use build::{box_solid, cylinder, extrude, revolve, sphere, torus};
|
||||
pub use build::{box_solid, cylinder, extrude, extrude_tapered, revolve, sphere, torus};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use ops::{BoolOp, boolean, transform};
|
||||
pub use step::step_export;
|
||||
|
||||
@@ -145,6 +145,30 @@ fn fillets_on_several_edges_and_angles() {
|
||||
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_extrudes() {
|
||||
// 40 x 40 square, 30 up, 10° outward: a frustum.
|
||||
let sq = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(-20.0, -20.0), Vec2::new(20.0, -20.0), Vec2::new(20.0, 20.0), Vec2::new(-20.0, 20.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let b = extrude_tapered(&Plane::XY, &sq, 30.0, 1.0, 10f64.to_radians()).unwrap();
|
||||
let top = 40.0 + 2.0 * 30.0 * 10f64.to_radians().tan();
|
||||
let v = 30.0 / 3.0 * (1600.0 + top * top + 40.0 * top);
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
|
||||
assert_eq!(m.merged.faces, 6);
|
||||
// Inward taper on a circle (a cone frustum), downward.
|
||||
let c = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] };
|
||||
let b = extrude_tapered(&Plane::XY, &c, 10.0, -1.0, -20f64.to_radians()).unwrap();
|
||||
let r2 = 10.0 - 10.0 * 20f64.to_radians().tan();
|
||||
let v = PI * 10.0 / 3.0 * (100.0 + r2 * r2 + 10.0 * r2);
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
|
||||
assert!(m.bbox.max.z < 1e-6 && m.bbox.min.z > -10.0 - 1e-6, "{:?}", m.bbox);
|
||||
assert!(extrude_tapered(&Plane::XY, &c, 10.0, 1.0, -60f64.to_radians()).is_err(), "closes the profile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_convergence() {
|
||||
@@ -216,3 +240,27 @@ fn debug_revolve_doc_case() {
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r2), Vec2::ZERO, Vec2::Y, PI / 2.0).unwrap().pop().unwrap();
|
||||
println!("reversed: {}", measure(&q).unwrap().volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_coplanar_union_rate() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::new(40.0, 20.0), 12.0), holes: vec![] }], 0.0, 40.0).unwrap().pop().unwrap();
|
||||
let mut ok = 0;
|
||||
for i in 0..20 {
|
||||
let sh = Vec3::new(0.0137 * i as f64, 0.0291 * i as f64, -0.0173 * i as f64);
|
||||
let (a2, b2) = (transform(&a, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap(), transform(&b, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap());
|
||||
for tol in [1e-3, 1e-2, 5e-2] {
|
||||
let r = guard("t", || {
|
||||
let (sa, sb) = (a2.deep_copy(), b2.deep_copy());
|
||||
Ok(truck_shapeops::or(&sa, &sb, tol))
|
||||
});
|
||||
let good = matches!(r, Ok(Some(_)));
|
||||
if good {
|
||||
ok += 1;
|
||||
}
|
||||
println!("shift {i} tol {tol}: {}", if good { "ok" } else { "fail" });
|
||||
}
|
||||
}
|
||||
println!("ok {ok}/60");
|
||||
}
|
||||
+78
-20
@@ -128,6 +128,62 @@ fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf {
|
||||
Surf::Other(id)
|
||||
}
|
||||
|
||||
/// Per-face vertex samples (position, normal) from the tessellation.
|
||||
fn face_vertices(mesh: &Mesh, nf: usize) -> Vec<Vec<(Vec3, Vec3)>> {
|
||||
let mut out: Vec<Vec<(Vec3, Vec3)>> = vec![Vec::new(); nf];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
for &k in t {
|
||||
if let (Some(p), Some(n), Some(v)) = (mesh.positions.get(k as usize), mesh.normals.get(k as usize), out.get_mut(*f as usize)) {
|
||||
v.push((*p, *n));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Normal of face `f` nearest to `p`.
|
||||
fn normal_near(verts: &[(Vec3, Vec3)], p: Vec3) -> Option<Vec3> {
|
||||
verts.iter().min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p))).map(|v| v.1)
|
||||
}
|
||||
|
||||
/// Cone signature of a non-analytic face: (axis, cos of the normal/axis angle) when all normals
|
||||
/// make the same angle with one axis.
|
||||
fn cone_signature(verts: &[(Vec3, Vec3)]) -> Option<(Vec3, f64)> {
|
||||
let n0 = verts.first()?.1;
|
||||
let n1 = verts.iter().map(|v| v.1).max_by(|a, b| (*a - n0).len().total_cmp(&(*b - n0).len()))?;
|
||||
let n2 = verts.iter().map(|v| v.1).max_by(|a, b| (*a - n0).cross(n1 - n0).len().total_cmp(&(*b - n0).cross(n1 - n0).len()))?;
|
||||
let mut a = (n1 - n0).cross(n2 - n0).normalized()?;
|
||||
if a.x + a.y * 1e-3 + a.z * 1e-6 < 0.0 {
|
||||
a = -a;
|
||||
}
|
||||
let c = n0.dot(a);
|
||||
verts.iter().all(|v| (v.1.dot(a) - c).abs() < 2e-3).then_some((a, c))
|
||||
}
|
||||
|
||||
/// Do adjacent faces `a` and `b` (sharing the edge polyline `edge`) lie on one surface?
|
||||
/// Analytic classes compare parameters; other faces must join smoothly along the edge and have
|
||||
/// the same cone signature (or both have none).
|
||||
fn same_surface(surfs: &[Surf], verts: &[Vec<(Vec3, Vec3)>], edge: Option<&Vec<Vec3>>, a: usize, b: usize, tol: f64) -> bool {
|
||||
let (Some(sa), Some(sb)) = (surfs.get(a), surfs.get(b)) else { return false };
|
||||
match (sa, sb) {
|
||||
(Surf::Other(_), Surf::Other(_)) => {}
|
||||
_ => return sa.same(sb, tol),
|
||||
}
|
||||
let (Some(va), Some(vb), Some(e)) = (verts.get(a), verts.get(b), edge) else { return false };
|
||||
let smooth = e.iter().all(|p| match (normal_near(va, *p), normal_near(vb, *p)) {
|
||||
(Some(na), Some(nb)) => na.dot(nb) > 1.0 - 2e-3,
|
||||
_ => false,
|
||||
});
|
||||
if !smooth {
|
||||
return false;
|
||||
}
|
||||
match (cone_signature(va), cone_signature(vb)) {
|
||||
(Some((xa, ca)), Some((xb, cb))) => xa.dot(xb) > 1.0 - 1e-4 && (ca - cb).abs() < 1e-3,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
if q == i {
|
||||
@@ -144,6 +200,7 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let surfs = classify_faces(mesh, nf, tol);
|
||||
let verts = face_vertices(mesh, nf);
|
||||
let mut faces_of: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
for (fi, f) in solid.face_iter().enumerate() {
|
||||
@@ -160,11 +217,11 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
|
||||
}
|
||||
order
|
||||
.iter()
|
||||
.map(|k| match faces_of.get(k).map(Vec::as_slice) {
|
||||
Some([a, b2]) => match (surfs.get(*a), surfs.get(*b2)) {
|
||||
(Some(sa), Some(sb)) => !matches!(sa, Surf::Plane { .. }) && sa.same(sb, tol * 10.0),
|
||||
_ => false,
|
||||
},
|
||||
.enumerate()
|
||||
.map(|(ei, k)| match faces_of.get(k).map(Vec::as_slice) {
|
||||
Some([a, b2]) => {
|
||||
!matches!(surfs.get(*a), Some(Surf::Plane { .. })) && same_surface(&surfs, &verts, mesh.edges.get(ei), *a, *b2, tol * 10.0)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
@@ -191,21 +248,17 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
let solid = &*b.solid;
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
let surfs = classify_faces(mesh, nf, tol);
|
||||
let verts = face_vertices(mesh, nf);
|
||||
// Zero-area faces (a revolved profile edge lying on the axis) don't count.
|
||||
let mut area = vec![0.0f64; nf];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
for &k in t {
|
||||
if let (Some(p), Some(n), Some(pv), Some(nv)) =
|
||||
(mesh.positions.get(k as usize), mesh.normals.get(k as usize), pts.get_mut(*f as usize), nrm.get_mut(*f as usize))
|
||||
{
|
||||
pv.push(*p);
|
||||
nv.push(*n);
|
||||
}
|
||||
if let (Some([p, q, r]), Some(a)) = (mesh.tri(t), area.get_mut(*f as usize)) {
|
||||
*a += (q - p).cross(r - p).len() * 0.5;
|
||||
}
|
||||
}
|
||||
let surfs: Vec<Surf> = (0..nf)
|
||||
.map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i))
|
||||
.collect();
|
||||
let degenerate: Vec<bool> = area.iter().map(|a| *a < (b.size() * 1e-6).powi(2)).collect();
|
||||
let is_deg = |f: usize| degenerate.get(f).copied().unwrap_or(false);
|
||||
|
||||
// Edge → faces, edge → vertices.
|
||||
let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
@@ -237,10 +290,9 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
}
|
||||
// Merge faces.
|
||||
let mut fp: Vec<usize> = (0..nf).collect();
|
||||
for faces in edge_faces.values() {
|
||||
for (ei, faces) in &edge_faces {
|
||||
if let [a, b] = faces[..]
|
||||
&& let (Some(sa), Some(sb)) = (surfs.get(a), surfs.get(b))
|
||||
&& sa.same(sb, tol * 10.0)
|
||||
&& same_surface(&surfs, &verts, mesh.edges.get(*ei), a, b, tol * 10.0)
|
||||
{
|
||||
let (ra, rb) = (find(&mut fp, a), find(&mut fp, b));
|
||||
if let Some(s) = fp.get_mut(ra) {
|
||||
@@ -251,6 +303,9 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
let mut groups: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut face_types: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in 0..nf {
|
||||
if is_deg(f) {
|
||||
continue;
|
||||
}
|
||||
let r = find(&mut fp, f);
|
||||
if groups.insert(r, f).is_none()
|
||||
&& let Some(s) = surfs.get(f)
|
||||
@@ -264,6 +319,9 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
eids.sort();
|
||||
for e in eids {
|
||||
let faces = edge_faces.get(e).cloned().unwrap_or_default();
|
||||
if faces.iter().any(|f| is_deg(*f)) {
|
||||
continue;
|
||||
}
|
||||
let gs: Vec<usize> = faces.iter().map(|f| find(&mut fp, *f)).collect();
|
||||
let pair = match gs[..] {
|
||||
[a, b] if a == b => continue,
|
||||
|
||||
Reference in new issue
Block a user