Revolve: a partial turn's planar faces come out whole (#726) (#1718)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 15:26:10 -04:00
1 parent 8907da5e86
commit 920ea42fe1
6 files changed
+118 -34

No files matched your search

+5 -2
View File
@@ -3167,6 +3167,9 @@ fn feature_tools_with_constructor(
{
return build_curved(&regions, -axis_w, tb);
}
// A new body is final: its planar faces come out whole. A boolean's tool is not
// healed (the boolean heals its result).
let revolve = if operation == Operation::NewBody { kernel::revolve_whole } else { kernel::revolve };
if *convention == crate::RevolveConvention::SketchNormal {
let mut out = Vec::new();
for (region, region_extent) in regions.iter().zip(&region_extents) {
@@ -3175,7 +3178,7 @@ fn feature_tools_with_constructor(
out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?);
continue;
}
let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?;
let bodies = revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?;
let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start);
for body in bodies {
out.push(if region_extent.start.abs() < 1e-12 {
@@ -3187,7 +3190,7 @@ fn feature_tools_with_constructor(
}
return Ok(out);
}
let bodies = kernel::revolve(&ss.plane, &regions, o, d, ang)?;
let bodies = revolve(&ss.plane, &regions, o, d, ang)?;
if start.abs() < 1e-12 {
return Ok(regions.iter().zip(bodies).map(|(r, body)| tool(r, body, None)).collect());
}
+24
View File
@@ -512,6 +512,30 @@ fn revolve_and_primitives() {
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4);
}
/// #726: a revolve swept each profile edge into two or three patches and left the planar ones
/// apart, so a pick on such a face took part of it, and a sketch or an extrude there was refused
/// ("the selected planar face no longer exists") until a later feature merged them.
#[test]
fn revolved_planar_faces_come_out_whole() {
for angle in [360, 240] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
run(&mut s, "sketch.line", json!({"points": [[8, 0], [30, 0], [30, -6], [18, -6], [18, -22], [8, -22]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"axis": "Z", "angle": angle}));
// Picks at 170°, in the last patch, which 240° covers whichever way it turns.
let at = |r: f64, z: f64| json!([r * 170f64.to_radians().cos(), r * 170f64.to_radians().sin(), z]);
// The flange's top annulus (z = 6, r 18…30), well inside it.
let sk = run(&mut s, "sketch.create", json!({"plane": {"face": at(24.0, 6.0)}}));
assert_eq!(sk["attachment"], "exact_face", "{angle}°: {sk}");
run(&mut s, "sketch.finish", json!({}));
// The bottom annulus (z = 0, r 8…30).
let v = volume(&mut s);
run(&mut s, "solid.extrude", json!({"face": at(20.0, 0.0), "distance": 2, "operation": "join"}));
assert!(volume(&mut s) > v + 1.0, "{angle}°: the extrude added nothing");
}
}
/// 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
+47
View File
@@ -305,6 +305,18 @@ pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2
/// Revolve each region about the sketch axis by `angle` radians.
/// This legacy API preserves its historical sweep sign behavior.
pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result<Vec<Body>> {
revolved(plane, regions, axis_origin, axis_dir, angle, false)
}
/// [`revolve`] for bodies that are final (new bodies, not a boolean's tools): a partial turn's
/// planar faces come out whole too, as a full turn's always do (#726). A tool is left as swept:
/// healed, its planar faces meet a target exactly where they are tangent to it, and the
/// boolean leaves pieces there that touch. The boolean heals its own result anyway.
pub fn revolve_whole(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result<Vec<Body>> {
revolved(plane, regions, axis_origin, axis_dir, angle, true)
}
fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64, whole: bool) -> Result<Vec<Body>> {
let angle = finite(angle, "angle")?;
if angle.abs() < 1e-6 {
return Err(KernelError::Invalid("revolve angle is zero".into()));
@@ -447,11 +459,46 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
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 };
let (solid, history) = if closed || whole { turned_whole(solid, history) } else { (solid, history) };
out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?);
}
Ok(out)
}
/// A revolved solid with its planar faces whole (#726): the sweep turns each profile edge into
/// two or three patches, the planar ones on revolved surfaces, which stayed apart until a later
/// feature healed the body, so a pick on one took part of the face. Healed as booleans heal
/// their results, each face is still generated, from the profile edges of the patches merged
/// into it. The solid as it was when healing fails or can't be followed.
fn turned_whole(solid: Solid, faces: Option<Vec<FaceProv>>) -> (Solid, Option<Vec<FaceProv>>) {
let Some(given) = faces.as_ref() else { return (solid, faces) };
let Ok(size) = Body::new(solid.clone()).map(|b| b.size()) else { return (solid, faces) };
let healed = guard("heal revolve", || {
let (healed, map) = crate::heal::heal_traced(solid.clone(), size, &[]);
let failed = || KernelError::Failed("heal revolve".into());
if !crate::boolean_record::edges_evaluate(&healed) {
return Err(failed());
}
let carried = map
.ok_or_else(failed)?
.faces
.iter()
.map(|under| {
let mut origins: Vec<_> = under.iter().map(|i| given.get(*i).map(|f| f.origins.clone())).collect::<Option<Vec<_>>>()?.concat();
origins.sort();
origins.dedup();
Some(FaceProv { origins, change: crate::history::Change::Generated })
})
.collect::<Option<Vec<_>>>()
.ok_or_else(failed)?;
Ok((healed, carried))
});
match healed {
Ok((healed, carried)) => (healed, Some(carried)),
Err(_) => (solid, faces),
}
}
/// Axis-aligned box between two corners.
pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
let (lo, hi) = (a.min(b), a.max(b));
+2 -2
View File
@@ -134,8 +134,8 @@ pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrate
pub use boundary::{Boundary, boundary_patch};
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
sweep_with, torus,
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, revolve_whole, sphere,
sweep, sweep_with, torus,
};
pub use continuity::{
ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics,
+16 -8
View File
@@ -208,15 +208,23 @@ fn expected_revolution(b: &Body, r: &Region2) -> Vec<Vec<FaceOrigin>> {
#[test]
fn revolve_partial_and_full() {
let r = Region2 { outer: rect(10.0, 0.0, 20.0, 5.0), holes: vec![rect(12.0, 1.0, 14.0, 2.0).reversed()] };
// Under half a turn truck sweeps in two parts, beyond it in three; a full turn has no caps.
for (angle, parts, caps) in [(FRAC_PI_2, 2, 2), (1.5 * PI, 3, 2), (TAU, 3, 0)] {
let b = crate::revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap();
let got = origins(&b);
assert_eq!(got, expected_revolution(&b, &r), "angle {angle}");
for k in 0..8 {
assert_eq!(got.iter().filter(|o| **o == [P(k)]).count(), parts, "segment {k} at angle {angle}");
// Under half a turn truck sweeps in two parts, beyond it in three; a full turn has no caps,
// and its planar faces (from the segments square to the axis) are whole (#726), as a partial
// turn's are when it is final (`revolve_whole`).
let segs: Vec<Seg2> = r.outer.segs.iter().chain(r.holes.iter().flat_map(|h| &h.segs)).cloned().collect();
for whole in [false, true] {
for (angle, parts, caps) in [(FRAC_PI_2, 2, 2), (1.5 * PI, 3, 2), (TAU, 3, 0)] {
let revolve = if whole { crate::revolve_whole } else { crate::revolve };
let b = revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap();
let got = origins(&b);
assert_eq!(got, expected_revolution(&b, &r), "angle {angle}, whole {whole}");
for (k, s) in segs.iter().enumerate() {
let square = matches!(s, Seg2::Line { a, b } if (a.y - b.y).abs() < 1e-12);
let want = if (angle == TAU || whole) && square { 1 } else { parts };
assert_eq!(got.iter().filter(|o| **o == [P(k)]).count(), want, "segment {k} at angle {angle}, whole {whole}");
}
assert_eq!(got.iter().filter(|o| **o == [START] || **o == [END]).count(), caps);
}
assert_eq!(got.iter().filter(|o| **o == [START] || **o == [END]).count(), caps);
}
}