makepad/libs/model/tests/rig_controls.rs
Admin d2130e6550 libs: model, mesh_edit, csg, scene, sim, render, xr
Squash of 25 work commits (Sep 1–12):
  237da90  render: the sprite lane hands the screen draw back the way it found it
  895b9a7  particles: an emitter can ride a body's own frame
  914471f  ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
  3fcccf3  sim: the whole world is implicitly editable, and one seam says where the ground is
  5692fab  sim: the landform world proves itself — walker through the tunnel included
  f4af6df  sim: terrain knows who changed it — a plan layer over player history
  adbb078  render: a water volume can be physics without a picture
  c17480f  render: a non-rigid body may carry its own orientation
  acad401  sim: an agent with no route holds and retries instead of walking into the wall
  22b2bf9  sim + chat: the composed world surface takes a map floor; the chat gets plan tools
  faf8112  web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
  d3472eb  tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
  4946c9e  sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
  4317f58  sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
  9791279  render: support rigged models and custom materials across viewers
  3ce2792  sim: use deck geometry for collision and sensing
  c8b79ff  Add portable PBR, rig and soft-body authoring support
  108d423  Add transactional polygon modeling and editable asset documents
  38f4d3b  Refine editable modeling and firm yarn character behavior
  08a2637  sim: add entity-owned lights and vehicle headlights
  00d96a7  render: add clustered lights, local shadows and incremental GI
  cf916af  render: import glTF asset extensions and wire clustered GI
  c6d2cca  model: cut transaction memory and raise capacity limits
  41af47a  raytrace: add a CPU probe-ray BVH budget example
  c963d0a  libs: the game sim splits into makepad-scene and makepad-soft-body; render, model, fab and the asset importer retarget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00

297 lines
14 KiB
Rust

use makepad_model::transform::*;
use makepad_model::*;
use std::collections::{BTreeMap, BTreeSet};
fn skeleton() -> Skeleton {
Skeleton {
joints: vec![
Joint {
name: "root".into(),
parent: None,
translation: [0.; 3],
},
Joint {
name: "middle".into(),
parent: Some(0),
translation: [0., 1., 0.],
},
Joint {
name: "end".into(),
parent: Some(1),
translation: [0., 1., 0.],
},
],
}
}
fn points(local: &[Transform]) -> Vec<[f64; 3]> {
let mut world = IDENTITY_MATRIX;
local
.iter()
.map(|t| {
world = matrix_mul(world, t.matrix().unwrap());
transform_point(world, [0.; 3])
})
.collect()
}
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
a.iter()
.zip(b)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt()
}
fn apply(d: &mut Document, name: &str, operations: Vec<Operation>) {
d.apply(
Transaction {
request_id: name.into(),
expected: d.head(),
operations,
},
None,
)
.unwrap();
}
#[test]
fn analytic_ik_reaches_target_preserves_lengths_and_refuses_unreachable_chain() {
let skel = skeleton();
let mut local = skel
.joints
.iter()
.map(|j| Transform {
translation: j.translation,
..Default::default()
})
.collect::<Vec<_>>();
let target = [1., 1., 0.];
solve_ik(&skel, &mut local, 0, 1, 2, target, [0., 0., 1.], false).unwrap();
let p = points(&local);
assert!(distance(p[2], target) < 1e-7);
assert!((distance(p[0], p[1]) - 1.).abs() < 1e-8);
assert!((distance(p[1], p[2]) - 1.).abs() < 1e-8);
let before = local.clone();
assert!(solve_ik(
&skel,
&mut local,
0,
1,
2,
[4., 4., 0.],
[0., 0., 1.],
false
)
.is_err());
assert_eq!(local, before);
solve_ik(&skel, &mut local, 0, 1, 2, [4., 4., 0.], [0., 0., 1.], true).unwrap();
let p = points(&local);
assert!((distance(p[0], p[2]) - 2.).abs() < 1e-6);
let mut d = Document::new(Limits::default()).unwrap();
apply(
&mut d,
"skeleton",
vec![
Operation::SetSkeleton { skeleton: skel },
Operation::Rig(RigOperation::Pose(Pose {
name: "rest".into(),
joints: BTreeMap::new(),
})),
],
);
let source = d.to_bytes(None).unwrap();
let bad = Transaction {
request_id: "bad-ik".into(),
expected: d.head(),
operations: vec![Operation::Rig(RigOperation::Ik {
pose: "rest".into(),
root: 0,
middle: 1,
end: 2,
target: [0., 8., 0.],
pole: [0., 0., 1.],
clamp_reach: false,
})],
};
assert!(d.apply(bad, None).is_err());
assert_eq!(d.to_bytes(None).unwrap(), source);
}
#[test]
fn locked_weights_survive_smoothing_binding_and_limit_failure() {
let mut d = Document::new(Limits::default()).unwrap();
apply(
&mut d,
"body",
vec![
Operation::Cube {
object: "body".into(),
size: [1.; 3],
},
Operation::SetSkeleton {
skeleton: skeleton(),
},
],
);
let vertices = d
.object("body")
.unwrap()
.vertices()
.iter()
.map(|v| v.id)
.collect::<Vec<_>>();
let weights = vertices
.iter()
.map(|vertex| Operation::SetWeights {
object: "body".into(),
vertex: *vertex,
weights: vec![
mesh::JointWeight {
joint: 0,
weight: 0.25,
},
mesh::JointWeight {
joint: 1,
weight: 0.25,
},
mesh::JointWeight {
joint: 2,
weight: 0.5,
},
],
})
.collect();
apply(&mut d, "weights", weights);
apply(
&mut d,
"locks",
vec![
Operation::Rig(RigOperation::WeightLocks {
object: "body".into(),
joints: BTreeSet::from([0]),
}),
Operation::Rig(RigOperation::Weights {
object: "body".into(),
vertices: vec![],
edit: WeightEdit::Smooth {
iterations: 2,
factor: 0.8,
},
}),
Operation::Rig(RigOperation::Weights {
object: "body".into(),
vertices: vec![],
edit: WeightEdit::Bind {
max_influences: 3,
power: 2.,
},
}),
],
);
for v in d.object("body").unwrap().vertices() {
assert!((v.weights.iter().find(|w| w.joint == 0).unwrap().weight - 0.25).abs() < 1e-12);
assert!((v.weights.iter().map(|w| w.weight).sum::<f64>() - 1.).abs() < 1e-12);
}
let source = d.to_bytes(None).unwrap();
let failed = Transaction {
request_id: "too-few".into(),
expected: d.head(),
operations: vec![Operation::Rig(RigOperation::Weights {
object: "body".into(),
vertices: vec![],
edit: WeightEdit::Normalize { max_influences: 1 },
})],
};
assert!(d.apply(failed, None).is_err());
assert_eq!(source, d.to_bytes(None).unwrap());
let opened = Document::from_bytes(&source, Limits::default(), None).unwrap();
assert_eq!(opened.rig(), d.rig());
assert_eq!(opened.to_bytes(None).unwrap(), source);
}
#[test]
fn parsed_controls_morph_topology_constraints_and_clip_options_roundtrip() {
let mut d = Document::new(Limits::default()).unwrap();
apply(
&mut d,
"body",
vec![
Operation::Cube {
object: "body".into(),
size: [1.; 3],
},
Operation::SetSkeleton {
skeleton: skeleton(),
},
Operation::AutoWeights {
object: "body".into(),
},
],
);
let id = d.object("body").unwrap().vertices()[0].id.0;
let commands = format!(
r#"[{{"op":"rig_rest","joint":0,"transform":{{"rotation":[0,0,0,1]}}}},{{"op":"pose","name":"rest","joints":[]}},{{"op":"pose","name":"raised","joints":[{{"joint":0,"transform":{{"translation":[0,0.5,0]}}}}]}},{{"op":"bake_clip","name":"bounce","poses":[{{"time":0,"pose":"rest"}},{{"time":1,"pose":"raised"}}],"fps":4}},{{"op":"morph","name":"shape","object":"body","deltas":[{{"vertex":"{id}","delta":[0.1,0,0]}}]}},{{"op":"clip_options","name":"bounce","interpolation":"cubic","root_motion":true,"events":[{{"time":0.5,"name":"peak","payload":"hello"}}],"morph_keys":[{{"morph":"shape","keys":[{{"time":0,"weight":0}},{{"time":1,"weight":1}}]}}]}}]"#
);
let ops = parse_operations(&json::parse(commands.as_bytes()).unwrap(), d.limits()).unwrap();
apply(&mut d, "controls", ops);
let source = d.to_bytes(None).unwrap();
let reopened = Document::from_bytes(&source, Limits::default(), None).unwrap();
assert_eq!(reopened.rig(), d.rig());
assert_eq!(reopened.to_bytes(None).unwrap(), source);
assert_ne!(d.rig().morphs["shape"].topology, [0; 32]);
assert_eq!(d.rig().clip_options["bounce"].events[0].payload, "hello");
let face = d.object("body").unwrap().faces()[0].id;
let changed = Transaction {
request_id: "invalid-topology".into(),
expected: d.head(),
operations: vec![Operation::DeleteFaces {
object: "body".into(),
faces: vec![face],
}],
};
assert!(d.apply(changed, None).is_err());
assert_eq!(d.to_bytes(None).unwrap(), source);
let cycle = Transaction {
request_id: "cycle".into(),
expected: d.head(),
operations: vec![
Operation::Rig(RigOperation::Constraint(RigConstraint {
name: "a".into(),
joint: 0,
enabled: true,
kind: ConstraintKind::Copy {
target: 1,
translation: true,
rotation: false,
scale: false,
influence: 1.,
},
})),
Operation::Rig(RigOperation::Constraint(RigConstraint {
name: "b".into(),
joint: 1,
enabled: true,
kind: ConstraintKind::Copy {
target: 0,
translation: true,
rotation: false,
scale: false,
influence: 1.,
},
})),
],
};
assert!(d.apply(cycle, None).is_err());
assert_eq!(d.to_bytes(None).unwrap(), source);
}
#[test]
fn mirrored_bones_preserve_existing_indices_weights_and_world_rest_symmetry(){
let mut d=Document::new(Limits::default()).unwrap();let skel=Skeleton{joints:vec![Joint{name:"center".into(),parent:None,translation:[0.;3]},Joint{name:"left".into(),parent:Some(0),translation:[1.,0.,0.]},Joint{name:"left_tip".into(),parent:Some(1),translation:[0.,1.,0.]}]};apply(&mut d,"base",vec![Operation::Cube{object:"body".into(),size:[1.;3]},Operation::SetSkeleton{skeleton:skel},Operation::AutoWeights{object:"body".into()},Operation::Rig(RigOperation::Rest{joint:1,transform:Transform{translation:[1.,0.,0.],rotation:quat_axis_angle([0.,0.,1.],0.4).unwrap(),scale:[1.;3]}})]);let before_mesh=d.object("body").unwrap().clone();let before_skeleton=d.skeleton().unwrap().clone();let before_world=d.rig().global_rest(d.skeleton().unwrap()).unwrap();
let mirror=RigOperation::MirrorJoints{joints:vec![1,2],axis:0,names:BTreeMap::from([(1,"right".into()),(2,"right_tip".into())])};let json=mirror.value();assert_eq!(RigOperation::parse(&json,d.limits()).unwrap().unwrap(),mirror);apply(&mut d,"mirror",vec![Operation::Rig(mirror)]);let after=d.skeleton().unwrap();assert_eq!(&after.joints[..3],&before_skeleton.joints);assert_eq!(after.joints[3].parent,Some(0));assert_eq!(after.joints[4].parent,Some(3));assert_eq!(d.object("body").unwrap(),&before_mesh);
let world=d.rig().global_rest(after).unwrap();let mut f=IDENTITY_MATRIX;f[0][0]= -1.;for(a,b)in [(1,3),(2,4)]{let expected=matrix_mul(matrix_mul(f,before_world[a]),f);for i in 0..4{for j in 0..4{assert!((world[b][i][j]-expected[i][j]).abs()<1e-8);}}}let source=d.to_bytes(None).unwrap();assert_eq!(Document::from_bytes(&source,Limits::default(),None).unwrap().to_bytes(None).unwrap(),source);
let bad=Transaction{request_id:"collision".into(),expected:d.head(),operations:vec![Operation::Rig(RigOperation::MirrorJoints{joints:vec![1],axis:0,names:BTreeMap::from([(1,"right".into())])})]};assert!(d.apply(bad,None).is_err());assert_eq!(d.to_bytes(None).unwrap(),source);
}
#[test]
fn mirror_refuses_local_shear_and_constraint_order_includes_target_ancestors(){
let mut d=Document::new(Limits::default()).unwrap();let skel=Skeleton{joints:vec![Joint{name:"root".into(),parent:None,translation:[0.;3]},Joint{name:"target_parent".into(),parent:Some(0),translation:[0.;3]},Joint{name:"target".into(),parent:Some(1),translation:[0.,1.,0.]},Joint{name:"aim".into(),parent:Some(0),translation:[0.;3]}]};apply(&mut d,"setup",vec![Operation::SetSkeleton{skeleton:skel},Operation::Rig(RigOperation::Pose(Pose{name:"p".into(),joints:BTreeMap::new()})),Operation::Rig(RigOperation::Constraint(RigConstraint{name:"a_aim".into(),joint:3,enabled:true,kind:ConstraintKind::Aim{target:2,axis:[0.,1.,0.],influence:1.}})),Operation::Rig(RigOperation::Constraint(RigConstraint{name:"z_parent".into(),joint:1,enabled:true,kind:ConstraintKind::Limit{min_translation:[2.,0.,0.],max_translation:[2.,0.,0.],min_scale:[1.;3],max_scale:[1.;3],max_angle:std::f64::consts::PI}})),Operation::Rig(RigOperation::SolvePose{pose:"p".into()})]);let rotation=d.rig().poses["p"].joints[&3].rotation;let facing=quat_rotate(rotation,[0.,1.,0.]);assert!(distance(facing,normalized([2.,1.,0.]).unwrap())<1e-8);
apply(&mut d,"scaled-root",vec![Operation::Rig(RigOperation::Rest{joint:0,transform:Transform{rotation:quat_axis_angle([0.,0.,1.],0.4).unwrap(),scale:[2.,1.,1.],..Default::default()}})]);let source=d.to_bytes(None).unwrap();let tx=Transaction{request_id:"shear".into(),expected:d.head(),operations:vec![Operation::Rig(RigOperation::MirrorJoints{joints:vec![1],axis:0,names:BTreeMap::from([(1,"mirrored".into())])})]};assert!(d.apply(tx,None).is_err());assert_eq!(d.to_bytes(None).unwrap(),source);
}
#[test]
fn public_ik_bad_hierarchy_and_overlapping_constraint_writers_fail_atomically(){
let mut skel=skeleton();let mut local=vec![Transform::default();3];let before=local.clone();skel.joints[0].parent=Some(1);assert!(solve_ik(&skel,&mut local,0,1,2,[1.,1.,0.],[0.,0.,1.],false).is_err());assert_eq!(local,before);
let mut d=Document::new(Limits::default()).unwrap();let mut skel=skeleton();skel.joints.push(Joint{name:"target".into(),parent:Some(0),translation:[1.,1.,0.]});apply(&mut d,"base",vec![Operation::SetSkeleton{skeleton:skel}]);let source=d.to_bytes(None).unwrap();let tx=Transaction{request_id:"overlap".into(),expected:d.head(),operations:vec![Operation::Rig(RigOperation::Constraint(RigConstraint{name:"ik".into(),joint:0,enabled:true,kind:ConstraintKind::TwoBoneIk{middle:1,end:2,target:3,pole:[0.,0.,1.],clamp_reach:false}})),Operation::Rig(RigOperation::Constraint(RigConstraint{name:"middle_limit".into(),joint:1,enabled:true,kind:ConstraintKind::Limit{min_translation:[-2.;3],max_translation:[2.;3],min_scale:[1.;3],max_scale:[1.;3],max_angle:1.}}))]};assert!(d.apply(tx,None).is_err());assert_eq!(d.to_bytes(None).unwrap(),source);
}