* feat(sketch): constructive solving ahead of the numeric solver Part 1 of splitting #251: the sketch crate only. A graph-constructive front end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the rest, and the rank analysis stays authoritative for degrees of freedom. Solves can follow a reference (the previous solution), so edits and drags keep their branch instead of flipping. New API: solve_with (hold, reference, analysis, budget) and solve_after_value_edit; solve and solve_holding keep their signatures as wrappers, so no caller changes here. When the seed moves an arc's centre, the arc's unconstructed ends start carried along (the arc translates) instead of left behind for LM to turn; found by the reference test 20-slot-constrained. Refs #203 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(doc): keep a sketch's solved state across value edits Part 2 of splitting #251 (builds on part 1, the sketch crate): - Sketch edits solve for the document's values continuing from the stored solved state (Document::solve_sketch), so a dimension edit keeps the sketch's branch instead of re-solving from scratch. - A value edit (parameters, a configuration, the units) that would make a sketch that solved before fail or turn over is refused with the conflicting constraints named; otherwise the solved sketches are written back into the document (Model::adopt_solved_sketches), which changes what saved designs store. - Later features are keyed by a sketch's solved geometry (to a 1e-6 mm quantum), so setting a value back reuses their earlier results. - Solved sketches in a model state are shared (Arc) between states. Refs #203 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(sketch): live drag that keeps its solution, ended by sketch.drag_end Part 3 of splitting #251 (builds on part 2): - sketch.drag follows a long move in short held steps, so the geometry keeps its branch, and reports how far it got (reached, at). - live: true runs one frame of an interactive drag: the sketch is solved once and shown, later features keep their last result (marked stale) until sketch.drag_end or any command that reads the model evaluates it (CommandSpec::live, reads_model, doc_only). - sketch.drag_end adds the constraint the drag let go on and evaluates the model once. - Undo and redo evaluate a new document once (execute does) instead of twice. - The viewport drives drags through live frames and drag_end; dimension text drags start only off sketch geometry. Refs #203 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
724 lines
29 KiB
Rust
724 lines
29 KiB
Rust
//! 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};
|
||
|
||
#[path = "contact_sweep.rs"]
|
||
mod contact_sweep;
|
||
|
||
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",
|
||
),
|
||
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
|
||
.at("SOLID", "ASSEMBLE")
|
||
.icon("joint")
|
||
.params("every occurrence takes part in contact (one set of all)"),
|
||
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
|
||
CommandSpec::new("contact.create", "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().doc_only().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("motion.study", "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.model_body_component(b) == comp && !b.body.is_mesh()) {
|
||
// Moving a body into another component preserves its world placement using an
|
||
// offset from its authoring frame. Contact and explode centres must use that
|
||
// same offset as display and physical properties.
|
||
let frame = s.doc.model_body_offset(b).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
|
||
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()?);
|
||
mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
|
||
}
|
||
|
||
fn mesh_overlap(ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh, tol: f64) -> Option<f64> {
|
||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||
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 {
|
||
// Fixed ray coordinates, not an approximation used to compute Euler's constant.
|
||
#[allow(clippy::approx_constant)]
|
||
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 baseline = s.doc.clone();
|
||
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| {
|
||
// Trial order must not affect free anchors or linked joint placements.
|
||
s.doc = baseline.clone();
|
||
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 cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None };
|
||
let sol;
|
||
let mut stopped = None;
|
||
if let Some(cache) = cache.filter(|cache| cache.has_pairs()) {
|
||
let mut end = s.scratch();
|
||
set(&mut end, 1.0);
|
||
let samples = cache.samples(s, &end)?;
|
||
let mut work = 0;
|
||
set(s, 0.0);
|
||
// Keep the existing recovery behaviour: an initially overlapping assembly may move
|
||
// out of its invalid placement. Sweeps constrain motions that start collision-free.
|
||
if cache.first_hit(s, &mut work)?.is_none() {
|
||
let mut lo = 0.0;
|
||
let mut bracket = None;
|
||
for sample in 1..=samples {
|
||
let hi = sample as f64 / samples as f64;
|
||
set(s, hi);
|
||
if let Some(hit) = cache.first_hit(s, &mut work)? {
|
||
bracket = Some((lo, hi, hit));
|
||
break;
|
||
}
|
||
lo = hi;
|
||
}
|
||
if let Some((mut lo, mut hi, hit)) = bracket {
|
||
for _ in 0..14 {
|
||
let mid = (lo + hi) / 2.0;
|
||
set(s, mid);
|
||
if cache.first_hit(s, &mut work)?.is_none() { 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);
|
||
}
|
||
} else {
|
||
sol = set(s, 1.0);
|
||
}
|
||
} 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 (joint.drive), 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 = "contact.create";
|
||
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 = "motion.study";
|
||
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;
|