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>
708 lines
25 KiB
Rust
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);
|
|
}
|