Joints mate by default (Fusion's convention): origin B's z is turned against origin A's, so two picked faces meet face to face and B sits on A instead of inside it; Flip now means don't mate. File format 4 inverts older joints' flip so they keep their placement; as-built joints and rigid groups stay where they are

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent d12c955220
commit 2c383460e9
5 files changed
+46 -14

No files matched your search

+23 -2
View File
@@ -13,13 +13,15 @@
//! - 3: appearances per body, face and component with names and opacity
//! (`"appearances": {"bodies": {name: {name, color, opacity}}, "faces": […], "components": {…}}`);
//! before, `"appearances"` mapped body names to bare `[r, g, b]` colours.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 3;
pub const FORMAT: u32 = 4;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -32,7 +34,20 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 2] = [|_| Ok(()), v2_to_v3];
const JSON_STEPS: [JsonStep; 3] = [|_| Ok(()), v2_to_v3, v3_to_v4];
/// Joints placed with the old convention (aligned unless flipped) keep their placement.
fn v3_to_v4(v: &mut Value) -> Result<()> {
if let Some(Value::Array(js)) = v.get_mut("assembly").and_then(|a| a.get_mut("joints")) {
for j in js.iter_mut() {
if let Some(o) = j.as_object_mut() {
let f = o.get("flip").and_then(Value::as_bool).unwrap_or(false);
o.insert("flip".into(), Value::Bool(!f));
}
}
}
Ok(())
}
/// Body colours `{name: [r, g, b]}` become named, opaque body appearances.
fn v2_to_v3(v: &mut Value) -> Result<()> {
@@ -182,6 +197,12 @@ mod tests {
old["appearances"] = serde_json::json!({"Body1": [200, 10, 20]});
let d = read(&old.to_string()).unwrap();
let l = d.appearances.bodies.get("Body1").unwrap();
// Format 3 joints (aligned unless flipped) keep their placement.
let mut j: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
j["format"] = Value::from("solvecraft/3");
j["assembly"] = serde_json::json!({"joints": [{"id": 1, "name": "J", "kind": "rigid", "a": {"occurrence": 0, "snap": {"type": "point", "point": [0, 0, 0]}}, "b": {"occurrence": 0, "snap": {"type": "point", "point": [0, 0, 0]}}}]});
let dj = read(&j.to_string()).unwrap();
assert!(dj.assembly.joints[0].flip, "aligned before = not mated now");
assert_eq!((l.color, l.opacity, l.name.as_str()), ([200, 10, 20], 1.0, "Custom"));
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
v["huge"] = Value::from(1e300);
+4 -2
View File
@@ -122,7 +122,8 @@ pub struct Joint {
/// Per value: (min, max) limits.
#[serde(default)]
pub limits: Vec<Option<(f64, f64)>>,
/// Turn B over (its z opposite to A's).
/// Don't mate: B's z along A's instead of against it (by default the origins mate, as two
/// faces meeting).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub flip: bool,
/// Distance from A's frame to B's along z, and a turn about it (radians).
@@ -404,7 +405,8 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
let flip_m = |j: &Joint| {
let mut m = rot_z(j.angle);
m[3][2] = j.offset;
if j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
// Origins mate (B's z against A's, as faces meet) unless flipped.
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
};
// B's world transform from A's (or A's from B's, going the other way).
let through = |j: &Joint, from_a: bool, t: &Mat| -> Option<Mat> {
+1 -1
View File
@@ -303,7 +303,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
b: JointOrigin { occurrence: ob, snap: fb },
values: vec![0.0; n],
limits: limits_param(s, p, kind, cmd)?,
flip: false,
flip: true, // as built: the origins coincide as they are, not mated
offset: 0.0,
angle: 0.0,
suppressed: false,
+17 -8
View File
@@ -45,7 +45,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
&mut s,
"JointAssembleCmdNew",
json!({
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}),
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
// Same frames: B stays put at angle 0.
@@ -89,7 +89,7 @@ fn slider_limits_clamp() {
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"}),
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "limits": [[0, 10]], "name": "Rail"}),
);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
// B now sits on A.
@@ -154,11 +154,7 @@ fn rigid_cycles_solve_and_conflicts_report() {
#[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, "JointAssembleCmdNew", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "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!({}));
@@ -174,7 +170,7 @@ fn motion_links_and_interference() {
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"}),
json!({"type": "slider", "a": {"occurrence": "A", "face": [5, 5, 10]}, "b": {"occurrence": "D", "face": [65, 5, 0]}, "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]}));
@@ -186,3 +182,16 @@ fn motion_links_and_interference() {
let l = run(&mut t, "joint.list", json!({}));
assert_eq!(l["joints"].as_array().map(Vec::len), Some(2));
}
/// Two faces joined mate: B's bottom face lands on A's top face, B on top of A (not inside
/// it); Flip turns B over instead.
#[test]
fn faces_mate_by_default() {
let mut s = two_cubes();
run(&mut s, "JointAssembleCmdNew", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
// B's bottom centre (25, 5, 0) at A's top centre, B above it.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 20.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
}
+1 -1
View File
@@ -32,7 +32,7 @@ fn rail() -> Session {
run(&mut s, "component.activate", json!({"component": "root"}));
let a = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "flip": true, "name": "Rail"}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
assert!((b_x(&s) - 20.0).abs() < 1e-6, "{}", b_x(&s));
s