From 10a67ef25969061babe3f022d97ab3679b1ad343 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 08:48:31 +0000 Subject: [PATCH] Contact Sets (EnableContactSetsCmd, EnableAllContactCmd, DisableAllContactCmd, ContactSetCmd, contact.edit/list/check): driving a joint stops where bodies of one set would pass through each other (bisection to the touch; penetration found by ray parity and depth on the tessellations); Motion Study (FusionMotionStudyCommand: joint values keyed along a timeline, motion.play, motion.export of occurrence poses per step as JSON or CSV without changing the design, contact-aware); Exploded views (explode.create automatic or by hand, explode.show without changing placements) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ROADMAP.md | 2 +- crates/doc/src/joints.rs | 21 +- crates/doc/src/lib.rs | 1 + crates/doc/src/motion.rs | 76 +++ crates/engine/src/cmd/joints.rs | 14 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/motion.rs | 688 ++++++++++++++++++++++++++ crates/engine/src/cmd/motion_tests.rs | 133 +++++ 8 files changed, 929 insertions(+), 8 deletions(-) create mode 100644 crates/doc/src/motion.rs create mode 100644 crates/engine/src/cmd/motion.rs create mode 100644 crates/engine/src/cmd/motion_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6f38346b..f99e8c48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,7 +48,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 | | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | -| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, contact sets later; interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | +| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index 502e2e5d..178c7738 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -148,11 +148,30 @@ pub struct Assembly { pub origins: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub links: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub contact_sets: Vec, + /// Contact sets take effect. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub contact_enabled: bool, + /// Every occurrence is in contact with every other (Enable All Contact). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub contact_all: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub studies: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exploded: Vec, } impl Assembly { pub fn is_empty(&self) -> bool { - self.joints.is_empty() && self.origins.is_empty() && self.links.is_empty() + self.joints.is_empty() + && self.origins.is_empty() + && self.links.is_empty() + && self.contact_sets.is_empty() + && !self.contact_enabled + && !self.contact_all + && self.studies.is_empty() + && self.exploded.is_empty() } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ba929fe0..2821e15f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,7 @@ mod eval; pub mod expr; pub mod format; pub mod joints; +pub mod motion; mod params; pub mod plastic; pub mod project; diff --git a/crates/doc/src/motion.rs b/crates/doc/src/motion.rs new file mode 100644 index 00000000..e99d487f --- /dev/null +++ b/crates/doc/src/motion.rs @@ -0,0 +1,76 @@ +//! Assembly extras: contact sets (occurrences that stop each other when joints are driven), +//! motion studies (joint values keyed along a timeline) and exploded views (occurrence moves +//! shown without changing the design). + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::Vec3; + +/// Occurrences whose bodies may touch but not pass through each other while joints move. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContactSet { + pub id: u64, + pub name: String, + pub occurrences: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub suppressed: bool, +} + +/// One joint value along a motion study: (step, value) points, values in internal units +/// (radians, mm), linear in between and held before the first and after the last. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StudyKey { + pub joint: u64, + #[serde(default)] + pub index: usize, + pub points: Vec<(f64, f64)>, +} + +impl StudyKey { + pub fn value_at(&self, step: f64) -> Option { + let first = self.points.first()?; + let last = self.points.last()?; + if step <= first.0 { + return Some(first.1); + } + if step >= last.0 { + return Some(last.1); + } + self.points.windows(2).find_map(|w| { + let (a, b) = (w.first()?, w.get(1)?); + (step >= a.0 && step <= b.0).then(|| if b.0 - a.0 < 1e-12 { b.1 } else { a.1 + (b.1 - a.1) * (step - a.0) / (b.0 - a.0) }) + }) + } +} + +/// A motion study: a timeline of `steps` steps along which joints follow their keys. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MotionStudy { + pub id: u64, + pub name: String, + pub steps: u32, + pub keys: Vec, +} + +/// An exploded view: occurrences moved apart (shown only; the design keeps its placements). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExplodedView { + pub id: u64, + pub name: String, + /// (occurrence, world translation) + pub moves: Vec<(u64, Vec3)>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_interpolate_and_hold() { + let k = StudyKey { joint: 1, index: 0, points: vec![(0.0, 0.0), (10.0, 1.0), (20.0, -1.0)] }; + assert_eq!(k.value_at(-5.0), Some(0.0)); + assert_eq!(k.value_at(5.0), Some(0.5)); + assert_eq!(k.value_at(15.0), Some(0.0)); + assert_eq!(k.value_at(99.0), Some(-1.0)); + assert_eq!(StudyKey { joint: 1, index: 0, points: vec![] }.value_at(1.0), None); + } +} diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 12e848c0..5284f613 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -64,7 +64,7 @@ fn sol_json(sol: &joints::Solution) -> Value { }) } -fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { +pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { let key = match v { Some(Value::Number(n)) => n.to_string(), Some(Value::String(x)) => x.clone(), @@ -145,7 +145,7 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result, cmd: &str) -> Result { +pub(crate) fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { let key = match v { Some(Value::Number(n)) => n.to_string(), Some(Value::String(x)) => x.clone(), @@ -155,7 +155,7 @@ fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { } /// A joint value: numbers are degrees for angles and mm for distances; text is an expression. -fn value(s: &Session, v: &Value, angle: bool, cmd: &str) -> Result { +pub(crate) fn value(s: &Session, v: &Value, angle: bool, cmd: &str) -> Result { let kind = if angle { Kind::Angle } else { Kind::Length }; let e = match v { Value::Number(n) => n.as_f64().filter(|x| x.is_finite() && x.abs() < 1e9).map(|x| x.to_string()), @@ -344,11 +344,13 @@ fn drive(s: &mut Session, p: &Value) -> Result { } } joints::clamp(j, &mut q); - j.values = q.clone(); - let sol = resolve(s); + // Moved as far toward `q` as contact sets allow. + let r = super::motion::drive_joint(s, id, q)?; + let sol = joints::solve(&s.doc, &s.model.state()); let mut out = sol_json(&sol); out["joint"] = json!(id); - out["values"] = json!(q); + out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.values.clone())); + out["stopped_by_contact"] = r["stopped_by_contact"].clone(); Ok(out) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1cb2d25e..6a9705ae 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,6 +15,7 @@ mod file; mod inspect; pub(crate) mod joints; mod measure_sel; +pub(crate) mod motion; mod section; pub(crate) use measure_sel::measure_items; mod parameters; @@ -142,6 +143,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(plastic::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); + v.extend(motion::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); v.extend(delete::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs new file mode 100644 index 00000000..c9ea4300 --- /dev/null +++ b/crates/engine/src/cmd/motion.rs @@ -0,0 +1,688 @@ +//! Contact Sets, Motion Study and Exploded views. + +use serde_json::{Value, json}; +use solvecraft_doc::Mat; +use solvecraft_doc::joints::{self, JointKind}; +use solvecraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey}; +use solvecraft_geom::Vec3; +use solvecraft_kernel::Body; + +use super::CommandSpec; +use super::joints::{joint_id, occurrence_id, resolve, value}; +use crate::params::{bad, bool_, str_, vec3}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("EnableContactSetsCmd", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params( + "enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other", + ), + CommandSpec::new("EnableAllContactCmd", "Enable All Contact", enable_all) + .at("SOLID", "ASSEMBLE") + .icon("joint") + .params("every occurrence takes part in contact (one set of all)"), + CommandSpec::new("DisableAllContactCmd", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"), + CommandSpec::new("ContactSetCmd", "New Contact Set", new_set) + .at("SOLID", "ASSEMBLE") + .icon("joint") + .params("occurrences: [ids or names] (two or more); name?"), + CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"), + CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"), + CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"), + CommandSpec::new("FusionMotionStudyCommand", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params( + "name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \ + (values: degrees for angles, mm for distances, or expressions)", + ), + CommandSpec::new("motion.play", "Play Motion Study", play) + .params("study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them) → positions"), + CommandSpec::new("motion.export", "Export Motion Study", export) + .noundo() + .params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"), + CommandSpec::new("motion.list", "List Motion Studies", list_studies).noundo(), + CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).params("study"), + CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("joint").params( + "name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \ + moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)", + ), + CommandSpec::new("explode.show", "Show Exploded View", explode_show) + .noundo() + .params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"), + CommandSpec::new("explode.list", "List Exploded Views", explode_list).noundo(), + CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).params("view"), +]; + +const MAX_ITEMS: usize = 10_000; + +fn next_id(s: &Session) -> u64 { + let a = &s.doc.assembly; + let ids = a.contact_sets.iter().map(|c| c.id).chain(a.studies.iter().map(|m| m.id)).chain(a.exploded.iter().map(|e| e.id)); + ids.max().unwrap_or(0) + 1 +} + +fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result> { + let list = p.get("occurrences").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`occurrences` must list occurrences"))?; + if list.len() > MAX_ITEMS { + return Err(bad(cmd, "too many occurrences")); + } + let mut out = Vec::new(); + for v in list { + let id = occurrence_id(s, Some(v), cmd)?; + if id == 0 { + return Err(bad(cmd, "the root is not an occurrence")); + } + if !out.contains(&id) { + out.push(id); + } + } + Ok(out) +} + +// ---- Contact ---- + +/// The placed bodies of the design, each with the occurrences it sits in. +fn placed(s: &Session) -> Vec<(Vec, Body)> { + let st = s.model.state(); + let mut out = Vec::new(); + for (comp, path, m) in s.doc.placements() { + if comp == 0 { + continue; + } + for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) { + if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) { + out.push((path.clone(), t)); + } + } + } + out +} + +/// Pairs of bodies in one active contact set that pass through each other: (occurrence names). +pub fn collisions(s: &Session) -> Vec<(String, String, f64)> { + let a = &s.doc.assembly; + if !a.contact_enabled { + return Vec::new(); + } + let sets: Vec> = if a.contact_all { + vec![s.doc.occurrences.iter().map(|o| o.id).collect()] + } else { + a.contact_sets.iter().filter(|c| !c.suppressed).map(|c| c.occurrences.clone()).collect() + }; + let bodies = placed(s); + let name = |id: u64| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default(); + let mut out = Vec::new(); + for set in &sets { + // The member of the set each body belongs to (its innermost occurrence in the set). + let tagged: Vec<(u64, &Body)> = bodies.iter().filter_map(|(path, b)| path.iter().rev().find(|o| set.contains(o)).map(|o| (*o, b))).collect(); + for (i, (oa, ba)) in tagged.iter().enumerate() { + for (ob, bb) in tagged.iter().skip(i + 1) { + if oa == ob { + continue; + } + if let Some(v) = overlap(ba, bb) { + out.push((name(*oa), name(*ob), v)); + } + } + } + } + out +} + +/// How deep one body reaches into the other, when more than a touch (`None`: apart or +/// touching). Points of each tessellation (vertices, edge midpoints, triangle centres) are +/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance. +fn overlap(a: &Body, b: &Body) -> Option { + let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?); + let (ba, bb) = (ma.bounds(), mb.bounds()); + let tol = 1e-4 * a.size().max(b.size()).max(1.0); + if ba.max.x < bb.min.x + tol + || bb.max.x < ba.min.x + tol + || ba.max.y < bb.min.y + tol + || bb.max.y < ba.min.y + tol + || ba.max.z < bb.min.z + tol + || bb.max.z < ba.min.z + tol + { + return None; + } + let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol)); + (depth > tol).then_some(depth) +} + +fn tris(m: &solvecraft_geom::Mesh) -> Vec<[Vec3; 3]> { + m.triangles.iter().filter_map(|t| m.tri(t)).collect() +} + +/// The deepest any sample point of `a` lies inside `b` (0 if none). +fn deepest(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, tol: f64) -> f64 { + let tb = tris(b); + let bx = b.bounds(); + let mut pts: Vec = a.positions.clone(); + for [p, q, r] in tris(a) { + pts.extend([(p + q) * 0.5, (q + r) * 0.5, (r + p) * 0.5, (p + q + r) * (1.0 / 3.0)]); + } + let mut best = 0.0f64; + for p in pts.into_iter().take(200_000) { + if p.x < bx.min.x - tol + || p.x > bx.max.x + tol + || p.y < bx.min.y - tol + || p.y > bx.max.y + tol + || p.z < bx.min.z - tol + || p.z > bx.max.z + tol + { + continue; + } + let d = tb.iter().map(|[a, b, c]| closest(p, *a, *b, *c).dist(p)).fold(f64::MAX, f64::min); + if d > tol.max(best) && inside(p, &tb) { + best = d; + } + } + best +} + +/// Ray parity along a skew direction (no axis-aligned edge hits). +fn inside(p: Vec3, tris: &[[Vec3; 3]]) -> bool { + let dir = Vec3::new(0.577_215_664_9, 0.327_118_441_9, 0.751_891_438_7); + let mut n = 0; + for [a, b, c] in tris { + let (e1, e2) = (*b - *a, *c - *a); + let h = dir.cross(e2); + let det = e1.dot(h); + if det.abs() < 1e-14 { + continue; + } + let f = 1.0 / det; + let sv = p - *a; + let u = f * sv.dot(h); + if !(0.0..=1.0).contains(&u) { + continue; + } + let q = sv.cross(e1); + let v = f * dir.dot(q); + if v < 0.0 || u + v > 1.0 { + continue; + } + if f * e2.dot(q) > 0.0 { + n += 1; + } + } + n % 2 == 1 +} + +/// The point of triangle abc nearest p. +fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { + let (ab, ac, ap) = (b - a, c - a, p - a); + let (d1, d2) = (ab.dot(ap), ac.dot(ap)); + if d1 <= 0.0 && d2 <= 0.0 { + return a; + } + let bp = p - b; + let (d3, d4) = (ab.dot(bp), ac.dot(bp)); + if d3 >= 0.0 && d4 <= d3 { + return b; + } + let vc = d1 * d4 - d3 * d2; + if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { + return a + ab * (d1 / (d1 - d3)); + } + let cp = p - c; + let (d5, d6) = (ab.dot(cp), ac.dot(cp)); + if d6 >= 0.0 && d5 <= d6 { + return c; + } + let vb = d5 * d2 - d1 * d6; + if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { + return a + ac * (d2 / (d2 - d6)); + } + let va = d3 * d6 - d5 * d4; + if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { + return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6))); + } + let den = va + vb + vc; + if den.abs() < 1e-300 { + return a; + } + a + ab * (vb / den) + ac * (vc / den) +} + +/// Set joint values, stopping short where contact sets would collide: the values are moved +/// from where they are toward `want` as far as they go without a collision. +fn drive_with_contact(s: &mut Session, want: &[(u64, Vec)]) -> Result { + let start: Vec<(u64, Vec)> = + want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect(); + let set = |s: &mut Session, t: f64| { + let doc = s.doc_mut(); + for ((id, a), (_, b)) in start.iter().zip(want) { + if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) { + let mut q: Vec = b.iter().enumerate().map(|(i, bv)| a.get(i).copied().unwrap_or(*bv) * (1.0 - t) + bv * t).collect(); + joints::clamp(j, &mut q); + j.values = q; + } + } + resolve(s) + }; + let mut sol = set(s, 1.0); + let mut stopped = None; + if s.doc.assembly.contact_enabled + && let Some(hit) = collisions(s).into_iter().next() + { + // Already colliding where it started: don't search, just report. + set(s, 0.0); + if collisions(s).is_empty() { + let (mut lo, mut hi) = (0.0, 1.0); + for _ in 0..14 { + let mid = (lo + hi) / 2.0; + set(s, mid); + if collisions(s).is_empty() { lo = mid } else { hi = mid } + } + sol = set(s, lo); + stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo})); + } else { + sol = set(s, 1.0); + } + } + let values: Vec = want + .iter() + .map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone())})) + .collect(); + Ok(json!({"values": values, "stopped_by_contact": stopped, "conflicts": sol.conflicts.len()})) +} + +/// Drive one joint (FusionMoveJointsCommand), honouring contact sets. +pub fn drive_joint(s: &mut Session, id: u64, q: Vec) -> Result { + drive_with_contact(s, &[(id, q)]) +} + +fn enable_sets(s: &mut Session, p: &Value) -> Result { + let on = bool_(p, "enabled").unwrap_or(true); + s.doc_mut().assembly.contact_enabled = on; + Ok(json!({"enabled": on})) +} + +fn enable_all(s: &mut Session, _p: &Value) -> Result { + let a = &mut s.doc_mut().assembly; + a.contact_enabled = true; + a.contact_all = true; + Ok(json!({"enabled": true, "all": true})) +} + +fn disable_all(s: &mut Session, _p: &Value) -> Result { + let a = &mut s.doc_mut().assembly; + a.contact_enabled = false; + a.contact_all = false; + Ok(json!({"enabled": false})) +} + +fn new_set(s: &mut Session, p: &Value) -> Result { + let cmd = "ContactSetCmd"; + let occ = occurrences(s, p, cmd)?; + if occ.len() < 2 { + return Err(bad(cmd, "a contact set needs two or more occurrences")); + } + if s.doc.assembly.contact_sets.len() >= MAX_ITEMS { + return Err(bad(cmd, "too many contact sets")); + } + let id = next_id(s); + let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1)); + let a = &mut s.doc_mut().assembly; + a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false }); + a.contact_enabled = true; + Ok(json!({"set": id, "name": name})) +} + +fn set_index(s: &Session, p: &Value, cmd: &str) -> Result { + let key = match p.get("set") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, "`set` must be an id or a name")), + }; + s.doc + .assembly + .contact_sets + .iter() + .position(|c| c.id.to_string() == key || c.name == key) + .ok_or_else(|| bad(cmd, format!("no contact set `{key}`"))) +} + +fn edit_set(s: &mut Session, p: &Value) -> Result { + let cmd = "contact.edit"; + let i = set_index(s, p, cmd)?; + if bool_(p, "delete").unwrap_or(false) { + s.doc_mut().assembly.contact_sets.remove(i); + return Ok(json!({"deleted": true})); + } + let occ = if p.get("occurrences").is_some() { Some(occurrences(s, p, cmd)?) } else { None }; + if occ.as_ref().is_some_and(|o| o.len() < 2) { + return Err(bad(cmd, "a contact set needs two or more occurrences")); + } + let Some(c) = s.doc_mut().assembly.contact_sets.get_mut(i) else { return Err(bad(cmd, "set")) }; + if let Some(o) = occ { + c.occurrences = o; + } + if let Some(n) = str_(p, "name").filter(|n| !n.trim().is_empty()) { + c.name = n.to_string(); + } + if let Some(x) = bool_(p, "suppressed") { + c.suppressed = x; + } + Ok(json!({"set": c.id})) +} + +fn list_sets(s: &mut Session, _p: &Value) -> Result { + let a = &s.doc.assembly; + Ok(json!({"enabled": a.contact_enabled, "all": a.contact_all, "sets": a.contact_sets})) +} + +fn check(s: &mut Session, _p: &Value) -> Result { + let c = collisions(s); + Ok(json!({"collisions": c.iter().map(|(a, b, d)| json!({"a": a, "b": b, "depth_mm": d})).collect::>()})) +} + +// ---- Motion studies ---- + +fn study_index(s: &Session, p: &Value, cmd: &str) -> Result { + let key = match p.get("study") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, "`study` must be an id or a name")), + }; + s.doc.assembly.studies.iter().position(|m| m.id.to_string() == key || m.name == key).ok_or_else(|| bad(cmd, format!("no motion study `{key}`"))) +} + +fn study(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionMotionStudyCommand"; + let existing = if p.get("study").is_some() { Some(study_index(s, p, cmd)?) } else { None }; + let steps = match p.get("steps") { + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite() && *n >= 1.0 && *n <= 100_000.0).ok_or_else(|| bad(cmd, "`steps` must be 1…100000"))?; + Some(n.round() as u32) + } + None => None, + }; + let mut keys = Vec::new(); + if let Some(list) = p.get("keys") { + let list = list.as_array().ok_or_else(|| bad(cmd, "`keys` must be a list"))?; + if list.len() > 1000 { + return Err(bad(cmd, "too many keys")); + } + for k in list { + let joint = joint_id(s, k.get("joint"), cmd)?; + let kind = s.doc.assembly.joints.iter().find(|j| j.id == joint).map(|j| j.kind).unwrap_or(JointKind::Rigid); + let index = k.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + if index >= kind.dofs().len() { + return Err(bad(cmd, format!("joint {joint} has no moving value {index}"))); + } + let pts = k.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "each key needs `points`: [[step, value], …]"))?; + if pts.is_empty() || pts.len() > 10_000 { + return Err(bad(cmd, "a key needs 1…10000 points")); + } + let mut points = Vec::new(); + for pt in pts { + let (Some(st), Some(v)) = (pt.get(0).and_then(Value::as_f64).filter(|x| x.is_finite() && x.abs() < 1e9), pt.get(1)) else { + return Err(bad(cmd, "points are [step, value]")); + }; + points.push((st, value(s, v, kind.is_angle(index), cmd)?)); + } + points.sort_by(|a, b| a.0.total_cmp(&b.0)); + keys.push(StudyKey { joint, index, points }); + } + } + let id = next_id(s); + let count = s.doc.assembly.studies.len(); + let a = &mut s.doc_mut().assembly; + let out = match existing.and_then(|i| a.studies.get_mut(i)) { + Some(m) => { + if let Some(n) = steps { + m.steps = n; + } + if p.get("keys").is_some() { + m.keys = keys; + } + if let Some(n) = str_(p, "name").filter(|n| !n.trim().is_empty()) { + m.name = n.to_string(); + } + m.clone() + } + None => { + if count >= MAX_ITEMS { + return Err(bad(cmd, "too many motion studies")); + } + let m = MotionStudy { + id, + name: str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Motion Study{}", count + 1)), + steps: steps.unwrap_or(100), + keys, + }; + a.studies.push(m.clone()); + m + } + }; + Ok(json!({"study": out.id, "name": out.name, "steps": out.steps, "keys": out.keys.len()})) +} + +/// Joint values of a study at a step: (joint, full value vector). +fn values_at(s: &Session, m: &MotionStudy, step: f64) -> Vec<(u64, Vec)> { + let mut out: Vec<(u64, Vec)> = Vec::new(); + for k in &m.keys { + let Some(v) = k.value_at(step) else { continue }; + let pos = match out.iter().position(|(j, _)| *j == k.joint) { + Some(i) => i, + None => { + let cur = s.doc.assembly.joints.iter().find(|j| j.id == k.joint).map(|j| j.values.clone()).unwrap_or_default(); + out.push((k.joint, cur)); + out.len() - 1 + } + }; + if let Some((_, q)) = out.get_mut(pos) { + if q.len() <= k.index { + q.resize(k.index + 1, 0.0); + } + if let Some(x) = q.get_mut(k.index) { + *x = v; + } + } + } + out +} + +/// Where each occurrence is: (name, world transform). +fn positions(s: &Session) -> Vec<(String, Mat)> { + s.doc.occurrences.iter().map(|o| (o.name.clone(), joints::occurrence_world(&s.doc, o.id))).collect() +} + +fn pose_json(m: &Mat) -> Value { + json!({"origin": [m[3][0], m[3][1], m[3][2]], "x": [m[0][0], m[0][1], m[0][2]], "y": [m[1][0], m[1][1], m[1][2]], "z": [m[2][0], m[2][1], m[2][2]]}) +} + +fn play(s: &mut Session, p: &Value) -> Result { + let cmd = "motion.play"; + let i = study_index(s, p, cmd)?; + let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?; + let step = p.get("step").and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`step` must be a number"))?; + let want = values_at(s, &m, step.clamp(0.0, f64::from(m.steps))); + let mut out = drive_with_contact(s, &want)?; + out["positions"] = positions(s).iter().map(|(n, t)| json!({"occurrence": n, "pose": pose_json(t)})).collect(); + Ok(out) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let cmd = "motion.export"; + let i = study_index(s, p, cmd)?; + let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?; + let samples = match p.get("samples") { + Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 2.0 && *n <= 10_000.0).ok_or_else(|| bad(cmd, "`samples` must be 2…10000"))? as usize, + None => (m.steps as usize + 1).min(10_000), + }; + // On a copy: the design stays as it is. + let mut t = s.scratch(); + let mut rows = Vec::new(); + for k in 0..samples { + let step = f64::from(m.steps) * k as f64 / (samples - 1) as f64; + let want = values_at(&t, &m, step); + let r = drive_with_contact(&mut t, &want)?; + rows.push(json!({ + "step": step, + "stopped_by_contact": r["stopped_by_contact"], + "positions": positions(&t).iter().map(|(n, x)| json!({"occurrence": n, "pose": pose_json(x)})).collect::>(), + })); + } + let out = json!({"study": m.name, "steps": m.steps, "samples": rows}); + if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096) { + let text = if path.to_ascii_lowercase().ends_with(".csv") { + let mut c = String::from("step,occurrence,x,y,z,xx,xy,xz,yx,yy,yz,zx,zy,zz\n"); + for r in &rows { + for p in r["positions"].as_array().into_iter().flatten() { + let pose = &p["pose"]; + let n = |k: &str, i: usize| pose[k][i].as_f64().unwrap_or(0.0); + c += &format!( + "{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n", + r["step"], + p["occurrence"].as_str().unwrap_or_default().replace(',', " "), + n("origin", 0), + n("origin", 1), + n("origin", 2), + n("x", 0), + n("x", 1), + n("x", 2), + n("y", 0), + n("y", 1), + n("y", 2), + n("z", 0), + n("z", 1), + n("z", 2) + ); + } + } + c + } else { + serde_json::to_string_pretty(&out).unwrap_or_default() + }; + solvecraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + return Ok(json!({"path": path, "samples": rows.len()})); + } + Ok(out) +} + +fn list_studies(s: &mut Session, _p: &Value) -> Result { + Ok(json!({"studies": s.doc.assembly.studies})) +} + +fn delete_study(s: &mut Session, p: &Value) -> Result { + let i = study_index(s, p, "motion.delete")?; + let m = s.doc_mut().assembly.studies.remove(i); + Ok(json!({"deleted": m.name})) +} + +// ---- Exploded views ---- + +fn view_index(s: &Session, p: &Value, cmd: &str) -> Result { + let key = match p.get("view") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, "`view` must be an id or a name")), + }; + s.doc.assembly.exploded.iter().position(|e| e.id.to_string() == key || e.name == key).ok_or_else(|| bad(cmd, format!("no exploded view `{key}`"))) +} + +/// World centre of each top-level occurrence's bodies. +fn centres(s: &Session) -> Vec<(u64, Vec3)> { + let bodies = placed(s); + let mut out = Vec::new(); + for o in s.doc.occurrences.iter().filter(|o| o.parent == 0) { + let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + for (_, b) in bodies.iter().filter(|(path, _)| path.first() == Some(&o.id)) { + if let Ok(m) = b.tessellate((b.size() * 1e-2).max(1e-2)) { + let bb = m.bounds(); + lo = Vec3::new(lo.x.min(bb.min.x), lo.y.min(bb.min.y), lo.z.min(bb.min.z)); + hi = Vec3::new(hi.x.max(bb.max.x), hi.y.max(bb.max.y), hi.z.max(bb.max.z)); + } + } + if lo.x <= hi.x { + out.push((o.id, (lo + hi) * 0.5)); + } + } + out +} + +fn explode_create(s: &mut Session, p: &Value) -> Result { + let cmd = "explode.create"; + let moves: Vec<(u64, Vec3)> = match p.get("moves") { + Some(Value::Array(list)) => { + if list.len() > MAX_ITEMS { + return Err(bad(cmd, "too many moves")); + } + list.iter() + .map(|m| { + let o = occurrence_id(s, m.get("occurrence"), cmd)?; + let t = m + .get("translate") + .and_then(vec3) + .filter(|v| v.len() < 1e7) + .ok_or_else(|| bad(cmd, "each move needs `translate`: [x, y, z]"))?; + Ok((o, t)) + }) + .collect::>()? + } + Some(_) => return Err(bad(cmd, "`moves` must be a list")), + None => { + let scale = match p.get("scale") { + Some(v) => v.as_f64().filter(|x| x.is_finite() && x.abs() <= 100.0).ok_or_else(|| bad(cmd, "`scale` must be a number up to 100"))?, + None => 1.0, + }; + let c = centres(s); + if c.len() < 2 { + return Err(bad(cmd, "an exploded view needs two or more occurrences")); + } + let mid = c.iter().fold(Vec3::ZERO, |a, (_, p)| a + *p) * (1.0 / c.len() as f64); + c.iter().map(|(o, p)| (*o, (*p - mid) * scale)).collect() + } + }; + if s.doc.assembly.exploded.len() >= MAX_ITEMS { + return Err(bad(cmd, "too many exploded views")); + } + let id = next_id(s); + let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1)); + s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() }); + Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::>()})) +} + +fn explode_show(s: &mut Session, p: &Value) -> Result { + let cmd = "explode.show"; + if p.get("view").is_none_or(|v| v.is_null() || v.as_str().is_some_and(|x| x.eq_ignore_ascii_case("none"))) { + s.pending_moves.clear(); + s.revision += 1; + return Ok(json!({"view": null})); + } + let i = view_index(s, p, cmd)?; + let v = s.doc.assembly.exploded.get(i).cloned().ok_or_else(|| bad(cmd, "view"))?; + s.pending_moves.clear(); + for (o, t) in &v.moves { + let Some(occ) = s.doc.occurrences.iter().find(|x| x.id == *o) else { continue }; + let mut m = occ.transform; + if let Some(r) = m.get_mut(3) { + r[0] += t.x; + r[1] += t.y; + r[2] += t.z; + } + s.pending_moves.insert(*o, m); + } + s.revision += 1; + Ok(json!({"view": v.id, "name": v.name})) +} + +fn explode_list(s: &mut Session, _p: &Value) -> Result { + Ok(json!({"views": s.doc.assembly.exploded})) +} + +fn explode_delete(s: &mut Session, p: &Value) -> Result { + let i = view_index(s, p, "explode.delete")?; + let v = s.doc_mut().assembly.exploded.remove(i); + Ok(json!({"deleted": v.name})) +} + +/// World position of a point of an occurrence (tests). +#[cfg(test)] +pub fn world_point(s: &Session, occurrence: u64, p: Vec3) -> Vec3 { + solvecraft_doc::apply_point(&joints::occurrence_world(&s.doc, occurrence), p) +} + +#[cfg(test)] +#[path = "motion_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs new file mode 100644 index 00000000..a133632d --- /dev/null +++ b/crates/engine/src/cmd/motion_tests.rs @@ -0,0 +1,133 @@ +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 occ(s: &Session, component: &str) -> u64 { + let c = s.doc.find_component(component).unwrap_or(0); + s.doc.occurrence_of(c).map(|o| o.id).unwrap_or(0) +} + +/// Where B's corner (20, 0, 0) is now. +fn b_x(s: &Session) -> f64 { + super::world_point(s, occ(s, "B"), Vec3::new(20.0, 0.0, 0.0)).x +} + +/// Cubes A (grounded, x 0…10) and B (x 20…30) on a slider along x: B's −x face on A's +x face +/// at value 0, B 10 mm away to start. +fn rail() -> Session { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 20)] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name})); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})); + } + run(&mut s, "component.activate", json!({"component": "root"})); + let a = occ(&s, "A"); + run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true})); + run(&mut s, "JointAssembleCmdNew", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "flip": true, "name": "Rail"})); + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10})); + assert!((b_x(&s) - 20.0).abs() < 1e-6, "{}", b_x(&s)); + s +} + +#[test] +fn contact_sets_stop_a_driven_joint_at_touching() { + let mut s = rail(); + // No contact: B passes into A. + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5})); + assert!((b_x(&s) - 5.0).abs() < 1e-6); + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10})); + run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]})); + let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5})); + assert!(r["stopped_by_contact"].is_object(), "{r}"); + // Stopped where the faces touch (to the search's resolution, 15 mm / 2^14). + assert!((b_x(&s) - 10.0).abs() < 2e-3, "{}", b_x(&s)); + assert!(b_x(&s) >= 10.0 - 2e-3, "a touch, not a pass"); + assert_eq!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().map(Vec::len), Some(0)); + // Away is free; contact off lets it through again. + let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 3})); + assert!(r["stopped_by_contact"].is_null() && (b_x(&s) - 13.0).abs() < 1e-6, "{r}"); + run(&mut s, "DisableAllContactCmd", json!({})); + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5})); + assert!((b_x(&s) - 5.0).abs() < 1e-6); + // Suppressed sets don't count; Enable All Contact puts every occurrence in one. + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10})); + run(&mut s, "EnableContactSetsCmd", json!({})); + run(&mut s, "contact.edit", json!({"set": "Contact Set1", "suppressed": true})); + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5})); + assert!((b_x(&s) - 5.0).abs() < 1e-6); + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10})); + run(&mut s, "EnableAllContactCmd", json!({})); + run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5})); + assert!((b_x(&s) - 10.0).abs() < 2e-3); + assert!(s.execute("ContactSetCmd", &json!({"occurrences": ["A"]})).is_err()); +} + +#[test] +fn motion_study_plays_and_exports() { + let mut s = rail(); + let r = + run(&mut s, "FusionMotionStudyCommand", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]})); + assert_eq!(r["keys"], 1); + run(&mut s, "motion.play", json!({"study": "Push", "step": 50})); + assert!((b_x(&s) - 12.5).abs() < 1e-6, "{}", b_x(&s)); + // With contact, the end of the study stops at touching. + run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]})); + let r = run(&mut s, "motion.play", json!({"study": "Push", "step": 100})); + assert!(r["stopped_by_contact"].is_object() && (b_x(&s) - 10.0).abs() < 2e-3, "{r}"); + // Export: positions per sample; the design keeps its joint value. + run(&mut s, "motion.play", json!({"study": "Push", "step": 0})); + let before = s.doc.clone(); + let e = run(&mut s, "motion.export", json!({"study": "Push", "samples": 5})); + assert_eq!(*s.doc, *before); + let rows = e["samples"].as_array().cloned().unwrap_or_default(); + assert_eq!(rows.len(), 5); + let bx = |row: &Value| { + let p = row["positions"].as_array().and_then(|a| a.iter().find(|p| p["occurrence"] == "B:1")).cloned().unwrap_or_default(); + p["pose"]["origin"][0].as_f64().unwrap_or(f64::NAN) + }; + // The B occurrence's origin moves by the slider value (it starts at 0 offset with value 0 at 10 mm… so 10 → −5). + assert!((bx(&rows[1]) - bx(&rows[0]) + 3.75).abs() < 1e-6, "{e}"); + assert!(rows[4]["stopped_by_contact"].is_object()); + let dir = std::env::temp_dir().join(format!("solvecraft-motion-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("push.csv"); + run(&mut s, "motion.export", json!({"study": "Push", "samples": 3, "path": csv.to_string_lossy()})); + let text = std::fs::read_to_string(&csv).unwrap(); + assert!(text.starts_with("step,occurrence,x,y,z") && text.lines().count() == 1 + 3 * 2, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + assert!(s.execute("FusionMotionStudyCommand", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err()); + run(&mut s, "motion.delete", json!({"study": "Push"})); + assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0)); +} + +#[test] +fn exploded_views_show_without_changing_the_design() { + let mut s = rail(); + let before = s.doc.occurrences.clone(); + let r = run(&mut s, "explode.create", json!({"name": "Apart", "scale": 1})); + assert_eq!(r["moves"].as_array().map(Vec::len), Some(2), "{r}"); + run(&mut s, "explode.show", json!({"view": "Apart"})); + // Centres at x 5 and 25 → middle 15: each moves 10 mm further out. + let bb = |s: &mut Session, b: &str| run(s, "MeasureCommand", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN); + let names: Vec = s.world_state().bodies.iter().map(|b| b.name.clone()).collect(); + assert!((bb(&mut s, &names[0]) + 10.0).abs() < 1e-6, "{names:?}"); + assert!((bb(&mut s, &names[1]) - 30.0).abs() < 1e-6); + assert_eq!(s.doc.occurrences, before, "shown only"); + run(&mut s, "explode.show", json!({"view": null})); + assert!(bb(&mut s, &names[0]).abs() < 1e-6); + run(&mut s, "explode.create", json!({"name": "Hand", "moves": [{"occurrence": "B", "translate": [0, 0, 50]}]})); + run(&mut s, "explode.show", json!({"view": "Hand"})); + assert!( + (run(&mut s, "MeasureCommand", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6 + ); + assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2)); +}