From 2406957039783c7626368fdbb1fa51a9ce745917 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:09:53 +0200 Subject: [PATCH] A loft gives the same body whatever the order of its sections: the ruled side faces were parameterised from the first section to the last, and a loft cut that touches the body along an edge (a fall lofted from a shelled basin's whole outline down to a drain pad) worked for one order and failed for the other, with an Option::unwrap() panic inside the kernel's intersection or "shell not oriented and closed" from the planar fallback. The loft now builds its sections in one canonical order (first-to-last step with a positive largest component), so either order gives the same, working body. Tests: the basin fall cut, joined and as a new body in both orders and with the pad in every corner (engine), and the kernel loft's volume and cut in both orders, including large-below and planes facing down --- crates/engine/src/cmd/features_more_tests.rs | 48 ++++++++++++++++++++ crates/kernel/src/build.rs | 22 +++++++-- crates/kernel/src/tests.rs | 43 ++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 078464e1..8db8d43e 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -137,3 +137,51 @@ fn coil_and_loft_to_a_point() { assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6); assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err()); } + +/// A 3 mm shelled basin (100 x 60 x 30, open top) with a fall lofted from its whole outline +/// 10 up down to a drain pad (`pad`: corners, 1 up), in either section order. The loft's top +/// edges lie on the outer walls; the cut used to fail for one order of the sections (an +/// `Option::unwrap()` panic inside the kernel, or "shell not oriented and closed"). +fn basin_fall(pad: [f64; 4], reverse: bool, op: &str) -> (Session, std::result::Result) { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "corner": [0, 0, 0]})); + run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": 3})); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Rim"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Pad"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [pad[0], pad[1]], "p1": [pad[2], pad[3]]})); + run(&mut s, "sketch.finish", json!({})); + let mut secs = vec![json!({"sketch": "Rim"}), json!({"sketch": "Pad"})]; + if reverse { + secs.reverse(); + } + let r = s.execute("solid.loft", &json!({"sections": secs, "operation": op})).map_err(|e| e.to_string()); + (s, r) +} + +#[test] +fn loft_cut_through_a_shelled_basin_in_either_order() { + // What is left after the cut: the basin less the loft's part of the floor and walls + // (integrated over the height), the same for a pad in any corner. + let want = 41582.028; + for pad in [[80.0, 40.0, 90.0, 50.0], [10.0, 40.0, 20.0, 50.0], [10.0, 10.0, 20.0, 20.0], [80.0, 10.0, 90.0, 20.0]] { + for reverse in [false, true] { + let (mut s, r) = basin_fall(pad, reverse, "cut"); + assert!(r.is_ok(), "{pad:?} reverse {reverse}: {r:?}"); + let v = volume(&mut s); + assert!((v - want).abs() < 1.0, "{pad:?} reverse {reverse}: {v} vs {want}"); + assert_eq!(measure(&mut s)["body_count"], 1); + } + } + // As a new body and joined: a valid solid of positive volume either way. + for op in ["new", "join"] { + let mut vols = Vec::new(); + for reverse in [false, true] { + let (mut s, r) = basin_fall([80.0, 40.0, 90.0, 50.0], reverse, op); + assert!(r.is_ok(), "{op} reverse {reverse}: {r:?}"); + vols.push(volume(&mut s)); + } + assert!(vols[0] > 42948.0 && rel(vols[0], vols[1]) < 1e-6, "{op}: {vols:?}"); + } +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index f6fc3ba6..e181d0b9 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -649,7 +649,8 @@ fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 { Loop2 { segs } } -/// Ruled loft through two or more planar sections (outer loops only). +/// Ruled loft through two or more planar sections (outer loops only). The sections and the +/// same sections in reverse order give the same body. pub fn loft(sections: &[(Plane, Loop2)]) -> Result { if sections.len() < 2 || sections.len() > 50 { return Err(KernelError::Invalid("a loft needs 2…50 sections".into())); @@ -658,10 +659,21 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result { if n == 0 || n > MAX_SEGS { return Err(KernelError::Invalid("empty section".into())); } - // Orient every section counter-clockwise about the first section's normal, then match - // segment counts and starting points to the section with the most segments. - let n0 = sections[0].0.normal(); - let oriented: Vec<(Plane, Loop2)> = sections + // A canonical order, so that reversing the sections changes nothing: the one whose + // first-to-last step has a positive largest component. The side faces' parameterisation + // follows the order, and the boolean worked on one and failed on the other (a cut whose + // loft touches the body along an edge: an internal panic, or "shell not oriented and + // closed"). + let centre = |(p, l): &(Plane, Loop2)| p.to_world(l.centroid()); + let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) }; + let step = centre(last) - centre(first); + let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0); + let ordered: Vec<&(Plane, Loop2)> = if big < 0.0 { sections.iter().rev().collect() } else { sections.iter().collect() }; + // Orient every section counter-clockwise about the first section's normal (`Body::new` + // turns the solid outward), then match segment counts and starting points to the section + // with the most segments. + let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z); + let oriented: Vec<(Plane, Loop2)> = ordered .iter() .map(|(p, l)| { let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() }; diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index b65c41a5..61b3dfa6 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1464,6 +1464,49 @@ fn smooth_loft_through_three_sections() { assert!(v > PI * 64.0 * 30.0 && v < 576.0 * 30.0, "{v}"); } +/// A loft from the basin's whole outline (10 up) down to a drain pad in a corner (1 up), cut +/// from a 3 mm shell: its top edges lie on the outer walls. One order of the sections failed +/// the cut ("shell not oriented and closed", or a panic inside the intersection), the other +/// worked; both orders now build the same body, valid and outward. +#[test] +fn loft_section_order_gives_the_same_body() { + let r = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]); + let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap(); + let big = (at(10.0), r(0.0, 0.0, 100.0, 60.0)); + let small = (at(1.0), r(80.0, 40.0, 90.0, 50.0)); + let basin = shell(&box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(), &[Vec3::new(50.0, 30.0, 30.0)], 3.0).unwrap(); + // The material inside the loft: the floor from 1 to 3 up, the walls from 3 to 10. + let v0 = measure(&basin).unwrap().volume; + let removed = v0 - 41582.028; + let mut volumes = Vec::new(); + for secs in [vec![big.clone(), small.clone()], vec![small.clone(), big.clone()]] { + let l = loft(&secs).unwrap(); + let m = measure(&l).unwrap(); + assert!(m.volume > 0.0, "{}", m.volume); + volumes.push(m.volume); + let cut = boolean(&basin, &l, BoolOp::Cut).unwrap().unwrap(); + let v = measure(&cut).unwrap().volume; + assert!((v0 - v - removed).abs() < 0.01 * removed, "{v} vs {}", v0 - removed); + let joined = boolean(&basin, &l, BoolOp::Union).unwrap().unwrap(); + assert!(measure(&joined).unwrap().volume > v0); + } + assert!(rel(volumes[0], volumes[1]) < 1e-12, "{volumes:?}"); + // Reversed in z too (large below, small above, and sketched on planes facing down). + let down = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::Y, Vec3::X).unwrap(); + for secs in [ + vec![(at(0.0), r(0.0, 0.0, 100.0, 60.0)), (at(20.0), r(40.0, 20.0, 60.0, 40.0))], + vec![(down(20.0), r(20.0, 40.0, 40.0, 60.0)), (at(0.0), r(0.0, 0.0, 100.0, 60.0))], + ] { + // Prismoid: h/6 · (A0 + 4 Amid + A1). + let want = 20.0 / 6.0 * (6000.0 + 4.0 * 60.0 * 40.0 + 400.0); + let back: Vec<(Plane, Loop2)> = secs.iter().rev().cloned().collect(); + for b in [loft(&secs).unwrap(), loft(&back).unwrap()] { + let v = measure(&b).unwrap().volume; + assert!(rel(v, want) < 1e-6, "{v} vs {want}"); + } + } +} + #[test] fn tangent_holes_pinch_alike() { // Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. The ±25 wall is cut