//! Contact Sets, Motion Study and Exploded views. use std::collections::BTreeSet; use serde_json::{Value, json}; use solidcraft_doc::Mat; use solidcraft_doc::joints::{self, JointKind}; use solidcraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey}; use solidcraft_geom::Vec3; use solidcraft_kernel::Body; use super::CommandSpec; use super::joints::{joint_id, occurrence_id, resolve_holding, 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> { 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, 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| solidcraft_doc::mat_mul(&m, offset)).unwrap_or(m); if let Ok(t) = solidcraft_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> = 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 { 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: &solidcraft_geom::Mesh, mb: &solidcraft_geom::Mesh, tol: f64) -> Option { 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: &solidcraft_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: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh, tol: f64) -> f64 { let tb = tris(b); let bx = b.bounds(); let mut pts: Vec = 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 or a closed loop could no /// longer close: the values are moved from where they are toward `want` as far as they go. The /// `given` values (joint, index) are driven; in a closed loop the others follow. fn drive_with_contact(s: &mut Session, want: &[(u64, Vec)], given: &BTreeSet<(u64, usize)>) -> Result { let baseline = s.doc.clone(); let start: Vec<(u64, Vec)> = want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect(); // Closed loops follow the driven values from where they start. let held: joints::Held = given .iter() .filter(|&&(id, i)| want.iter().any(|(w, q)| *w == id && i < q.len())) .map(|&(id, i)| { // A value the file leaves out is 0, as the solver reads it. let from = start.iter().find(|(s, _)| *s == id).and_then(|(_, q)| q.get(i).copied()); ((id, i), from.unwrap_or(0.0)) }) .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 = b.iter().enumerate().map(|(i, bv)| a.get(i).copied().unwrap_or(0.0) * (1.0 - t) + bv * t).collect(); joints::clamp(j, &mut q); j.values = q; } } resolve_holding(s, &held) }; // Loops already open at the start stay as they are; a loop the motion opens stops it. let open_at_start: BTreeSet = set(s, 0.0).conflicts.iter().map(|c| c.0).collect(); let opened = |sol: &joints::Solution| sol.conflicts.iter().find(|c| !open_at_start.contains(&c.0)).map(|c| c.1.clone()); let cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None }; let mut 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 mut stopped_by_loop = None; if let Some(mut name) = opened(&sol) { // The furthest the loops still close, short of where contact stopped; the joint named is // the one whose loop opens first. let (mut lo, mut hi) = (0.0, stopped.as_ref().and_then(|v| v["fraction"].as_f64()).unwrap_or(1.0)); for _ in 0..14 { let mid = (lo + hi) / 2.0; match opened(&set(s, mid)) { None => lo = mid, Some(n) => (hi, name) = (mid, n), } } sol = set(s, lo); stopped = None; stopped_by_loop = Some(json!({"joint": name, "fraction": lo})); } let values: Vec = 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, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()})) } /// Drive one joint (joint.drive), honouring contact sets. pub fn drive_joint(s: &mut Session, id: u64, q: Vec, given: usize) -> Result { let given = (0..given.min(q.len())).map(|i| (id, i)).collect(); drive_with_contact(s, &[(id, q)], &given) } fn enable_sets(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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 { 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 { 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 { 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 { 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 { let c = collisions(s); Ok(json!({"collisions": c.iter().map(|(a, b, d)| json!({"a": a, "b": b, "depth_mm": d})).collect::>()})) } // ---- Motion studies ---- fn study_index(s: &Session, p: &Value, cmd: &str) -> Result { 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 { 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)> { let mut out: Vec<(u64, Vec)> = 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 } /// The values a study keys: (joint, index). fn keyed(m: &MotionStudy) -> BTreeSet<(u64, usize)> { m.keys.iter().map(|k| (k.joint, k.index)).collect() } /// 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 { 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, &keyed(&m))?; 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 { 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, &keyed(&m))?; rows.push(json!({ "step": step, "stopped_by_contact": r["stopped_by_contact"], "stopped_by_loop": r["stopped_by_loop"], "positions": positions(&t).iter().map(|(n, x)| json!({"occurrence": n, "pose": pose_json(x)})).collect::>(), })); } 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() }; solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?; solidcraft_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 { Ok(json!({"studies": s.doc.assembly.studies})) } fn delete_study(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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::>()? } 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::>()})) } fn explode_show(s: &mut Session, p: &Value) -> Result { 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 { Ok(json!({"views": s.doc.assembly.exploded})) } fn explode_delete(s: &mut Session, p: &Value) -> Result { 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 { solidcraft_doc::apply_point(&joints::occurrence_world(&s.doc, occurrence), p) } #[cfg(test)] #[path = "motion_tests.rs"] mod tests;