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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent a283e424d9
commit 10a67ef259
8 files changed
+929 -8

No files matched your search

+1 -1
View File
@@ -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 |
+20 -1
View File
@@ -148,11 +148,30 @@ pub struct Assembly {
pub origins: Vec<NamedOrigin>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<MotionLink>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contact_sets: Vec<crate::motion::ContactSet>,
/// 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<crate::motion::MotionStudy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exploded: Vec<crate::motion::ExplodedView>,
}
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()
}
}
+1
View File
@@ -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;
+76
View File
@@ -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<u64>,
#[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<f64> {
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<StudyKey>,
}
/// 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);
}
}
+8 -6
View File
@@ -64,7 +64,7 @@ fn sol_json(sol: &joints::Solution) -> Value {
})
}
fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
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<JointOrigin
Ok(o)
}
fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
pub(crate) fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
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<u64> {
}
/// 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<f64> {
pub(crate) fn value(s: &Session, v: &Value, angle: bool, cmd: &str) -> Result<f64> {
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<Value> {
}
}
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)
}
+2
View File
@@ -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());
+688
View File
@@ -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<Vec<u64>> {
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<u64>, 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<Vec<u64>> = 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<f64> {
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<Vec3> = 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<f64>)]) -> Result<Value> {
let start: Vec<(u64, Vec<f64>)> =
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<f64> = 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<Value> = 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<f64>) -> Result<Value> {
drive_with_contact(s, &[(id, q)])
}
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<usize> {
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<Value> {
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<Value> {
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<Value> {
let c = collisions(s);
Ok(json!({"collisions": c.iter().map(|(a, b, d)| json!({"a": a, "b": b, "depth_mm": d})).collect::<Vec<_>>()}))
}
// ---- Motion studies ----
fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
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<Value> {
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<f64>)> {
let mut out: Vec<(u64, Vec<f64>)> = 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<Value> {
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<Value> {
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::<Vec<_>>(),
}));
}
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<Value> {
Ok(json!({"studies": s.doc.assembly.studies}))
}
fn delete_study(s: &mut Session, p: &Value) -> Result<Value> {
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<usize> {
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<Value> {
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::<Result<_>>()?
}
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::<Vec<_>>()}))
}
fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
Ok(json!({"views": s.doc.assembly.exploded}))
}
fn explode_delete(s: &mut Session, p: &Value) -> Result<Value> {
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;
+133
View File
@@ -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<String> = 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));
}