diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index be3b2ccf..1f2e8a8d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1301,7 +1301,6 @@ fn feature_tools(vals: &BTreeMap, 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, 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 = 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 ®ions { 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::>(); 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, f: &Feature, st: &ModelState) - .collect::>() }; let regions: Vec = 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 = 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, ®ions, 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, ®ions, 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 { + 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 { + 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, target: &Body, dist_to_start: impl Fn(Vec3) -> f64) -> Result> { + 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 { let m = b.mesh(); diff --git a/crates/engine/src/extent_tests.rs b/crates/engine/src/extent_tests.rs new file mode 100644 index 00000000..cb6c3fe3 --- /dev/null +++ b/crates/engine/src/extent_tests.rs @@ -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"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1e122b57..3b68622f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -425,5 +425,7 @@ impl Session { } } +#[cfg(test)] +mod extent_tests; #[cfg(test)] mod tests;