Motion Trace: regenerate kept curves only when inputs change (#5248) (#5270)

* claim: #5248

* feat(motion): regenerate kept traces when source inputs change

* feat(motion): add a parametric Chebyshev trace demo

* perf(joints): reuse face measurements across motion samples

Face-centred joint origins called Body::faces on every solve, bypassing the
reference-mesh reuse used for rigid placements. Share face measurements with
unchanged model geometry at the existing tolerance, while guarding replaced
bodies and applying offsets after the cached measurements.

Cover repeated named/nearest-face solves, shape edits, retained snapshots and
direct replacement with deterministic work counts. The 145-point Chebyshev
dimension and range updates retain identical sampled and interpolated curves.

* perf(naming): skip preparation for cached face names

Both face lookup paths spawned fresh workers over every body even when their name cells were ready. Prepare only cold cells, with direct handling of zero or one cold body. Preserve the existing parallel naming path and all face selection semantics.

Deterministic regressions cover repeated picked and unique-name lookups, misses, partial warmup, renames, snapshots and moved copies. Release Chebyshev medians fall from 859 to 50 ms for a dimension edit and 871 to 34 ms for a sweep edit; all 145 samples and interpolated points remain identical. Full cargo xtask ci passes with RUST_TEST_THREADS=4 and the existing local Windows validation overlays.
This commit is contained in:
takuto-NA
2026-10-23 13:42:36 +00:00
committed by GitHub
parent 46a8f86e80
commit 63d81cc096
22 changed files with 1146 additions and 26 deletions
+1
View File
@@ -14,6 +14,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| File | Author | Source | Licence |
|---|---|---|---|
| examples/chebyshev-motion-trace.json | SolidCraft contributors | Original parametric Chebyshev lambda mechanism and associative-trace demo, constructed from analytic circle intersections; classical link proportions documented at https://doi.org/10.1002/rcs.70095; no external CAD geometry or artwork | MIT OR Apache-2.0 |
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
+1
View File
@@ -740,6 +740,7 @@
"cmd.motion_plot": "Plot Motion Study",
"cmd.motion_study": "Motion Study",
"cmd.motion_trace": "Motion Trace",
"cmd.motion_trace_edit": "Edit Motion Trace",
"cmd.motion_trace_keep": "Keep Motion Trace",
"cmd.occurrence_copy": "Paste (Instance)",
"cmd.occurrence_ground": "Ground",
+1
View File
@@ -740,6 +740,7 @@
"cmd.motion_plot": "モーション スタディのグラフ",
"cmd.motion_study": "モーション スタディ",
"cmd.motion_trace": "モーション トレース",
"cmd.motion_trace_edit": "モーション トレースを編集",
"cmd.motion_trace_keep": "モーション トレースを保持",
"cmd.occurrence_copy": "貼り付け(インスタンス)",
"cmd.occurrence_ground": "空間固定",
+1
View File
@@ -740,6 +740,7 @@
"cmd.motion_plot": "运动研究图表",
"cmd.motion_study": "运动研究",
"cmd.motion_trace": "运动轨迹",
"cmd.motion_trace_edit": "编辑运动轨迹",
"cmd.motion_trace_keep": "保留运动轨迹",
"cmd.occurrence_copy": "粘贴(实例)",
"cmd.occurrence_ground": "固定",
+19
View File
@@ -17,6 +17,8 @@ use crate::expr::{self, Kind, Value};
use crate::lineage::Traced;
use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel, Result};
type JointFaces = (Body, kernel::Result<Arc<[kernel::FaceInfo]>>);
/// A body in the evaluated model.
#[derive(Clone, Debug)]
pub struct ModelBody {
@@ -31,6 +33,9 @@ pub struct ModelBody {
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
// meshing any snapshot also warms every unchanged copy of this body.
mesh: Arc<OnceLock<Arc<Mesh>>>,
/// Joint face measurements use their own tolerance, independent of display quality.
/// Shared by snapshots of this geometry; the body identity guards direct replacement.
joint_faces: Arc<OnceLock<JointFaces>>,
/// Lazy canonical reference triangles for a proper rigid occurrence placement.
mesh_placement: Option<Arc<placed_mesh::PlacedMesh>>,
/// The display plan (none when the body has no plan: a mesh body, or one the planner
@@ -71,6 +76,7 @@ impl ModelBody {
body,
feature,
mesh: Arc::new(OnceLock::new()),
joint_faces: Arc::new(OnceLock::new()),
mesh_placement: None,
plan: Arc::new(Default::default()),
plan_mesh: Arc::new(Default::default()),
@@ -86,6 +92,19 @@ impl ModelBody {
}
}
/// Face measurements for joint origins, at their original chord tolerance. Pose changes
/// and scratch copies share them; a geometry edit makes a new body and cache.
pub(crate) fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
let measure = || {
#[cfg(test)]
cost_tests::count(|c| c.joint_faces += 1);
let tol = (self.body.size() * 2e-3).max(1e-3);
self.body.faces(tol).map(Arc::from)
};
let (body, faces) = self.joint_faces.get_or_init(|| (self.body.clone(), measure()));
if body.same(&self.body) { faces.clone().map_err(Into::into) } else { measure().map_err(Into::into) }
}
/// A box around the body without meshing it ([`Body::outline_bounds`]), worked out once.
pub fn outline_bounds(&self) -> solidcraft_geom::Aabb3 {
match self.outline.get_or_init(|| (self.body.clone(), self.body.outline_bounds())) {
+100 -2
View File
@@ -13,15 +13,16 @@ use solidcraft_sketch::Sketch;
use crate::*;
/// Work counted on this thread: bodies moved into a component's frame, STEP texts digested.
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces measured.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Counts {
pub moved: usize,
pub digested: usize,
pub joint_faces: usize,
}
thread_local! {
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0 }) };
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0 }) };
}
pub(super) fn count(f: impl FnOnce(&mut Counts)) {
@@ -39,6 +40,103 @@ fn counted(f: impl FnOnce()) -> Counts {
COUNTS.with(Cell::get)
}
#[test]
fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
use crate::joints::{Joint, JointKind, JointOrigin, Snap, XRef};
let mut doc = Document::new("joint face work");
let part = doc.add_component(Some("Crank"), 0).unwrap();
let occurrence = doc.occurrences.iter().find(|o| o.component == part).unwrap().id;
let id = doc
.add_feature(
FeatureKind::Cylinder {
base: point_expr(Vec3::ZERO),
axis: Dir(Vec3::Z),
radius: "2".into(),
height: "10".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
doc.feature_mut(id).unwrap().component = part;
let mut model = Model::new();
model.evaluate(&doc);
let state = model.state();
let pick = Vec3::new(0.0, 0.0, 10.0);
let face = joints::face_name_at(&doc, &state, part, pick, false).unwrap();
doc.assembly.joints.push(Joint {
id: 100,
name: "Pivot".into(),
kind: JointKind::Revolute,
a: JointOrigin { occurrence: 0, snap: Snap::Frame { origin: pick, z: Vec3::Z, x: Some(Vec3::X) } },
b: JointOrigin { occurrence, snap: Snap::FaceCenter { pick, face: None, x: Some(XRef::Dir(Vec3::X)) } },
values: vec![0.0],
limits: Vec::new(),
flip: true,
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
});
// One body supplies both old nearest-face snaps and persistent named-face snaps.
// A scratch model shares its measurements, even though each sample changes the pose.
let snapshot = model.clone();
let work = counted(|| {
for name in [None, Some(face)] {
doc.assembly.joints[0].b.snap = Snap::FaceCenter { pick, face: name, x: Some(XRef::Dir(Vec3::X)) };
for k in 0..145 {
let angle = (k as f64 * 2.5).to_radians();
doc.assembly.joints[0].values[0] = angle;
let solution = joints::solve(&doc, &snapshot.state());
assert!(solution.errors.is_empty() && solution.conflicts.is_empty(), "{solution:?}");
let point = apply_point(&solution.world[&occurrence], Vec3::new(5.0, 0.0, 10.0));
assert!(point.dist(Vec3::new(5.0 * angle.cos(), 5.0 * angle.sin(), 10.0)) < 1e-9);
doc.apply_joint_solution(&solution);
}
}
});
assert_eq!(work.joint_faces, 1, "pose-only samples must not remeasure unchanged faces: {work:?}");
// Editing geometry creates a fresh measurement without changing a retained snapshot.
let FeatureKind::Cylinder { height, .. } = &mut doc.feature_mut(id).unwrap().kind else { panic!("cylinder") };
*height = "12".into();
model.evaluate(&doc);
let origin = &doc.assembly.joints[0].b;
let work = counted(|| {
let changed = joints::resolve_origin(&doc, &model.state(), origin).unwrap();
let retained = joints::resolve_origin(&doc, &snapshot.state(), origin).unwrap();
assert!((changed[3][2] - 12.0).abs() < 1e-9);
assert!((retained[3][2] - 10.0).abs() < 1e-9);
});
assert_eq!(work.joint_faces, 1, "only the edited body must be measured: {work:?}");
}
#[test]
fn joint_face_measurements_never_follow_a_replaced_body_or_stale_offset() {
use crate::joints::{JointOrigin, Snap};
let mut doc = Document::new("replaced joint geometry");
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 10.0).unwrap(), 0));
let origin = JointOrigin { occurrence: 0, snap: Snap::FaceCenter { pick: Vec3::new(0.0, 0.0, 10.0), face: None, x: None } };
let work = counted(|| {
joints::resolve_origin(&doc, &state, &origin).unwrap();
});
assert_eq!(work.joint_faces, 1);
let retained = state.clone();
// Body is public; a clone's old measurement must never mask a direct replacement.
state.bodies[0].body = kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 12.0).unwrap();
let changed = joints::resolve_origin(&doc, &state, &origin).unwrap();
assert!((changed[3][2] - 12.0).abs() < 1e-9);
let work = counted(|| {
let old = joints::resolve_origin(&doc, &retained, &origin).unwrap();
assert!((old[3][2] - 10.0).abs() < 1e-9);
doc.body_offsets.insert("Pin".into(), rigid(Vec3::new(0.0, 0.0, 1.0), Vec3::ZERO, Vec3::Z, 0.0));
let moved = joints::resolve_origin(&doc, &retained, &origin).unwrap();
assert!((moved[3][2] - 11.0).abs() < 1e-9);
});
assert_eq!(work.joint_faces, 0, "offsets are applied after the shared face measurements");
}
/// `parts` imported files of two boxes each, every one in its own component placed away from
/// the origin (as inserted parts are).
fn placed_imports(parts: usize) -> Document {
@@ -0,0 +1,4 @@
//! Associative motion traces retain their occurrence path, point reference, joint bounds or
//! study identity, sample count and assembly branch seeds, alongside the generated sketch.
//! Older designs default to no associations: their saved curves remain fixed. Readers from
//! the last release refuse the newer format number instead of silently dropping associations.
+22 -2
View File
@@ -572,6 +572,8 @@ pub struct Assembly {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub studies: Vec<crate::motion::MotionStudy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub traces: Vec<crate::motion::MotionTrace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exploded: Vec<crate::motion::ExplodedView>,
}
@@ -597,6 +599,15 @@ impl Assembly {
}
}
}
for trace in &self.traces {
if let crate::motion::TraceMotion::Joint { joint, from, to, .. } = &trace.motion {
for (label, value) in [("from", from), ("to", to)] {
if let serde_json::Value::String(e) = value {
out.push((*joint, format!("trace {} {label}", trace.sketch), e.as_str()));
}
}
}
}
out
}
@@ -616,6 +627,15 @@ impl Assembly {
}
}
}
for trace in &mut self.traces {
if let crate::motion::TraceMotion::Joint { from, to, .. } = &mut trace.motion {
for value in [from, to] {
if let serde_json::Value::String(e) = value {
out.push(e);
}
}
}
}
out
}
@@ -627,6 +647,7 @@ impl Assembly {
&& !self.contact_enabled
&& !self.contact_all
&& self.studies.is_empty()
&& self.traces.is_empty()
&& self.exploded.is_empty()
}
}
@@ -731,8 +752,7 @@ fn nearest_plane(doc: &Document, st: &ModelState, o: &JointOrigin, pick: Vec3) -
let mut best: Option<(f64, Vec3, Vec3)> = None;
for b in component_bodies(doc, st, comp) {
let offset = doc.model_body_offset(b);
let tol = (b.body.size() * 2e-3).max(1e-3);
for f in b.body.faces(tol)? {
for f in b.joint_faces()?.iter() {
let Some(n) = f.plane_normal else { continue };
let (c, n) = match offset {
Some(m) => (apply_point(m, f.centroid), apply_vector(m, n)),
+1 -2
View File
@@ -64,8 +64,7 @@ impl NamedFace<'_> {
/// The face's centre and outward normal (the component's coordinates), when it is planar.
pub fn plane(&self) -> Result<Option<(Vec3, Vec3)>> {
let tol = (self.body.body.size() * 2e-3).max(1e-3);
let faces = self.body.body.faces(tol)?;
let faces = self.body.joint_faces()?;
Ok(faces.get(self.face).and_then(|f| f.plane_normal.map(|n| (apply_point(&self.offset, f.centroid), apply_vector(&self.offset, n)))))
}
}
+23
View File
@@ -5,6 +5,29 @@
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
/// The motion retained by an associative trace. Joint bounds keep their expressions, in
/// command units (degrees or millimetres); studies are referenced by their stable identity.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TraceMotion {
Joint { joint: u64, index: usize, from: serde_json::Value, to: serde_json::Value },
Study { study: u64 },
}
/// A generated root sketch curve and the inputs needed to regenerate it. The seed values
/// select the original assembly branch: playing or dragging the mechanism does not redefine
/// its trace. Older saved curves have no entry here and remain ordinary fixed sketch curves.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MotionTrace {
pub sketch: u64,
pub wire: String,
pub occurrence: Vec<u64>,
pub point: crate::joints::JointOrigin,
pub motion: TraceMotion,
pub samples: Option<usize>,
pub seeds: Vec<(u64, Vec<f64>)>,
}
/// Occurrences whose bodies may touch but not pass through each other while joints move.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ContactSet {
+2 -7
View File
@@ -266,10 +266,7 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
// Every body's names are read below: work out the ones not known yet on all cores first
// (each body's names are its own, whichever thread works them out).
if legacy_name(name).is_none() {
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
solidcraft_kernel::for_each_on_threads(&bodies, 16, |b| {
let _ = naming::names(b);
});
naming::warm_names(st.bodies.iter().filter(|b| !b.body.is_mesh()));
}
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
}
@@ -445,9 +442,7 @@ pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<Fac
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
// As [`locate_face`]: the names of several bodies are worked out on all cores first.
if bodies.len() > 1 && legacy_name(name).is_none() {
solidcraft_kernel::for_each_on_threads(&bodies, 16, |(_, b)| {
let _ = naming::names(b);
});
naming::warm_names(bodies.iter().map(|(_, b)| *b));
}
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
if hit.split {
+96
View File
@@ -229,6 +229,23 @@ pub fn face_name_exact(b: &ModelBody, index: usize) -> bool {
names(b).verified(index)
}
/// Prepare only names not yet known before a lookup reads several bodies. A ready cell is
/// shared by snapshots and poses: dispatching threads just to read it costs more than a lookup.
pub(crate) fn warm_names<'a>(bodies: impl Iterator<Item = &'a ModelBody>) {
let bodies: Vec<_> = bodies.filter(|b| b.names.names.get().is_none()).collect();
#[cfg(test)]
tests::record_warm_jobs(bodies.len());
match bodies.as_slice() {
[] => {}
[body] => {
let _ = names(body);
}
_ => solidcraft_kernel::for_each_on_threads(&bodies, 16, |b| {
let _ = names(b);
}),
}
}
/// A body's names, with what finding them again needs: from the history of the feature that
/// made it where it reports one, else from geometry (unverified).
pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
@@ -872,6 +889,85 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
#[cfg(test)]
mod tests {
thread_local! {
static WARM_JOBS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub(super) fn record_warm_jobs(n: usize) {
WARM_JOBS.with(|jobs| jobs.set(jobs.get() + n));
}
fn take_warm_jobs() -> usize {
WARM_JOBS.with(|jobs| jobs.replace(0))
}
fn lookup_bodies() -> ModelState {
let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 2., 1.)).unwrap();
let mut st = ModelState::default();
st.bodies = (0..13).map(|i| ModelBody::new(format!("Part{i}"), body.clone(), i + 1)).collect();
st
}
fn same_hit(a: &FaceHit, b: &FaceHit) {
assert_eq!((a.body, a.face, a.point), (b.body, b.face, b.point));
assert_eq!((a.split, a.merged, a.verified, a.on_point, a.legacy), (b.split, b.merged, b.verified, b.on_point, b.legacy));
}
#[test]
fn cached_face_lookups_schedule_no_name_warmup() {
let st = lookup_bodies();
for b in &st.bodies {
let _ = face_names(b);
}
let name = face_names(&st.bodies[0])[0].clone();
let picked = locate_face(&st, &name, Vec3::ZERO).unwrap();
let unique = locate_one(&st, None, &name).unwrap();
take_warm_jobs();
// The two public lookup paths used by picked origins and named-face commands.
for _ in 0..145 {
same_hit(&locate_face(&st, &name, Vec3::ZERO).unwrap(), &picked);
same_hit(&locate_one(&st, None, &name).unwrap(), &unique);
}
assert!(locate_face(&st, "absent:face:0", Vec3::ZERO).is_none());
assert!(locate_one(&st, None, "absent:face:0").is_err());
assert_eq!(take_warm_jobs(), 0, "cached names must not dispatch preparation work");
}
#[test]
fn face_lookup_warms_only_cold_and_invalidated_names() {
// Exercise both entry points as the first reader of a partly named model.
for picked in [false, true] {
let mut st = lookup_bodies();
let name = face_names(&st.bodies[0])[0].clone();
let lookup = |st: &ModelState, name: &str| {
if picked { locate_face(st, name, Vec3::ZERO).unwrap() } else { locate_one(st, None, name).unwrap() }
};
take_warm_jobs();
let hit = lookup(&st, &name);
assert_eq!(take_warm_jobs(), 12, "only the other bodies need names");
let snapshot = st.clone();
same_hit(&lookup(&snapshot, &name), &hit);
assert_eq!(take_warm_jobs(), 0, "snapshots share ready names");
st.bodies[0].name = "Renamed".into();
st.bodies[0].names = st.bodies[0].names.renamed();
let renamed = lookup(&st, "Renamed:face:0");
same_hit(&renamed, &hit);
assert_eq!(take_warm_jobs(), 1, "a rename invalidates only its own names");
assert!(locate_face(&st, &name, Vec3::ZERO).is_none());
same_hit(&lookup(&snapshot, &name), &hit);
assert_eq!(take_warm_jobs(), 0, "the retained snapshot stays ready");
let source = st.bodies[1].clone();
st.bodies[1].names = NameCell::same(&source);
same_hit(&lookup(&st, "Renamed:face:0"), &renamed);
assert_eq!(take_warm_jobs(), 1, "a lazy moved copy is prepared once");
assert!(Arc::ptr_eq(&names(&st.bodies[1]), &names(&source)));
same_hit(&lookup(&st, "Renamed:face:0"), &renamed);
assert_eq!(take_warm_jobs(), 0);
}
}
/// Pieces lying almost level along the sort axis must keep the order they arrived in, whatever
/// the low bits of their centres do. The centre is the mean of a tessellated boundary, so those
/// bits depend on where the loop's polyline starts and can differ between processes — which is
+1 -1
View File
@@ -313,7 +313,7 @@ pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
}
/// A joint origin from parameters: a named origin, or a snap given in world coordinates.
fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin> {
pub(crate) fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin> {
let v = v.ok_or_else(|| bad(cmd, "joint origins `a` and `b` are required"))?;
if let Value::String(name) = v {
return s
+188
View File
@@ -1377,6 +1377,194 @@ fn four_bar_coupler_point_traces_its_closed_form_curve() {
assert!(from_study.iter().zip(&pts).all(|(a, b)| near(*a, *b)), "{r}");
}
fn kept_trace_fit(s: &Session, id: u64) -> Vec<Vec3> {
s.doc.sketch(id).unwrap().wires.first().unwrap().fit.clone()
}
#[test]
fn chebyshev_demo_regenerates_its_analytic_trajectory_without_resampling_playback() {
let mut s = Session::default();
s.run_script(&serde_json::from_str(include_str!("../../../../examples/chebyshev-motion-trace.json")).unwrap()).unwrap();
let trace = s.doc.assembly.traces.first().unwrap().clone();
let original = kept_trace_fit(&s, trace.sketch);
assert_eq!(original.len(), 145);
assert_eq!(s.doc.assembly.joints.len(), 4);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
// Independent circle-intersection construction: P is the crank end, Q the
// coupler/rocker pin, and the tracer extends the coupler by another PQ.
let mut q = Vec3::new(40.0, 50.0, 0.0);
for (i, got) in original.iter().enumerate() {
let theta = (90.0 + 2.5 * i as f64).to_radians();
let p = Vec3::new(20.0 * theta.cos(), 20.0 * theta.sin(), 0.0);
let [a, b] = meet(p, 50.0, Vec3::new(40.0, 0.0, 0.0), 50.0);
q = if a.dist(q) < b.dist(q) { a } else { b };
let want = q * 2.0 - p + Vec3::Z * 10.0;
assert!(near(*got, want), "sample {i}: {got:?} wants {want:?}");
}
let count = s.motion_traces.recomputations;
for angle in [30, 90, 180, 270, 0] {
let result = run(&mut s, "joint.drive", json!({"joint":"Crank drive","value":angle,"quiet":true}));
assert!(result["stopped_by_loop"].is_null(), "{result}");
}
assert_eq!(s.motion_traces.recomputations, count);
assert_eq!(kept_trace_fit(&s, trace.sketch), original);
run(&mut s, "parameters.change", json!({"name":"coupler_length","expression":"55 mm"}));
let updated = kept_trace_fit(&s, trace.sketch);
let [q, _] = meet(Vec3::new(0.0, 20.0, 0.0), 55.0, Vec3::new(40.0, 0.0, 0.0), 50.0);
assert!(near(updated[0], q * 2.0 - Vec3::new(0.0, 20.0, 0.0) + Vec3::Z * 10.0));
assert_ne!(updated, original);
assert_eq!(s.doc.assembly.traces[0].wire, trace.wire);
assert_eq!(s.motion_traces.recomputations, count + 1, "a dimension edit resamples once");
assert!(s.model.result(trace.sketch).unwrap().warning.is_none());
run(&mut s, "parameters.change", json!({"changes":{"coupler_length":"2.5 * unit_length","unit_length":"24 mm"}}));
let scaled = kept_trace_fit(&s, trace.sketch);
assert!(scaled.iter().zip(&original).all(|(a, b)| near(*a, Vec3::new(b.x * 1.2, b.y * 1.2, 10.0))));
run(&mut s, "parameters.change", json!({"name":"trace_sweep","expression":"180 deg"}));
let half = kept_trace_fit(&s, trace.sketch);
assert!(near(half[0], scaled[0]) && near(*half.last().unwrap(), scaled[72]));
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
#[test]
fn associative_trace_reuses_pose_and_unrelated_edits_but_follows_drive_expressions() {
let (mut s, p0, q0) = four_bar();
let mid = (p0 + q0) * 0.5;
run(&mut s, "parameters.add", json!({"name":"swing","expression":"90 deg"}));
let r = run(
&mut s,
"motion.trace.keep",
json!({"occurrence":"Coupler","point":[mid.x,mid.y,mid.z],
"joint":"Crank","from":0,"to":"swing","samples":19}),
);
let id = r["sketch"].as_u64().unwrap();
let original = kept_trace_fit(&s, id);
let count = s.motion_traces.recomputations;
for angle in [20, 45, 75, 10] {
run(&mut s, "joint.drive", json!({"joint":"Crank","value":angle,"quiet":true}));
assert_eq!(kept_trace_fit(&s, id), original);
}
run(&mut s, "parameters.add", json!({"name":"unrelated","expression":"123 mm"}));
run(&mut s, "parameters.change", json!({"name":"unrelated","expression":"456 mm"}));
assert_eq!(s.motion_traces.recomputations, count, "pose and unrelated edits must not resample");
let mut preview = s.scratch();
run(&mut preview, "joint.drive", json!({"joint":"Crank","value":80,"quiet":true}));
assert_eq!(preview.motion_traces.recomputations, count, "scratch previews reuse the same valid inputs");
run(&mut s, "parameters.change", json!({"name":"swing","expression":"180 deg"}));
let updated = kept_trace_fit(&s, id);
assert_ne!(updated, original);
assert_eq!(s.motion_traces.recomputations, count + 1, "a parameter command may refresh twice; sample once");
run(&mut s, "edit.undo", json!({}));
assert_eq!(kept_trace_fit(&s, id), original);
run(&mut s, "edit.redo", json!({}));
assert_eq!(kept_trace_fit(&s, id), updated);
assert_eq!(s.motion_traces.recomputations, count + 1, "undo and redo reuse the retained input results");
run(&mut s, "parameters.rename", json!({"name":"swing","new_name":"travel"}));
assert_eq!(s.motion_traces.recomputations, count + 1, "renaming a parameter leaves resolved inputs unchanged");
let count = s.motion_traces.recomputations;
let before = s.doc.clone();
for invalid in [
json!({"sketch":id,"samples":501}),
json!({"sketch":id,"index":99}),
json!({"sketch":id,"from":"missing_parameter"}),
json!({"sketch":id,"joint":"Missing","from":0,"to":90}),
] {
assert!(s.execute("motion.trace.edit", &invalid).is_err());
assert_eq!(*s.doc, *before, "invalid trace edits must be atomic");
}
run(&mut s, "motion.trace.edit", json!({"sketch":id,"samples":19}));
assert_eq!(s.motion_traces.recomputations, count, "an identical definition is reusable");
assert!(std::sync::Arc::ptr_eq(&before, &s.doc), "an identical edit must not refresh the model or add an undo step");
run(&mut s, "motion.trace.edit", json!({"sketch":id,"auto_update":false}));
run(&mut s, "parameters.change", json!({"name":"travel","expression":"270 deg"}));
assert_eq!(kept_trace_fit(&s, id), updated, "detaching preserves the curve");
}
#[test]
fn associative_trace_follows_a_study_and_survives_serialization() {
let (mut s, p0, q0) = four_bar();
let mid = (p0 + q0) * 0.5;
let study = run(
&mut s,
"motion.study",
json!({"name":"Turn","steps":18,
"keys":[{"joint":"Crank","points":[[0,0],[18,90]]}]}),
);
let r = run(&mut s, "motion.trace.keep", json!({"occurrence":"Coupler","point":[mid.x,mid.y,mid.z],"study":"Turn"}));
let id = r["sketch"].as_u64().unwrap();
let original = kept_trace_fit(&s, id);
run(
&mut s,
"motion.study",
json!({"study":study["study"],"name":"Turn","steps":36,
"keys":[{"joint":"Crank","points":[[0,0],[36,180]]}]}),
);
assert_eq!(kept_trace_fit(&s, id).len(), 37);
assert_ne!(kept_trace_fit(&s, id), original);
let doc: solidcraft_doc::Document = serde_json::from_value(serde_json::to_value(&*s.doc).unwrap()).unwrap();
let mut opened = Session::new(doc);
run(&mut opened, "motion.trace.edit", json!({"sketch":id,"samples":25}));
assert_eq!(kept_trace_fit(&opened, id).len(), 25);
run(&mut opened, "motion.delete", json!({"study":"Turn"}));
assert!(run(&mut opened, "motion.list", json!({}))["traces"][0]["error"].is_string());
assert!(opened.model.result(id).unwrap().warning.as_ref().unwrap().contains("last successful"));
let count = opened.motion_traces.recomputations;
opened.refresh();
assert_eq!(opened.motion_traces.recomputations, count, "a missing dependency must not retry on every refresh");
run(&mut opened, "edit.undo", json!({}));
assert!(run(&mut opened, "motion.list", json!({}))["traces"][0]["error"].is_null());
assert!(opened.model.result(id).unwrap().warning.is_none());
}
#[test]
fn associative_trace_follows_a_resized_picked_face_and_keeps_downstream_paths_current() {
let mut s = two_cubes();
pin(&mut s, "revolute", "Pivot", "A", "B", Vec3::new(20.0, 0.0, 0.0), Vec3::Z);
let r = run(
&mut s,
"motion.trace.keep",
json!({"occurrence":"B","point":[25,5,10],
"point_ref":{"occurrence":"B","face":[25,5,10]},"joint":"Pivot","from":0,"to":90,"samples":2}),
);
let id = r["sketch"].as_u64().unwrap();
let original = kept_trace_fit(&s, id);
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "solid.pipe", json!({"path_sketch":id,"path":r["curves"],"diameter":1,"operation":"new","body_name":"TracePipe"}));
let pipe_before = s.model.state().body("TracePipe").unwrap().outline_bounds();
let comp = s.doc.find_component("B").unwrap();
let height =
s.doc.features.iter().find(|f| f.component == comp && matches!(f.kind, solidcraft_doc::FeatureKind::Box { .. })).unwrap().param_names[2]
.clone();
let count = s.motion_traces.recomputations;
run(&mut s, "parameters.change", json!({"name":height,"expression":"15 mm"}));
let updated = kept_trace_fit(&s, id);
assert!(updated.iter().all(|p| (p.z - 15.0).abs() < 1e-6));
assert_ne!(original, updated);
assert_eq!(s.motion_traces.recomputations, count + 1);
let model = s.model.state();
assert_eq!(model.sketch(id).unwrap().sketch.wires.first().unwrap().fit, updated);
let pipe_after = model.body("TracePipe").unwrap().outline_bounds();
assert!((pipe_after.min.z - pipe_before.min.z - 5.0).abs() < 1e-6);
}
#[test]
fn associative_trace_ignores_another_mechanism_on_the_same_ground() {
let (mut s, p0, q0) = four_bar();
part(&mut s, "Other", [100.0, 0.0, -5.0]);
pin(&mut s, "revolute", "Other pivot", "Ground", "Other", Vec3::new(100.0, 0.0, 0.0), Vec3::Z);
let mid = (p0 + q0) * 0.5;
run(
&mut s,
"motion.trace.keep",
json!({"occurrence":"Coupler","point":[mid.x,mid.y,mid.z],
"joint":"Crank","from":0,"to":180,"samples":19}),
);
let count = s.motion_traces.recomputations;
run(&mut s, "joint.limits", json!({"joint":"Other pivot","min":-20,"max":30}));
assert_eq!(s.motion_traces.recomputations, count);
}
/// The middle of a slider-crank's rod: halfway between the crank pin on its circle and the wrist
/// pin at a·cos θ + √(b² − (a·sin θ)²).
#[test]
+43 -8
View File
@@ -18,6 +18,8 @@ use crate::{EngineError, Result, Session};
mod analysis;
#[path = "motion_animation.rs"]
mod animation;
#[path = "motion_trace.rs"]
pub(crate) mod associative;
#[path = "contact_sweep.rs"]
pub(crate) mod contact_sweep;
#[path = "motion_sweep.rs"]
@@ -76,9 +78,14 @@ pub static COMMANDS: &[CommandSpec] = &[
)),
CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).icon("trace").params(concat!(
trace_doc!(),
"; name? (default Motion TraceN) - keeps the trace as a 3D sketch spline through its points (a new sketch with 3D Sketch \
on, in the root component, where no joint moves it; a fixed curve, it does not follow later changes) → sketch, name, curves, points"
"; name? (default Motion TraceN); auto_update?: bool (default true); point_ref?: joint origin snap - keeps the trace as a \
root 3D sketch spline. Associative curves follow mechanism and driving-input edits; false keeps a fixed curve. \
point_ref retains a geometry snap; otherwise point is retained in component coordinates → sketch, name, curves, points"
)),
CommandSpec::new("motion.trace.edit", "Edit Motion Trace", associative::edit).icon("trace").params(
"sketch: id|name; joint? with from and to, or study?; index?; samples? (2…500); auto_update?: false to detach. \
Edits a saved associative trace's driving conditions in one undo step",
),
CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).icon("graph").noundo().params(analysis::PLOT_DOC),
CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).icon("interference").noundo().params(analysis::INTERFERENCE_DOC),
CommandSpec::new("joint.sweep", "Sweep Joint", sweep::sweep).icon("interference").params(sweep::SWEEP_DOC),
@@ -881,7 +888,16 @@ fn trace(s: &mut Session, p: &Value) -> Result<Value> {
/// world, which no joint moves) with 3D Sketch on and the spline through the trace's points.
fn keep_trace(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.trace.keep";
let (mut points, mut out) = sample_trace(s, p, cmd)?;
let source = if bool_(p, "auto_update") != Some(false) { Some(associative::source(s, p, cmd)?) } else { None };
let (mut points, out) = sample_trace(s, p, cmd)?;
finish_trace(&mut points);
if points.len() < 2 {
return Err(bad(cmd, "the point does not move: there is no trace to keep"));
}
keep_sampled_trace(s, p, cmd, points, out, source)
}
fn finish_trace(points: &mut Vec<Vec3>) {
// Where the motion stood still (at a limit or a stop), one fit point.
points.dedup_by(|a, b| a.dist(*b) < 1e-6);
// A full cycle comes back to where it started (to the loop solver's tolerance): the spline
@@ -896,9 +912,16 @@ fn keep_trace(s: &mut Session, p: &Value) -> Result<Value> {
}
points.push(first);
}
if points.len() < 2 {
return Err(bad(cmd, "the point does not move: there is no trace to keep"));
}
}
fn keep_sampled_trace(
s: &mut Session,
p: &Value,
cmd: &str,
points: Vec<Vec3>,
mut out: Value,
source: Option<solidcraft_doc::motion::MotionTrace>,
) -> Result<Value> {
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
None => (1..=s.doc.features.len() + 1).map(|k| format!("Motion Trace{k}")).find(|n| s.doc.find_feature(n).is_none()).unwrap_or_default(),
@@ -919,15 +942,27 @@ fn keep_trace(s: &mut Session, p: &Value) -> Result<Value> {
s.revision += 1;
let (made, spline) = made?;
let sketch = made.get("sketch").cloned().unwrap_or(Value::Null);
out["sketch"] = sketch;
out["sketch"] = sketch.clone();
out["name"] = made.get("name").cloned().unwrap_or(Value::Null);
out["curves"] = spline.get("curves").cloned().unwrap_or(Value::Null);
out["points"] = json!(points.len());
if let Some(mut source) = source {
source.sketch = sketch.as_u64().ok_or_else(|| bad(cmd, "trace sketch is missing"))?;
source.wire = spline
.get("curves")
.and_then(Value::as_array)
.and_then(|v| v.first())
.and_then(Value::as_str)
.ok_or_else(|| bad(cmd, "trace curve is missing"))?
.to_string();
associative::remember(s, &source);
s.doc_mut().assembly.traces.push(source);
}
Ok(out)
}
fn list_studies(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"studies": s.doc.assembly.studies}))
Ok(json!({"studies": s.doc.assembly.studies, "traces": associative::list(s)}))
}
fn delete_study(s: &mut Session, p: &Value) -> Result<Value> {
+428
View File
@@ -0,0 +1,428 @@
//! Associative trace definitions and dependency checks. Sampling uses the existing motion
//! driver on a scratch session; only a changed generated wire is handed to the evaluator.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_doc::joints::{self, JointOrigin, Snap};
use solidcraft_doc::motion::{MotionTrace, TraceMotion};
use solidcraft_doc::{IDENTITY, apply_point};
use solidcraft_geom::Vec3;
use crate::params::{bad, bool_};
use crate::{Result, Session};
const CMD: &str = "motion.trace.edit";
#[derive(Clone, Debug, Default)]
pub(crate) struct Cache {
entries: Arc<BTreeMap<u64, CacheEntry>>,
history: Arc<BTreeMap<u64, Vec<(Value, Vec<Vec3>)>>>,
#[cfg(test)]
pub(crate) recomputations: usize,
}
#[derive(Clone, Debug)]
struct CacheEntry {
inputs: std::result::Result<Value, String>,
error: Option<String>,
}
fn motion(s: &Session, p: &Value, cmd: &str) -> Result<TraceMotion> {
let _ = super::motion_plan(s, p, cmd)?;
if p.get("study").is_some() {
let study = s.doc.assembly.studies.get(super::study_index(s, p, cmd)?).ok_or_else(|| bad(cmd, "study is missing"))?;
Ok(TraceMotion::Study { study: study.id })
} else {
Ok(TraceMotion::Joint {
joint: super::joint_id(s, p.get("joint"), cmd)?,
index: p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize,
from: p.get("from").cloned().unwrap_or(Value::Null),
to: p.get("to").cloned().unwrap_or(Value::Null),
})
}
}
pub(super) fn source(s: &Session, p: &Value, cmd: &str) -> Result<MotionTrace> {
if s.doc.assembly.traces.len() >= super::MAX_ITEMS {
return Err(bad(cmd, "too many motion traces"));
}
let (occurrence, local) = super::traced_point(s, p, cmd)?;
let point = match p.get("point_ref") {
Some(v) => {
let mut reference = v.clone();
// The origin parser uses an occurrence's first placement. A trace can name a
// different nested instance; convert its world pick to that canonical frame.
let id = occurrence.last().copied().unwrap_or(0);
let placed = placed_world(s, &occurrence)?;
let inverse = solidcraft_doc::mat_inverse(&placed).ok_or_else(|| bad(cmd, "invalid occurrence placement"))?;
let canonical = joints::occurrence_world(&s.doc, id);
for key in ["face", "circle", "edge", "point"] {
if let Some(at) = reference.get(key).and_then(crate::params::vec3) {
let at = apply_point(&canonical, apply_point(&inverse, at));
reference[key] = json!([at.x, at.y, at.z]);
}
}
let o = super::super::joints::origin_param(s, Some(&reference), cmd)?;
if o.occurrence != occurrence.last().copied().unwrap_or(0) {
return Err(bad(cmd, "point_ref must belong to the traced occurrence"));
}
o
}
None => JointOrigin { occurrence: occurrence.last().copied().unwrap_or(0), snap: Snap::Point { point: local, z: None, x: None } },
};
let referenced = apply_point(&joints::resolve_origin(&s.doc, &s.model.state(), &point)?, Vec3::ZERO);
if referenced.dist(local) > 1e-4 {
return Err(bad(cmd, "point_ref must resolve to the traced point"));
}
let motion = motion(s, p, cmd)?;
let samples = p.get("samples").map(|_| super::sample_count(p, 100, super::MAX_TRACE, cmd)).transpose()?;
Ok(MotionTrace {
sketch: 0,
wire: String::new(),
occurrence,
point,
motion,
samples,
seeds: s.doc.assembly.joints.iter().map(|j| (j.id, j.values.clone())).collect(),
})
}
fn params(t: &MotionTrace, point: Vec3) -> Value {
let mut p = match &t.motion {
TraceMotion::Joint { joint, index, from, to } => json!({"joint":joint,"index":index,"from":from,"to":to}),
TraceMotion::Study { study } => json!({"study":study}),
};
p["occurrence"] = json!(t.occurrence);
p["point"] = json!([point.x, point.y, point.z]);
if let Some(n) = t.samples {
p["samples"] = json!(n);
}
p
}
fn local(s: &Session, t: &MotionTrace) -> Result<Vec3> {
if t.occurrence.len() > 64
|| t.seeds.len() > joints::MAX_JOINTS
|| t.seeds.iter().any(|(_, q)| q.len() > 6 || q.iter().any(|v| !v.is_finite()))
|| t.point.occurrence != t.occurrence.last().copied().unwrap_or(0)
{
return Err(bad(CMD, "invalid saved trace inputs"));
}
let m = joints::resolve_origin(&s.doc, &s.model.state(), &t.point)?;
let p = apply_point(&m, Vec3::ZERO);
if !p.len().is_finite() || p.len() >= 1e7 {
return Err(bad(CMD, "invalid trace point"));
}
Ok(p)
}
fn placed_world(s: &Session, path: &[u64]) -> Result<solidcraft_doc::Mat> {
s.doc.path_placement(path).map(|(_, world)| world).ok_or_else(|| bad(CMD, "the trace occurrence path is missing"))
}
/// Only the moving graph connected to the trace/driver is followed. Grounded anchors stop
/// traversal, so another mechanism attached to the same ground does not invalidate this one.
fn graph(s: &Session, t: &MotionTrace) -> (BTreeSet<u64>, BTreeSet<u64>) {
let a = &s.doc.assembly;
// With contact enabled another mechanism can be an obstacle. Its joint frames and
// stationary placements are then dependencies too, even without a joint to this trace.
if a.contact_enabled {
return (s.doc.occurrences.iter().map(|o| o.id).collect(), a.joints.iter().map(|j| j.id).collect());
}
let anchor = |id: u64| id == 0 || s.doc.occurrences.iter().any(|o| o.id == id && o.grounded);
let mut occurrences: BTreeSet<u64> = t.occurrence.iter().copied().collect();
let mut ids: BTreeSet<u64> = match &t.motion {
TraceMotion::Joint { joint, .. } => BTreeSet::from([*joint]),
TraceMotion::Study { study } => a.studies.iter().find(|m| m.id == *study).into_iter().flat_map(|m| m.keys.iter().map(|k| k.joint)).collect(),
};
loop {
let before = (occurrences.len(), ids.len());
for j in a.joints.iter().take(joints::MAX_JOINTS) {
if ids.contains(&j.id) || (!j.suppressed && [j.a.occurrence, j.b.occurrence].iter().any(|o| !anchor(*o) && occurrences.contains(o))) {
ids.insert(j.id);
occurrences.extend([j.a.occurrence, j.b.occurrence]);
}
}
for l in a.links.iter().take(joints::MAX_JOINTS) {
if ids.contains(&l.a) || ids.contains(&l.b) {
ids.extend([l.a, l.b]);
}
}
let parents: Vec<_> = s.doc.occurrences.iter().filter(|o| occurrences.contains(&o.id)).flat_map(|o| s.doc.first_path(o.parent)).collect();
occurrences.extend(parents);
if before == (occurrences.len(), ids.len()) {
break;
}
}
(occurrences, ids)
}
fn inputs(s: &Session, t: &MotionTrace) -> Result<Value> {
let at = local(s, t)?;
// The plan reads only driving inputs, not the picked point or displayed placement.
let p = params(t, at);
placed_world(s, &t.occurrence)?;
let (default, given, plan) = super::motion_plan(s, &p, CMD)?;
let n = super::sample_count(&p, default.min(super::MAX_TRACE), super::MAX_TRACE, CMD)?;
let (occurrences, ids) = graph(s, t);
let st = s.model.state();
let seeds: BTreeMap<u64, &Vec<f64>> = t.seeds.iter().map(|(id, q)| (*id, q)).collect();
let mut joints = Vec::new();
for j in s.doc.assembly.joints.iter().filter(|j| ids.contains(&j.id)) {
let mut definition = j.clone();
definition.name.clear();
definition.values = seeds.get(&j.id).map(|q| (*q).clone()).unwrap_or_default();
let (offset, angle, q) = joints::expressed(&s.doc, &definition)?;
definition.offset = offset;
definition.angle = angle;
definition.values = q;
definition.exprs = Default::default();
let fa = joints::resolve_origin(&s.doc, &st, &j.a)?;
let fb = joints::resolve_origin(&s.doc, &st, &j.b)?;
joints.push(json!([definition, fa, fb]));
}
// Poses of driven parts are outputs. Only anchors and placements no joint places are
// inputs. Include ancestors of nested paths to notice an enclosing assembly's move.
let placed: BTreeSet<u64> = s.doc.assembly.joints.iter().filter(|j| !j.suppressed).flat_map(|j| [j.a.occurrence, j.b.occurrence]).collect();
let poses: Vec<Value> = s
.doc
.occurrences
.iter()
.filter(|o| occurrences.contains(&o.id))
.map(|o| json!([o.id, o.parent, o.component, o.grounded, if o.grounded || !placed.contains(&o.id) { o.transform } else { IDENTITY }]))
.collect();
let links: Vec<_> = s
.doc
.assembly
.links
.iter()
.filter(|l| ids.contains(&l.a) || ids.contains(&l.b))
.map(|l| {
let mut l = l.clone();
l.coupling = None;
l
})
.collect();
let study = match &t.motion {
TraceMotion::Study { study } => s.doc.assembly.studies.iter().find(|m| m.id == *study).map(|m| json!([m.steps, m.keys])),
_ => None,
};
let contact = if s.doc.assembly.contact_enabled {
// Contact reads geometry, not just the joint frames. Content keys avoid meshing or
// hashing triangles here; unkeyed bodies conservatively invalidate on a new revision.
let bodies: Vec<_> = st.bodies.iter().map(|b| json!([b.name, b.component, b.geometry_key().unwrap_or(s.revision)])).collect();
json!([s.doc.assembly.contact_all, s.doc.assembly.contact_sets, bodies])
} else {
Value::Null
};
// A plan fills unkeyed coordinates from the current pose. They belong to the retained
// branch seed instead; normalizing the small value vectors avoids copying the document
// merely to check whether a cached trace is still valid.
let mut ends = [plan(s, 0, n), plan(s, n - 1, n)];
for end in &mut ends {
for (id, values) in end {
if let Some(seed) = seeds.get(id) {
for (i, v) in values.iter_mut().enumerate() {
if !given.contains(&(*id, i)) {
*v = seed.get(i).copied().unwrap_or(0.0);
}
}
}
}
}
Ok(json!([t.sketch, t.wire, t.occurrence, at, n, given, ends, joints, poses, links, study, contact]))
}
fn seeded(s: &Session, t: &MotionTrace) -> Session {
let mut scratch = s.scratch();
let seeds: BTreeMap<_, _> = t.seeds.iter().map(|(id, q)| (*id, q)).collect();
for j in &mut scratch.doc_mut().assembly.joints {
if let Some(q) = seeds.get(&j.id) {
j.values = (*q).clone();
}
}
scratch
}
pub(super) fn remember(s: &mut Session, t: &MotionTrace) {
let key = inputs(s, t).map_err(|e| e.to_string());
if let Ok(key) = &key
&& let Some(points) = s.doc.sketch(t.sketch).ok().and_then(|sk| sk.wires.iter().find(|w| w.id == t.wire)).map(|w| w.fit.clone())
{
remember_points(s, t.sketch, key.clone(), points);
}
Arc::make_mut(&mut s.motion_traces.entries).insert(t.sketch, CacheEntry { inputs: key, error: None });
}
fn remember_points(s: &mut Session, id: u64, key: Value, points: Vec<Vec3>) {
let history = Arc::make_mut(&mut s.motion_traces.history).entry(id).or_default();
if history.iter().all(|(old, _)| *old != key) {
if history.len() >= 8 {
history.remove(0);
}
history.push((key, points));
}
}
fn same_points(a: &[Vec3], b: &[Vec3]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.dist(*b) < 1e-9)
}
fn sample(s: &Session, t: &MotionTrace) -> Result<Vec<Vec3>> {
let at = local(s, t)?;
let mut scratch = seeded(s, t);
scratch.pending_moves.clear();
let world = placed_world(&scratch, &t.occurrence)?;
let p = params(t, apply_point(&world, at));
let (mut points, _) = super::sample_trace(&scratch, &p, CMD)?;
super::finish_trace(&mut points);
if points.len() < 2 {
return Err(bad(CMD, "the traced point no longer moves"));
}
Ok(points)
}
/// Refresh generated curves once after the input model and joint origins are current. A
/// failed dependency retains the last curve and publishes a warning instead of a stale success.
pub(crate) fn refresh(s: &mut Session) {
// Designs without associations pay no dependency-walk or geometry-check cost.
if s.doc.assembly.traces.is_empty() {
s.motion_traces.entries = Default::default();
s.motion_traces.history = Default::default();
return;
}
let existing: BTreeSet<u64> = s.doc.features.iter().map(|f| f.id).collect();
if s.doc.assembly.traces.iter().any(|t| !existing.contains(&t.sketch)) {
s.doc_mut().assembly.traces.retain(|t| existing.contains(&t.sketch));
}
if s.doc.assembly.traces.is_empty() {
s.motion_traces.entries = Default::default();
s.motion_traces.history = Default::default();
return;
}
let traces: Vec<_> = s.doc.assembly.traces.iter().take(super::MAX_ITEMS).cloned().collect();
for r in &mut s.model.results {
if traces.iter().any(|t| t.sketch == r.id) && r.warning.as_ref().is_some_and(|w| w.starts_with("Motion Trace: ")) {
r.warning = None;
}
}
if s.motion_traces.entries.keys().any(|id| !traces.iter().any(|t| t.sketch == *id)) {
Arc::make_mut(&mut s.motion_traces.entries).retain(|id, _| traces.iter().any(|t| t.sketch == *id));
Arc::make_mut(&mut s.motion_traces.history).retain(|id, _| traces.iter().any(|t| t.sketch == *id));
}
let mut changed = false;
for t in &traces {
if s.doc.feature(t.sketch).is_none_or(|f| f.suppressed) {
continue;
}
let key = inputs(s, t).map_err(|e| e.to_string());
let cached = key
.as_ref()
.ok()
.and_then(|key| s.motion_traces.history.get(&t.sketch).and_then(|h| h.iter().find(|(old, _)| old == key)).map(|(_, p)| p.clone()));
let unchanged = s.motion_traces.entries.get(&t.sketch).is_some_and(|old| old.inputs == key);
if unchanged
&& cached.as_ref().is_none_or(|points| {
s.doc.sketch(t.sketch).ok().and_then(|sk| sk.wires.iter().find(|w| w.id == t.wire)).is_some_and(|w| same_points(&w.fit, points))
})
{
continue;
}
let result = match &key {
Ok(_) => match cached {
Some(points) => Ok(points),
None => {
#[cfg(test)]
{
s.motion_traces.recomputations += 1;
}
sample(s, t).map_err(|e| e.to_string())
}
},
Err(e) => Err(e.clone()),
};
if let (Ok(key), Ok(points)) = (&key, &result) {
remember_points(s, t.sketch, key.clone(), points.clone());
}
let error = match result {
Ok(points) => match s.doc.sketch(t.sketch).ok().and_then(|sk| sk.wires.iter().find(|w| w.id == t.wire)) {
Some(wire) if same_points(&wire.fit, &points) => None,
Some(_) => {
if let Ok(sk) = s.doc_mut().sketch_mut(t.sketch)
&& let Some(wire) = sk.wires.iter_mut().find(|w| w.id == t.wire)
{
wire.pts = solidcraft_sketch::fit_polyline(&points);
wire.fit = points;
changed = true;
}
None
}
None => Some("the generated trace curve is missing; detach or recreate the trace".to_string()),
},
Err(e) => Some(e),
};
Arc::make_mut(&mut s.motion_traces.entries).insert(t.sketch, CacheEntry { inputs: key, error });
}
if changed {
s.model.evaluate(&s.doc);
s.revision += 1;
}
for t in &traces {
if let Some(error) = s.motion_traces.entries.get(&t.sketch).and_then(|e| e.error.as_ref())
&& let Some(r) = s.model.results.iter_mut().find(|r| r.id == t.sketch)
{
r.warning = Some(format!("Motion Trace: {error}; showing its last successful curve"));
}
}
}
pub(super) fn list(s: &Session) -> Vec<Value> {
s.doc
.assembly
.traces
.iter()
.map(|t| {
json!({"sketch":t.sketch,"motion":t.motion,"samples":t.samples,
"error":s.motion_traces.entries.get(&t.sketch).and_then(|e| e.error.as_ref())})
})
.collect()
}
pub(super) fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let key = p.get("sketch").map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).ok_or_else(|| bad(CMD, "give a sketch"))?;
let id = s.doc.find_feature(&key).map(|f| f.id).ok_or_else(|| bad(CMD, "trace sketch is missing"))?;
let i = s.doc.assembly.traces.iter().position(|t| t.sketch == id).ok_or_else(|| bad(CMD, "the sketch has no associative trace"))?;
if bool_(p, "auto_update") == Some(false) {
s.doc_mut().assembly.traces.remove(i);
Arc::make_mut(&mut s.motion_traces.entries).remove(&id);
Arc::make_mut(&mut s.motion_traces.history).remove(&id);
return Ok(json!({"sketch":id,"auto_update":false}));
}
let mut t = s.doc.assembly.traces.get(i).cloned().ok_or_else(|| bad(CMD, "trace is missing"))?;
if p.get("study").is_some() || p.get("joint").is_some() {
t.motion = motion(s, p, CMD)?;
} else if let TraceMotion::Joint { from, to, index, .. } = &mut t.motion {
if let Some(v) = p.get("from") {
*from = v.clone();
}
if let Some(v) = p.get("to") {
*to = v.clone();
}
if let Some(v) = p.get("index") {
*index = v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, "invalid index"))?;
}
}
if p.get("samples").is_some() {
t.samples = Some(super::sample_count(p, 100, super::MAX_TRACE, CMD)?);
}
inputs(s, &t)?;
// Validate before publishing an edited definition; execute can then undo the whole edit.
if s.doc.assembly.traces.get(i) != Some(&t)
&& let Some(slot) = s.doc_mut().assembly.traces.get_mut(i)
{
*slot = t;
}
Ok(json!({"sketch":id,"auto_update":true}))
}
+5
View File
@@ -309,6 +309,8 @@ pub struct Session {
/// Full model evaluations (`refresh`), for tests.
#[cfg(test)]
pub(crate) evaluations: usize,
/// Resolved associative trace inputs, independent of the document revision and view state.
pub(crate) motion_traces: cmd::motion::associative::Cache,
}
/// A sketch changed by one frame of an interactive gesture (`live: true`).
@@ -445,6 +447,7 @@ impl Session {
render_jobs: Default::default(),
#[cfg(test)]
evaluations: 0,
motion_traces: Default::default(),
crash_journal: false,
}
}
@@ -653,6 +656,7 @@ impl Session {
if !self.doc.assembly.joints.is_empty() {
cmd::joints::resolve(self);
}
cmd::motion::associative::refresh(self);
if let Some(id) = self.active_sketch
&& self.doc.feature(id).is_none()
{
@@ -978,6 +982,7 @@ impl Session {
saved_visibility: self.saved_visibility.clone(),
#[cfg(test)]
evaluations: 0,
motion_traces: self.motion_traces.clone(),
crash_journal: false,
}
}
@@ -674,4 +674,19 @@ fn motion_trace_keeps_the_picked_point_path() {
apply_point(&seen, apply_point(&mat_inverse(&put).unwrap(), at(&json!({"point": start}))))
};
assert!(drawn.dist(shown) < 1e-6, "{drawn:?} wants {shown:?}");
// The dialog retained the picked face, rather than its old coordinates. Resizing that
// part raises its top face and the whole saved trajectory by the same amount.
let component = app.session.doc.find_component("Coupler").unwrap();
let height = app
.session
.doc
.features
.iter()
.find(|f| f.component == component && matches!(f.kind, solidcraft_engine::doc::FeatureKind::Box { .. }))
.unwrap()
.param_names[2]
.clone();
app.run("parameters.change", json!({"name":height,"expression":"4 mm"})).unwrap();
let updated = &app.session.doc.sketch(sketch).unwrap().wires[0].fit;
assert!(updated.len() == wire.fit.len() && updated.iter().zip(&wire.fit).all(|(a, b)| (*a - *b - Vec3::new(0.0, 0.0, 2.0)).len() < 1e-6));
}
+16
View File
@@ -73,6 +73,22 @@ pub(super) fn placement(s: &Session, inputs: &[SelInput]) -> Option<(Vec<u64>, V
/// The trace's parameters (OK's `motion.trace.keep` and the preview's `motion.trace`).
pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result<Value, String> {
let mut p = picked_point(s, inputs)?;
// Retain a geometric snap, rather than just its current world coordinates. Map a pick
// shown in an exploded view back to the actual placement, as picked_point does.
if let Some(pick) = sel_items(inputs, 0).first()
&& let Some(snap) = snap_of(s, pick)
&& let Some((path, _, shown, placed)) = placement(s, inputs)
&& let Some(inverse) = mat_inverse(&shown)
{
let mut reference = snap.param;
reference["occurrence"] = json!(path.last().copied().unwrap_or(0));
for key in ["point", "face", "circle", "edge"] {
if let Some(at) = reference.get(key).and_then(solidcraft_engine::params::vec3) {
reference[key] = pt(apply_point(&placed, apply_point(&inverse, at)));
}
}
p["point_ref"] = reference;
}
if f.by_study {
p["study"] = json!(s.doc.assembly.studies.get(f.study).ok_or("no motion study yet")?.id);
} else {
+22 -4
View File
@@ -212,14 +212,32 @@ first) or a motion study. The path previews in the viewport as the mechanism wou
loops follow, and the path ends where a loop can no longer close or a contact set stops the
motion, which the dialog says under the values. OK keeps the path as a 3D sketch spline in a new
sketch of the root component (**Motion Trace1**, ...), where no joint moves it, and it can serve
as a sweep or pipe path. It is a fixed curve: it
does not follow later changes to the mechanism. A path that comes back to where it started is
kept as a closed, smooth spline.
as a sweep or pipe path. New traces retain their source and update when the mechanism's
joint origins, limits, motion links or driving conditions change. Playing or dragging the
mechanism does not redefine the trace: it keeps the assembly branch from when it was made.
Unrelated parameter and display changes do not resample it. A path that comes back to where
it started is kept as a closed, smooth spline.
`motion.trace {occurrence, point, joint, from, to, index?, samples?}` (or `{occurrence, point,
study, samples?}`) answers the world points without changing the design, and
`motion.trace.keep` with the same parameters (and `name?`) keeps them. A trace takes 2 to 500
`motion.trace.keep` with the same parameters (and `name?`) keeps them. `point_ref` can retain a
joint-origin snap (such as `{occurrence, face: [x,y,z]}`), so the point follows its resized
face; without it, the point is fixed in the part's coordinates. The dialog retains its face
or circle snap. `motion.trace.edit {sketch, from?, to?, samples?}` changes a joint trace's
driving conditions; `joint` with `from` and `to`, or `study`, replaces its motion source.
Expressions in `from` and `to` follow parameter edits. Study traces follow edits to the
referenced study, including its step count when Samples was left at the default.
`auto_update: false` in `motion.trace.keep` makes a fixed curve; in `motion.trace.edit` it
detaches an existing association, preserving the curve. Curves saved by earlier versions
remain fixed. `motion.list` includes the associations and their errors. If a source is lost,
the sketch warns and keeps its last successful curve until the source is restored or the
association is detached. Changes and their generated curves undo and redo together.
A trace takes 2 to 500
samples: 100 by default for a joint, one per step for a study.
The [Chebyshev linkage demo](https://github.com/tangency-labs/solidcraft/blob/main/docs/chebyshev-motion-trace.md)
builds a complete four-joint mechanism, plays a saved study and demonstrates the
same retained trajectory updating after a link-length or driving-range edit.
**Export Animation…** in the **Motion Study** dialog saves the study as an animated GIF, drawn
from the viewport's camera as the study plays (the keys in the dialog count, even before OK).
`motion.export_animation` does the same for scripts, the CLI and agents, without a window: give a
+101
View File
@@ -0,0 +1,101 @@
# Chebyshev linkage motion-trace demo
The [command recipe](../examples/chebyshev-motion-trace.json) builds a coloured
Chebyshev lambda linkage with four revolute joints, a saved motion study and an
associative trace of the red tip. One crank revolution draws a closed curve with
an approximately straight section. The blue crank, fixed pivot spacing, teal
rocker and amber coupler use lengths 20, 40, 50 and 50 mm; the coupler extends
another 50 mm beyond the rocker pin. These are the classical lambda proportions
1:2:2.5:5 described in [Won et al. (2025)](https://doi.org/10.1002/rcs.70095).
The geometry is constructed directly from circle intersections.
## Build and play
From the repository root, build the CLI and generate the editable design:
```sh
cargo build --release -p solidcraft-cli
target/release/solidcraft-cli run examples/chebyshev-motion-trace.json --out target/chebyshev.solidcraft
```
On Windows the executable has the `.exe` extension. Open `target/chebyshev.solidcraft`
in SolidCraft. Open **Motion Study**, choose **Chebyshev cycle** and play it. The
red tip follows the retained **Chebyshev trajectory** sketch while the blue crank
turns through 360°. Playback reuses the trace rather than sampling it again.
## Change a dimension or the driving range
Open the design's parameters and change the favourite `coupler_length` from
`2.5 * unit_length` to `55 mm`. The amber link lengthens, its joint locations
follow the geometry, and the existing trajectory changes shape automatically.
Changing `unit_length` scales the link lengths together; changing `trace_sweep`
from `360 deg` to `180 deg` retains just the first half of the trajectory. The
saved study remains a full crank revolution so it can also show the untraced half.
For a repeatable CLI comparison, save this as `target/change-chebyshev.json`:
```json
[
{
"command": "parameters.change",
"params": {"name": "coupler_length", "expression": "55 mm"}
}
]
```
```sh
target/release/solidcraft-cli run target/change-chebyshev.json --in target/chebyshev.solidcraft --out target/chebyshev-length55.solidcraft --timings
```
Use `trace_sweep` and `180 deg` in that command to demonstrate the driving-range
edit instead. Both edits update the same sketch and wire. Undo/Redo restores the
corresponding geometry and trace together. The engine test executes the recipe,
compares all 145 trace points with an independent circle-intersection calculation,
and checks resizing, uniform scaling, the half-turn range and playback reuse.
## Export a demonstration
Save the following as `target/animate-chebyshev.json` and run it on the generated
design. This uses SolidCraft's renderer and motion driver to export a looping GIF:
```json
[
{
"command": "motion.export_animation",
"params": {
"study": "Chebyshev cycle",
"path": "target/chebyshev-cycle.gif",
"samples": 49,
"fps": 16,
"width": 900,
"height": 650,
"grid": false,
"axes": false,
"camera": {
"target": [25, 40, 5],
"yaw": 0,
"pitch": 1.25,
"distance": 215,
"fov": 0
}
}
}
]
```
```sh
target/release/solidcraft-cli run target/animate-chebyshev.json --in target/chebyshev.solidcraft
```
For comparable still images, use the same camera before and after the edit:
```sh
target/release/solidcraft-cli snapshot --in target/chebyshev.solidcraft --out target/chebyshev-before.png --width 900 --height 650 --camera '{"target":[25,40,5],"yaw":0,"pitch":1.25,"distance":215,"fov":0}' --no-grid --no-axes
target/release/solidcraft-cli snapshot --in target/chebyshev-length55.solidcraft --out target/chebyshev-after.png --width 900 --height 650 --camera '{"target":[25,40,5],"yaw":0,"pitch":1.25,"distance":215,"fov":0}' --no-grid --no-axes
```
`--timings` reports command execution separately from process startup and file
loading. A dimension edit includes rebuilding the affected solids and trace;
`motion.trace.edit` with a changed sample count measures trace regeneration on
existing geometry. Repeating the same trace definition measures the unchanged
case. Keep the build profile, sample count and hardware with any reported times.
+56
View File
@@ -0,0 +1,56 @@
{
"commands": [
{"command":"file.new","params":{"name":"Chebyshev lambda motion trace"}},
{"command":"parameters.add","params":{"name":"unit_length","expression":"20 mm","favorite":true,"comment":"Chebyshev scale: crank 1, ground 2, rocker 2.5, coupler 2.5 + 2.5"}},
{"command":"parameters.add","params":{"name":"crank_length","expression":"unit_length"}},
{"command":"parameters.add","params":{"name":"ground_length","expression":"2 * unit_length"}},
{"command":"parameters.add","params":{"name":"coupler_length","expression":"2.5 * unit_length","favorite":true,"comment":"Distance between the coupler pins; try 55 mm to reshape the trajectory"}},
{"command":"parameters.add","params":{"name":"rocker_length","expression":"2.5 * unit_length"}},
{"command":"parameters.add","params":{"name":"trace_sweep","expression":"360 deg","favorite":true,"comment":"Retained trace range; try 180 deg"}},
{"command":"parameters.add","params":{"name":"pivot_span","expression":"sqrt(ground_length^2 + crank_length^2)"}},
{"command":"parameters.add","params":{"name":"circle_along","expression":"(coupler_length^2 - rocker_length^2 + pivot_span^2) / (2 * pivot_span)"}},
{"command":"parameters.add","params":{"name":"circle_height","expression":"sqrt(coupler_length^2 - circle_along^2)"}},
{"command":"parameters.add","params":{"name":"coupler_q_x","expression":"(circle_along * ground_length + circle_height * crank_length) / pivot_span"}},
{"command":"parameters.add","params":{"name":"coupler_q_y","expression":"crank_length + (circle_height * ground_length - circle_along * crank_length) / pivot_span"}},
{"command":"parameters.add","params":{"name":"coupler_angle","expression":"atan2(coupler_q_y - crank_length, coupler_q_x)","unit":"deg"}},
{"command":"parameters.add","params":{"name":"rocker_angle","expression":"atan2(coupler_q_y, coupler_q_x - ground_length)","unit":"deg"}},
{"command":"component.create","params":{"name":"Ground"}},
{"command":"solid.box","params":{"corner":[-7,-8,-6],"length":"ground_length + 14 mm","width":16,"height":4,"body_name":"Base"}},
{"command":"solid.cylinder","params":{"base":[0,0,-2],"radius":4,"height":12,"body_name":"Fixed crank pivot"}},
{"command":"solid.cylinder","params":{"base":["ground_length",0,-2],"radius":4,"height":12,"body_name":"Fixed rocker pivot"}},
{"command":"appearance.assign","params":{"components":["Ground"],"color":"#526477"}},
{"command":"component.activate","params":{"component":"root"}},
{"command":"component.create","params":{"name":"Crank"}},
{"command":"solid.box","params":{"corner":[0,-3,0],"length":"crank_length","width":6,"height":3,"body_name":"Crank bar"}},
{"command":"solid.move","params":{"bodies":["Crank bar"],"axis":[0,0,1],"angle":"90 deg"}},
{"command":"solid.cylinder","params":{"base":[0,0,3],"radius":3,"height":7,"body_name":"Crank bearing"}},
{"command":"solid.cylinder","params":{"base":[0,"crank_length",3],"radius":3,"height":7,"body_name":"Crank pin"}},
{"command":"appearance.assign","params":{"components":["Crank"],"color":"#328BD3"}},
{"command":"component.activate","params":{"component":"root"}},
{"command":"component.create","params":{"name":"Coupler"}},
{"command":"solid.box","params":{"corner":[0,-2.5,3],"length":"2 * coupler_length","width":5,"height":3,"body_name":"Extended coupler"}},
{"command":"solid.move","params":{"bodies":["Extended coupler"],"axis":[0,0,1],"angle":"coupler_angle","translate":[0,"crank_length",0]}},
{"command":"solid.cylinder","params":{"base":[0,"crank_length",6],"radius":2.5,"height":4,"body_name":"Coupler crank pin"}},
{"command":"solid.cylinder","params":{"base":["coupler_q_x","coupler_q_y",6],"radius":2.5,"height":4,"body_name":"Coupler rocker pin"}},
{"command":"solid.cylinder","params":{"base":["2 * coupler_q_x","2 * coupler_q_y - crank_length",6],"radius":2,"height":4,"body_name":"Tracer tip"}},
{"command":"appearance.assign","params":{"components":["Coupler"],"color":"#E3AB4B"}},
{"command":"appearance.assign","params":{"bodies":["Tracer tip"],"color":"#D95360"}},
{"command":"component.activate","params":{"component":"root"}},
{"command":"component.create","params":{"name":"Rocker"}},
{"command":"solid.box","params":{"corner":[0,-3,0],"length":"rocker_length","width":6,"height":3,"body_name":"Rocker bar"}},
{"command":"solid.move","params":{"bodies":["Rocker bar"],"axis":[0,0,1],"angle":"rocker_angle","translate":["ground_length",0,0]}},
{"command":"solid.cylinder","params":{"base":["ground_length",0,3],"radius":3,"height":7,"body_name":"Rocker bearing"}},
{"command":"solid.cylinder","params":{"base":["coupler_q_x","coupler_q_y",3],"radius":3,"height":7,"body_name":"Rocker pin"}},
{"command":"appearance.assign","params":{"components":["Rocker"],"color":"#3EA69B"}},
{"command":"component.activate","params":{"component":"root"}},
{"command":"occurrence.ground","params":{"occurrence":"Ground:1","grounded":true}},
{"command":"joint.create","params":{"name":"Crank drive","type":"revolute","flip":true,"a":{"occurrence":"Ground","face":[0,0,10],"x":[1,0,0]},"b":{"occurrence":"Crank","face":[0,0,10],"x":[1,0,0]}}},
{"command":"joint.create","params":{"name":"Crank to coupler","type":"revolute","flip":true,"a":{"occurrence":"Crank","face":[0,20,10],"x":[1,0,0]},"b":{"occurrence":"Coupler","face":[0,20,10],"x":[1,0,0]}}},
{"command":"joint.create","params":{"name":"Coupler to rocker","type":"revolute","flip":true,"a":{"occurrence":"Coupler","face":[40,50,10],"x":[1,0,0]},"b":{"occurrence":"Rocker","face":[40,50,10],"x":[1,0,0]}}},
{"command":"joint.create","params":{"name":"Rocker pivot","type":"revolute","flip":true,"a":{"occurrence":"Ground","face":[40,0,10],"x":[1,0,0]},"b":{"occurrence":"Rocker","face":[40,0,10],"x":[1,0,0]}}},
{"command":"motion.study","params":{"name":"Chebyshev cycle","steps":72,"keys":[{"joint":"Crank drive","points":[[0,0],[72,360]]}]}},
{"command":"motion.trace.keep","params":{"name":"Chebyshev trajectory","occurrence":"Coupler","point":[80,80,10],"point_ref":{"occurrence":"Coupler","face":[80,80,10]},"joint":"Crank drive","from":0,"to":"trace_sweep","samples":145}},
{"command":"component.activate","params":{"component":"root"}}
]
}