Files
OneCAD/crates/engine/src/frames_tests.rs
T
bherbruckandClaude Opus 5.5 7a2eb059c4 Components are frames (user bug #4)
What a component holds moves with its occurrence, and picks in the world land in the
component's frame:
- Sketches and construction planes show where their component is placed (free move, joint
  drive, Capture Position); sketch.inspect reports sketch points in the world.
- frames.rs maps a command's world picks into the frame of the body they lie on (through the
  inverse of its placement: a moved component or a pasted instance), points that make new
  geometry into the active component's frame, and directions with them; instance body names
  ("Body1 (Pin:2)") become the component's body. Fillet/chamfer edges, shell/offset/draft
  faces, face extrude/revolve, sketch on face, holes, sheet metal and plastic picks, primitives,
  appearance faces, analyses. Once per command (not again for commands run by commands); the
  active-frame fallback stays for commands without a table. Nothing changes while every
  placement is the identity.
- component.move_bodies keeps a body where it is in the world (Document.body_offsets: from the
  new component's frame to the one it was made in; renames and deleted components follow).
- The UI reads the placed model (Session::world_state) for display, picking, sketch editing and
  dialogs; live previews use it once anything is placed.
Tests (engine): sketches and construction planes move (and turn) with the component; world
picks fillet, sketch, hole and make boxes in a moved component; a pick on a pasted instance;
move_bodies keeps the world place there and back; a sketch on a face follows a Move feature.
QA's s34 now runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:04:32 +00:00

147 lines
7.4 KiB
Rust

//! Components as frames: what a component holds moves with its occurrence, and picks in the
//! world land in the component's own frame (user bug #4).
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
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 near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-6
}
fn world_bbox(s: &Session, body: &str) -> (Vec3, Vec3) {
let w = s.world_state();
let b = w.body(body).unwrap_or_else(|| panic!("no body {body}")).mesh().bounds();
(b.min, b.max)
}
fn sketch_point_world(s: &mut Session, sketch: &str, point: &str) -> Vec3 {
let si = run(s, "sketch.inspect", json!({ "sketch": sketch }));
let p = si["points"].as_array().unwrap().iter().find(|p| p["id"] == point).unwrap()["world"].clone();
Vec3::new(p[0].as_f64().unwrap(), p[1].as_f64().unwrap(), p[2].as_f64().unwrap())
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// A component "Part" with a 30 x 20 x 10 block (sketch "Base" on XY, "Top" on its top face)
/// and a construction plane, moved 50 mm along X.
fn moved_part() -> Session {
let mut s = Session::default();
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Part"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [30, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 10}));
run(&mut s, "SketchCreate", json!({"plane": {"face": [15, 10, 10]}, "name": "Top"}));
run(&mut s, "CircleCenterRadius", json!({"center": [15, 10], "radius": 3}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 25, "name": "Shelf"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]}));
s
}
/// (a) Sketches and construction planes move with their component's occurrence.
#[test]
fn sketches_and_planes_move_with_the_component() {
let mut s = moved_part();
let (lo, hi) = world_bbox(&s, "Body1");
assert!(near(lo, Vec3::new(50.0, 0.0, 0.0)) && near(hi, Vec3::new(80.0, 20.0, 10.0)), "{lo:?} {hi:?}");
assert!(near(sketch_point_world(&mut s, "Base", "p1"), Vec3::new(50.0, 0.0, 0.0)));
let c = sketch_point_world(&mut s, "Top", "p1");
assert!((c.z - 10.0).abs() < 1e-6 && c.x >= 50.0 - 1e-6, "{c:?}");
let shelf = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Shelf").unwrap().2;
assert!(near(shelf.origin, Vec3::new(50.0, 0.0, 25.0)), "{:?}", shelf.origin);
// A turn about Z carries them too.
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [50, 0, 0]}));
assert!(near(sketch_point_world(&mut s, "Base", "p3"), Vec3::new(30.0, 30.0, 0.0)), "{:?}", sketch_point_world(&mut s, "Base", "p3"));
}
/// (b) Picks on a moved component, given in the world, land on its geometry: fillet edges, a
/// sketch on a face, a hole, a shell; new geometry of the active component goes where asked.
#[test]
fn world_picks_map_into_the_component() {
let mut s = moved_part();
let v0 = volume(&mut s);
// The edge along X at the top front: world (65, 0, 10).
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[65, 0, 10]], "radius": 2}));
assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material");
// A sketch on the moved top face sits at world z = 10 over the moved block.
run(&mut s, "SketchCreate", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 10], "radius": 2}));
run(&mut s, "SketchStop", json!({}));
let pad = sketch_point_world(&mut s, "Pad", "p1");
assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}");
let v1 = volume(&mut s);
let h = run(&mut s, "FusionHoleCommand", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}");
// A box made at world (100, 0, 0) in the active (moved) component shows there.
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0], "operation": "new"}));
let last = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
let (lo, _) = world_bbox(&s, &last);
assert!(near(lo, Vec3::new(100.0, 0.0, 0.0)), "{lo:?}");
}
/// (b) A pasted instance's pick fillets the component (every instance).
#[test]
fn picks_on_an_instance_map_through_its_placement() {
let mut s = Session::default();
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Pin"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.copy", json!({"component": "Pin", "translate": [0, 40, 0]}));
let v0 = volume(&mut s);
// An edge of the second instance only (world y = 40).
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[5, 40, 10]], "radius": 2}));
let v1 = volume(&mut s);
assert!(v1 < v0 - 1.0, "{v0} {v1}");
}
/// (c) Moving a body into a moved component keeps it where it is in the world.
#[test]
fn moving_bodies_between_components_keeps_their_place() {
let mut s = moved_part();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "PrimitiveBox", json!({"length": 4, "width": 4, "height": 4, "corner": [0, 40, 0], "body_name": "Loose"}));
let before = world_bbox(&s, "Loose");
run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "Part"}));
let after = world_bbox(&s, "Loose");
assert!(near(before.0, after.0) && near(before.1, after.1), "{before:?} {after:?}");
// It moves with the component from now on, and a pick on it still lands.
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 0, 10]}));
let moved = world_bbox(&s, "Loose");
assert!(near(moved.0, before.0 + Vec3::new(0.0, 0.0, 10.0)), "{moved:?}");
let v0 = volume(&mut s);
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[2, 40, 14]], "radius": 1}));
assert!(volume(&mut s) < v0 - 0.1);
// Back to the root: still in place.
run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "root"}));
let back = world_bbox(&s, "Loose");
assert!(near(back.0, moved.0) && near(back.1, moved.1), "{back:?} {moved:?}");
}
/// (d) A sketch on a body's face follows the body when a Move feature moves it.
#[test]
fn sketch_on_a_face_follows_a_moved_body() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 20, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": {"face": [10, 10, 10]}, "name": "OnTop"}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 2}));
run(&mut s, "SketchStop", json!({}));
// The move goes before the sketch in the timeline: the face the sketch is on moves up.
run(&mut s, "timeline.rollTo", json!({"position": 1}));
run(&mut s, "FusionMoveCommand", json!({"bodies": ["Body1"], "translate": [0, 0, 15]}));
run(&mut s, "timeline.rollTo", json!({}));
let c = sketch_point_world(&mut s, "OnTop", "p1");
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
}