Extrude and Revolve To Object on curved faces follow the face: the tool runs through the target body and is cut back to its surface (the piece that starts at the profile); tests against a cylinder and a sphere (volumes by integration) and a revolve ending on a post; Shell Outside/Both on a cylinder through the exact curved offsets

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 12:47:56 +00:00
1 parent 1e515547d8
commit 1f619e49cc
3 files changed
+196 -3

No files matched your search

+88 -3
View File
@@ -1301,7 +1301,6 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
None if extent.through_all => through_all_distance(st, &ss.plane, targets)?,
None => val(vals, &extent.distance, Kind::Length)?,
};
let _ = n;
if let Some(tp) = &extent.taper {
let taper = val(vals, tp, Kind::Angle)?;
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
@@ -1328,15 +1327,33 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
(None, crate::Direction::Symmetric) => (-d, d),
};
// A negative distance flips the side.
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
// To a curved (or slanted) face: run right through the target body, then keep what
// lies before its surface (the tool less the body, the piece at the start).
let curved = extent.to.and_then(|p| curved_target(st, n, p));
if let Some(bi) = curved
&& let Some(tb) = st.bodies.get(bi)
{
let m = tb.mesh();
let hs: Vec<f64> = m.positions.iter().map(|q| (*q - ss.plane.origin).dot(n) - off).collect();
let margin = tb.body.size() * 0.05 + 1.0;
if hi > 0.0 {
hi = hs.iter().cloned().fold(hi, f64::max) + margin;
} else {
lo = hs.iter().cloned().fold(lo, f64::min) - margin;
}
}
let mut tools = Vec::new();
for r in &regions {
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
let n = ss.plane.normal();
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?);
}
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let start = if hi > 0.0 { lo + off } else { hi + off };
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
}
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => {
@@ -1386,6 +1403,29 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
.collect::<Vec<_>>()
};
let regions: Vec<Region2> = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect();
// To a curved face: turn past the whole target body, then cut back to its surface
// (the piece that starts at the profile).
let curved = to.and_then(|p| curved_target_any(st, p));
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
let flat = |q: Vec3| {
let v = q - origin_w;
v - axis_w * v.dot(axis_w)
};
let u0 = flat(c);
let angle_of = |q: Vec3| {
let v = flat(q);
let a = (axis_w * -1.0).dot(u0.cross(v)).atan2(u0.dot(v));
if a < 0.0 { a + std::f64::consts::TAU } else { a }
};
let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max);
let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3);
let bodies = kernel::revolve(&ss.plane, &regions, o, d, sweep)?;
let n = ss.plane.normal();
let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX };
return trim_to_body(bodies, &tb.body, tol_side);
}
let bodies = kernel::revolve(&ss.plane, &regions, o, d, ang)?;
if start.abs() < 1e-12 {
return Ok(bodies);
@@ -1949,6 +1989,51 @@ fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
h(p)
}
/// The body at `p` when the face there is not a plane square to `n` (To Object must then
/// follow the face's shape).
fn curved_target(st: &ModelState, n: Vec3, p: Vec3) -> Option<usize> {
for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) {
let Some(fi) = crate::appearance::face_index_at(b, p) else { continue };
let tol = (b.body.size() * 1e-3).max(1e-3);
let planar =
b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).and_then(|f| f.plane_normal).is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6);
return (!planar).then_some(bi);
}
None
}
/// The body at `p` when the face there is not planar.
fn curved_target_any(st: &ModelState, p: Vec3) -> Option<usize> {
for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) {
let Some(fi) = crate::appearance::face_index_at(b, p) else { continue };
let tol = (b.body.size() * 1e-3).max(1e-3);
let planar = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).is_some_and(|f| f.plane_normal.is_some());
return (!planar).then_some(bi);
}
None
}
/// Tools run through `target`, cut back to its surface: each tool less the body, keeping the
/// piece(s) that touch the start (`dist_to_start` 0 there).
fn trim_to_body(tools: Vec<Body>, target: &Body, dist_to_start: impl Fn(Vec3) -> f64) -> Result<Vec<Body>> {
let mut out = Vec::new();
for t in tools {
let Some(cut) = kernel::boolean(&t, target, BoolOp::Cut)? else { continue };
for lump in cut.lumps()? {
let tol = (lump.size() * 1e-4).max(1e-6);
let touches =
lump.tessellate((lump.size() * 1e-2).max(1e-2)).map(|m| m.positions.iter().any(|q| dist_to_start(*q) < tol)).unwrap_or(false);
if touches {
out.push(lump);
}
}
}
if out.is_empty() {
return Err(DocError::Invalid("nothing lies between the profile and the To object".into()));
}
Ok(out)
}
/// One point on each face of a body (a triangle centre, so it lies on the face).
fn face_samples(b: &ModelBody) -> Vec<Vec3> {
let m = b.mesh();
+106
View File
@@ -0,0 +1,106 @@
//! To Object extents on curved targets, and shells outside/both on curved bodies.
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn body(s: &mut Session, b: &str) -> Value {
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone()
}
/// A square post extruded up to a horizontal cylinder (axis along x, r 10, centre z 30): its
/// top takes the cylinder's shape, so the post is shortest in the middle.
#[test]
fn extrude_to_a_cylinder_follows_it() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [-20, 0, 30], "axis": [1, 0, 0], "radius": 10, "height": 40, "body_name": "Bar"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "P"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-4, -4], "p1": [4, 4]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [0, 0, 20], "body_name": "Post"}));
let b = body(&mut s, "Post");
// Highest at the post's sides (y = ±4): z = 30 − √(100 − 16); the volume follows the arc.
let top = b["bbox"]["max"][2].as_f64().unwrap_or(0.0);
assert!((top - (30.0 - (100.0f64 - 16.0).sqrt())).abs() < 1e-3, "{b}");
// ∫ over y of (30 − √(100 − y²)) × 8 (x width), y from −4 to 4.
let n = 2000;
let want: f64 = (0..n).map(|i| -4.0 + 8.0 * (i as f64 + 0.5) / n as f64).map(|y| (30.0 - (100.0 - y * y).sqrt()) * 8.0 * 8.0 / n as f64).sum();
assert!((b["volume_mm3"].as_f64().unwrap_or(0.0) - want).abs() < want * 2e-3, "{} vs {want}", b["volume_mm3"]);
}
/// A round post extruded up to a sphere (r 15 at z 40): it stops on the sphere's underside.
#[test]
fn extrude_to_a_sphere_follows_it() {
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 40], "radius": 15, "body_name": "Ball"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "P"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [0, 0, 25], "body_name": "Post"}));
let b = body(&mut s, "Post");
let top = b["bbox"]["max"][2].as_f64().unwrap_or(0.0);
assert!((top - (40.0 - (225.0f64 - 25.0).sqrt())).abs() < 1e-2, "{b}");
// Volume: cylinder to z 25 plus the ring between the sphere cap and the plane z = 25.
let cap = |r: f64| 40.0 - (225.0 - r * r).sqrt();
let n = 2000;
let want: f64 = (0..n).map(|i| 5.0 * (i as f64 + 0.5) / n as f64).map(|r| cap(r) * 2.0 * PI * r * 5.0 / n as f64).sum();
assert!((b["volume_mm3"].as_f64().unwrap_or(0.0) - want).abs() < want * 2e-3, "{} vs {want}", b["volume_mm3"]);
}
/// Shell Outside and Both on a cylinder (open top): exact offsets of the curved wall.
#[test]
fn shell_outside_and_both_on_a_cylinder() {
let shell = |dir: &str| {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20, "body_name": "C"}));
run(&mut s, "solid.shell", json!({"faces": [[0, 0, 20]], "thickness": 2, "direction": dir}));
body(&mut s, "C")["volume_mm3"].as_f64().unwrap_or(0.0)
};
let out = PI * (144.0 * 22.0 - 100.0 * 20.0);
assert!((shell("outside") - out).abs() < out * 1e-3, "{} vs {out}", shell("outside"));
let both = PI * (121.0 * 21.0 - 81.0 * 19.0);
assert!((shell("both") - both).abs() < both * 1e-3, "{} vs {both}", shell("both"));
}
/// A ring section revolved To Object up to a post standing in its path: it ends on the post's
/// round side (touching, not overlapping), between first contact and the post's centre.
#[test]
fn revolve_to_a_cylinder_follows_it() {
for side in [1.0, -1.0] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 14.0 * side, -5], "radius": 4, "height": 15, "body_name": "Post"}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
let r = s.execute("solid.revolve", &json!({"sketch": "S", "axis": "y", "to": [4, 14.0 * side, 2], "body_name": "Arc"}));
if r.is_err() {
continue; // the post on the side the revolve turns away from is past the turn
}
let st = s.model.state();
let (arc, post) = (st.body("Arc").unwrap(), st.body("Post").unwrap());
let overlap = solvecraft_kernel::boolean(&arc.body, &post.body, solvecraft_kernel::BoolOp::Intersect)
.ok()
.flatten()
.map(|b| solvecraft_kernel::measure(&b).map(|m| m.volume).unwrap_or(0.0))
.unwrap_or(0.0);
assert!(overlap < 1e-3, "no overlap: {overlap}");
let v = solvecraft_kernel::measure(&arc.body).unwrap().volume;
let full = PI * (225.0 - 100.0) * 5.0;
// The post is at a quarter turn one way (three quarters the other): the ring stops
// short of the post's centre line, within its half-width of it.
let turned = v / full * 360.0;
let to_centre = if turned < 180.0 { 90.0 } else { 270.0 };
assert!(turned < to_centre && turned > to_centre - 30.0, "{turned}°");
return;
}
panic!("neither post was reached");
}
+2
View File
@@ -425,5 +425,7 @@ impl Session {
}
}
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod tests;