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:
1 parent
a283e424d9
commit
10a67ef259
8 files changed
+929
-8
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user