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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 15:04:32 +00:00
1 parent c0725ebc86
commit 7a2eb059c4
30 files changed
+669 -72

No files matched your search

+3
View File
@@ -332,6 +332,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
f.body = name.to_string();
}
if let Some(o) = d.body_offsets.remove(&old) {
d.body_offsets.insert(name.to_string(), o);
}
if let Some(c) = d.body_components.remove(&old) {
d.body_components.insert(name.to_string(), c);
}
+19 -3
View File
@@ -156,7 +156,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
d.components.retain(|c| !gone.contains(&c.id));
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
d.body_components.retain(|_, c| !gone.contains(c));
d.body_offsets.retain(|b, _| !moved_out.contains(b));
if gone.contains(&s.active_component) {
s.active_component = 0;
}
@@ -174,10 +176,23 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
for b in &bodies {
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
// The body keeps its place in the world: where it is shown now (its component's frame and
// any earlier offset), seen from the new component's frame.
let from = s.doc.body_component(b, feature);
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
.unwrap_or(solvecraft_doc::IDENTITY);
let doc = s.doc_mut();
if own == to {
s.doc_mut().body_components.remove(b);
doc.body_components.remove(b);
} else {
s.doc_mut().body_components.insert(b.clone(), to);
doc.body_components.insert(b.clone(), to);
}
if solvecraft_doc::is_identity(&offset) {
doc.body_offsets.remove(b);
} else {
doc.body_offsets.insert(b.clone(), offset);
}
}
Ok(json!({"bodies": bodies, "component": to}))
@@ -355,7 +370,8 @@ fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
/// The active component's frame from world coordinates (for new features' geometry).
pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
if s.active_component == 0 {
// Commands whose picks `frames` maps are already in their frames.
if s.active_component == 0 || crate::frames::mapped() {
return;
}
let m = s.doc.component_transform(s.active_component);
+4 -1
View File
@@ -102,6 +102,9 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
fn sketch_json(s: &Session, id: u64) -> Option<Value> {
let state = s.model.state();
let ss = state.sketch(id)?;
// Where the sketch is in the world (its component's placement).
let world = s.world_state();
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
let sk = &ss.sketch;
let curves: Vec<Value> = sk
.curves
@@ -129,7 +132,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
.points
.iter()
.enumerate()
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.collect();
let constraints: Vec<Value> = sk
.constraints
+358
View File
@@ -0,0 +1,358 @@
//! Component frames for picks. The viewport shows the placed model (`Session::world_state`):
//! a component's bodies and sketches moved by its occurrence (and pasted instances moved by
//! theirs). Features are made and kept in their component's own frame. So a command's picked
//! points and directions, given in the world, are mapped into the frame of the body they lie on
//! (through the inverse of that body's placement), and points that create geometry into the
//! active component's frame, before the command runs. Instance body names ("Body1 (Occ:2)")
//! become the component's body name.
//!
//! Nothing changes while every placement is the identity (the common case, and every design
//! without moved components).
use serde_json::{Map, Value};
use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, mat_inverse};
use solvecraft_geom::Vec3;
use crate::Session;
/// How a parameter is mapped.
#[derive(Clone, Copy, PartialEq)]
enum K {
/// A point on a body (a pick): into that body's frame.
Pick,
/// A list of picks: `[[x,y,z]…]` or `[{point|face|edge: [x,y,z], body?}…]`.
Picks,
/// A point that places new geometry: into the active component's frame.
Make,
/// A direction: turned with the command's frame.
Dir,
/// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name.
Plane,
/// A body name, or a list of them.
Body,
}
use K::*;
/// The parameters each command takes in world coordinates.
fn table(id: &str) -> &'static [(&'static str, K)] {
match id {
"Extrude" | "Revolve" => &[("face", Pick), ("targets", Body), ("participants", Body)],
"PrimitiveBox" | "PrimitiveSphere" => &[("corner", Make), ("center", Make)],
"PrimitiveCylinder" | "PrimitiveCoil" | "PrimitiveTorus" => &[("base", Make), ("center", Make), ("axis", Dir)],
"FusionFilletEdgesCommand" | "FusionChamferCommand" => &[("edges", Picks), ("body", Body)],
"FusionHoleCommand" => &[("position", Pick), ("direction", Dir), ("body", Body)],
"ModifyScale" => &[("origin", Make), ("bodies", Body)],
"FusionOffsetFacesCommand" | "FusionShellBodyCommand" => &[("faces", Picks), ("body", Body)],
"FusionThreadCommand" => &[("face", Pick)],
"FusionDraftCommand" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
"FusionPressPullCommand" => &[("face", Pick), ("edges", Picks)],
"FusionReplaceFaceCommand" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
"AlignCmd" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)],
"MirrorCommand" | "FusionSplitBodyCommand" => &[("plane", Plane), ("bodies", Body), ("body", Body)],
"FusionBossCommand" | "FusionSnapFitCommand" | "FusionRestCommand" => {
&[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)]
}
"FusionLipCommand" | "ConvertToSheetMetalCmd" => &[("face", Pick), ("body", Body)],
"FusionSheetMetalFlangeCommand" | "FusionSheetMetalHemFlangeCommand" => &[("edges", Picks), ("body", Body)],
"SheetMetalFoldCmd" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
"SketchCreate" | "sketch.redefine" => &[("plane", Plane)],
"FusionAddEditDecalCommand" => &[("face", Pick)],
"IntersectCmd" => &[("refs", Picks)],
"ProjectToSurface" | "SketchIsoparametricCurve" => &[("face", Pick), ("body", Body)],
"IntersectionCurve" => &[("a", Picks), ("b", Picks)],
"FusionCurvatureCombAnalysisCommand" | "FusionMinimumRadiusAnalysisCommand" | "FusionIsoCurveAnalysisCommand" => {
&[("edges", Picks), ("faces", Picks)]
}
"AppearanceCommand" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
"FusionMoveCommand" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)],
"FusionCombineCommand" => &[("target", Body), ("tools", Body)],
"SoftDeleteCommand" | "StockModelCommand" | "PhysicalMaterialCommand" => &[("bodies", Body)],
_ => &[],
}
}
/// The world placement of bodies (name → frame), with moves not captured yet.
struct Frames {
bodies: Vec<(String, String, Mat)>,
active: Mat,
}
fn frames(s: &Session) -> Frames {
let st = s.model.state();
let doc: std::borrow::Cow<solvecraft_doc::Document> = if s.pending_moves.is_empty() {
std::borrow::Cow::Borrowed(&*s.doc)
} else {
let mut d = (*s.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = s.pending_moves.get(&o.id) {
o.transform = *m;
}
}
std::borrow::Cow::Owned(d)
};
// World name, the component's own body name, and its frame.
let bodies = doc
.body_frames(&st)
.into_iter()
.map(|(world, _, _, m)| {
let own = st
.bodies
.iter()
.find(|b| world == b.name || world.starts_with(&format!("{} (", b.name)))
.map(|b| b.name.clone())
.unwrap_or_else(|| world.clone());
(world, own, m)
})
.collect();
Frames { bodies, active: doc.component_transform(s.active_component) }
}
/// Does any placement move anything (so the world differs from the components' frames)?
pub fn placed(s: &Session) -> bool {
!s.pending_moves.is_empty() || !s.doc.body_offsets.is_empty() || s.doc.placements().iter().any(|(_, _, m)| !is_identity(m))
}
fn vec3(v: &Value) -> Option<Vec3> {
let a = v.as_array()?;
if a.len() != 3 {
return None;
}
let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite());
Some(Vec3::new(c(0)?, c(1)?, c(2)?))
}
fn pt(p: Vec3) -> Value {
serde_json::json!([p.x, p.y, p.z])
}
/// The frame of the body a world point lies on (the nearest), or of the named body.
fn frame_at(s: &Session, f: &Frames, p: Vec3, body: Option<&str>) -> Option<Mat> {
if let Some(b) = body
&& let Some((_, _, m)) = f.bodies.iter().find(|(w, own, _)| w == b || (own == b && !w.contains(" (")))
{
return Some(*m);
}
let world = s.world_state();
let mut best: Option<(f64, Mat)> = None;
for wb in &world.bodies {
let Some((_, _, m)) = f.bodies.iter().find(|(w, _, _)| *w == wb.name) else { continue };
let mesh = wb.mesh();
let bb = mesh.bounds();
let tol = (bb.diagonal() * 1e-3).max(1e-3);
if p.x < bb.min.x - tol
|| p.y < bb.min.y - tol
|| p.z < bb.min.z - tol
|| p.x > bb.max.x + tol
|| p.y > bb.max.y + tol
|| p.z > bb.max.z + tol
{
continue;
}
let d = mesh.triangles.iter().filter_map(|t| mesh.tri(t)).map(|[a, b, c]| tri_dist(p, a, b, c)).fold(f64::INFINITY, f64::min);
if d <= tol && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, *m));
}
}
best.map(|(_, m)| m)
}
fn tri_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
let n = (b - a).cross(c - a);
let Some(nn) = n.normalized() else { return p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)) };
let h = (p - a).dot(nn);
let q = p - nn * h;
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12);
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
}
struct Mapper<'a> {
s: &'a Session,
f: Frames,
/// The frame of the command's first pick (its directions turn with it).
first: Option<Mat>,
changed: bool,
}
impl Mapper<'_> {
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
let p = vec3(v)?;
let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active);
self.first.get_or_insert(m);
self.local_point(&m, p)
}
fn local_point(&mut self, m: &Mat, p: Vec3) -> Option<Value> {
if is_identity(m) {
return None;
}
let inv = mat_inverse(m)?;
self.changed = true;
Some(pt(apply_point(&inv, p)))
}
fn make(&mut self, v: &Value) -> Option<Value> {
let p = vec3(v)?;
let m = self.f.active;
self.local_point(&m, p)
}
fn dir(&mut self, v: &Value) -> Option<Value> {
let d = vec3(v)?;
let m = self.first.unwrap_or(self.f.active);
if is_identity(&m) {
return None;
}
let inv = mat_inverse(&m)?;
self.changed = true;
Some(pt(apply_vector(&inv, d)))
}
/// A list of picks (points, or objects holding one).
fn picks(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
match v {
Value::Array(a) if vec3(v).is_some() && a.iter().all(Value::is_number) => self.pick(v, body),
Value::Array(a) => {
let out: Vec<Value> = a.iter().map(|x| self.picks(x, body).unwrap_or_else(|| x.clone())).collect();
Some(Value::Array(out))
}
Value::Object(o) => {
let b = o.get("body").and_then(Value::as_str).map(str::to_string);
let mut m = o.clone();
for k in ["point", "face", "edge", "vertex", "at"] {
if let Some(x) = o.get(k)
&& let Some(y) = self.pick(x, b.as_deref().or(body))
{
m.insert(k.into(), y);
}
}
if let Some(bn) = &b
&& let Some(own) = self.own_name(bn)
{
m.insert("body".into(), Value::String(own));
}
Some(Value::Object(m))
}
_ => None,
}
}
fn plane(&mut self, v: &Value) -> Option<Value> {
let o = v.as_object()?;
let mut m = o.clone();
if let Some(x) = o.get("face").and_then(|x| self.pick(x, None)) {
m.insert("face".into(), x);
}
if let Some(x) = o.get("origin").and_then(|x| self.pick(x, None)) {
m.insert("origin".into(), x);
}
for k in ["normal", "x_dir", "y_dir"] {
if let Some(x) = o.get(k).and_then(|x| self.dir(x)) {
m.insert(k.into(), x);
}
}
Some(Value::Object(m))
}
/// The component's body name for a world (instance) body name.
fn own_name(&mut self, world: &str) -> Option<String> {
let own = self.f.bodies.iter().find(|(w, _, _)| w == world).map(|(_, own, _)| own.clone())?;
(own != world).then(|| {
self.changed = true;
own
})
}
fn body(&mut self, v: &Value) -> Option<Value> {
match v {
Value::String(n) => self.own_name(n).map(Value::String),
Value::Array(a) => {
let out: Vec<Value> = a.iter().map(|x| self.body(x).unwrap_or_else(|| x.clone())).collect();
Some(Value::Array(out))
}
_ => None,
}
}
}
thread_local! {
static DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
/// The outermost command's coordinates were mapped here (its table lists them).
static MAPPED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Did this module map the running command's coordinates? (Otherwise a new feature's geometry
/// goes into the active component's frame as a whole.)
pub fn mapped() -> bool {
MAPPED.with(std::cell::Cell::get)
}
/// A command running inside another (its parameters are already in component frames).
pub struct Nested;
impl Nested {
/// Enter command `id`; true when it is the outermost one.
pub fn enter(id: &str) -> (Nested, bool) {
let outer = DEPTH.with(|d| {
let n = d.get();
d.set(n + 1);
n == 0
});
if outer {
let inner = if id == "timeline.redefine" { None } else { Some(id) };
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty())));
}
(Nested, outer)
}
}
impl Drop for Nested {
fn drop(&mut self) {
DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
}
}
/// `params` with world picks mapped into component frames (`None`: nothing to change).
pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
if id == "timeline.redefine" {
let cmd = params.get("command")?.as_str()?;
let inner = to_local(s, cmd, params.get("params")?)?;
let mut out = params.clone();
out["params"] = inner;
return Some(out);
}
let keys = table(id);
if keys.is_empty() || !placed(s) {
return None;
}
let obj = params.as_object()?;
let mut m = Mapper { s, f: frames(s), first: None, changed: false };
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
let mut out: Map<String, Value> = obj.clone();
// Picks first: their frame turns the directions.
for pass in [true, false] {
for (k, kind) in keys {
let Some(v) = obj.get(*k) else { continue };
let is_pick = matches!(kind, Pick | Picks | Plane);
if is_pick != pass {
continue;
}
let new = match kind {
Pick => m.pick(v, body.as_deref()),
Picks => m.picks(v, body.as_deref()),
Make => m.make(v),
Dir => m.dir(v),
Plane => m.plane(v),
Body => m.body(v),
};
if let Some(n) = new {
out.insert((*k).to_string(), n);
}
}
}
m.changed.then_some(Value::Object(out))
}
#[cfg(test)]
#[path = "frames_tests.rs"]
mod tests;
+146
View File
@@ -0,0 +1,146 @@
//! 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:?}");
}
+6
View File
@@ -9,6 +9,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod frames;
pub mod params;
pub mod recovery;
pub mod sample;
@@ -297,6 +298,11 @@ impl Session {
let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch };
let null = Value::Object(Default::default());
let params = if params.is_null() { &null } else { params };
// Picks in the world go into their component's frame (once: commands run by commands
// already have them there).
let (_nested, outermost) = frames::Nested::enter(spec.id);
let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None };
let params = mapped.as_ref().unwrap_or(params);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let r = match r {
Ok(r) => r,
+15 -1
View File
@@ -189,7 +189,21 @@ pub fn construction_planes(s: &Session) -> Vec<(u64, String, Plane)> {
.features
.iter()
.filter_map(|f| match &f.kind {
FeatureKind::ConstructionPlane { plane } if !f.suppressed => s.doc.resolve_plane(&vals, plane, 0).ok().map(|p| (f.id, f.name.clone(), p)),
FeatureKind::ConstructionPlane { plane } if !f.suppressed => s.doc.resolve_plane(&vals, plane, 0).ok().map(|p| {
// Shown where its component is placed.
let m = s.doc.component_transform(f.component);
let p = if solvecraft_doc::is_identity(&m) {
p
} else {
Plane::new(
solvecraft_doc::apply_point(&m, p.origin),
solvecraft_doc::apply_vector(&m, p.x),
solvecraft_doc::apply_vector(&m, p.y),
)
.unwrap_or(p)
};
(f.id, f.name.clone(), p)
}),
_ => None,
})
.collect()