Joints engine: joint origins that snap to faces, round ends or points (re-found on every solve, so joints follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) with values, limits, flip and offset, As-built Joint, Rigid Group, Drive Joints, Motion Link, a joint-graph solver from grounded occurrences (degrees of freedom per occurrence, loop conflicts reported), Interference between bodies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
13b72bb011
commit
c2309eb43b
7 files changed
+1242
No files matched your search
@@ -0,0 +1,188 @@
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn occ(s: &Session, component: &str) -> u64 {
|
||||
let c = s.doc.find_component(component).unwrap_or(0);
|
||||
s.doc.occurrence_of(c).map(|o| o.id).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn place(s: &Session, component: &str, p: Vec3) -> Vec3 {
|
||||
solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occ(s, component)), p)
|
||||
}
|
||||
|
||||
fn near(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
/// A hinge leaf (oracle 63's knuckle geometry): a 40 x 30 x 3 plate and an r4 knuckle along X
|
||||
/// at y = 33, z = 4.
|
||||
fn leaf(s: &mut Session, name: &str) {
|
||||
run(s, "component.activate", json!({"component": "root"}));
|
||||
run(s, "FusionCreateNewComponentCommand", json!({"name": name}));
|
||||
run(s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
|
||||
run(s, "PrimitiveCylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolute_hinge_turns_about_the_knuckle() {
|
||||
let mut s = Session::default();
|
||||
leaf(&mut s, "LeafA");
|
||||
leaf(&mut s, "LeafB");
|
||||
let o = occ(&s, "LeafA");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
let r = run(
|
||||
&mut s,
|
||||
"JointAssembleCmdNew",
|
||||
json!({
|
||||
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}),
|
||||
);
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
// Same frames: B stays put at angle 0.
|
||||
assert!(near(place(&s, "LeafB", Vec3::new(20.0, 0.0, 0.0)), Vec3::new(20.0, 0.0, 0.0)));
|
||||
let l = run(&mut s, "joint.list", json!({}));
|
||||
let dof = l["dof"].as_array().unwrap().iter().find(|d| d["occurrence"] == occ(&s, "LeafB")).unwrap()["dof"].clone();
|
||||
assert_eq!(dof, 1, "{l}");
|
||||
// Drive 90°: B turns about the knuckle axis (through (0, 33, 4), along the frame's z = −X).
|
||||
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": 90}));
|
||||
let want = solvecraft_doc::rigid(Vec3::ZERO, Vec3::new(0.0, 33.0, 4.0), Vec3::new(-1.0, 0.0, 0.0), FRAC_PI_2);
|
||||
let p = Vec3::new(20.0, 0.0, 0.0);
|
||||
assert!(near(place(&s, "LeafB", p), solvecraft_doc::apply_point(&want, p)), "{:?}", place(&s, "LeafB", p));
|
||||
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
|
||||
// LeafA did not move; the knuckle axis is fixed.
|
||||
assert!(near(place(&s, "LeafA", p), p));
|
||||
assert!(near(place(&s, "LeafB", Vec3::new(10.0, 33.0, 4.0)), Vec3::new(10.0, 33.0, 4.0)));
|
||||
// Expressions drive too, and undo puts it back.
|
||||
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": "pi rad"}));
|
||||
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 66.0, 8.0)));
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes in components A (grounded) and B.
|
||||
fn two_cubes() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
let o = occ(&s, "A");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_limits_clamp() {
|
||||
let mut s = two_cubes();
|
||||
// B's bottom face onto A's top face (faces meet), sliding along their common normal.
|
||||
let r = run(
|
||||
&mut s,
|
||||
"JointAssembleCmdNew",
|
||||
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "flip": true, "limits": [[0, 10]], "name": "Rail"}),
|
||||
);
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
// B now sits on A.
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
|
||||
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 25}));
|
||||
assert_eq!(r["values"], json!([10.0]), "{r}");
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 20.0)));
|
||||
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
|
||||
// Changing the limits clamps the current value.
|
||||
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 8}));
|
||||
run(&mut s, "joint.limits", json!({"joint": "Rail", "min": 0, "max": 4}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 14.0)));
|
||||
// Joints follow edits: A gets taller, B rides on its new top.
|
||||
let h = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
|
||||
.and_then(|f| f.param_names.get(2).cloned())
|
||||
.unwrap_or_default();
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": h, "expression": "15"}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 19.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rigid_cycles_solve_and_conflicts_report() {
|
||||
let mut s = two_cubes();
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "C"}));
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
// A rigid group and a closing as-built joint: a consistent loop.
|
||||
run(&mut s, "RigidGroupCmd", json!({"occurrences": ["A", "B", "C"]}));
|
||||
let r = run(&mut s, "JointAsBuiltCmd", json!({"type": "rigid", "a": "B", "b": "C"}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert!(near(place(&s, "C", Vec3::new(40.0, 0.0, 0.0)), Vec3::new(40.0, 0.0, 0.0)));
|
||||
let l = run(&mut s, "joint.list", json!({}));
|
||||
assert!(l["dof"].as_array().unwrap().iter().all(|d| d["dof"] == 0), "{l}");
|
||||
// A joint that wants C elsewhere conflicts and says so.
|
||||
let r = run(
|
||||
&mut s,
|
||||
"JointAssembleCmdNew",
|
||||
json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "C", "point": [40, 0, 0]}, "name": "Wrong"}),
|
||||
);
|
||||
let c = r["conflicts"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(c.len(), 1, "{r}");
|
||||
assert!(c[0]["why"].as_str().unwrap_or("").contains("off by"), "{r}");
|
||||
// Suppressing it clears the conflict.
|
||||
let r = run(&mut s, "joint.edit", json!({"joint": "Wrong", "suppressed": true}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0));
|
||||
assert!(
|
||||
s.execute(
|
||||
"JointAssembleCmdNew",
|
||||
&json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "A", "point": [1, 0, 0]}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(s.execute("JointAssembleCmdNew", &json!({"type": "hinge"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_links_and_interference() {
|
||||
let mut s = two_cubes();
|
||||
run(
|
||||
&mut s,
|
||||
"JointAssembleCmdNew",
|
||||
json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "flip": true, "name": "Screw"}),
|
||||
);
|
||||
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 0]}));
|
||||
// No overlap while B sits on A; then push B 4 mm into A.
|
||||
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, -4]}));
|
||||
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 400.0).abs() < 1e-3, "{i}");
|
||||
// A motion link: a second joint's value follows.
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "D"}));
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
|
||||
run(
|
||||
&mut s,
|
||||
"JointAssembleCmdNew",
|
||||
json!({"type": "slider", "a": {"occurrence": "A", "face": [5, 5, 10]}, "b": {"occurrence": "D", "face": [65, 5, 0]}, "flip": true, "name": "Follower"}),
|
||||
);
|
||||
run(&mut s, "FusionMotionRelationshipCommand", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
|
||||
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 3]}));
|
||||
assert!(near(place(&s, "D", Vec3::new(65.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 16.0)), "{:?}", place(&s, "D", Vec3::new(65.0, 5.0, 0.0)));
|
||||
// Saved and reopened, joints come back.
|
||||
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(back.assembly, s.doc.assembly);
|
||||
let mut t = Session::new(back);
|
||||
let l = run(&mut t, "joint.list", json!({}));
|
||||
assert_eq!(l["joints"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
Reference in new issue
Block a user