Files
solidcraft-upstream/crates/engine/src/extent_tests.rs
T
MqriusandClaude Opus 5.5 1d7450b04c fix(doc): To Object takes the face at the pick, not the first body near it (#429)
object_height, curved_target and curved_target_any took the first solid body with any face
within its pick tolerance of the To Object point. That tolerance is 5 % of the body's size,
so a large body beside a small target took picks made on the target: a ridge extruded up
to a post 2 mm above a plate treated the plate as the target, nothing trimmed the tool, and
it ran past the post (to x 9.08 instead of -2.31).

They now share face_at: the nearest face over every solid body, still within that body's
tolerance. appearance gains nearest_face (the face and its distance) and pick_tolerance,
which face_index_at now uses as well.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:55:47 -04:00

128 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 = solidcraft_kernel::boolean(&arc.body, &post.body, solidcraft_kernel::BoolOp::Intersect)
.ok()
.flatten()
.map(|b| solidcraft_kernel::measure(&b).map(|m| m.volume).unwrap_or(0.0))
.unwrap_or(0.0);
assert!(overlap < 1e-3, "no overlap: {overlap}");
let v = solidcraft_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");
}
/// To Object picks the face that is at the picked point, not the first body with any face
/// near it. Here the pick is on a post (r 2.75) only 2 mm above a plate: the ridge must end
/// on the post. Before, the plate (within 5 % of its size of the pick) was taken as the
/// target, nothing trimmed the tool, and the ridge ran past the post.
#[test]
fn extrude_to_a_cylinder_beside_another_body() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-98, -5, 0], "length": 101, "width": 10, "height": 3, "body_name": "Plate"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 0, 1], "radius": 2.75, "height": 10, "body_name": "Post"}));
// A ridge above the plate, y -1.5..0, z 4..7, from the plate's far end towards the post.
run(&mut s, "sketch.create", json!({"plane": {"origin": [-98, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "End"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1.5, 4], "p1": [0, 7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "End", "to": [-2.75, 0, 5], "operation": "new", "body_name": "Ridge"}));
let b = body(&mut s, "Ridge");
// Its end follows the post: farthest at the ridge's edge y = -1.5, x = -√(2.75² − 1.5²).
let end = b["bbox"]["max"][0].as_f64().unwrap_or(f64::MAX);
assert!((end + (2.75f64 * 2.75 - 1.5 * 1.5).sqrt()).abs() < 1e-3, "{b}");
assert!((b["bbox"]["min"][0].as_f64().unwrap_or(0.0) + 98.0).abs() < 1e-6, "{b}");
}