diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d58a6d4d..6acd799a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -22,6 +22,17 @@ fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-12) } +#[test] +fn round_as_tall_as_a_disc_uses_up_its_wall() { + // #190: a disc 13 in radius and 2 high, its top edge rounded by 2. + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 13, "height": 2})); + run(&mut s, "solid.fillet", json!({"edges": [[0, 13, 2], [0, -13, 2]], "radius": "2 mm"})); + let (a, c) = ((1.0 - PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))); + let expect = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c); + assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s)); +} + #[test] fn fillet_as_wide_as_a_face_rounds_it_off() { // #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces). diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index c94afc87..ec651c89 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -698,7 +698,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option> { Some(ids) } -fn edge_mid(e: &mt::Edge) -> Option { +pub(crate) fn edge_mid(e: &mt::Edge) -> Option { use mt::{BoundedCurve, ParametricCurve}; let c = e.curve(); let (t0, t1) = c.range_tuple(); diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index fb540bf7..31c6ce00 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -372,7 +372,40 @@ fn build( *slot = v; } } - let vb: Vec = pb.iter().map(|p| builder::vertex(p3(*p))).collect(); + // Model vertices at the joints (the second half of a circle starts at none). + let mut joint_vertex: Vec> = (0..k) + .map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None }) + .collect(); + if open { + joint_vertex.push(edges.last().map(|e| e.back().clone())); + } + let loop_ids: Vec = edges.iter().map(|e| e.id()).collect(); + let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id()); + // A blend as tall as its walls uses them up: each wall seam ends exactly where the + // wall-side rail would, at a vertex of the walls' far boundary, which becomes the rail. + let wall_end: Vec> = joint_vertex + .iter() + .zip(&pb) + .map(|(v, target)| { + let v = v.as_ref()?; + faces.iter().flat_map(|g| g.edge_iter()).find_map(|e| { + if loop_ids.contains(&e.id()) || on_face(&e) || !(e.front() == v || e.back() == v) || !matches!(e.curve(), mt::Curve::Line(_)) { + return None; + } + let other = if e.front() == v { e.back().clone() } else { e.front().clone() }; + (from_p3(other.point()).dist(*target) <= tol * 10.0).then_some(other) + }) + }) + .collect(); + let used_up = wall_end.iter().filter(|x| x.is_some()).count(); + if used_up > 0 && (used_up != nj || open) { + return Err(unsupported("the blend uses up only part of the walls")); + } + let walls_used_up = used_up > 0; + let vb: Vec = match walls_used_up { + true => wall_end.into_iter().flatten().collect(), + false => pb.iter().map(|p| builder::vertex(p3(*p))).collect(), + }; let centre = |i: usize| centres.get(i).copied().unwrap_or_default(); let profile_mid = |i: usize| { let (Some(p), c) = (joint_point(i), centre(i)) else { return Vec3::ZERO }; @@ -442,21 +475,34 @@ fn build( if face_rails.iter().zip(&collapsed).any(|(r, c)| r.is_none() && !c) { return Err(KernelError::Failed("rail".into())); } - let wall_rails: Vec = - (0..k).map(|i| rail(&vb, i, &wall_off)).collect::>().ok_or_else(|| KernelError::Failed("rail".into()))?; + let wall_rails: Vec = if walls_used_up { + // Each piece's wall rail is the wall's far edge between the joints, where the offset + // rail lies (checked at the piece's middle). + (0..k) + .map(|i| -> Result { + let (Some(a), Some(b), Some(s), Some(wf)) = (vb.get(i), vb.get(nxt(i)), segs.get(i), walls.get(i)) else { + return Err(KernelError::Failed("rail".into())); + }; + let mid = match *s { + Seg::Line { a, b } => (a + b) * 0.5, + Seg::Arc { mid, .. } => mid, + }; + let want = wall_off(mid, s); + let g = faces.get(*wf).ok_or_else(|| KernelError::Failed("wall".into()))?; + g.edge_iter() + .find(|e| (e.front() == a && e.back() == b) || (e.front() == b && e.back() == a)) + .filter(|e| crate::blend::edge_mid(e).is_some_and(|m| m.dist(want) <= tol * 10.0)) + .map(|e| if e.front() == a { e.clone() } else { e.inverse() }) + .ok_or_else(|| unsupported("the blend uses up walls whose far edge is not where the blend ends")) + }) + .collect::>()? + } else { + (0..k).map(|i| rail(&vb, i, &wall_off)).collect::>().ok_or_else(|| KernelError::Failed("rail".into()))? + }; // Seams between neighbouring walls at the joints get shorter. - // Model vertices at the joints (the second half of a circle starts at none). - let mut joint_vertex: Vec> = (0..k) - .map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None }) - .collect(); - if open { - joint_vertex.push(edges.last().map(|e| e.back().clone())); - } // At a run's ends the face's next loop edges stop at the face-side point. - let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id()); let mut seam_subst: HashMap = HashMap::new(); let mut seam_ends: HashMap, Option)> = HashMap::new(); - let loop_ids: Vec = edges.iter().map(|e| e.id()).collect(); for (i, v) in joint_vertex.iter().enumerate() { let Some(v) = v else { continue }; for g in faces { @@ -476,6 +522,10 @@ fn build( return Err(unsupported("the wall seams must be straight")); } let face_side = on_face(&e); + // A used-up wall goes, seams and all. + if walls_used_up && !face_side { + continue; + } let target = if face_side { pa.get(i) } else { pb.get(i) }.copied().unwrap_or_default(); let along = (from_p3(other.point()) - from_p3(v.point())).dot(target - from_p3(v.point())); let reach = (target - from_p3(v.point())).len2(); @@ -558,6 +608,9 @@ fn build( let rebuilt = if gi == fi { rebuild(g, &face_rails)? } else if walls.contains(&gi) { + if walls_used_up { + continue; + } let wr: Vec> = wall_rails.iter().cloned().map(Some).collect(); rebuild(g, &wr)? } else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index c914ca4c..854573c4 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -181,6 +181,37 @@ fn fillets_on_several_edges_and_angles() { assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err()); } +#[test] +fn loop_fillet_as_tall_as_its_wall_consumes_it() { + // The removed cross-section (a square of side r less a quarter disc) and its centroid's + // distance from the wall: r(10 − 3π) / (3(4 − π)). + let spandrel = |r: f64| ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))); + // #190: a disc 13 in radius and 2 high, its top circle rounded by 2. Pappus for the ring. + let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap(); + let f = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], 2.0).unwrap(); + let (a, c) = spandrel(2.0); + let want = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c); + let m = measure(&f).unwrap(); + assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume); + // Top, round and bottom: the wall is gone. + assert_eq!(m.merged.faces, 3, "{:?}", m.merged); + assert!(f.validity().is_empty(), "{:?}", f.validity()); + // A 40 x 30 x 5 plate, its whole top outline rounded by 5 (mitred at the corners). + let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 5.0)).unwrap(); + let top = [Vec3::new(20.0, 0.0, 5.0), Vec3::new(40.0, 15.0, 5.0), Vec3::new(20.0, 30.0, 5.0), Vec3::new(0.0, 15.0, 5.0)]; + let f = fillet(&plate, &top, 5.0).unwrap(); + let (a, c) = spandrel(5.0); + let want = 6000.0 - a * 2.0 * ((40.0 - 2.0 * c) + (30.0 - 2.0 * c)); + let m = measure(&f).unwrap(); + assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume); + assert!(f.validity().is_empty(), "{:?}", f.validity()); + // Taller than the wall: still refused, the body unchanged. + for r in [2.0 + 1e-3, 2.5] { + let e = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], r).unwrap_err(); + assert!(e.to_string().contains("taller than the walls"), "{r}: {e}"); + } +} + #[test] fn fillet_consuming_a_side_face_makes_a_full_round() { // 14 x 6 x 10: r = 3 on the four vertical edges uses up both 6 mm end faces (#199).