makepad/libs/model/tests/review_pose.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

708 lines
25 KiB
Rust

use makepad_model::transform::*;
use makepad_model::*;
use makepad_render::{StaticModel, MODEL_VERTEX_FLOATS};
use std::collections::BTreeMap;
fn apply(doc: &mut Document, request: &str, operations: Vec<Operation>) {
doc.apply(
Transaction {
request_id: request.into(),
expected: doc.head(),
operations,
},
None,
)
.unwrap();
}
fn node(object: &str, parent: Option<&str>, transform: Transform) -> Operation {
Operation::Scene(SceneOperation::Node {
object: object.into(),
node: SceneNode {
parent: parent.map(str::to_string),
transform,
..Default::default()
},
})
}
fn points(model: &StaticModel) -> Vec<[f64; 3]> {
assert!(
model.driven_parts.is_empty(),
"review must contain the baked wheel geometry"
);
assert!(model.anim_parts.is_empty());
model
.vertices
.chunks_exact(MODEL_VERTEX_FLOATS)
.map(|v| [v[0] as f64, v[1] as f64, v[2] as f64])
.collect()
}
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
length(sub(a, b))
}
fn assert_points(actual: &[[f64; 3]], expected: &[[f64; 3]]) {
assert!(!actual.is_empty());
// GLB exporters split corner normals/UVs, so compare geometric point
// sets in both directions without depending on vertex packing/order.
for (label, source, target) in [
("unexpected", actual, expected),
("missing", expected, actual),
] {
for point in source {
let nearest = target
.iter()
.map(|candidate| distance(*point, *candidate))
.fold(f64::INFINITY, f64::min);
assert!(
nearest < 2.0e-5,
"{label} review vertex {point:?}; nearest distance {nearest}"
);
}
}
}
fn baked(doc: &Document, pose: &ReviewPose) -> Vec<[f64; 3]> {
let compiled = doc.compile_review_pose(pose, None).unwrap();
let parsed = makepad_gltf::parse_glb_bytes(&compiled.glb).unwrap();
assert!(parsed.document.skins.as_ref().is_none_or(Vec::is_empty));
assert!(parsed
.document
.animations
.as_ref()
.is_none_or(Vec::is_empty));
let model = StaticModel::parse_glb(&compiled.glb).unwrap();
assert_eq!(model.indices.len(), compiled.triangles * 3);
points(&model)
}
fn vehicle() -> Document {
let mut doc = Document::new(Limits::default()).unwrap();
apply(
&mut doc,
"vehicle",
vec![
Operation::Cube {
object: "body".into(),
size: [2., 0.6, 5.],
},
Operation::Cube {
object: "front".into(),
size: [0.3, 0.7, 0.5],
},
Operation::Cube {
object: "rear".into(),
size: [0.25, 0.6, 0.4],
},
Operation::Cube {
object: "hub".into(),
size: [0.1, 0.2, 0.15],
},
node(
"body",
None,
Transform {
translation: [4., 2., -3.],
scale: [1.5; 3],
..Default::default()
},
),
node(
"front",
Some("body"),
Transform {
translation: [1., -0.8, 2.],
scale: [0.8; 3],
..Default::default()
},
),
node(
"rear",
Some("body"),
Transform {
translation: [1., -0.8, -2.],
scale: [0.9; 3],
..Default::default()
},
),
node(
"hub",
Some("front"),
Transform {
translation: [0.3, 0.03, 0.1],
rotation: quat_axis_angle([1., 0., 0.], 0.4).unwrap(),
..Default::default()
},
),
Operation::Scene(SceneOperation::VehicleWheel {
object: "front".into(),
wheel: VehicleWheel {
connection: "wheel_front_left".into(),
pivot: [0.1, 0.05, -0.2],
radius: 0.42,
width: 0.36,
visual: None,
},
}),
Operation::Scene(SceneOperation::VehicleWheel {
object: "rear".into(),
wheel: VehicleWheel {
connection: "wheel_rear_left".into(),
pivot: [-0.05, 0.1, 0.08],
radius: 0.405,
width: 0.3375,
visual: None,
},
}),
],
);
doc
}
fn expected_vehicle(doc: &Document, steer: f64, suspension: f64) -> Vec<[f64; 3]> {
// This explicit formula follows Sandbox's rotation about model-space Y
// with angle -wheel.steer. The authored front pivot is not its origin.
let anchor = [5.62, 0.86, -0.24];
let (sin, cos) = steer.sin_cos();
doc.objects()
.flat_map(|(name, mesh)| {
let frame = doc.scene().world_matrix(name).unwrap();
mesh.vertices().iter().map(move |v| {
let world = transform_point(frame, v.position);
match name {
"front" | "hub" => {
let d = sub(world, anchor);
[
anchor[0] + cos * d[0] - sin * d[2],
world[1] + suspension,
anchor[2] + sin * d[0] + cos * d[2],
]
}
"rear" => [world[0], world[1] + suspension, world[2]],
"body" => world,
other => panic!("unexpected fixture object {other}"),
}
})
})
.collect()
}
#[test]
fn reflected_wheel_child_review_preserves_outward_winding_uvs_and_corner_normals() {
let mut doc = vehicle();
// The wheel frame itself must remain positive and uniformly scaled. Its
// decorative child is a valid reflected object, even during steering.
let mut child = doc.scene().nodes["hub"].clone();
child.transform.scale = [-1., 0.7, 1.2];
let mesh = doc.object("hub").unwrap();
let mut operations = mesh.corners().iter().map(|corner| Operation::CornerNormal {
object: "hub".into(), corner: corner.id,
normal: Some(normalized(mesh.vertex(corner.vertex).unwrap().position).unwrap()),
}).collect::<Vec<_>>();
operations.push(Operation::Scene(SceneOperation::Node { object: "hub".into(), node: child }));
// The compact review fixture has only the two left wheels. Add their
// right counterparts so this regression also passes publication admission.
for (source, size) in [("front", [0.3,0.7,0.5]), ("rear", [0.25,0.6,0.4])] {
let object = format!("{source}_right");
let mut node = doc.scene().nodes[source].clone();
node.transform.translation[0] = -1.;
let mut wheel = doc.scene().wheels[source].clone();
wheel.connection = format!("wheel_{source}_right");
operations.extend([
Operation::Cube { object: object.clone(), size },
Operation::Scene(SceneOperation::Node { object: object.clone(), node }),
Operation::Scene(SceneOperation::VehicleWheel { object, wheel }),
]);
}
apply(&mut doc, "mirrored-child", operations);
let before = doc.to_bytes(None).unwrap();
doc.compile(None).unwrap(); // Fixture is admitted for ordinary publication.
for (steer, suspension) in [(0., 0.), (0.35, 0.12)] {
let product = doc.compile_review_pose(&ReviewPose::Vehicle { steer, suspension }, None).unwrap();
let loaded = makepad_gltf::load_gltf_from_bytes(&product.glb, None).unwrap();
let decoded = loaded.document.meshes_slice().iter().enumerate().flat_map(|(mesh, value)|
(0..value.primitives.len()).map(move |primitive| (mesh, primitive)))
.map(|(mesh, primitive)| makepad_gltf::decode_mesh_primitive(&loaded, mesh, primitive).unwrap()).collect::<Vec<_>>();
assert_eq!(decoded.len(), product.primitives.len());
let index = product.primitives.iter().position(|source| source.object == "hub").unwrap();
let actual = &decoded[index];
let wheel = &doc.scene().wheels["front"];
let anchor = transform_point(doc.scene().world_matrix("front").unwrap(), wheel.pivot);
let rotation = Transform { rotation: quat_axis_angle([0.,1.,0.],-steer).unwrap(), ..Default::default() }.matrix().unwrap();
let translate = |position| Transform { translation: position, ..Default::default() }.matrix().unwrap();
let motion = matrix_mul(translate(add(anchor,[0.,suspension,0.])),matrix_mul(rotation,translate(mul(anchor,-1.))));
let frame = matrix_mul(motion,doc.scene().world_matrix("hub").unwrap());
let center = transform_point(frame,[0.;3]);
for triangle in actual.indices.chunks_exact(3) {
let p: Vec<_> = triangle.iter().map(|&index|actual.positions[index as usize].map(f64::from)).collect();
let geometric_normal = cross(sub(p[1],p[0]),sub(p[2],p[0]));
let middle = mul(add(add(p[0],p[1]),p[2]),1./3.);
assert!(dot(geometric_normal,sub(middle,center))>0., "reflected review triangle faces inward");
}
let mut expected = doc.object("hub").unwrap().clone();
let ids = expected.vertices().iter().map(|v|v.id).collect::<Vec<_>>();
let mut ctx = mesh::Context::default();
expected.transform(&ids,frame,&mut ctx).unwrap();
let expected = expected.triangulate(&mut ctx).unwrap();
for (index, position) in actual.positions.iter().enumerate() {
let normal = actual.normals.as_ref().unwrap()[index].map(f64::from);
let uv = actual.texcoords0.as_ref().unwrap()[index].map(f64::from);
assert!(expected.vertices.iter().any(|vertex|
distance(position.map(f64::from),vertex.position)<2e-5
&& distance(normal,vertex.normal)<2e-5
&& (uv[0]-vertex.uv[0]).abs()<1e-6 && (uv[1]-vertex.uv[1]).abs()<1e-6),
"reflected corner changed its normal or UV association");
}
assert_eq!(doc.to_bytes(None).unwrap(),before);
}
}
#[test]
fn vehicle_review_turns_front_wheel_and_child_about_world_anchor_without_steering_rear() {
let doc = vehicle();
let source = doc.to_bytes(None).unwrap();
let head = doc.head();
let rest = baked(
&doc,
&ReviewPose::Vehicle {
steer: 0.,
suspension: 0.,
},
);
assert_points(&rest, &expected_vehicle(&doc, 0., 0.));
for (steer, suspension) in [
(0.55, 0.),
(-0.55, 0.),
(0., 0.24),
(0.55, 0.24),
(-0.55, -0.2),
] {
let actual = baked(&doc, &ReviewPose::Vehicle { steer, suspension });
assert_points(&actual, &expected_vehicle(&doc, steer, suspension));
assert!(
actual
.iter()
.any(|point| rest.iter().all(|p| distance(*point, *p) > 0.01)),
"pose did not move geometry"
);
assert_eq!(doc.head(), head);
assert_eq!(doc.to_bytes(None).unwrap(), source);
}
let defaults = doc.default_review_poses();
assert_eq!(defaults.len(), 4);
assert!(matches!(defaults[0], ReviewPose::Vehicle { steer, suspension } if steer < 0. && (suspension-0.336).abs()<1e-9));
assert!(matches!(defaults[1], ReviewPose::Vehicle { steer, suspension } if steer > 0. && (suspension-0.336).abs()<1e-9));
assert!(
matches!(defaults[2], ReviewPose::Vehicle { suspension, .. } if (suspension + 0.336).abs() < 1e-9)
);
assert!(
matches!(defaults[3], ReviewPose::Vehicle { suspension, .. } if (suspension + 0.336).abs() < 1e-9)
);
}
#[test]
fn authored_visual_limits_bound_review_without_mutating_the_vehicle() {
let mut doc=vehicle();
let visual=VisualWheelMotion{steer_gain:0.5,steer_max:0.18,compression:0.06,droop:0.09};
let operations=doc.scene().wheels.iter().map(|(object,wheel)|{
let mut wheel=wheel.clone();wheel.visual=Some(visual);
Operation::Scene(SceneOperation::VehicleWheel{object:object.clone(),wheel})
}).collect();
apply(&mut doc,"visual-limits",operations);
let source=doc.to_bytes(None).unwrap();
for (input,travel,angle,height) in [(0.55,0.32,0.18,0.06),(-0.55,-0.32,-0.18,-0.09),(0.1,0.02,0.05,0.02)] {
assert_points(&baked(&doc,&ReviewPose::Vehicle{steer:input,suspension:travel}),&expected_vehicle(&doc,angle,height));
assert_eq!(doc.to_bytes(None).unwrap(),source);
}
let defaults=doc.default_review_poses();
assert!(matches!(defaults[0],ReviewPose::Vehicle{suspension,..} if (suspension-0.06).abs()<1e-9));
assert!(matches!(defaults[3],ReviewPose::Vehicle{suspension,..} if (suspension+0.09).abs()<1e-9));
}
fn rest_root() -> Transform {
Transform {
translation: [2., -1., 3.],
rotation: quat_axis_angle([0., 0., 1.], 0.3).unwrap(),
scale: [1.5, 0.8, 1.2],
}
}
fn rest_child() -> Transform {
Transform {
translation: [0., 2., 0.5],
rotation: quat_axis_angle([1., 0., 0.], -0.2).unwrap(),
scale: [0.8, 1.1, 0.9],
}
}
fn posed_root() -> Transform {
Transform {
translation: [2.4, -0.7, 2.9],
..rest_root()
}
}
fn posed_child() -> Transform {
Transform {
translation: [0.1, 2.2, 0.8],
rotation: quat_axis_angle([0., 0., 1.], 0.6).unwrap(),
scale: [0.9, 1.4, 0.8],
}
}
fn character(weighted: bool) -> Document {
let mut doc = Document::new(Limits::default()).unwrap();
apply(
&mut doc,
"character",
vec![
Operation::Cube {
object: "character".into(),
size: [1., 0.7, 1.3],
},
node(
"character",
None,
Transform {
translation: [2.8, 1.4, 2.6],
rotation: quat_axis_angle([0., 1., 0.], 0.4).unwrap(),
scale: [0.7, 1.3, 0.9],
},
),
Operation::SetSkeleton {
skeleton: Skeleton {
joints: vec![
Joint {
name: "root".into(),
parent: None,
translation: [0.; 3],
},
Joint {
name: "arm".into(),
parent: Some(0),
translation: [0., 2., 0.],
},
],
},
},
Operation::Rig(RigOperation::Rest {
joint: 0,
transform: rest_root(),
}),
Operation::Rig(RigOperation::Rest {
joint: 1,
transform: rest_child(),
}),
Operation::Rig(RigOperation::Pose(Pose {
name: "reach".into(),
joints: BTreeMap::from([(0, posed_root()), (1, posed_child())]),
})),
Operation::SetClip {
clip: AnimationClip {
name: "wave".into(),
channels: vec![
AnimationChannel {
joint: 0,
path: AnimationPath::Translation,
keys: vec![
Keyframe {
time: 0.,
value: [2., -1., 3., 0.],
},
Keyframe {
time: 2.,
value: [2.8, -0.8, 2.6, 0.],
},
],
},
AnimationChannel {
joint: 1,
path: AnimationPath::Rotation,
keys: vec![
Keyframe {
time: 0.,
value: rest_child().rotation,
},
Keyframe {
time: 2.,
value: quat_axis_angle([1., 0., 0.], 0.8).unwrap(),
},
],
},
],
},
},
],
);
if weighted {
let operations = doc
.object("character")
.unwrap()
.vertices()
.iter()
.map(|v| Operation::SetWeights {
object: "character".into(),
vertex: v.id,
weights: vec![
mesh::JointWeight {
joint: 0,
weight: 0.25,
},
mesh::JointWeight {
joint: 1,
weight: 0.75,
},
],
})
.collect();
apply(&mut doc, "weights", operations);
}
let deltas = doc
.object("character")
.unwrap()
.vertices()
.iter()
.map(|v| (v.id, [0.1, 0.2, 0.]))
.collect();
apply(
&mut doc,
"morph",
vec![
Operation::Rig(RigOperation::Morph {
name: "breathing".into(),
object: "character".into(),
deltas,
weight: 0.4,
}),
Operation::Rig(RigOperation::ClipOptions {
name: "wave".into(),
options: ClipOptions {
morph_keys: BTreeMap::from([(
"breathing".into(),
vec![
MorphKey {
time: 0.,
weight: 0.,
},
MorphKey {
time: 2.,
weight: 1.,
},
],
)]),
..Default::default()
},
}),
],
);
doc
}
fn expected_character(
doc: &Document,
root: Transform,
child: Transform,
morph_weight: f64,
) -> Vec<[f64; 3]> {
let rest = [
rest_root().matrix().unwrap(),
matrix_mul(
rest_root().matrix().unwrap(),
rest_child().matrix().unwrap(),
),
];
let posed = [
root.matrix().unwrap(),
matrix_mul(root.matrix().unwrap(), child.matrix().unwrap()),
];
let skin = [
matrix_mul(posed[0], inverse(rest[0]).unwrap()),
matrix_mul(posed[1], inverse(rest[1]).unwrap()),
];
let object = doc.scene().world_matrix("character").unwrap();
doc.object("character")
.unwrap()
.vertices()
.iter()
.map(|v| {
let p = transform_point(
object,
add(v.position, [0.1 * morph_weight, 0.2 * morph_weight, 0.]),
);
add(
mul(transform_point(skin[0], p), 0.25),
mul(transform_point(skin[1], p), 0.75),
)
})
.collect()
}
#[test]
fn named_pose_bakes_blended_skinning_in_world_space_with_nontrivial_rest_and_object_transforms() {
let doc = character(true);
let source = doc.to_bytes(None).unwrap();
let head = doc.head();
let actual = baked(
&doc,
&ReviewPose::Pose {
name: "reach".into(),
},
);
let expected = expected_character(&doc, posed_root(), posed_child(), 0.4);
assert_points(&actual, &expected);
let rest = expected_character(&doc, rest_root(), rest_child(), 0.4);
assert!(actual
.iter()
.any(|p| rest.iter().all(|q| distance(*p, *q) > 0.1)));
assert_eq!(doc.head(), head);
assert_eq!(doc.to_bytes(None).unwrap(), source);
}
#[test]
fn clip_samples_bake_interpolated_joints_and_morphs_and_preserve_source() {
let doc = character(true);
let source = doc.to_bytes(None).unwrap();
let head = doc.head();
let mut samples = Vec::new();
for time in [0., 0.5, 1.5, 2.] {
let actual = baked(
&doc,
&ReviewPose::Clip {
name: "wave".into(),
time,
},
);
let root = Transform {
translation: [2. + 0.4 * time, -1. + 0.1 * time, 3. - 0.2 * time],
..rest_root()
};
let child = Transform {
rotation: quat_axis_angle([1., 0., 0.], -0.2 + 0.5 * time).unwrap(),
..rest_child()
};
assert_points(&actual, &expected_character(&doc, root, child, time / 2.));
samples.push(actual);
assert_eq!(doc.head(), head);
assert_eq!(doc.to_bytes(None).unwrap(), source);
}
assert!(samples[1]
.iter()
.any(|p| samples[2].iter().all(|q| distance(*p, *q) > 0.05)));
let defaults = doc.default_review_poses();
assert_eq!(defaults.len(), 3);
assert!(matches!(&defaults[0], ReviewPose::Pose { name } if name == "reach"));
assert!(matches!(&defaults[1], ReviewPose::Clip { name, time: 0.5 } if name == "wave"));
assert!(matches!(&defaults[2], ReviewPose::Clip { name, time: 1.5 } if name == "wave"));
}
#[test]
fn malformed_motion_requests_are_rejected() {
for text in [
"[]",
"{}",
r#"{"kind":"unknown"}"#,
r#"{"kind":"vehicle","steer":0,"suspension":0,"extra":true}"#,
r#"{"kind":"vehicle","steer":0}"#,
r#"{"kind":"vehicle","steer":"left","suspension":0}"#,
r#"{"kind":"vehicle","steer":1.21,"suspension":0}"#,
r#"{"kind":"vehicle","steer":0,"suspension":5.01}"#,
r#"{"kind":"pose","name":""}"#,
r#"{"kind":"clip","name":"wave","time":-0.1}"#,
r#"{"kind":"clip","name":"wave","time":3601}"#,
r#"{"kind":"clip","name":"wave"}"#,
] {
assert!(
ReviewPose::from_json(&json::parse(text.as_bytes()).unwrap()).is_err(),
"accepted {text}"
);
}
let bad = json::obj(vec![
("kind", json::s("vehicle")),
("steer", json::Value::F64(f64::NAN)),
("suspension", json::Value::Int(0)),
]);
assert!(ReviewPose::from_json(&bad).is_err());
let bad_name = json::obj(vec![
("kind", json::s("pose")),
("name", json::s(&"p".repeat(97))),
]);
assert!(ReviewPose::from_json(&bad_name).is_err());
}
#[test]
fn invalid_typed_poses_and_cancellation_refuse_without_mutating_documents() {
let car = vehicle();
let person = character(true);
for doc in [&car, &person] {
let source = doc.to_bytes(None).unwrap();
let head = doc.head();
for pose in [
ReviewPose::Vehicle {
steer: f64::NAN,
suspension: 0.,
},
ReviewPose::Vehicle {
steer: 2.,
suspension: 0.,
},
ReviewPose::Vehicle {
steer: 0.,
suspension: 6.,
},
ReviewPose::Pose {
name: "missing".into(),
},
ReviewPose::Clip {
name: "missing".into(),
time: 0.,
},
ReviewPose::Clip {
name: "wave".into(),
time: f64::NAN,
},
ReviewPose::Clip {
name: "wave".into(),
time: -1.,
},
ReviewPose::Clip {
name: "wave".into(),
time: 2.01,
},
] {
assert!(
doc.compile_review_pose(&pose, None).is_err(),
"accepted {pose:?}"
);
}
for pose in doc.default_review_poses() {
assert_eq!(
doc.compile_review_pose(&pose, Some(&|| true)).unwrap_err(),
Error::Mesh(mesh::MeshError::Cancelled)
);
}
assert_eq!(doc.head(), head);
assert_eq!(doc.to_bytes(None).unwrap(), source);
}
}
#[test]
fn skeleton_without_weights_fails_instead_of_returning_a_rest_pose() {
let doc = character(false);
let source = doc.to_bytes(None).unwrap();
let head = doc.head();
for pose in [
ReviewPose::Pose {
name: "reach".into(),
},
ReviewPose::Clip {
name: "wave".into(),
time: 0.5,
},
] {
assert_eq!(
doc.compile_review_pose(&pose, None).unwrap_err(),
Error::Invalid("motion review requires normalized vertex weights")
);
}
assert_eq!(doc.head(), head);
assert_eq!(doc.to_bytes(None).unwrap(), source);
}