Squash of 25 work commits (Sep 1–12):237da90render: the sprite lane hands the screen draw back the way it found it895b9a7particles: an emitter can ride a body's own frame914471fai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets3fcccf3sim: the whole world is implicitly editable, and one seam says where the ground is5692fabsim: the landform world proves itself — walker through the tunnel includedf4af6dfsim: terrain knows who changed it — a plan layer over player historyadbb078render: a water volume can be physics without a picturec17480frender: a non-rigid body may carry its own orientationacad401sim: an agent with no route holds and retries instead of walking into the wall22b2bf9sim + chat: the composed world surface takes a map floor; the chat gets plan toolsfaf8112web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the webd3472ebtsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant4946c9esim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap4317f58sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks9791279render: support rigged models and custom materials across viewers3ce2792sim: 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>
297 lines
14 KiB
Rust
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);
|
|
}
|