Motion Trace: a point's path as a joint is driven or a study plays, kept as a 3D spline (#1529)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 01:54:34 -04:00
1 parent dcffab09b1
commit 571122c4ba
19 files changed
+769 -9

No files matched your search

+230
View File
@@ -1179,5 +1179,235 @@ fn a_new_joint_starts_inside_its_limits_and_says_so() {
assert!(r.get("warnings").is_none(), "{r}");
}
/// The trace's points as world points.
fn traced(r: &Value) -> Vec<Vec3> {
r["points"].as_array().into_iter().flatten().filter_map(crate::params::vec3).collect()
}
/// A point rigidly carried from segment (a0, b0) to segment (a, b) in the XY plane.
fn carried(p0: Vec3, a0: Vec3, b0: Vec3, a: Vec3, b: Vec3) -> Vec3 {
let turn = (b.y - a.y).atan2(b.x - a.x) - (b0.y - a0.y).atan2(b0.x - a0.x);
let d = p0 - a0;
a + Vec3::new(d.x * turn.cos() - d.y * turn.sin(), d.x * turn.sin() + d.y * turn.cos(), d.z)
}
/// A point on the four-bar's coupler, 10 mm off the line between its pins, traces the coupler
/// curve: the crank pin on its circle, the rocker pin where the coupler and rocker circles meet
/// (on the open branch), and the point carried with the pins. A motion study turning the crank
/// traces the same curve, and the design is left as it was.
#[test]
fn four_bar_coupler_point_traces_its_closed_form_curve() {
let (mut s, p0, q0) = four_bar();
let d = (q0 - p0) * (1.0 / q0.dist(p0));
let c0 = (p0 + q0) * 0.5 + Vec3::new(-d.y, d.x, 0.0) * 10.0;
let before = s.doc.clone();
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 360, "samples": 73});
let r = run(&mut s, "motion.trace", p);
assert_eq!(*s.doc, *before, "a trace leaves the design as it was");
assert!(r["stopped_at"].is_null() && r["samples"] == 73, "{r}");
let pts = traced(&r);
assert_eq!(pts.len(), 73);
let mut q = q0;
for (k, got) in pts.iter().enumerate() {
let v = 5.0 * k as f64;
if k > 0 {
q = rocker_pin(v - 5.0, v, q);
}
let th = (90.0 + v).to_radians();
let want = carried(c0, p0, q0, Vec3::new(20.0 * th.cos(), 20.0 * th.sin(), 0.0), q);
assert!(near(*got, want), "{v}°: {got:?} wants {want:?}");
}
assert!(pts.first().zip(pts.last()).is_some_and(|(a, b)| near(*a, *b)), "a full turn closes the curve");
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 72, "keys": [{"joint": "Crank", "points": [[0, 0], [72, 360]]}]}));
let r = run(&mut s, "motion.trace", json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "study": "Turn"}));
let from_study = traced(&r);
assert_eq!(from_study.len(), 73, "{r}");
assert!(from_study.iter().zip(&pts).all(|(a, b)| near(*a, *b)), "{r}");
}
/// 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]
fn slider_crank_rod_traces_its_closed_form_curve() {
let (mut s, p0, s0) = slider_crank();
let m0 = (p0 + s0) * 0.5;
let r =
run(&mut s, "motion.trace", json!({"occurrence": "Rod", "point": [m0.x, m0.y, m0.z], "joint": "Crank", "from": 0, "to": 360, "samples": 37}));
let pts = traced(&r);
assert_eq!(pts.len(), 37, "{r}");
for (k, got) in pts.iter().enumerate() {
let th = (90.0 + 10.0 * k as f64).to_radians();
let x = 15.0 * th.cos() + (40.0f64.powi(2) - (15.0 * th.sin()).powi(2)).sqrt();
let want = (Vec3::new(15.0 * th.cos(), 15.0 * th.sin(), 0.0) + Vec3::new(x, 0.0, 0.0)) * 0.5;
assert!(near(*got, want), "{}°: {got:?} wants {want:?}", 10 * k);
}
}
/// Driving the rocker past its swing stops where the loop can't close: the trace ends there.
#[test]
fn a_trace_ends_where_the_loop_stops_the_motion() {
let (mut s, _, q0) = four_bar();
let start = q0.y.atan2(q0.x - 45.0).to_degrees();
let r = run(
&mut s,
"motion.trace",
json!({"occurrence": "Rocker", "point": [q0.x, q0.y, q0.z], "joint": "Rocker", "from": 0, "to": 100, "samples": 21}),
);
assert!(r["stopped_by_loop"].is_object(), "{r}");
let k = r["stopped_at"].as_u64().unwrap_or(u64::MAX) as usize;
let pts = traced(&r);
assert!(k > 0 && k < 20 && pts.len() == k + 1 && r["samples"] == k + 1, "{r}");
let angle = |p: &Vec3| p.y.atan2(p.x - 45.0).to_degrees();
for (i, p) in pts.iter().take(k).enumerate() {
assert!((angle(p) - (start + 5.0 * i as f64)).abs() < 1e-6, "sample {i}: {}", angle(p));
}
let limit = ((900.0f64 - 3625.0) / 3600.0).acos().to_degrees();
let last = pts.last().map(angle).unwrap_or(f64::NAN);
assert!(last > limit - 5.0 && last <= limit + 1e-9, "stopped at {last}°, the limit is {limit}°");
}
/// Keep makes a 3D sketch spline through the trace, in one undo step.
#[test]
fn a_kept_trace_is_a_3d_sketch_spline() {
let (mut s, p0, q0) = four_bar();
let c0 = (p0 + q0) * 0.5;
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 180, "samples": 19});
let pts = traced(&run(&mut s, "motion.trace", p.clone()));
let features = s.doc.features.len();
let r = run(&mut s, "motion.trace.keep", p);
assert_eq!((r["name"].as_str(), r["points"].as_u64()), (Some("Motion Trace1"), Some(19)), "{r}");
assert!(s.active_sketch.is_none(), "the sketch is left");
let id = r["sketch"].as_u64().unwrap_or(0);
let sk = s.doc.sketch(id).cloned().unwrap_or_default();
assert!(sk.view.three_d && sk.wires.len() == 1, "{r}");
let fit = sk.wires.first().map(|w| w.fit.clone()).unwrap_or_default();
assert!(fit.len() == pts.len() && fit.iter().zip(&pts).all(|(a, b)| a.dist(*b) < 1e-9), "{fit:?}");
let again = json!({"occurrence": "Coupler", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90});
assert_eq!(run(&mut s, "motion.trace.keep", again)["name"], "Motion Trace2");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.features.len(), features);
// A point on the ground does not move: there is nothing to keep.
let e = s.execute("motion.trace.keep", &json!({"occurrence": "Ground", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90})).unwrap_err();
assert!(e.to_string().contains("does not move"), "{e}");
}
/// A full turn kept closes exactly on its start, also when the sample before the end lies a hair
/// from it (a study keyed to 359.99999° one step before 360°).
#[test]
fn a_kept_full_turn_closes_on_its_start() {
let mut s = Session::default();
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
part(&mut s, "Arm", [0.0, -1.0, 0.0]);
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
pin(&mut s, "revolute", "Pivot", "Ground", "Arm", Vec3::ZERO, Vec3::Z);
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 5, "keys": [{"joint": "Pivot", "points": [[0, 0], [4, 359.99999], [5, 360]]}]}));
let r = run(&mut s, "motion.trace.keep", json!({"occurrence": "Arm", "point": [1, 0, 0], "study": "Turn"}));
let id = r["sketch"].as_u64().unwrap_or(0);
let fit = s.doc.sketch(id).ok().and_then(|sk| sk.wires.first().map(|w| w.fit.clone())).unwrap_or_default();
assert_eq!(fit.len(), 5, "{fit:?}");
assert_eq!(fit.first(), fit.last(), "{fit:?}");
}
/// Kept while a component is active and shown moved (a move not captured yet): the spline goes
/// in the root, where no joint moves it, through the traced points; the component stays active.
#[test]
fn a_trace_kept_in_a_moved_component_stays_on_the_path() {
let (mut s, p0, q0) = four_bar();
let crank = occ(&s, "Crank");
let c0 = (p0 + q0) * 0.5;
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 90, "samples": 4});
let pts = traced(&run(&mut s, "motion.trace", p.clone()));
run(&mut s, "component.activate", json!({"component": "Crank", "occurrence": crank, "path": [crank]}));
run(&mut s, "occurrence.move", json!({"occurrence": crank, "translate": [0, 0, 100], "capture": false}));
let active = (s.active_component, s.active_occurrence_path.clone());
let r = run(&mut s, "motion.trace.keep", p);
assert_eq!((s.active_component, s.active_occurrence_path.clone()), active);
let id = r["sketch"].as_u64().unwrap_or(0);
assert_eq!(s.doc.feature(id).map(|f| f.component), Some(0), "{r}");
let fit = s.doc.sketch(id).ok().and_then(|sk| sk.wires.first().map(|w| w.fit.clone())).unwrap_or_default();
assert!(fit.len() == pts.len() && fit.iter().zip(&pts).all(|(a, b)| near(*a, *b)), "{fit:?} wants {pts:?}");
}
/// A part inside a sub-assembly placed twice: the occurrence path picks the instance traced. The
/// second Arm turns about its pivot and its Tip goes round; the first Arm, and so the Tip at its
/// first placement, stays put.
#[test]
fn a_trace_follows_the_instance_its_path_names() {
let mut s = Session::default();
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
run(&mut s, "component.activate", json!({"component": "root"}));
let arm = run(&mut s, "component.create", json!({"name": "Arm"}));
run(&mut s, "component.create", json!({"name": "Tip"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [10, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let second = run(&mut s, "occurrence.copy", json!({"component": arm["component"], "translate": [0, 50, 0]}))["occurrence"].as_u64().unwrap_or(0);
let at = |o: Value| json!({"occurrence": o, "point": [0, 50, 0], "z": [0, 0, 1]});
run(&mut s, "joint.create", json!({"type": "revolute", "a": at(json!(g)), "b": at(json!(second)), "flip": true, "name": "Pivot"}));
let tip = occ(&s, "Tip");
let trace = |s: &mut Session, o: Value| {
traced(&run(s, "motion.trace", json!({"occurrence": o, "point": [11, 51, 1], "joint": "Pivot", "from": 0, "to": 90, "samples": 3})))
};
let turned = trace(&mut s, json!([second, tip]));
let pivot = Vec3::new(0.0, 50.0, 1.0);
assert!(turned.iter().all(|p| (p.dist(pivot) - 122f64.sqrt()).abs() < 1e-6), "{turned:?}");
assert!(turned.first().zip(turned.last()).is_some_and(|(a, b)| (a.dist(*b) - 244f64.sqrt()).abs() < 1e-6), "a quarter turn: {turned:?}");
let first = trace(&mut s, json!(tip));
assert!(first.iter().all(|p| p.dist(Vec3::new(11.0, 51.0, 1.0)) < 1e-9), "{first:?}");
assert!(s.execute("motion.trace", &json!({"occurrence": [tip, second], "point": [0, 0, 0], "joint": "Pivot", "from": 0, "to": 90})).is_err());
assert!(s.execute("motion.trace", &json!({"occurrence": vec![second; 65], "point": [0, 0, 0], "joint": "Pivot", "from": 0, "to": 90})).is_err());
}
/// Trace params come from scripts and the control channel: bad ones are refused, never a panic.
#[test]
fn hostile_trace_params_are_refused() {
let (mut s, _, _) = four_bar();
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 4, "keys": [{"joint": "Crank", "points": [[0, 0], [4, 90]]}]}));
let base = json!({"occurrence": "Coupler", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90});
let with = |k: &str, v: Value| {
let mut p = base.clone();
p[k] = v;
p
};
let without = |k: &str| {
let mut p = base.clone();
p.as_object_mut().map(|o| o.remove(k));
p
};
for p in [
with("samples", json!(0)),
with("samples", json!(1)),
with("samples", json!(501)),
with("samples", json!(1e300)),
with("samples", json!(-3)),
with("samples", json!("many")),
with("point", json!([f64::MAX, 0, 0])),
with("point", json!([0, 0])),
with("point", json!("here")),
without("point"),
with("occurrence", json!("Nowhere")),
without("occurrence"),
with("joint", json!("Nowhere")),
with("index", json!(1)),
with("index", json!(-1)),
with("from", json!("nonsense +")),
with("to", json!(1e300)),
without("from"),
without("to"),
with("study", json!("Turn")),
with("study", json!(null)),
without("joint"),
] {
for cmd in ["motion.trace", "motion.trace.keep"] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
}
}
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Crank", "name": "Lock"}));
assert!(s.execute("motion.trace", &with("joint", json!("Lock"))).is_err(), "a rigid joint does not move");
}
#[path = "joints_coupling_tests.rs"]
mod coupling;
+178
View File
@@ -17,6 +17,14 @@ use crate::{EngineError, Result, Session};
#[path = "contact_sweep.rs"]
mod contact_sweep;
macro_rules! trace_doc {
() => {
"occurrence: id|name, or an occurrence path [ids] (an instance inside sub-assemblies); point: [x,y,z] world point on it, where it is placed now; study: id|name, or joint: id|name with from and \
to (degrees for angles, mm for distances, or expressions) and index? (which value, default 0); samples? (2…500, default \
the study's steps + 1, or 100) - the point's path as the study plays or the joint is driven, closed loops following"
};
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "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",
@@ -46,6 +54,16 @@ pub static COMMANDS: &[CommandSpec] = &[
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.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!(
trace_doc!(),
" → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \
ends there), stopped_by_loop, stopped_by_contact (the design is not changed)"
)),
CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_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"
)),
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(
@@ -658,6 +676,166 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// Most samples of a motion trace: each is a solve, and a kept trace keeps every one as a fit
/// point of its spline.
const MAX_TRACE: usize = solidcraft_sketch::MAX_FIT_POINTS;
/// The joint values a trace drives to at sample `k` of `n`.
type Plan = Box<dyn Fn(&Session, usize, usize) -> Vec<(u64, Vec<f64>)>>;
/// Where a point of an occurrence goes as a study plays or a joint is driven: its world
/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one
/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at
/// the sample where the motion stops short. Returns the points and the answer without them.
fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)> {
// The placement traced: an occurrence path through nested sub-assemblies (one instance of
// each), or an occurrence at its first placement (none: the root).
let path: Vec<u64> = match p.get("occurrence") {
Some(Value::Array(ids)) if ids.len() <= 64 => {
ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, "an occurrence path lists occurrence ids"))).collect::<Result<_>>()?
}
Some(Value::Array(_)) => return Err(bad(cmd, "an occurrence path has at most 64 ids")),
v => {
let occ = occurrence_id(s, v, cmd)?;
match s.doc.occurrences.iter().find(|o| o.id == occ) {
Some(o) => s.doc.first_path(o.parent).into_iter().chain([o.id]).collect(),
None => Vec::new(),
}
}
};
let at = p.get("point").and_then(vec3).filter(|v| v.len() < 1e7).ok_or_else(|| bad(cmd, "`point` must be [x, y, z], a world point"))?;
let mut t = s.scratch();
// Traced from where the parts are placed: moves only shown (an exploded view, a move not
// captured yet) are not where the joints are solved.
t.pending_moves.clear();
let world = |t: &Session| -> Result<Mat> {
if path.is_empty() {
Ok(solidcraft_doc::IDENTITY)
} else {
t.occurrence_placement(&path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string()))
}
};
let local = solidcraft_doc::mat_inverse(&world(&t)?)
.map(|m| solidcraft_doc::apply_point(&m, at))
.ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?;
let samples = |default: usize| match p.get("samples") {
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && *n >= 2.0 && *n <= MAX_TRACE as f64)
.map(|n| n.round() as usize)
.ok_or_else(|| bad(cmd, format!("`samples` must be 2…{MAX_TRACE}"))),
None => Ok(default),
};
let (n, given, plan): (usize, BTreeSet<(u64, usize)>, Plan) = match (p.get("study"), p.get("joint")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `study` or `joint`, not both")),
(Some(_), None) => {
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?;
let n = samples((m.steps as usize).saturating_add(1).clamp(2, MAX_TRACE))?;
(n, keyed(&m), Box::new(move |t, k, n| values_at(t, &m, f64::from(m.steps) * k as f64 / (n - 1) as f64)))
}
(None, Some(j)) => {
let id = joint_id(s, Some(j), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
let dofs = kind.dofs().len();
if dofs == 0 {
return Err(bad(cmd, "a rigid joint does not move"));
}
let index = match p.get("index") {
None => 0,
Some(v) => v
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < dofs)
.ok_or_else(|| bad(cmd, format!("`index` must be below {dofs}, the joint's value count")))?,
};
let end = |key: &str| match p.get(key) {
Some(v) => value(s, v, kind.is_angle(index), cmd),
None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the trace runs between (no `{key}`)"))),
};
let (from, to) = (end("from")?, end("to")?);
let plan: Plan = Box::new(move |t, k, n| {
let mut q = t.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.values.clone()).unwrap_or_default();
q.resize(q.len().max(dofs), 0.0);
if let Some(x) = q.get_mut(index) {
*x = from + (to - from) * k as f64 / (n - 1) as f64;
}
vec![(id, q)]
});
(samples(100)?, BTreeSet::from([(id, index)]), plan)
}
(None, None) => return Err(bad(cmd, "give a `study`, or a `joint` with `from` and `to`")),
};
let mut points = Vec::new();
let mut stop = (Value::Null, Value::Null, Value::Null);
for k in 0..n {
let want = plan(&t, k, n);
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
points.push(solidcraft_doc::apply_point(&world(&t)?, local));
if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() {
stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone());
break;
}
}
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2});
Ok((points, out))
}
fn trace(s: &mut Session, p: &Value) -> Result<Value> {
let (points, mut out) = sample_trace(s, p, "motion.trace")?;
out["points"] = points.iter().map(|v| json!([v.x, v.y, v.z])).collect();
Ok(out)
}
/// The trace as a 3D sketch spline, in one undo step: a new sketch of the root component (the
/// 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)?;
// 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
// closes there, smoothly, through the start itself (and a sample that would double it goes).
if points.len() > 3
&& let (Some(&first), Some(&last)) = (points.first(), points.last())
&& first.dist(last) < 1e-4
{
points.pop();
if points.last().is_some_and(|p| p.dist(first) < 1e-6) {
points.pop();
}
points.push(first);
}
if points.len() < 2 {
return Err(bad(cmd, "the point does not move: there is no trace to keep"));
}
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(),
};
let pts: Vec<Value> = points.iter().map(|v| json!([v.x, v.y, v.z])).collect();
// Made in the root, whatever is being edited, which is then as it was (also on an error).
let before = (s.active_sketch, s.active_component, s.active_occurrence_path.take());
s.active_component = 0;
let made = (|| {
let run = |s: &mut Session, id: &str, q: Value| super::find_command(id).ok_or_else(|| bad(cmd, id.to_string()))?.run_nested(s, &q);
let made = run(s, "sketch.create", json!({"plane": "XY", "project_edges": false, "name": name}))?;
let sketch = made.get("sketch").cloned().unwrap_or(Value::Null);
run(s, "sketch.options", json!({"sketch": sketch, "sketch_3d": true}))?;
let spline = run(s, "sketch.spline3d", json!({"sketch": sketch, "points": pts}))?;
Ok::<_, EngineError>((made, spline))
})();
(s.active_sketch, s.active_component, s.active_occurrence_path) = before;
s.revision += 1;
let (made, spline) = made?;
let sketch = made.get("sketch").cloned().unwrap_or(Value::Null);
out["sketch"] = sketch;
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());
Ok(out)
}
fn list_studies(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"studies": s.doc.assembly.studies}))
}