1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use pfx_scene::Entry;
6use pfx_scene::types as file;
7
8use super::build::{Places, Reader, Step, inner};
9use super::{Object, SceneError, SceneMesh, multiply};
10use crate::sha256;
11
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct Physics {
14 pub gravity: [f32; 3],
15 pub rate: f32,
16 pub substeps: u32,
17 pub iterations: u32,
18}
19
20impl Default for Physics {
21 fn default() -> Self {
22 Self {
23 gravity: [0.0, -9.81, 0.0],
24 rate: 60.0,
25 substeps: 1,
26 iterations: 4,
27 }
28 }
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum BodyKind {
33 Dynamic,
34 Kinematic,
35 Fixed,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub enum BodyShape {
40 Box { half: [f32; 3] },
41 Sphere { radius: f32 },
42 Capsule { half_height: f32, radius: f32 },
43 Cylinder { half_height: f32, radius: f32 },
44}
45
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct Body {
48 pub kind: BodyKind,
49 pub shape: BodyShape,
50 pub offset: [f32; 3],
51 pub density: f32,
52 pub friction: f32,
53 pub restitution: f32,
54 pub velocity: [f32; 3],
55 pub spin: [f32; 3],
56 pub gravity_scale: f32,
57 pub damping: [f32; 2],
58 pub ccd: bool,
59}
60
61#[derive(Clone, Debug, PartialEq)]
62pub struct Trigger {
63 pub shape: BodyShape,
64 pub offset: [f32; 3],
65 pub layer: Option<String>,
66 pub mask: Option<Vec<String>>,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum CharacterPlane {
71 Xy,
72 Yz,
73}
74
75#[derive(Clone, Debug, PartialEq)]
76pub struct Character {
77 pub plane: Option<CharacterPlane>,
78 pub radius: f32,
79 pub height: f32,
80 pub max_climb: f32,
81 pub max_step: f32,
82 pub snap: f32,
83 pub coyote: u32,
84 pub jump_buffer: u32,
85 pub jump_speed: f32,
86 pub variable_jump: f32,
87 pub wall_slide: Option<f32>,
88 pub wall_jump: Option<[f32; 2]>,
89 pub layer: Option<String>,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum Cue {
94 Start,
95 Hit,
96 Game,
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct Sound {
101 pub file: PathBuf,
102 pub bytes: Arc<Vec<u8>>,
103 pub hash: [u8; 32],
104 pub volume: f32,
105 pub pan: f32,
106 pub looping: bool,
107 pub cue: Cue,
108 pub object: Option<String>,
109}
110
111pub(super) fn build_physics(file: &file::Physics) -> Physics {
112 let base = Physics::default();
113 Physics {
114 gravity: file.gravity.unwrap_or(base.gravity),
115 rate: file.rate.unwrap_or(base.rate),
116 substeps: file.substeps.unwrap_or(base.substeps),
117 iterations: file.iterations.unwrap_or(base.iterations),
118 }
119}
120
121pub(super) fn build_sound(
122 places: &Places,
123 entry: &Entry<file::Sound>,
124 reader: &mut Reader,
125) -> Result<Sound, SceneError> {
126 let name = entry.key.as_str();
127 let steps = [Step::Key("sound"), Step::Key(inner(name))];
128 let sound = &entry.value;
129 let path = places.root.join(&sound.file);
130 let bytes = reader
131 .read(&path)
132 .map_err(|message| places.refusal(&entry.file, &steps, message))?;
133 if bytes.starts_with(b"OggS") {
134 if let Err(message) = check_vorbis(&bytes) {
135 return places.error(
136 &entry.file,
137 &steps,
138 format!("sound {name}: {} {message}", sound.file),
139 );
140 }
141 } else if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
142 return places.error(
143 &entry.file,
144 &steps,
145 format!(
146 "sound {name}: {} is not a WAV or an Ogg Vorbis file",
147 sound.file
148 ),
149 );
150 }
151 Ok(Sound {
152 file: path,
153 hash: sha256(&bytes),
154 bytes: Arc::new(bytes),
155 volume: sound.volume.unwrap_or(1.0),
156 pan: sound.pan.unwrap_or(0.0),
157 looping: sound.looping,
158 cue: match sound.play.unwrap_or_default() {
159 file::Cue::Start => Cue::Start,
160 file::Cue::Hit => Cue::Hit,
161 file::Cue::Game => Cue::Game,
162 },
163 object: sound.object.clone(),
164 })
165}
166
167fn check_vorbis(bytes: &[u8]) -> Result<(), String> {
168 let mut at = 0usize;
169 let mut first = true;
170 let mut frames = 0i64;
171 let mut rate = 0u32;
172 while at < bytes.len() {
173 let head = bytes
174 .get(at..at + 27)
175 .filter(|head| &head[0..4] == b"OggS" && head[4] == 0)
176 .ok_or("is a damaged Ogg file")?;
177 let segments = usize::from(head[26]);
178 let table = bytes
179 .get(at + 27..at + 27 + segments)
180 .ok_or("is a truncated Ogg file")?;
181 let body_len: usize = table.iter().map(|len| usize::from(*len)).sum();
182 let body_at = at + 27 + segments;
183 let body = bytes
184 .get(body_at..body_at + body_len)
185 .ok_or("is a truncated Ogg file")?;
186 if first {
187 if body.len() < 30 || &body[0..7] != b"\x01vorbis" {
188 return Err("is an Ogg file with no Vorbis stream".into());
189 }
190 let version = u32::from_le_bytes([body[7], body[8], body[9], body[10]]);
191 let channels = body[11];
192 rate = u32::from_le_bytes([body[12], body[13], body[14], body[15]]);
193 if version != 0 || channels == 0 || rate == 0 {
194 return Err("has a Vorbis header with no audio".into());
195 }
196 first = false;
197 }
198 let granule = i64::from_le_bytes([
199 head[6], head[7], head[8], head[9], head[10], head[11], head[12], head[13],
200 ]);
201 if granule > 0 {
202 frames = granule;
203 }
204 at = body_at + body_len;
205 }
206 if first {
207 return Err("is not a WAV or an Ogg Vorbis file".into());
208 }
209 if frames == 0 || rate == 0 {
210 return Err("is an Ogg Vorbis file with no duration".into());
211 }
212 Ok(())
213}
214
215pub(super) fn build_bodies(
216 places: &Places,
217 entries: &[Entry<file::Object>],
218 objects: &[Object],
219 meshes: &BTreeMap<String, SceneMesh>,
220) -> Result<BTreeMap<String, Body>, SceneError> {
221 let mut out = BTreeMap::new();
222 for (entry, object) in entries.iter().zip(objects) {
223 let Some(file) = &entry.value.body else {
224 continue;
225 };
226 let name = entry.key.as_str();
227 let steps = [Step::Named("object", inner(name)), Step::Key("body")];
228 let Some(mesh) = meshes.get(&object.mesh) else {
229 return places.error(
230 &entry.file,
231 &steps,
232 format!("object {name} has a body and no mesh"),
233 );
234 };
235 let body = build_body(name, file, object, mesh)
236 .map_err(|message| places.refusal(&entry.file, &steps, message))?;
237 out.insert(name.to_string(), body);
238 }
239 Ok(out)
240}
241
242pub(super) fn build_triggers(
243 places: &Places,
244 entries: &[Entry<file::Object>],
245 objects: &[Object],
246 meshes: &BTreeMap<String, SceneMesh>,
247) -> Result<BTreeMap<String, Trigger>, SceneError> {
248 let mut out = BTreeMap::new();
249 for (entry, object) in entries.iter().zip(objects) {
250 let Some(file) = &entry.value.trigger else {
251 continue;
252 };
253 let name = entry.key.as_str();
254 let steps = [Step::Named("object", inner(name)), Step::Key("trigger")];
255 let Some(mesh) = meshes.get(&object.mesh) else {
256 return places.error(
257 &entry.file,
258 &steps,
259 format!("object {name} has a trigger and no mesh"),
260 );
261 };
262 let (shape, offset) = volume(
263 name,
264 "trigger",
265 Volume {
266 shape: file.shape.unwrap_or_default(),
267 half: file.half,
268 radius: file.radius,
269 half_height: file.half_height,
270 offset: file.offset,
271 },
272 object,
273 mesh,
274 )
275 .map_err(|message| places.refusal(&entry.file, &steps, message))?;
276 out.insert(
277 name.to_string(),
278 Trigger {
279 shape,
280 offset,
281 layer: file.layer.clone(),
282 mask: file.mask.clone(),
283 },
284 );
285 }
286 Ok(out)
287}
288
289pub(super) fn build_characters(
290 places: &Places,
291 entries: &[Entry<file::Object>],
292 objects: &[Object],
293 meshes: &BTreeMap<String, SceneMesh>,
294) -> Result<BTreeMap<String, Character>, SceneError> {
295 let mut out = BTreeMap::new();
296 for (entry, object) in entries.iter().zip(objects) {
297 let Some(file) = &entry.value.character else {
298 continue;
299 };
300 let name = entry.key.as_str();
301 let steps = [Step::Named("object", inner(name)), Step::Key("character")];
302 let Some(mesh) = meshes.get(&object.mesh) else {
303 return places.error(
304 &entry.file,
305 &steps,
306 format!("object {name} has a character and no mesh"),
307 );
308 };
309 let character = build_character(name, file, object, mesh)
310 .map_err(|message| places.refusal(&entry.file, &steps, message))?;
311 out.insert(name.to_string(), character);
312 }
313 Ok(out)
314}
315
316fn build_character(
317 name: &str,
318 file: &file::Character,
319 object: &Object,
320 mesh: &SceneMesh,
321) -> Result<Character, String> {
322 let (low, high) = bounds(mesh);
323 let scale = object.scale.map(f32::abs);
324 let half: [f32; 3] = std::array::from_fn(|axis| (high[axis] - low[axis]) * 0.5 * scale[axis]);
325 let radius = file.radius.unwrap_or(half[0].max(half[2]));
326 let height = file.height.unwrap_or(half[1] * 2.0);
327 if !(radius.is_finite() && radius > 0.0 && height.is_finite() && height > 0.0) {
328 return Err(format!(
329 "object {name}: its character has no size; give it a positive radius and height"
330 ));
331 }
332 if height < radius * 2.0 {
333 return Err(format!(
334 "object {name}: its character's height {height} is less than twice its radius {radius}"
335 ));
336 }
337 let plane = match file.kind.unwrap_or_default() {
338 file::CharacterKind::ThreeD => None,
339 file::CharacterKind::TwoD => Some(match file.plane.unwrap_or_default() {
340 file::Plane::Xy => CharacterPlane::Xy,
341 file::Plane::Yz => CharacterPlane::Yz,
342 }),
343 };
344 Ok(Character {
345 plane,
346 radius,
347 height,
348 max_climb: file.max_climb.unwrap_or(file::Character::MAX_CLIMB),
349 max_step: file.max_step.unwrap_or(file::Character::MAX_STEP),
350 snap: file.snap.unwrap_or(file::Character::SNAP),
351 coyote: file.coyote.unwrap_or(file::Character::COYOTE),
352 jump_buffer: file.jump_buffer.unwrap_or(file::Character::JUMP_BUFFER),
353 jump_speed: file.jump_speed.unwrap_or(file::Character::JUMP_SPEED),
354 variable_jump: file.variable_jump.unwrap_or(file::Character::VARIABLE_JUMP),
355 wall_slide: file.wall_slide,
356 wall_jump: file.wall_jump,
357 layer: file.layer.clone(),
358 })
359}
360
361pub(super) fn body_layers(entries: &[Entry<file::Object>]) -> BTreeMap<String, String> {
362 entries
363 .iter()
364 .filter_map(|entry| {
365 let layer = entry.value.body.as_ref()?.layer.clone()?;
366 Some((entry.key.clone(), layer))
367 })
368 .collect()
369}
370
371fn build_body(
372 name: &str,
373 file: &file::Body,
374 object: &Object,
375 mesh: &SceneMesh,
376) -> Result<Body, String> {
377 let kind = match file.kind.unwrap_or_default() {
378 file::BodyKind::Dynamic => BodyKind::Dynamic,
379 file::BodyKind::Kinematic => BodyKind::Kinematic,
380 file::BodyKind::Fixed => BodyKind::Fixed,
381 };
382 let (shape, offset) = volume(
383 name,
384 "body",
385 Volume {
386 shape: file.shape.unwrap_or_default(),
387 half: file.half,
388 radius: file.radius,
389 half_height: file.half_height,
390 offset: file.offset,
391 },
392 object,
393 mesh,
394 )?;
395 Ok(Body {
396 kind,
397 shape,
398 offset,
399 density: file.density.unwrap_or(file::Body::DENSITY),
400 friction: file.friction.unwrap_or(file::Body::FRICTION),
401 restitution: file.restitution.unwrap_or(file::Body::RESTITUTION),
402 velocity: file.velocity.unwrap_or([0.0; 3]),
403 spin: file.spin.unwrap_or([0.0; 3]),
404 gravity_scale: file.gravity_scale.unwrap_or(file::Body::GRAVITY_SCALE),
405 damping: file.damping.unwrap_or([0.0; 2]),
406 ccd: file.ccd,
407 })
408}
409
410struct Volume {
411 shape: file::BodyShape,
412 half: Option<[f32; 3]>,
413 radius: Option<f32>,
414 half_height: Option<f32>,
415 offset: Option<[f32; 3]>,
416}
417
418fn volume(
419 name: &str,
420 what: &str,
421 file: Volume,
422 object: &Object,
423 mesh: &SceneMesh,
424) -> Result<(BodyShape, [f32; 3]), String> {
425 let (low, high) = bounds(mesh);
426 let scale = object.scale.map(f32::abs);
427 let half: [f32; 3] = std::array::from_fn(|axis| (high[axis] - low[axis]) * 0.5 * scale[axis]);
428 let centre: [f32; 3] =
429 std::array::from_fn(|axis| (high[axis] + low[axis]) * 0.5 * object.scale[axis]);
430 let round = half[0].max(half[2]);
431 let (shape, shape_name) = match file.shape {
432 file::BodyShape::Box => (
433 BodyShape::Box {
434 half: file.half.unwrap_or(half),
435 },
436 "box",
437 ),
438 file::BodyShape::Sphere => (
439 BodyShape::Sphere {
440 radius: file
441 .radius
442 .unwrap_or_else(|| half[0].max(half[1]).max(half[2])),
443 },
444 "sphere",
445 ),
446 file::BodyShape::Capsule => {
447 let radius = file.radius.unwrap_or(round);
448 (
449 BodyShape::Capsule {
450 half_height: file
451 .half_height
452 .unwrap_or_else(|| (half[1] - radius).max(0.0)),
453 radius,
454 },
455 "capsule",
456 )
457 }
458 file::BodyShape::Cylinder => (
459 BodyShape::Cylinder {
460 half_height: file.half_height.unwrap_or(half[1]),
461 radius: file.radius.unwrap_or(round),
462 },
463 "cylinder",
464 ),
465 };
466 let sizes = match shape {
467 BodyShape::Box { half } => half.to_vec(),
468 BodyShape::Sphere { radius } => vec![radius],
469 BodyShape::Capsule {
470 half_height,
471 radius,
472 } => vec![half_height + radius, radius],
473 BodyShape::Cylinder {
474 half_height,
475 radius,
476 } => vec![half_height, radius],
477 };
478 if sizes.iter().any(|size| !(size.is_finite() && *size > 0.0)) {
479 return Err(format!(
480 "object {name}: its {what} has a {shape_name} with no size; give it a positive size"
481 ));
482 }
483 Ok((shape, file.offset.unwrap_or(centre)))
484}
485
486fn bounds(mesh: &SceneMesh) -> ([f32; 3], [f32; 3]) {
487 let mut low = [f32::INFINITY; 3];
488 let mut high = [f32::NEG_INFINITY; 3];
489 for part in &mesh.parts {
490 for position in &part.geometry.positions {
491 let point = multiply(
492 part.transform,
493 [
494 [position[0], position[1], position[2], 1.0],
495 [0.0; 4],
496 [0.0; 4],
497 [0.0; 4],
498 ],
499 )[0];
500 for axis in 0..3 {
501 low[axis] = low[axis].min(point[axis]);
502 high[axis] = high[axis].max(point[axis]);
503 }
504 }
505 }
506 if low[0] > high[0] {
507 return ([0.0; 3], [0.0; 3]);
508 }
509 (low, high)
510}