From 7f92093cc82f8865504e9a3b2990d462427f0cb7 Mon Sep 17 00:00:00 2001 From: Mqrius Date: Tue, 13 Oct 2026 02:25:46 +0200 Subject: [PATCH] fix(kernel): revolve with edges on the axis is built around the origin, then moved (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full revolve of a profile with more than one edge on the axis (a spherical shell's half ring: both radial lines lie on it) took the generic sweep, built in place. Truck tessellates the degenerate faces those edges sweep reliably only with the axis through the world origin, so on a sketch plane off it the body measured about a third too large (a shell from r 18.5 to 40, 10 mm above XY: 324 339 mm³ instead of 241 561), and booleans with it failed or removed the wrong side. Such profiles are now swept about an axis through the origin and translated into place, as revolve_touching_axis already does for profiles with one edge on the axis. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ crates/kernel/src/build.rs | 18 +++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b09c2af9..6c150300 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -331,6 +331,38 @@ fn revolve_and_primitives() { assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4); } +/// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a +/// sketch plane that does not pass through the origin: the body is the shell, as it is on XY. +/// Before, built in place, it measured a third too large (truck tessellates the degenerate faces +/// on the axis reliably only with the axis through the origin). +#[test] +fn revolve_a_spherical_shell_off_the_origin() { + for (plane, z) in [(json!("XY"), 0.0), (json!({"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), 10.0)] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": plane, "name": "Ring"})); + run(&mut s, "sketch.line", json!({"points": [[-40, 0], [40, 0]]})); + run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]})); + run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [18.5, 0], "end": [-18.5, 0]})); + run(&mut s, "sketch.finish", json!({})); + // The half ring: the larger of the two regions above the axis. + let si = run(&mut s, "sketch.inspect", json!({"sketch": "Ring"})); + let ring = si["profiles"] + .as_array() + .unwrap() + .iter() + .max_by(|a, b| a["area_mm2"].as_f64().unwrap().total_cmp(&b["area_mm2"].as_f64().unwrap())) + .unwrap()["index"] + .clone(); + run(&mut s, "solid.revolve", json!({"sketch": "Ring", "profiles": [ring], "axis": "l1", "body_name": "Shell"})); + let m = run(&mut s, "inspect.measure", json!({"bodies": ["Shell"]})); + let want = 4.0 / 3.0 * PI * (40.0f64.powi(3) - 18.5f64.powi(3)); + let v = m["total"]["volume_mm3"].as_f64().unwrap(); + assert!(rel(v, want) < 5e-4, "plane at z {z}: {v} vs {want}"); + let b = &m["bodies"][0]["bbox"]; + assert!((b["min"][2].as_f64().unwrap() - (z - 40.0)).abs() < 1e-6 && (b["max"][2].as_f64().unwrap() - (z + 40.0)).abs() < 1e-6, "{b}"); + } +} + #[test] fn sketch_tools_and_constraints() { let mut s = Session::default(); diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index ade9ef80..8d0ca544 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -384,9 +384,25 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: out.push(b); continue; } - let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?; + // Edges on the axis (a spherical shell's two radial lines, say) sweep into degenerate faces + // there, which truck only tessellates reliably with the axis through the world origin. Off + // it, the body measured too large and booleans with it failed or removed the wrong side. + // Build such a profile there and move it into place, as `revolve_touching_axis` does. + let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; + let axis_edges = r.outer.segs.iter().filter(|s| matches!(**s, Seg2::Line { a, b } if on_axis(a) && on_axis(b))).count(); + let shift = if axis_edges > 0 { o } else { Vec3::ZERO }; + let (profile, f) = if shift.len() > 0.0 { + let local = Plane { origin: plane.origin - shift, ..*plane }; + let profile = region_wires(&local, &given.oriented())?; + let f = attach_profile(&wires_of(&profile))?; + (profile, f) + } else { + (profile, f) + }; + let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o - shift), v3(axis), mt::Rad(angle))))?; let closed = angle.abs() >= std::f64::consts::TAU; let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?; + let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?); } Ok(out)