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

340 lines
26 KiB
Rust

use makepad_model::*;
use makepad_render::skin::SkinnedModel;
fn apply(d: &mut Document, id: &str, operations: Vec<Operation>) -> Result<Applied> {
d.apply(Transaction { request_id: id.into(), expected: d.head(), operations }, None)
}
fn commands(d: &mut Document, id: &str, text: &str) -> Result<Applied> {
let operations = parse_operations(&json::parse(text.as_bytes()).unwrap(), d.limits())?;
apply(d, id, operations)
}
fn fixture() -> Document {
let mut d = Document::new(Limits { max_joints: 128, ..Default::default() }).unwrap();
commands(&mut d, "geometry", r#"[
{"op":"sphere","object":"body","radius":0.45,"segments":16,"rings":8},
{"op":"sphere","object":"eye","radius":0.14,"segments":8,"rings":4},
{"op":"sphere","object":"pupil","radius":0.05,"segments":8,"rings":4},
{"op":"sphere","object":"fuzz","radius":0.47,"segments":8,"rings":4}
]"#).unwrap();
for (object, position) in [("body",[0.,0.8,0.]),("fuzz",[0.,0.8,0.]),("eye",[0.18,0.95,-0.4]),("pupil",[0.18,0.95,-0.53])] {
apply(&mut d, &format!("place-{object}"), vec![Operation::Scene(SceneOperation::Node { object: object.into(), node: SceneNode { transform: Transform { translation: position, ..Default::default() }, ..Default::default() } })]).unwrap();
}
d
}
fn bind(d: &mut Document) {
commands(d,"soft",r#"[{"op":"soft_body_bind","object":"body","deform_objects":["fuzz"],"attachments":[{"name":"eyes","objects":["eye","pupil"],"pivot":[0.18,0.95,-0.4]}]}]"#).unwrap();
}
#[test]
fn cage_binding_preserves_mesh_contacts_source_and_portable_skin_metadata() {
let mut d=fixture();
let original=d.object("body").unwrap().vertices().iter().map(|v|(v.id,v.position)).collect::<Vec<_>>();
bind(&mut d);
let m=d.soft_body().unwrap();
assert_eq!((m.rest_positions.len(),m.tetrahedra.len(),d.skeleton().unwrap().joints.len()),(43,80,82));
assert_eq!(d.object("body").unwrap().vertices().iter().map(|v|(v.id,v.position)).collect::<Vec<_>>(),original);
for sample in &m.surface_samples {
let p=m.rest_point(*sample).unwrap();
assert!(original.iter().any(|(_,q)| (0..3).map(|i|(p[i] as f64-q[i]-if i==1{0.8}else{0.}).powi(2)).sum::<f64>() < 1e-10),"contacts must lie on visible body, not cage or fuzz");
}
let source=d.to_bytes(None).unwrap();
let round=Document::from_bytes(&source,d.limits().clone(),None).unwrap();
assert_eq!(round.soft_body(),d.soft_body());
assert_eq!(round.head(),d.head());
assert_eq!(round.to_bytes(None).unwrap(),source);
let product=round.compile(None).unwrap();
let loaded=SkinnedModel::parse_glb_validated(&product.glb).unwrap();
assert_eq!(loaded.soft_body(),round.soft_body());
assert!(std::sync::Arc::ptr_eq(&loaded.soft_body_shared().unwrap(),&loaded.soft_body_shared().unwrap()));
assert_eq!(loaded.joint_count(),82);
}
#[test]
fn budget_bad_config_and_post_bind_geometry_changes_are_atomic() {
let mut small=Document::new(Limits::default()).unwrap();
let before=small.to_bytes(None).unwrap();
assert!(commands(&mut small,"over-budget",r#"[{"op":"soft_body_bind","object":"body"}]"#).is_err());
assert_eq!(small.to_bytes(None).unwrap(),before);
let mut d=fixture(); let before=d.to_bytes(None).unwrap();
for text in [r#"[{"op":"soft_body_bind","object":"body","config":{"substeps":0}}]"#,r#"[{"op":"soft_body_bind","object":"body","config":{"guess":2}}]"#,r#"[{"op":"soft_body_bind","object":"body","attachments":[{"name":"eye","objects":["body"]}]}]"#] {
assert!(commands(&mut d,"bad",text).is_err()); assert_eq!(d.to_bytes(None).unwrap(),before);
}
bind(&mut d);
let before=d.to_bytes(None).unwrap(); let vertices=d.object("body").unwrap().vertices().iter().map(|v|v.id).collect();
assert!(apply(&mut d,"move",vec![Operation::Transform{object:"body".into(),vertices,matrix:Transform{translation:[0.1,0.,0.],..Default::default()}.matrix().unwrap()}]).is_err());
assert_eq!(d.to_bytes(None).unwrap(),before);
let joint=d.soft_body().unwrap().attachments[0].joint as u32;
assert!(apply(&mut d,"animate-physical-frame",vec![Operation::SetClip{clip:AnimationClip{name:"bad".into(),channels:vec![AnimationChannel{joint,path:AnimationPath::Rotation,keys:vec![Keyframe{time:0.,value:[0.,0.,0.,1.]},Keyframe{time:1.,value:transform::quat_axis_angle([1.,0.,0.],0.2).unwrap()}]}]}}]).is_err());
assert_eq!(d.to_bytes(None).unwrap(),before);
let metadata=d.soft_body().cloned();
apply(&mut d,"texture",vec![Operation::TextureSolid{material:0,width:8,height:8,color:[240,80,140]}]).unwrap();
assert_eq!(d.soft_body(),metadata.as_ref());
}
#[test]
fn rigid_frame_accepts_animated_child_and_refuses_malformed_metadata() {
let mut d=fixture();bind(&mut d);
let mut skeleton=d.skeleton().unwrap().clone();let frame=d.soft_body().unwrap().attachments[0].joint as u32;
let gaze=skeleton.joints.len() as u32;
skeleton.joints.push(Joint{name:"gaze".into(),parent:Some(frame),translation:[0.;3]});
let mut operations=vec![Operation::SetSkeleton{skeleton}];
operations.extend(parse_operations(&json::parse(format!(r#"[{{"op":"weight_edit","object":"pupil","vertices":[],"edit":{{"type":"assign","joint":{gaze}}}}}]"#).as_bytes()).unwrap(),d.limits()).unwrap());
operations.push(Operation::SetClip{clip:AnimationClip{name:"idle".into(),channels:vec![AnimationChannel{joint:gaze,path:AnimationPath::Rotation,keys:vec![Keyframe{time:0.,value:[0.,0.,0.,1.]},Keyframe{time:1.,value:transform::quat_axis_angle([0.,1.,0.],0.1).unwrap()}]}]}});
apply(&mut d,"gaze",operations).unwrap();
let source=d.to_bytes(None).unwrap();let reopened=Document::from_bytes(&source,d.limits().clone(),None).unwrap();assert_eq!(reopened.head(),d.head());
let glb=d.compile(None).unwrap().glb;let model=SkinnedModel::parse_glb_validated(&glb).unwrap();assert_eq!(model.clips[0].name,"idle");
let good=d.soft_body().unwrap();
for bad in [ {let mut m=good.clone();m.version=9;m}, {let mut m=good.clone();m.surface_samples[0].weights=[1.;4];m}, {let mut m=good.clone();m.tet_joints[0]=m.attachments[0].joint;m}, {let mut m=good.clone();m.attachments[0].rest_pivot[0]+=0.1;m}, {let mut m=good.clone();m.tetrahedra[0].swap(0,1);m} ] {
assert!(makepad_gltf::SoftBodyMetadata::from_value(&bad.to_value(),83).is_err());
}
}
#[test]
fn compact_rigid_assignment_respects_locked_weights_and_is_atomic() {
let mut d=fixture();bind(&mut d);let joint=d.soft_body().unwrap().attachments[0].joint as u32;
apply(&mut d,"lock",vec![Operation::Rig(RigOperation::WeightLocks{object:"pupil".into(),joints:std::collections::BTreeSet::from([joint])})]).unwrap();
let before=d.to_bytes(None).unwrap();
for target in [0,127] {assert!(commands(&mut d,"bad-assign",&format!(r#"[{{"op":"weight_edit","object":"pupil","vertices":[],"edit":{{"type":"assign","joint":{target}}}}}]"#)).is_err());assert_eq!(d.to_bytes(None).unwrap(),before);}
commands(&mut d,"same-assign",&format!(r#"[{{"op":"weight_edit","object":"pupil","vertices":[],"edit":{{"type":"assign","joint":{joint}}}}}]"#)).unwrap();
}
#[test]
fn imported_glb_rejects_metadata_skin_drift_before_model_publication() {
let mut d=fixture();bind(&mut d);let original=d.compile(None).unwrap().glb;
let json_len=u32::from_le_bytes(original[12..16].try_into().unwrap()) as usize;
let mut value=json::parse_depth(&original[20..20+json_len],32).unwrap();
let json::Value::Obj(fields)=&mut value else{unreachable!()};
let json::Value::Arr(nodes)=&mut fields.iter_mut().find(|(name,_)|name=="nodes").unwrap().1 else{unreachable!()};
let node=nodes.iter_mut().find(|n|n.get("name").and_then(json::Value::as_str)==Some("__soft_body_tet_00")).unwrap();
let json::Value::Obj(fields)=node else{unreachable!()};
fields.retain(|(key,_)|key!="translation");fields.push(("translation".into(),json::Value::Arr(vec![json::Value::F64(0.02),json::Value::Int(0),json::Value::Int(0)])));
let mut encoded=value.to_json().into_bytes();while encoded.len()%4!=0{encoded.push(b' ');}
let mut changed=original[..12].to_vec();changed.extend_from_slice(&(encoded.len() as u32).to_le_bytes());changed.extend_from_slice(&original[16..20]);changed.extend(encoded);changed.extend_from_slice(&original[20+json_len..]);
let len=changed.len() as u32;changed[8..12].copy_from_slice(&len.to_le_bytes());
assert!(SkinnedModel::parse_glb_validated(&changed).err().unwrap().contains("affine joint"));
// Even permissive legacy entry points enforce the contract once marked.
assert!(SkinnedModel::parse_glb(&changed).is_err());
}
#[test]
fn engine_joint_budget_is_explicit_per_document_and_required_for_source_reopen() {
let mut engine=Engine::new(Limits::default());
for budget in [0,129,usize::MAX]{assert!(engine.open_with_joint_budget("bad",None,Some(budget),None).is_err());assert!(engine.document("bad").is_err());}
let opened=engine.open_with_joint_budget("large",None,Some(128),None).unwrap();assert_eq!(opened.get("max_joints").and_then(json::Value::as_u64),Some(128));
let ops=json::parse(br#"[{"op":"sphere","object":"body","radius":0.4,"segments":8,"rings":4},{"op":"soft_body_bind","object":"body"}]"#).unwrap();
engine.execute("model.apply",&json::obj(vec![("document",json::s("large")),("request_id",json::s("bind")),("expected",head_json(engine.document("large").unwrap().head())),("operations",ops)]),None).unwrap();
let before=engine.document("large").unwrap().to_snapshot_bytes(None).unwrap();
assert!(engine.open_with_joint_budget("large",None,Some(64),None).is_err());
assert_eq!(engine.document("large").unwrap().to_snapshot_bytes(None).unwrap(),before);
engine.open("large",None,None).unwrap();assert_eq!(engine.document("large").unwrap().limits().max_joints,128);
engine.open("ordinary",None,None).unwrap();assert_eq!(engine.document("ordinary").unwrap().limits().max_joints,64);
let mut fresh=Engine::new(Limits::default());
assert!(fresh.open("published",Some(&before),None).is_err());assert!(fresh.document("published").is_err());
fresh.open_with_joint_budget("published",Some(&before),Some(128),None).unwrap();
assert_eq!(fresh.document("published").unwrap().head(),engine.document("large").unwrap().head());
assert_eq!(fresh.document("published").unwrap().soft_body(),engine.document("large").unwrap().soft_body());
}
#[test]
fn published_soft_source_can_unbind_reshape_and_rebind_without_losing_animated_rig() {
use std::collections::{BTreeMap, BTreeSet};
let mut d=fixture();
apply(&mut d,"existing-rig",vec![Operation::SetSkeleton{skeleton:Skeleton{joints:vec![
Joint{name:"root".into(),parent:None,translation:[0.1,0.2,0.]},
Joint{name:"arm".into(),parent:Some(0),translation:[0.3,0.,0.]},
]}},Operation::Rig(RigOperation::Rest{joint:1,transform:Transform{translation:[0.3,0.,0.],rotation:transform::quat_axis_angle([0.,0.,1.],0.2).unwrap(),..Default::default()}})]).unwrap();
bind(&mut d);
let frame=d.soft_body().unwrap().attachments[0].joint as u32;
let cells=d.soft_body().unwrap().tet_joints.clone();
let mut skeleton=d.skeleton().unwrap().clone();let gaze=skeleton.joints.len() as u32;
skeleton.joints.push(Joint{name:"gaze".into(),parent:Some(frame),translation:[0.,0.,-0.03]});
let gaze_rest=Transform{translation:[0.,0.,-0.03],rotation:transform::quat_axis_angle([0.,1.,0.],0.05).unwrap(),..Default::default()};
apply(&mut d,"face-controls",vec![
Operation::SetSkeleton{skeleton},
Operation::Rig(RigOperation::Rest{joint:gaze,transform:gaze_rest}),
Operation::Rig(RigOperation::Weights{object:"pupil".into(),vertices:vec![],edit:WeightEdit::Assign{joint:gaze}}),
Operation::Rig(RigOperation::WeightLocks{object:"pupil".into(),joints:BTreeSet::from([gaze])}),
Operation::Rig(RigOperation::Pose(Pose{name:"look".into(),joints:BTreeMap::from([(gaze,gaze_rest)])})),
Operation::Rig(RigOperation::Constraint(RigConstraint{name:"gaze-limit".into(),joint:gaze,enabled:true,
kind:ConstraintKind::Limit{min_translation:[-1.;3],max_translation:[1.;3],min_scale:[0.5;3],max_scale:[2.;3],max_angle:1.}})),
Operation::SetClip{clip:AnimationClip{name:"idle".into(),channels:vec![AnimationChannel{joint:gaze,path:AnimationPath::Rotation,
keys:vec![Keyframe{time:0.,value:gaze_rest.rotation},Keyframe{time:1.,value:transform::quat_axis_angle([0.,1.,0.],0.15).unwrap()}]}]}},
Operation::Scene(SceneOperation::Socket(Socket{name:"gaze-socket".into(),attachment:Attachment::Joint(gaze),transform:Transform::default()})),
]).unwrap();
let rig=d.rig().clone();let clips=d.clips().clone();let sockets=d.scene().sockets.clone();let count=d.skeleton().unwrap().joints.len();
let weights=d.object("pupil").unwrap().vertices().iter().map(|v|(v.id,v.weights.clone())).collect::<Vec<_>>();
let source=d.to_snapshot_bytes(None).unwrap();
d=Document::from_bytes(&source,d.limits().clone(),None).unwrap();
assert_eq!(d.history_position(),(0,0));
for cycle in 0..3 {
commands(&mut d,&format!("unbind-{cycle}"),r#"[{"op":"soft_body_unbind"}]"#).unwrap();
assert!(d.soft_body().is_none());assert_eq!(d.skeleton().unwrap().joints.len(),count);
assert_eq!(d.rig(),&rig);assert_eq!(d.clips(),&clips);
// Reopening the detached snapshot must not depend on an undo record.
d=Document::from_bytes(&d.to_snapshot_bytes(None).unwrap(),d.limits().clone(),None).unwrap();
if cycle==0 {
let vertices=d.object("body").unwrap().vertices().iter().map(|v|v.id).collect();
apply(&mut d,"reshape",vec![Operation::Transform{object:"body".into(),vertices,matrix:Transform{scale:[1.1,1.,1.],..Default::default()}.matrix().unwrap()},
Operation::Scene(SceneOperation::Node{object:"eye".into(),node:SceneNode{transform:Transform{translation:[0.23,0.95,-0.4],..Default::default()},..Default::default()}}),
Operation::Scene(SceneOperation::Node{object:"pupil".into(),node:SceneNode{transform:Transform{translation:[0.23,0.95,-0.53],..Default::default()},..Default::default()}}),
]).unwrap();
}
commands(&mut d,&format!("rebind-{cycle}"),r#"[{"op":"soft_body_bind","object":"body","deform_objects":["fuzz"],"attachments":[{"name":"eyes","objects":["eye","pupil"],"pivot":[0.23,0.95,-0.4]}]}]"#).unwrap();
assert_eq!(d.skeleton().unwrap().joints.len(),count,"rebind must not leak generated joints");
assert_eq!(d.soft_body().unwrap().tet_joints,cells);assert_eq!(d.soft_body().unwrap().attachments[0].joint as u32,frame);
assert_eq!(d.soft_body().unwrap().attachments[0].rest_pivot,[0.23,0.95,-0.4]);
assert_eq!(d.rig(),&rig);assert_eq!(d.clips(),&clips);assert_eq!(d.scene().sockets,sockets);
assert_eq!(d.object("pupil").unwrap().vertices().iter().map(|v|(v.id,v.weights.clone())).collect::<Vec<_>>(),weights);
assert_eq!(d.skeleton().unwrap().joints[gaze as usize].parent,Some(frame));
let model=SkinnedModel::parse_glb_validated(&d.compile(None).unwrap().glb).unwrap();
assert_eq!(model.clips[0].name,"idle");assert_eq!(model.soft_body(),d.soft_body());
}
// History codecs include the new unit operation as well as snapshot state.
let full=d.to_bytes(None).unwrap();let round=Document::from_bytes(&full,d.limits().clone(),None).unwrap();
assert_eq!(round.to_bytes(None).unwrap(),full);
}
#[test]
fn unbind_is_atomic_and_reserved_cell_collisions_refuse_without_rig_loss() {
let mut d=fixture();let before=d.to_bytes(None).unwrap();
assert!(commands(&mut d,"missing",r#"[{"op":"soft_body_unbind"}]"#).is_err());assert_eq!(d.to_bytes(None).unwrap(),before);
assert!(commands(&mut d,"extra",r#"[{"op":"soft_body_unbind","object":"body"}]"#).is_err());
bind(&mut d);let before=d.to_bytes(None).unwrap();
assert!(commands(&mut d,"rollback",r#"[{"op":"soft_body_unbind"},{"op":"soft_body_bind","object":"missing"}]"#).is_err());
assert_eq!(d.to_bytes(None).unwrap(),before);
commands(&mut d,"unbind",r#"[{"op":"soft_body_unbind"}]"#).unwrap();
let history=d.to_bytes(None).unwrap();
assert_eq!(Document::from_bytes(&history,d.limits().clone(),None).unwrap().to_bytes(None).unwrap(),history);
let joint=d.skeleton().unwrap().joints.iter().position(|j|j.name=="__soft_body_tet_00").unwrap() as u32;
apply(&mut d,"rename",vec![Operation::Rig(RigOperation::RenameJoint{joint,name:"user-renamed-cell".into()})]).unwrap();
let before=d.to_bytes(None).unwrap();
assert!(commands(&mut d,"collision",r#"[{"op":"soft_body_bind","object":"body"}]"#).is_err());
assert_eq!(d.to_bytes(None).unwrap(),before);
}
#[test]
fn removing_and_restoring_attachment_group_reuses_its_retained_frame() {
let mut d=fixture();bind(&mut d);let frame=d.soft_body().unwrap().attachments[0].joint;
let skeleton=d.skeleton().unwrap().clone();
commands(&mut d,"detach-group",r#"[{"op":"soft_body_unbind"},{"op":"soft_body_bind","object":"body","deform_objects":["fuzz"]}]"#).unwrap();
assert!(d.soft_body().unwrap().attachments.is_empty());assert_eq!(d.skeleton(),Some(&skeleton));
SkinnedModel::parse_glb_validated(&d.compile(None).unwrap().glb).unwrap();
commands(&mut d,"restore-group",r#"[{"op":"soft_body_unbind"},{"op":"soft_body_bind","object":"body","deform_objects":["fuzz"],"attachments":[{"name":"eyes","objects":["eye","pupil"],"pivot":[0.18,0.95,-0.4]}]}]"#).unwrap();
assert_eq!(d.soft_body().unwrap().attachments[0].joint,frame);assert_eq!(d.skeleton(),Some(&skeleton));
}
#[test]
fn accessory_affine_weights_fail_during_edit_and_valid_root_binding_remains_publishable() {
let mut d=fixture();bind(&mut d);
commands(&mut d,"hat",r#"[{"op":"cube","object":"hat","size":[0.1,0.1,0.1]}]"#).unwrap();
let before=d.to_bytes(None).unwrap();
let error=commands(&mut d,"auto-hat",r#"[{"op":"weight_edit","object":"hat","vertices":[],"edit":{"type":"bind","max_influences":4,"power":2}}]"#).unwrap_err();
assert!(format!("{error}").contains("soft-body affine"),"{error}");assert_eq!(d.to_bytes(None).unwrap(),before);
let cell=d.soft_body().unwrap().tet_joints[0];
assert!(commands(&mut d,"wrong-cell",&format!(r#"[{{"op":"weight_edit","object":"hat","vertices":[],"edit":{{"type":"assign","joint":{cell}}}}}]"#)).is_err());
assert_eq!(d.to_bytes(None).unwrap(),before);
commands(&mut d,"rigid-hat",r#"[{"op":"weight_edit","object":"hat","vertices":[],"edit":{"type":"assign","joint":0}}]"#).unwrap();
SkinnedModel::parse_glb_validated(&d.compile(None).unwrap().glb).unwrap();
// An accessory can deliberately use affine cells too, provided every
// vertex satisfies the same embedding contract as the runtime consumer.
let metadata=d.soft_body().unwrap();
let cage=makepad_soft_body::SoftBodyDefinition{
rest_positions:metadata.rest_positions.clone(),tetrahedra:metadata.tetrahedra.clone(),surface_samples:vec![],anchors:metadata.anchors.clone(),
};
let binder=cage.binder().unwrap();
let mut operations=vec![Operation::Scene(SceneOperation::Node{object:"hat".into(),node:SceneNode{transform:Transform{translation:[0.,0.8,0.],..Default::default()},..Default::default()}})];
for v in d.object("hat").unwrap().vertices(){
let cell=binder.bind_point([v.position[0] as f32,(v.position[1]+0.8) as f32,v.position[2] as f32]).unwrap().tetrahedron as usize;
operations.push(Operation::SetWeights{object:"hat".into(),vertex:v.id,weights:vec![mesh::JointWeight{joint:metadata.tet_joints[cell] as u32,weight:1.}]});
}
apply(&mut d,"valid-affine-hat",operations).unwrap();
SkinnedModel::parse_glb_validated(&d.compile(None).unwrap().glb).unwrap();
}
fn parsed_soft_settings(extra:&str)->makepad_gltf::SoftBodySettings {
let limits=Limits{max_joints:128,..Default::default()};
let source=format!(r#"[{{"op":"soft_body_bind","object":"body"{extra}}}]"#);
let mut operations=parse_operations(&json::parse(source.as_bytes()).unwrap(),&limits).unwrap();
let Operation::SoftBody(bind)=operations.remove(0) else{unreachable!()};bind.settings
}
#[test]
fn explicit_yarn_preset_is_firm_and_preserves_generic_and_serialized_settings() {
let generic=parsed_soft_settings("");
assert_eq!(generic.edge_compliance,0.02);assert_eq!(generic.pose_compliance,0.02);assert_eq!(generic.damping,4.);
let yarn=parsed_soft_settings(r#", "preset":"yarn_ball""#);
let mut expected=generic;expected.edge_compliance=0.001;expected.pose_compliance=0.001;expected.damping=22.;
assert_eq!(yarn,expected,"preset changes only the textile response knobs");
let overrides=parsed_soft_settings(r#", "preset":"yarn_ball", "config":{"edge_compliance":0.002,"damping":30}"#);
assert_eq!(overrides.edge_compliance,0.002);assert_eq!(overrides.pose_compliance,0.001);assert_eq!(overrides.damping,30.);
// Full stored config wins even if a caller retains the preset label.
assert_eq!(parsed_soft_settings(&format!(r#", "preset":"yarn_ball", "config":{}"#,generic.to_value().to_json())),generic);
for preset in [false,true]{
let mut doc=fixture();let mut operation=parse_operations(&json::parse(format!(r#"[{{"op":"soft_body_bind","object":"body"{}}}]"#,if preset{r#", "preset":"yarn_ball""#}else{""}).as_bytes()).unwrap(),doc.limits()).unwrap();
apply(&mut doc,"bind-preset",std::mem::take(&mut operation)).unwrap();
let settings=doc.soft_body().unwrap().settings;
for bytes in [doc.to_bytes(None).unwrap(),doc.to_snapshot_bytes(None).unwrap()]{
let restored=Document::from_bytes(&bytes,doc.limits().clone(),None).unwrap();
assert_eq!(restored.soft_body().unwrap().settings,settings);
}
assert_eq!(settings,if preset{yarn}else{generic});
}
}
#[test]
fn yarn_preset_keeps_acceleration_landing_and_facing_motion_small_at_three_sizes() {
use makepad_soft_body::{SoftBodyDefinition,SoftBodySettings,SoftBodyState,SoftBodyPose,SoftBodyCollider,SoftBodyFrame};
use std::sync::Arc;
let yarn=parsed_soft_settings(r#", "preset":"yarn_ball""#);
let settings=SoftBodySettings{edge_compliance:yarn.edge_compliance,pose_compliance:yarn.pose_compliance,damping:yarn.damping,..Default::default()};
for radius in [0.225f32,0.45,0.9]{for scenario in ["acceleration","landing","turn"]{
let definition=Arc::new(SoftBodyDefinition::ellipsoid([0.,radius*1.7,0.],[radius;3]).unwrap());
let start=SoftBodyPose{translation:[0.,if scenario=="landing"{radius*0.7}else{0.},0.],..Default::default()};
let mut state=SoftBodyState::new(definition.clone(),settings,start).unwrap();
let mut generic=(scenario=="acceleration").then(||SoftBodyState::new(definition.clone(),SoftBodySettings::default(),start).unwrap());
let mut generic_frame=SoftBodyFrame::default();let mut generic_peak=0.0f32;
let mut frame=SoftBodyFrame::default();let mut max_displacement=0.0f32;let mut final_displacement=0.0f32;
let mut max_strain=0.0f32;let mut contacts=0u64;let mut previous=Vec::<[f32;3]>::new();let mut turn_delta=0.0f32;
for tick in 0..180{
let mut pose=start;let mut colliders=Vec::new();
match scenario{
"acceleration"=>{
pose.translation[0]=if tick<60{0.}else if tick<90{let t=(tick-60)as f32/60.;radius*6.*t*t}
else if tick<120{radius*1.5+radius*6.*(tick-90)as f32/60.}else{radius*4.5};
},
"landing"=>{
let t=(tick as f32/60.-1.).max(0.);pose.translation[1]=(radius*0.7-4.9*t*t).max(0.);
// The body's padded rest surface sits on this plane.
// Exclude contact skin thickness from textile strain.
colliders.push(SoftBodyCollider::Plane{normal:[0.,1.,0.],offset:radius*0.7-settings.contact_radius});
},
"turn"=>{
let yaw=if tick<60{0.}else{(tick-59)as f32*std::f32::consts::PI*0.75};
pose.rotation=[0.,(yaw*0.5).sin(),0.,(yaw*0.5).cos()];state.transport_rotation(pose.rotation).unwrap();
},_=>unreachable!(),
}
let stats=state.step(1./60.,pose,&colliders).unwrap();
if let Some(generic)=&mut generic{
assert!(!generic.step(1./60.,pose,&colliders).unwrap().recovered);
generic.write_frame(&mut generic_frame);
generic_peak=generic_peak.max(generic_frame.positions.iter().zip(&definition.rest_positions).map(|(a,b)|a.iter().zip(b).map(|(a,b)|(a-b).powi(2)).sum::<f32>().sqrt()).fold(0.,f32::max));
}
assert!(!stats.recovered,"{scenario}, radius {radius}: solver recovered");
assert!(stats.min_volume_ratio>0.9,"{scenario}, radius {radius}: lost volume");
max_strain=max_strain.max(stats.max_edge_strain);contacts+=stats.contacts as u64;state.write_frame(&mut frame);
final_displacement=frame.positions.iter().zip(&definition.rest_positions).map(|(a,b)|a.iter().zip(b).map(|(a,b)|(a-b).powi(2)).sum::<f32>().sqrt()).fold(0.,f32::max);
max_displacement=max_displacement.max(final_displacement);
if scenario=="turn"&&tick>=60{turn_delta=turn_delta.max(frame.positions.iter().zip(&previous).map(|(a,b)|a.iter().zip(b).map(|(a,b)|(a-b).powi(2)).sum::<f32>().sqrt()).fold(0.,f32::max));}
previous.clone_from(&frame.positions);
}
eprintln!("yarn response radius={radius} scenario={scenario} max/radius={} final/radius={} edge_strain={max_strain} contacts={contacts} turn_delta/radius={} generic_peak/radius={}",max_displacement/radius,final_displacement/radius,turn_delta/radius,generic_peak/radius);
assert!(max_displacement/radius<0.01,"{scenario}: textile deformation exceeded 1% of radius");
assert!(final_displacement/radius<0.0025,"{scenario}: textile did not settle within 0.25% of radius");
if scenario=="turn"{assert!(turn_delta/radius<0.001,"turning injected visible deformation");}
if scenario=="acceleration"{
assert!(max_displacement/radius>0.0005,"textile secondary motion was eliminated");
assert!(max_displacement<generic_peak*0.2,"yarn preset did not materially reduce generic softness");
}
if scenario=="landing"{assert!(contacts>0,"landing did not exercise actual contact constraints");}
}}
}