1use std::num::NonZeroU32;
2use std::path::PathBuf;
3use std::process::ExitCode;
4
5use glam::{Vec2, Vec3, Vec4};
6use loam::app::args::Args;
7use loam::app::capture::{CaptureFormat, CaptureRequest, CaptureStage, PaletteMode};
8use loam::app::environment::Environment;
9use loam::app::session::{launch_or_headless, FrameHook, Orbit, SessionApp};
10use loam::math::{Bivector4, EuclideanR4, Rotor, WPlane};
11use loam::physics::body::MASK_ALL;
12use loam::physics::euclidean_r4::{
13 halfspace4_body_r4, polytope_body_r4, register_default_narrowphase, regular_polytope4_inertia,
14};
15use loam::physics::BodyId;
16use loam::render::{FragmentShading, SkyGroundPass, TriangleFeed};
17use loam::runtime::host::{self, HostConfig, HostError};
18use loam::runtime::{
19 ActionId, Bindings, Command, Ctx, Dispatch, DomainBuilder, DomainError, DomainHandle, Domains,
20 Entity, Key, LogCapacity, Order, Outcome, Phase, Physics, PhysicsConfig, Pose, Rejection,
21 Rigid, Session, SimConfig, SpawnBundle, DOMAIN_STEP,
22};
23use loam::shape::polytope::{polytope_section_faces_append, Polytope4, SectionScratch};
24use loam::shape::{Shape, TriangleMesh};
25use loam::text::ab_glyph::FontRef;
26use loam::text::glyph::{
27 append_field_prism, layout_word, DistanceField2D, GlyphParams, GlyphSolid,
28};
29use loam::time::director::{Ease, Track};
30
31const WORD: &str = "LOAM";
32const FONT: &[u8] = include_bytes!("../../fonts/lmroman10-bold.otf");
33
34const TICK_HZ: u32 = 60;
35const SUBSTEPS: u32 = 8;
36const GRAVITY: f32 = -9.8;
37const PILE_PGS_ITERS: usize = 20;
38const RESTITUTION: f32 = 0.0;
39
40const GROUP_SCENERY: u32 = 1 << 0;
41const GROUP_FALLING: u32 = 1 << 1;
42const GROUP_LANDED: u32 = 1 << 2;
43const MASK_FALLING: u32 = GROUP_SCENERY | GROUP_LANDED;
44
45const ASSEMBLE_FRAMES: u32 = 90;
46const LETTER_STAGGER_FRAMES: u32 = 12;
47const LETTER_SLIDE_FRAMES: u32 = 36;
48const SETTLE_FRAMES: u32 = 120;
49const RAIN_START_FRAME: u32 = ASSEMBLE_FRAMES + SETTLE_FRAMES;
50const PHYSICS_FRAMES: u32 = 360;
51const FREEZE_FRAME: u32 = RAIN_START_FRAME + PHYSICS_FRAMES;
52const SWEEP_FRAMES: u32 = 300;
53const SEQUENCE_FRAMES: u32 = FREEZE_FRAME + SWEEP_FRAMES;
54
55const W_ENTRY_SPAN: f32 = 0.6;
56const RELEASE_CLEARANCE: f32 = 0.20;
57const LETTER_MASS: f32 = 1.0;
58const LETTER_COLOR: [f32; 4] = [0.92, 0.90, 0.86, 1.0];
59const FLOOR_Y: f32 = 0.0;
60const W_SLICE: f32 = 0.0;
61const W_PER_LETTERFORM: f32 = 0.3;
62const SLICE_SWEEP_RANGE: f32 = 4.0 * W_PER_LETTERFORM;
63const MORPH_PAD_EM: f32 = 0.25;
64
65const RAIN_CAP: usize = 64;
66const RAIN_INTERVAL_JITTER: u32 = 4;
67const RAIN_INTERVAL_MIN: u32 = 7 - RAIN_INTERVAL_JITTER;
68const RAIN_SIZE: f32 = 0.30;
69const RAIN_MASS: f32 = 0.75;
70const RAIN_HEIGHT: (f32, f32) = (2.6, 3.6);
71const RAIN_ENTRY_SPEED: f32 = 1.0;
72const RAIN_W_SPREAD: f32 = 0.10;
73const RAIN_Z_SPREAD: f32 = 0.20;
74const RAIN_TUMBLE: f32 = 6.0;
75const RAIN_SHAPES: [Polytope4; 6] = [
76 Polytope4::Cell24,
77 Polytope4::Pentatope,
78 Polytope4::Cell600,
79 Polytope4::Cell16,
80 Polytope4::Tesseract,
81 Polytope4::Cell120,
82];
83
84const DEFAULT_SEED: u64 = 0x10a3_5eed;
85const BOOT_ORBIT_DISTANCE: f32 = 4.0;
86const BOOT_ORBIT_PITCH: f32 = -0.12;
87
88const PAUSE: ActionId = ActionId(0);
89const RESEED: ActionId = ActionId(1);
90
91#[derive(Clone)]
92struct Letter {
93 index: usize,
94 hull: Vec<Vec4>,
95 mark: Vec4,
96 entry: Vec4,
97 slide: Track<Vec4>,
98 w_before: f32,
99}
100
101struct Scene {
102 r4: DomainHandle<EuclideanR4>,
103}
104
105#[derive(Clone, Copy)]
106struct Drop {
107 polytope: Polytope4,
108 color: [f32; 3],
109}
110
111#[derive(Clone, Copy)]
112struct Held {
113 body: BodyId,
114 velocity: Vec4,
115 spin: Bivector4,
116}
117
118#[derive(Clone, Copy)]
119struct Stage {
120 tick: u32,
121 next_spawn: u32,
122 rng: u64,
123 seed: u64,
124 paused: bool,
125 released: bool,
126 frozen: bool,
127}
128
129impl Stage {
130 fn seeded(seed: u64) -> Self {
131 Self {
132 tick: 0,
133 next_spawn: RAIN_START_FRAME,
134 rng: (seed ^ 0x9e37_79b9_7f4a_7c15).max(1),
135 seed,
136 paused: false,
137 released: false,
138 frozen: false,
139 }
140 }
141
142 fn frame(&self) -> u32 {
143 self.tick / SUBSTEPS
144 }
145
146 fn slice(&self) -> f32 {
147 let Some(since) = self.tick.checked_sub(FREEZE_FRAME * SUBSTEPS) else {
148 return W_SLICE;
149 };
150 let phase = std::f32::consts::TAU * since as f32 / (SWEEP_FRAMES * SUBSTEPS) as f32;
151 W_SLICE + SLICE_SWEEP_RANGE * phase.sin()
152 }
153
154 fn draw(&mut self) -> u64 {
156 self.rng ^= self.rng >> 12;
157 self.rng ^= self.rng << 25;
158 self.rng ^= self.rng >> 27;
159 self.rng.wrapping_mul(0x2545_f491_4f6c_dd1d)
160 }
161}
162
163impl Default for Stage {
164 fn default() -> Self {
165 Self::seeded(DEFAULT_SEED)
166 }
167}
168
169loam::runtime::stores! {
170 #[derive(Default)]
171 pub struct HeroStores {
172 letters: Store<Letter>,
173 drops: Store<Drop>,
174 stage: Value<Stage>,
175 held: Value<Vec<Held>>,
176 environment: Value<Environment>,
177 mesh: Value<TriangleMesh<3>>,
178 camera: Value<Orbit>,
179 }
180}
181
182fn unit(draw: u64) -> f32 {
183 ((draw >> 40) as u32) as f32 * (1.0 / 16_777_216.0)
184}
185
186fn signed_unit(draw: u64) -> f32 {
187 2.0 * unit(draw) - 1.0
188}
189
190fn lerp(a: f32, b: f32, u: f32) -> f32 {
191 a + (b - a) * u
192}
193
194fn fps() -> NonZeroU32 {
195 NonZeroU32::new(TICK_HZ).unwrap_or(NonZeroU32::MIN)
196}
197
198fn refused(what: impl std::fmt::Display) -> HostError {
199 HostError::Host(what.to_string())
200}
201
202#[derive(Clone)]
203struct MorphField {
204 letters: Vec<Vec<f32>>,
205 blended: DistanceField2D,
206}
207
208impl MorphField {
209 fn new(solids: &[GlyphSolid], cell: f32) -> Option<Self> {
210 let inked: Vec<&GlyphSolid> = solids.iter().filter(|s| !s.is_blank()).collect();
211 let mut half = Vec2::ZERO;
212 for solid in &inked {
213 let field = solid.field()?;
214 let (nx, ny) = field.sample_counts();
215 let lo = field.sample_position(0, 0);
216 let hi = field.sample_position(nx - 1, ny - 1);
217 let centre = 0.5 * (lo + hi);
218 half = half.max((hi - centre).abs());
219 }
220 half += Vec2::splat(MORPH_PAD_EM);
221 let counts = (
222 (2.0 * half.x / cell).ceil() as usize + 1,
223 (2.0 * half.y / cell).ceil() as usize + 1,
224 );
225 let origin = -half;
226
227 let mut letters = Vec::with_capacity(inked.len());
228 for solid in &inked {
229 let field = solid.field()?;
230 let centre = solid
231 .rigid_hull_4d()
232 .map_or(Vec2::ZERO, |(c, _)| Vec2::new(c.x, c.y));
233 let mut grid = Vec::with_capacity(counts.0 * counts.1);
234 for j in 0..counts.1 {
235 for i in 0..counts.0 {
236 let p = origin + Vec2::new(i as f32, j as f32) * cell;
237 grid.push(field.sample(p + centre));
238 }
239 }
240 letters.push(grid);
241 }
242 if letters.is_empty() {
243 return None;
244 }
245 Some(Self {
246 blended: DistanceField2D::from_samples(
247 origin,
248 cell,
249 counts.0,
250 counts.1,
251 vec![0.0; counts.0 * counts.1],
252 )?,
253 letters,
254 })
255 }
256
257 fn blend_at(&mut self, u: f32) -> &DistanceField2D {
258 let n = self.letters.len();
259 let wrapped = u.rem_euclid(n as f32);
260 let lo = wrapped.floor() as usize % n;
261 let t = wrapped - wrapped.floor();
262 let (a, b) = (&self.letters[lo], &self.letters[(lo + 1) % n]);
263 for (out, (x, y)) in self
264 .blended
265 .samples_mut()
266 .iter_mut()
267 .zip(a.iter().zip(b.iter()))
268 {
269 *out = x + (y - x) * t;
270 }
271 &self.blended
272 }
273}
274
275fn letters_of(solids: &[GlyphSolid]) -> Option<Vec<Letter>> {
276 let seconds = |frames: u32| frames as f32 / TICK_HZ as f32;
277 let mut letters = Vec::new();
278 for (index, solid) in solids.iter().filter(|s| !s.is_blank()).enumerate() {
279 let (centre, shape) = solid.rigid_hull_4d()?;
280 let Shape::ConvexPolytope4D { vertices } = shape else {
281 return None;
282 };
283 let lowest = vertices.iter().fold(f32::INFINITY, |m, v| m.min(v.y));
284 let mark = Vec4::new(centre.x, RELEASE_CLEARANCE - lowest, centre.z, centre.w);
285 let side = if index.is_multiple_of(2) { -1.0 } else { 1.0 };
286 letters.push(Letter {
287 index,
288 hull: vertices,
289 mark,
290 entry: mark + Vec4::new(0.0, 0.0, 0.0, side * W_ENTRY_SPAN),
291 slide: Track::new(),
292 w_before: 0.0,
293 });
294 }
295 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
296 for letter in &letters {
297 for v in &letter.hull {
298 lo = lo.min(letter.mark.x + v.x);
299 hi = hi.max(letter.mark.x + v.x);
300 }
301 }
302 let shift = -0.5 * (lo + hi);
303 for letter in &mut letters {
304 letter.mark.x += shift;
305 letter.entry.x += shift;
306 let start = LETTER_STAGGER_FRAMES * letter.index as u32;
307 letter.slide = Track::new()
308 .key(seconds(start), letter.entry, Ease::Linear)
309 .key(
310 seconds(start + LETTER_SLIDE_FRAMES),
311 letter.mark,
312 Ease::InOutCubic,
313 );
314 }
315 Some(letters)
316}
317
318fn word_centre(letters: &[Letter]) -> Vec3 {
319 let (mut lo, mut hi) = (Vec3::splat(f32::INFINITY), Vec3::splat(f32::NEG_INFINITY));
320 for letter in letters {
321 for v in &letter.hull {
322 let world = letter.mark.truncate() + v.truncate();
323 lo = lo.min(world);
324 hi = hi.max(world);
325 }
326 }
327 0.5 * (lo + hi)
328}
329
330fn word_span(letters: &[Letter]) -> (f32, f32) {
331 let lo = letters.iter().fold(f32::INFINITY, |m, l| m.min(l.mark.x));
332 let hi = letters
333 .iter()
334 .fold(f32::NEG_INFINITY, |m, l| m.max(l.mark.x));
335 (lo - RAIN_SIZE, hi + RAIN_SIZE)
336}
337
338fn drop_color(polytope: Polytope4) -> [f32; 3] {
339 match polytope {
340 Polytope4::Pentatope => [0.95, 0.55, 0.30],
341 Polytope4::Tesseract => [0.30, 0.55, 0.95],
342 Polytope4::Cell16 => [0.55, 0.95, 0.40],
343 Polytope4::Cell24 => [0.95, 0.45, 0.85],
344 Polytope4::Cell120 => [0.40, 0.85, 0.85],
345 Polytope4::Cell600 => [0.95, 0.85, 0.40],
346 }
347}
348
349fn record_request(args: &Args) -> Option<CaptureRequest> {
350 let dir = args.get("record").map(PathBuf::from);
351 if dir.is_none() && !args.has_bare_flag("record") {
352 return None;
353 }
354 Some(CaptureRequest::StartSequence {
355 format: CaptureFormat::Apng,
356 stage: CaptureStage::Pre,
357 dir,
358 name: Some("hero".into()),
359 fps: Some(TICK_HZ as u16),
360 scale: None,
361 palette: PaletteMode::default(),
362 })
363}
364
365fn bindings() -> Bindings {
366 Bindings::new()
367 .key(Key::Space, PAUSE)
368 .key(Key::Letter('n'), RESEED)
369}
370
371fn physics_of(
372 domains: &mut Domains,
373 r4: DomainHandle<EuclideanR4>,
374) -> Result<&mut Physics<EuclideanR4>, Rejection> {
375 domains
376 .typed(r4)?
377 .physics_mut()
378 .ok_or(Rejection::Unsupported("physics on r4"))
379}
380
381fn hold(
382 d: &mut Dispatch<'_, HeroStores>,
383 r4: DomainHandle<EuclideanR4>,
384 asleep: bool,
385) -> Result<(), Rejection> {
386 let physics = physics_of(d.domains, r4)?;
387 if !asleep {
388 for held in d.app.held.get() {
389 if let Some(entity) = physics.entity(held.body) {
390 physics.set_velocity(entity, held.velocity, held.spin)?;
391 }
392 }
393 d.app.held.get_mut().clear();
394 return Ok(());
395 }
396 let mut held = Vec::new();
397 for dense in 0..physics.world().bodies().len() {
398 let id = physics.world().bodies().id_at(dense);
399 let Some(body) = physics.world().body(id) else {
400 continue;
401 };
402 if body.is_static() {
403 continue;
404 }
405 held.push(Held {
406 body: id,
407 velocity: body.velocity,
408 spin: body.angular_velocity,
409 });
410 }
411 for row in &held {
412 if let Some(entity) = physics.entity(row.body) {
413 physics.sleep_body(entity)?;
414 }
415 }
416 d.app.held.set(held);
417 Ok(())
418}
419
420fn spawn_drop(
421 d: &mut Dispatch<'_, HeroStores>,
422 r4: DomainHandle<EuclideanR4>,
423 polytope: Polytope4,
424 position: Vec4,
425 tumble: Bivector4,
426) -> Result<Entity, Rejection> {
427 let entity = d.spawn(SpawnBundle::new().at(r4, Pose::at(position)).row(Drop {
428 polytope,
429 color: drop_color(polytope),
430 }))?;
431 let vertices: Vec<Vec4> = polytope
432 .topology()
433 .vertices
434 .iter()
435 .map(|v| RAIN_SIZE * *v)
436 .collect();
437 let body = polytope_body_r4(
438 position,
439 Vec4::new(0.0, -RAIN_ENTRY_SPEED, 0.0, 0.0),
440 vertices,
441 RAIN_MASS,
442 )
443 .ok_or(Rejection::Unsupported("polytope rain body"))?;
444 let domain = d.domains.typed(r4)?;
445 domain.spawn_body(entity, body)?;
446 let physics = domain
447 .physics_mut()
448 .ok_or(Rejection::Unsupported("physics on r4"))?;
449 physics.set_restitution(entity, RESTITUTION)?;
450 physics.set_velocity(entity, Vec4::new(0.0, -RAIN_ENTRY_SPEED, 0.0, 0.0), tumble)?;
451 physics.set_collision_filter(entity, GROUP_FALLING, MASK_FALLING)?;
452 physics.set_mass_properties(
453 entity,
454 RAIN_MASS,
455 regular_polytope4_inertia(polytope, RAIN_MASS, RAIN_SIZE),
456 )?;
457 Ok(entity)
458}
459
460fn compose(
461 app: &mut HeroStores,
462 domains: &mut Domains,
463 r4: DomainHandle<EuclideanR4>,
464 morph: &mut MorphField,
465 local: &mut Vec<Vec4>,
466 scratch: &mut SectionScratch,
467 half_depth: f32,
468) {
469 let stage = *app.stage.get();
470 let Ok(domain) = domains.read(r4) else {
471 return;
472 };
473 let slice = stage.slice();
474 let mesh = app.mesh.get_mut();
475 mesh.vertices.clear();
476 mesh.colors.clear();
477 mesh.indices.clear();
478 for (entity, letter) in app.letters.iter() {
479 let Some(pose) = domain.poses().get(entity) else {
480 continue;
481 };
482 let base = mesh.vertices.len();
483 let u = letter.index as f32 - (pose.point.w - slice) / W_PER_LETTERFORM;
484 if !append_field_prism(morph.blend_at(u), half_depth, LETTER_COLOR, mesh) {
485 continue;
486 }
487 let translate = pose.point.truncate();
488 for v in &mut mesh.vertices[base..] {
489 let posed = pose.frame.apply(Vec4::new(v[0], v[1], v[2], 0.0));
490 *v = (posed.truncate() + translate).to_array();
491 }
492 }
493 for (entity, drop) in app.drops.iter() {
494 let Some(pose) = domain.poses().get(entity) else {
495 continue;
496 };
497 let topology = drop.polytope.topology();
498 local.clear();
499 local.extend(
500 topology
501 .vertices
502 .iter()
503 .map(|v| RAIN_SIZE * pose.frame.apply(*v) + Vec4::W * pose.point.w),
504 );
505 let [r, g, b] = drop.color;
506 let base = mesh.vertices.len();
507 polytope_section_faces_append(
508 topology.edges,
509 topology.cells,
510 local,
511 WPlane::new(slice),
512 [r, g, b, 1.0],
513 scratch,
514 mesh,
515 );
516 let translate = pose.point.truncate();
517 for v in &mut mesh.vertices[base..] {
518 v[0] += translate.x;
519 v[1] += translate.y;
520 v[2] += translate.z;
521 }
522 }
523}
524
525fn build() -> Result<(Session<HeroStores>, Scene), HostError> {
526 let font = FontRef::try_from_slice(FONT).map_err(refused)?;
527 let params = GlyphParams::default();
528 let solids = layout_word(&font, WORD, ¶ms).map_err(refused)?;
529 let letters = letters_of(&solids).ok_or_else(|| refused("a letter has no convex hull"))?;
530 let mut morph = MorphField::new(&solids, params.em_size / params.resolution as f32)
531 .ok_or_else(|| refused("the word laid out with no ink to morph"))?;
532 let centre = word_centre(&letters);
533 let half_depth = 0.5 * params.depth;
534
535 let mut session = Session::new(
536 HeroStores::default(),
537 SimConfig {
538 fixed_hz: TICK_HZ * SUBSTEPS,
539 max_ticks_per_frame: 2 * SUBSTEPS,
540 },
541 );
542 let r4 = session.register_domain(
543 DomainBuilder::new("r4", EuclideanR4)
544 .tracked(LogCapacity::default())
545 .physics(PhysicsConfig::new(register_default_narrowphase).gravity(Vec4::Y * GRAVITY))
546 .map_err(|error| HostError::Setup(Rejection::Edit(error)))?,
547 );
548 session.dispatch(|d| -> Result<(), Rejection> {
549 for letter in letters {
550 let entry = letter.entry;
551 d.spawn(SpawnBundle::new().at(r4, Pose::at(entry)).row(letter))?;
552 }
553 let floor_entity = d.spawn(SpawnBundle::new().at(r4, Pose::at(Vec4::ZERO)))?;
554 let floor = halfspace4_body_r4(Vec4::Y, FLOOR_Y)
555 .ok_or(Rejection::Unsupported("half-space floor"))?;
556 let domain = d.domains.typed(r4)?;
557 domain.spawn_body(floor_entity, floor)?;
558 let physics = domain
559 .physics_mut()
560 .ok_or(Rejection::Unsupported("physics on r4"))?;
561 physics.set_solver_iterations(PILE_PGS_ITERS);
562 physics.set_restitution(floor_entity, RESTITUTION)?;
563 Ok(())
564 })?;
565
566 let mut orbit = Orbit::around(centre.to_array(), BOOT_ORBIT_DISTANCE);
567 orbit.pitch = BOOT_ORBIT_PITCH;
568 session.app.camera.set(orbit);
569 loam::app::session::orbit(&mut session, |app: &mut HeroStores| app.camera.get_mut());
570
571 session.system(Phase::Dispatch, "keys", move |ctx: Ctx<'_, HeroStores>| {
572 if ctx.input.pressed(PAUSE) {
573 let paused = !ctx.app.stage.get().paused;
574 ctx.commands
575 .try_app_fn("pause", move |d: &mut Dispatch<'_, HeroStores>| {
576 hold(d, r4, paused)?;
577 d.app.stage.get_mut().paused = paused;
578 Ok(Outcome::Done)
579 });
580 }
581 if ctx.input.pressed(RESEED) {
582 let seed = ctx.app.stage.get().seed.wrapping_add(1);
583 ctx.commands.submit(Command::Reset);
584 ctx.commands
585 .try_app_fn("reseed", move |d: &mut Dispatch<'_, HeroStores>| {
586 d.app.stage.set(Stage::seeded(seed));
587 Ok(Outcome::Done)
588 });
589 }
590 Ok(())
591 });
592
593 session.system_at(
594 Phase::Simulation,
595 Order::Before(DOMAIN_STEP),
596 "assemble",
597 move |ctx: Ctx<'_, HeroStores>| -> Result<(), DomainError> {
598 let stage = *ctx.app.stage.get();
599 if stage.paused {
600 return Ok(());
601 }
602 let domain = ctx.domains.typed(r4)?;
603 if !stage.released {
604 for (entity, letter) in ctx.app.letters.iter() {
605 let Some(at) = letter.slide.sample(stage.frame(), fps()) else {
606 continue;
607 };
608 domain.set_point(entity, at)?;
609 }
610 if stage.frame() < ASSEMBLE_FRAMES {
611 return Ok(());
612 }
613 for (entity, letter) in ctx.app.letters.iter() {
614 let body =
615 polytope_body_r4(letter.mark, Vec4::ZERO, letter.hull.clone(), LETTER_MASS)
616 .ok_or(DomainError::Unsupported("letter collider"))?;
617 domain.spawn_body(entity, body)?;
618 domain
619 .physics_mut()
620 .ok_or(DomainError::Unsupported("physics on r4"))?
621 .set_restitution(entity, RESTITUTION)?;
622 }
623 ctx.app.stage.get_mut().released = true;
624 return Ok(());
625 }
626 let physics = domain
627 .physics_mut()
628 .ok_or(DomainError::Unsupported("physics on r4"))?;
629 for (entity, letter) in ctx.app.letters.iter_mut() {
630 letter.w_before = physics
631 .body(entity)
632 .and_then(|id| physics.world().body(id))
633 .map_or(0.0, |body| body.velocity.w);
634 }
635 Ok(())
636 },
637 );
638
639 session.system_at(
640 Phase::Simulation,
641 Order::After(DOMAIN_STEP),
642 "settle",
643 move |ctx: Ctx<'_, HeroStores>| -> Result<(), DomainError> {
644 let mut stage = *ctx.app.stage.get();
645 if stage.paused || stage.frame() >= SEQUENCE_FRAMES {
646 return Ok(());
647 }
648 {
649 let domain = ctx.domains.typed(r4)?;
650 let physics = domain
651 .physics_mut()
652 .ok_or(DomainError::Unsupported("physics on r4"))?;
653 for (entity, letter) in ctx.app.letters.iter() {
654 let Some(id) = physics.body(entity) else {
655 continue;
656 };
657 let scenery_only = physics
658 .world()
659 .manifolds()
660 .filter(|(key, manifold)| {
661 !manifold.points.is_empty() && (key.0 == id || key.1 == id)
662 })
663 .all(|(key, _)| {
664 let other = if key.0 == id { key.1 } else { key.0 };
665 physics
666 .world()
667 .body(other)
668 .is_some_and(|body| body.inv_mass() == 0.0)
669 });
670 let Some(body) = physics.world().body(id).copied() else {
671 continue;
672 };
673 let mut angular = body.angular_velocity;
674 angular.xw = 0.0;
675 angular.yw = 0.0;
676 angular.zw = 0.0;
677 let mut velocity = body.velocity;
678 if scenery_only && velocity.w.abs() > letter.w_before.abs() {
679 velocity.w = letter.w_before;
680 }
681 if velocity != body.velocity || angular != body.angular_velocity {
682 physics.set_velocity(entity, velocity, angular)?;
683 }
684 }
685 for (entity, _) in ctx.app.drops.iter() {
686 let Some(id) = physics.body(entity) else {
687 continue;
688 };
689 if physics
690 .world()
691 .body(id)
692 .is_none_or(|body| body.collision_group != GROUP_FALLING)
693 {
694 continue;
695 }
696 let touched = physics.world().manifolds().any(|(key, manifold)| {
697 (key.0 == id || key.1 == id) && !manifold.points.is_empty()
698 });
699 if touched {
700 physics.set_collision_filter(entity, GROUP_LANDED, MASK_ALL)?;
701 }
702 }
703 }
704
705 stage.tick += 1;
706 if stage.frame() >= FREEZE_FRAME {
707 if !stage.frozen {
708 stage.frozen = true;
709 ctx.commands
710 .try_app_fn(
711 "freeze",
712 move |d: &mut Dispatch<'_, HeroStores>| match hold(d, r4, true) {
713 Ok(()) => Ok(Outcome::Done),
714 Err(error) => {
715 d.app.stage.get_mut().frozen = false;
716 Err(error)
717 }
718 },
719 );
720 }
721 } else if stage.frame() >= stage.next_spawn && ctx.app.drops.len() < RAIN_CAP {
722 let polytope = RAIN_SHAPES[ctx.app.drops.len() % RAIN_SHAPES.len()];
723 let span = word_span(ctx.app.letters.rows());
724 let position = Vec4::new(
725 lerp(span.0, span.1, unit(stage.draw())),
726 lerp(RAIN_HEIGHT.0, RAIN_HEIGHT.1, unit(stage.draw())),
727 RAIN_Z_SPREAD * signed_unit(stage.draw()),
728 RAIN_W_SPREAD * signed_unit(stage.draw()),
729 );
730 let tumble = Bivector4::new(
731 RAIN_TUMBLE * signed_unit(stage.draw()),
732 RAIN_TUMBLE * signed_unit(stage.draw()),
733 RAIN_TUMBLE * signed_unit(stage.draw()),
734 RAIN_TUMBLE * signed_unit(stage.draw()),
735 RAIN_TUMBLE * signed_unit(stage.draw()),
736 RAIN_TUMBLE * signed_unit(stage.draw()),
737 );
738 let jitter = (stage.draw() % (2 * RAIN_INTERVAL_JITTER as u64 + 1)) as u32;
739 stage.next_spawn = stage.frame() + RAIN_INTERVAL_MIN + jitter;
740 ctx.commands
741 .try_app_fn("rain", move |d: &mut Dispatch<'_, HeroStores>| {
742 spawn_drop(d, r4, polytope, position, tumble).map(Outcome::Spawned)
743 });
744 }
745 ctx.app.stage.set(stage);
746 Ok(())
747 },
748 );
749
750 let mut local = Vec::new();
751 let mut scratch = SectionScratch::default();
752 let mut composed = None;
753 session.system(
754 Phase::Publication,
755 "compose",
756 move |ctx: Ctx<'_, HeroStores>| {
757 let tick = ctx.app.stage.get().tick;
758 let key = (tick, ctx.app.mesh.version());
759 if composed == Some(key) {
760 return Ok(());
761 }
762 compose(
763 ctx.app,
764 ctx.domains,
765 r4,
766 &mut morph,
767 &mut local,
768 &mut scratch,
769 half_depth,
770 );
771 composed = Some((tick, ctx.app.mesh.version()));
772 Ok(())
773 },
774 );
775
776 session.set_initial()?;
777 Ok((session, Scene { r4 }))
778}
779
780fn host(args: Args, record: Option<CaptureRequest>) -> SessionApp<HeroStores> {
781 let feed = TriangleFeed::default();
782 let sky = SkyGroundPass::new(Environment::default().ground(FLOOR_Y, true));
783 let mut start = record;
784 let mut recording = start.is_some();
785 SessionApp::with_args(HostConfig::new("loam", bindings()), args)
786 .recover_on_fault(RESEED)
787 .pass(Box::new(sky.clone()))
788 .pass(feed.pass(FragmentShading::FaceNormalLambert))
789 .target_fps(TICK_HZ as f32)
790 .on_frame(move |hook: &mut FrameHook<'_, HeroStores>| {
791 let environment = *hook.session.app.environment.get();
792 let root = hook.session.views().root();
793 if let Some(image) = hook.session.views().get(root) {
794 feed.set_view(&image.eye, Rigid::IDENTITY);
795 sky.publish(
796 &image.eye,
797 environment.sky,
798 environment.ground(FLOOR_Y, environment.floor_visible),
799 );
800 }
801 let version = hook.session.app.mesh.version();
802 feed.set_mesh(version, hook.session.app.mesh.get());
803 if let Some(request) = start.take() {
804 if let Err(error) = hook.capture.start(request) {
805 tracing::error!("hero capture start refused: {error}");
806 recording = false;
807 }
808 }
809 if recording && hook.session.app.stage.get().frame() >= SEQUENCE_FRAMES {
810 if let Err(error) = hook.capture.stop() {
811 tracing::error!("hero capture stop refused: {error}");
812 }
813 recording = false;
814 }
815 })
816 .command(
817 "ground",
818 "checker colors and fog: dark|light <r> <g> <b>, fog <density>, reset",
819 |args, submit, out| {
820 Environment::default().apply(args)?;
821 let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
822 submit.app_fn("ground", move |d: &mut Dispatch<'_, HeroStores>| {
823 let refs: Vec<&str> = owned.iter().map(String::as_str).collect();
824 match d.app.environment.get_mut().apply(&refs) {
825 Ok(line) => tracing::info!("{line}"),
826 Err(error) => tracing::error!("{error}"),
827 }
828 });
829 out.line("ground: applied at the next boundary");
830 Ok(())
831 },
832 )
833 .command(
834 "floor",
835 "the ground plane: on | off (bare flips)",
836 |args, submit, out| {
837 let next = match args.first().copied() {
838 None => None,
839 Some("on") => Some(true),
840 Some("off") => Some(false),
841 Some(other) => {
842 out.line(format!("floor: unknown arg `{other}` (try on|off)"));
843 return Ok(());
844 }
845 };
846 submit.app_fn("floor", move |d: &mut Dispatch<'_, HeroStores>| {
847 let visible = d.app.environment.get().floor_visible;
848 d.app.environment.get_mut().floor_visible = next.unwrap_or(!visible);
849 });
850 out.line("floor: applied at the next boundary");
851 Ok(())
852 },
853 )
854}
855
856fn letter_height(
857 session: &Session<HeroStores>,
858 r4: DomainHandle<EuclideanR4>,
859 index: usize,
860) -> Option<f32> {
861 let entity = session
862 .app
863 .letters
864 .iter()
865 .find(|(_, letter)| letter.index == index)
866 .map(|(entity, _)| entity)?;
867 let pose = session.domains().read(r4).ok()?.poses().get(entity)?;
868 Some(pose.point.y)
869}
870
871fn headless(
872 session: &mut Session<HeroStores>,
873 r4: DomainHandle<EuclideanR4>,
874 steps: u32,
875) -> Result<(), HostError> {
876 host::run_headless(session, &HostConfig::new("hero", bindings()), steps, &[])?;
877 let y = letter_height(session, r4, 0).ok_or_else(|| refused("the first letter has no pose"))?;
878 let frame = session.app.stage.get().frame();
879 println!("hero letter 0 after {steps} ticks (frame {frame}): y = {y:.4}");
880 Ok(())
881}
882
883fn main() -> ExitCode {
884 let outcome = launch_or_headless(
885 |args| {
886 let (session, _) = build()?;
887 let record = record_request(&args);
888 Ok((session, host(args, record)))
889 },
890 |args| {
891 let steps = args
892 .flag_value("headless")
893 .and_then(|count| count.parse::<u32>().ok())
894 .ok_or_else(|| refused("--headless needs a tick count"))?;
895 let (mut session, scene) = build()?;
896 headless(&mut session, scene.r4, steps)
897 },
898 );
899 match outcome {
900 Ok(()) => ExitCode::SUCCESS,
901 Err(error) => {
902 eprintln!("hero: {error:?}");
903 ExitCode::FAILURE
904 }
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use loam::math::Rotor4;
911 use loam::physics::manifold::PENETRATION_SLOP;
912 use loam::runtime::{Input, Publication};
913 use loam::shape::Visualizable;
914
915 use super::*;
916
917 const SETTLE_TICKS: u32 = (ASSEMBLE_FRAMES + 60) * SUBSTEPS;
918
919 const REST_WINDOW_FRAMES: u32 = 60;
920 const REST_SPREAD: f32 = 0.02;
921 const TUNNEL_DEPTH: f32 = 0.075;
922
923 fn solids() -> Vec<GlyphSolid> {
924 let font = FontRef::try_from_slice(FONT).expect("the vendored font parses");
925 layout_word(&font, WORD, &GlyphParams::default()).expect("the word lays out")
926 }
927
928 fn morph_field() -> MorphField {
929 let params = GlyphParams::default();
930 MorphField::new(&solids(), params.em_size / params.resolution as f32)
931 .expect("a morph field")
932 }
933
934 fn seeded(seed: u64) -> (Session<HeroStores>, Scene) {
935 let (mut session, scene) = build().expect("hero builds");
936 session.app.stage.set(Stage::seeded(seed));
937 (session, scene)
938 }
939
940 fn frames(session: &mut Session<HeroStores>, count: u32) {
941 for _ in 0..count {
942 session.boundary(Input::default()).expect("boundary");
943 for _ in 0..SUBSTEPS {
944 session.tick().expect("tick");
945 }
946 }
947 }
948
949 fn rows(session: &Session<HeroStores>) -> Vec<Letter> {
950 let mut letters: Vec<Letter> = session
951 .app
952 .letters
953 .iter()
954 .map(|(_, letter)| letter.clone())
955 .collect();
956 letters.sort_by_key(|letter| letter.index);
957 letters
958 }
959
960 fn poses(session: &Session<HeroStores>, r4: DomainHandle<EuclideanR4>) -> Vec<Vec4> {
961 let mut found: Vec<(usize, Entity)> = session
962 .app
963 .letters
964 .iter()
965 .map(|(entity, letter)| (letter.index, entity))
966 .collect();
967 found.sort_by_key(|(index, _)| *index);
968 let domain = session.domains().read(r4).expect("the r4 domain");
969 found
970 .iter()
971 .filter_map(|(_, entity)| domain.poses().get(*entity).map(|pose| pose.point))
972 .collect()
973 }
974
975 fn letter_rotors(session: &Session<HeroStores>, r4: DomainHandle<EuclideanR4>) -> Vec<Rotor4> {
976 let mut found: Vec<(usize, Entity)> = session
977 .app
978 .letters
979 .iter()
980 .map(|(entity, letter)| (letter.index, entity))
981 .collect();
982 found.sort_by_key(|(index, _)| *index);
983 let domain = session.domains().read(r4).expect("the r4 domain");
984 found
985 .iter()
986 .filter_map(|(_, entity)| domain.poses().get(*entity).map(|pose| pose.frame))
987 .collect()
988 }
989
990 fn drop_poses(session: &Session<HeroStores>, r4: DomainHandle<EuclideanR4>) -> Vec<Vec4> {
991 let entities: Vec<Entity> = session.app.drops.iter().map(|(entity, _)| entity).collect();
992 let domain = session.domains().read(r4).expect("the r4 domain");
993 entities
994 .iter()
995 .filter_map(|entity| domain.poses().get(*entity).map(|pose| pose.point))
996 .collect()
997 }
998
999 fn bounds_of(mesh: &TriangleMesh<3>) -> Option<(Vec3, Vec3)> {
1000 mesh.vertices
1001 .iter()
1002 .map(|v| (Vec3::from_array(*v), Vec3::from_array(*v)))
1003 .reduce(|(lo, hi), (l, h)| (lo.min(l), hi.max(h)))
1004 }
1005
1006 fn section_bounds(morph: &mut MorphField, u: f32) -> (Vec3, Vec3) {
1007 let mut mesh = TriangleMesh::<3>::default();
1008 assert!(
1009 append_field_prism(
1010 morph.blend_at(u),
1011 0.5 * GlyphParams::default().depth,
1012 LETTER_COLOR,
1013 &mut mesh,
1014 ),
1015 "the blend at {u} cut an empty section"
1016 );
1017 bounds_of(&mesh).expect("a non-empty section has bounds")
1018 }
1019
1020 fn blend_of(letter: &Letter, at: Vec4) -> f32 {
1021 letter.index as f32 - (at.w - W_SLICE) / W_PER_LETTERFORM
1022 }
1023
1024 fn composed(session: &mut Session<HeroStores>) -> TriangleMesh<3> {
1025 let mut publication = Publication::default();
1026 session.publish(&mut publication).expect("publication");
1027 session.app.mesh.get().clone()
1028 }
1029
1030 fn deepest_dynamic_point(
1031 session: &mut Session<HeroStores>,
1032 r4: DomainHandle<EuclideanR4>,
1033 ) -> f32 {
1034 let world = physics_of(session.domains_mut(), r4)
1035 .expect("physics")
1036 .world();
1037 let mut deepest = f32::INFINITY;
1038 for body in world.bodies().iter() {
1039 let Some(Shape::ConvexPolytope4D { vertices }) = world.collider(body) else {
1040 continue;
1041 };
1042 for v in vertices {
1043 deepest = deepest.min(body.orientation.rotation.apply(*v).y + body.position.y);
1044 }
1045 }
1046 deepest
1047 }
1048
1049 const ONE_SUBSTEP_FALL: f32 = -GRAVITY / ((TICK_HZ * SUBSTEPS) * (TICK_HZ * SUBSTEPS)) as f32;
1050
1051 fn run(steps: u32) -> (Session<HeroStores>, Scene) {
1052 let (mut session, scene) = build().expect("hero builds");
1053 host::run_headless(
1054 &mut session,
1055 &HostConfig::new("hero", bindings()),
1056 steps,
1057 &[],
1058 )
1059 .expect("headless");
1060 (session, scene)
1061 }
1062
1063 #[test]
1064 fn a_released_letter_falls_its_clearance_onto_the_floor() {
1065 let (session, scene) = run(SETTLE_TICKS);
1066 let r4 = scene.r4;
1067 let mark = session
1068 .app
1069 .letters
1070 .iter()
1071 .find(|(_, letter)| letter.index == 0)
1072 .map(|(_, letter)| letter.mark.y)
1073 .expect("the first letter");
1074 let y = letter_height(&session, r4, 0).expect("a pose");
1075 assert!(
1076 (y - (mark - RELEASE_CLEARANCE)).abs() < 2.0 * PENETRATION_SLOP,
1077 "the letter rests at {y}, not at its mark {mark} less the {RELEASE_CLEARANCE} clearance"
1078 );
1079 }
1080
1081 #[test]
1082 fn the_letters_are_directed_until_the_solver_owns_them() {
1083 let (mut session, scene) = run(ASSEMBLE_FRAMES * SUBSTEPS);
1084 let r4 = scene.r4;
1085 let bodies = |session: &mut Session<HeroStores>| {
1086 let physics = physics_of(session.domains_mut(), r4).expect("physics");
1087 physics.world().bodies().len()
1088 };
1089 assert_eq!(bodies(&mut session), 1, "a letter became a body early");
1090 let directed = letter_height(&session, r4, 0).expect("a pose");
1091
1092 host::run_headless(&mut session, &HostConfig::new("hero", bindings()), 1, &[])
1093 .expect("headless");
1094 assert_eq!(
1095 bodies(&mut session),
1096 session.app.letters.len() + 1,
1097 "the release left a letter without a body"
1098 );
1099 let handed = letter_height(&session, r4, 0).expect("a pose");
1100 assert!(
1101 (handed - directed).abs() < 2.0 * ONE_SUBSTEP_FALL,
1102 "the release put the letter at {handed}, not where the director left it at {directed}"
1103 );
1104 }
1105
1106 #[test]
1107 fn record_takes_a_directory_or_the_default_and_is_off_otherwise() {
1108 assert!(record_request(&Args::from_argv(["--fps=60"])).is_none());
1109 let bare = record_request(&Args::from_argv(["--record"])).expect("a bare flag records");
1110 assert!(matches!(
1111 bare,
1112 CaptureRequest::StartSequence { dir: None, .. }
1113 ));
1114 let named = record_request(&Args::from_argv(["--record=out/hero"])).expect("a named dir");
1115 let CaptureRequest::StartSequence { dir, .. } = named else {
1116 panic!("--record started something other than a sequence");
1117 };
1118 assert_eq!(dir, Some(PathBuf::from("out/hero")));
1119 }
1120
1121 #[test]
1122 fn morph_wraps_across_both_ends_of_the_word() {
1123 let mut morph = morph_field();
1124 let cycle = morph.letters.len() as f32;
1125 let point = Vec2::new(0.13, 0.27);
1126 for position in [-2.25_f32, -0.25, 0.0, 0.25, 3.5] {
1127 let expected = morph.blend_at(position).sample(point);
1128 let wrapped = morph.blend_at(position + 2.0 * cycle).sample(point);
1129 assert!((expected - wrapped).abs() < 1e-6);
1130 }
1131 }
1132
1133 #[test]
1134 fn at_its_mark_every_letter_cuts_its_own_letterform() {
1135 let (mut session, scene) = seeded(DEFAULT_SEED);
1136 let r4 = scene.r4;
1137 frames(&mut session, ASSEMBLE_FRAMES);
1138 let letters = rows(&session);
1139 let at = poses(&session, r4);
1140 let mut morph = morph_field();
1141 let baked = solids();
1142 let cell = GlyphParams::default().em_size / GlyphParams::default().resolution as f32;
1143 for letter in &letters {
1144 let (lo, hi) = section_bounds(&mut morph, blend_of(letter, at[letter.index]));
1145 let solid = baked
1146 .iter()
1147 .filter(|solid| !solid.is_blank())
1148 .nth(letter.index)
1149 .expect("a solid per letter");
1150 let own = bounds_of(&Visualizable::<3>::to_triangles(solid).expect("glyph mesh"))
1151 .expect("baked mesh");
1152 let (want, got) = (own.1 - own.0, hi - lo);
1153 assert!(
1154 (want.x - got.x).abs() < 2.0 * cell && (want.y - got.y).abs() < 2.0 * cell,
1155 "letter {} settled on a section {got:?} against its own {want:?}",
1156 letter.index
1157 );
1158 }
1159 }
1160
1161 #[test]
1162 fn a_letter_mid_approach_is_a_different_letterform_and_not_a_scaled_copy() {
1163 let mut morph = morph_field();
1164 let (mut settled, scene) = seeded(DEFAULT_SEED);
1165 let r4 = scene.r4;
1166 frames(&mut settled, ASSEMBLE_FRAMES);
1167 let own = section_bounds(
1168 &mut morph,
1169 blend_of(&rows(&settled)[0], poses(&settled, r4)[0]),
1170 );
1171
1172 let (mut approaching, scene) = seeded(DEFAULT_SEED);
1173 let r4 = scene.r4;
1174 frames(&mut approaching, LETTER_SLIDE_FRAMES / 2);
1175 let u = blend_of(&rows(&approaching)[0], poses(&approaching, r4)[0]);
1176 let mid = section_bounds(&mut morph, u);
1177 let nearest = section_bounds(&mut morph, u.floor());
1178 assert_ne!(
1179 mid,
1180 nearest,
1181 "the section at {u} equals the letterform at {}, so the morph snaps",
1182 u.floor()
1183 );
1184
1185 let ratio = |(lo, hi): (Vec3, Vec3)| (hi.x - lo.x) / (hi.y - lo.y);
1186 assert!(
1187 (ratio(mid) - ratio(own)).abs() > 0.05,
1188 "mid-approach aspect {} matches its own {}, so the section is only scaling",
1189 ratio(mid),
1190 ratio(own)
1191 );
1192 assert!(mid.1.y - mid.0.y > 0.1, "the approach drew almost nothing");
1193 }
1194
1195 #[test]
1196 fn the_assembly_slides_every_letter_onto_its_mark_before_the_release() {
1197 let (mut session, scene) = seeded(DEFAULT_SEED);
1198 let r4 = scene.r4;
1199 let letters = rows(&session);
1200 let entries = poses(&session, r4);
1201 for letter in &letters {
1202 assert_eq!(entries[letter.index], letter.entry);
1203 let offset = letter.entry - letter.mark;
1204 assert!(
1205 offset.w.abs() > W_PER_LETTERFORM,
1206 "letter {} starts less than a letterform away, so it never morphs",
1207 letter.index
1208 );
1209 assert!(
1210 offset.truncate().length() < 1e-6,
1211 "letter {} enters by a 3D translation of {:?}",
1212 letter.index,
1213 offset.truncate()
1214 );
1215 }
1216 frames(&mut session, LETTER_STAGGER_FRAMES);
1217 assert_ne!(poses(&session, r4), entries);
1218
1219 frames(&mut session, ASSEMBLE_FRAMES - LETTER_STAGGER_FRAMES);
1220 let marks: Vec<Vec4> = letters.iter().map(|letter| letter.mark).collect();
1221 assert_eq!(poses(&session, r4), marks, "a letter missed its mark");
1222 }
1223
1224 #[test]
1225 fn the_assembled_word_is_centred_on_what_the_camera_aims_at() {
1226 let (session, _) = seeded(DEFAULT_SEED);
1227 let letters = rows(&session);
1228 let centre = word_centre(&letters);
1229 assert!(
1230 centre.x.abs() < 1e-5,
1231 "the word centres at x {}, so it hangs off one side of frame",
1232 centre.x
1233 );
1234 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
1235 for letter in &letters {
1236 for v in &letter.hull {
1237 lo = lo.min(letter.mark.x + v.x);
1238 hi = hi.max(letter.mark.x + v.x);
1239 }
1240 }
1241 assert!(
1242 (lo + hi).abs() < 1e-5,
1243 "the word spans [{lo}, {hi}] em, which is not symmetric about the aim"
1244 );
1245 assert!(
1246 centre.y > 0.0 && centre.y < 1.0,
1247 "the word centres at y {}, outside a one-em letter's own height",
1248 centre.y
1249 );
1250 }
1251
1252 #[test]
1253 fn reset_at_the_same_tick_rebuilds_the_published_mesh() {
1254 let (mut session, _) = seeded(DEFAULT_SEED);
1255 let before = composed(&mut session);
1256
1257 session.reset().expect("reset");
1258 let after = composed(&mut session);
1259
1260 assert_eq!(
1261 (after.vertices, after.indices, after.colors),
1262 (before.vertices, before.indices, before.colors)
1263 );
1264 }
1265
1266 #[test]
1267 fn a_settled_letter_is_drawn_standing_on_the_floor_within_the_covers_margin() {
1268 let (mut session, _scene) = seeded(DEFAULT_SEED);
1269 frames(&mut session, RAIN_START_FRAME);
1270 assert!(session.app.drops.is_empty(), "the rain started early");
1271 let margin = solids()
1272 .iter()
1273 .map(GlyphSolid::collider_margin)
1274 .fold(0.0f32, f32::max);
1275
1276 let mesh = composed(&mut session);
1277 assert!(!mesh.vertices.is_empty(), "the settled word drew nothing");
1278 let drawn = mesh.vertices.iter().fold(f32::INFINITY, |m, v| m.min(v[1]));
1279 assert!(
1280 drawn > -2.0 * PENETRATION_SLOP,
1281 "the word is drawn {drawn} below the floor"
1282 );
1283 assert!(
1284 drawn < margin + 2.0 * PENETRATION_SLOP,
1285 "the word floats {drawn} above the floor, past the {margin} margin"
1286 );
1287 }
1288
1289 #[test]
1290 fn the_seed_moves_the_rain_and_leaves_the_letters_landing_untouched() {
1291 let settled = |seed: u64| {
1292 let (mut session, scene) = seeded(seed);
1293 let r4 = scene.r4;
1294 frames(&mut session, RAIN_START_FRAME);
1295 let letters = poses(&session, r4);
1296 frames(&mut session, REST_WINDOW_FRAMES);
1297 (letters, drop_poses(&session, r4))
1298 };
1299 let (letters_a, drops_a) = settled(DEFAULT_SEED);
1300 let (letters_b, drops_b) = settled(DEFAULT_SEED ^ 0xdead_beef);
1301 assert_eq!(
1302 letters_a, letters_b,
1303 "the seed reached the letters' landing"
1304 );
1305 assert!(
1306 drops_a.len() != drops_b.len() || drops_a.iter().zip(&drops_b).any(|(a, b)| a != b),
1307 "two seeds rained identically, so the seed is decoration"
1308 );
1309 }
1310
1311 #[test]
1312 fn release_rain_and_freeze_preserve_the_sequence_boundaries() {
1313 let (mut session, scene) = seeded(DEFAULT_SEED);
1314 let r4 = scene.r4;
1315 let letters = rows(&session);
1316 let mut rest_start: Vec<Vec4> = Vec::new();
1317 let mut frozen: Vec<(Vec4, Rotor4)> = Vec::new();
1318 let mut falling_seen = false;
1319 let mut slice_sweep = 0.0_f32;
1320 for frame in 1..=SEQUENCE_FRAMES {
1321 frames(&mut session, 1);
1322 let deepest = deepest_dynamic_point(&mut session, r4);
1323 assert!(
1324 deepest > -TUNNEL_DEPTH,
1325 "floor depth {deepest} at frame {frame}"
1326 );
1327
1328 let at = poses(&session, r4);
1329 if frame == RAIN_START_FRAME - REST_WINDOW_FRAMES {
1330 rest_start = at.clone();
1331 }
1332 for letter in &letters {
1333 let pose = at[letter.index];
1334 if (ASSEMBLE_FRAMES..=RAIN_START_FRAME).contains(&frame) {
1335 assert!(
1336 (pose.w - letter.mark.w).abs() < 1e-6,
1337 "the floor moved letter {} through w",
1338 letter.index
1339 );
1340 }
1341 if (RAIN_START_FRAME - REST_WINDOW_FRAMES..=RAIN_START_FRAME).contains(&frame) {
1342 assert!(
1343 pose.distance(rest_start[letter.index]) < REST_SPREAD,
1344 "letter {} has not settled",
1345 letter.index
1346 );
1347 }
1348 }
1349
1350 for (index, rotor) in letter_rotors(&session, r4).iter().enumerate() {
1351 let determinant = rotor.apply(Vec4::X).truncate().dot(
1352 rotor
1353 .apply(Vec4::Y)
1354 .truncate()
1355 .cross(rotor.apply(Vec4::Z).truncate()),
1356 );
1357 assert!(
1358 (determinant - 1.0).abs() < 1e-3,
1359 "letter {index} left the draw's rotation plane at frame {frame}"
1360 );
1361 }
1362 let world = physics_of(session.domains_mut(), r4)
1363 .expect("physics")
1364 .world();
1365 for (key, manifold) in world.manifolds() {
1366 if manifold.points.is_empty() {
1367 continue;
1368 }
1369 let falling = |id: BodyId| {
1370 world
1371 .body(id)
1372 .is_some_and(|body| body.collision_group == GROUP_FALLING)
1373 };
1374 assert!(
1375 !(falling(key.0) && falling(key.1)),
1376 "airborne drops collided"
1377 );
1378 }
1379 falling_seen |= (0..world.bodies().len())
1380 .map(|dense| world.bodies().id_at(dense))
1381 .any(|id| {
1382 world
1383 .body(id)
1384 .is_some_and(|body| body.collision_group == GROUP_FALLING)
1385 });
1386
1387 if frame == RAIN_START_FRAME {
1388 assert!(session.app.drops.is_empty(), "the rain started early");
1389 }
1390 if frame == FREEZE_FRAME {
1391 let drops = session.app.drops.len();
1392 let world = physics_of(session.domains_mut(), r4)
1393 .expect("physics")
1394 .world();
1395 let landed = (0..world.bodies().len())
1396 .map(|dense| world.bodies().id_at(dense))
1397 .filter(|id| {
1398 world
1399 .body(*id)
1400 .is_some_and(|body| body.collision_group == GROUP_LANDED)
1401 })
1402 .count();
1403 assert!(
1404 falling_seen && landed * 2 > drops,
1405 "{landed} of {drops} drops had landed by the freeze"
1406 );
1407 frozen = (0..world.bodies().len())
1408 .map(|dense| world.bodies().id_at(dense))
1409 .map(|id| world.body(id).expect("dense body"))
1410 .map(|body| (body.position, body.orientation.rotation))
1411 .collect();
1412 }
1413 if frame > FREEZE_FRAME {
1414 let stage = *session.app.stage.get();
1415 let world = physics_of(session.domains_mut(), r4)
1416 .expect("physics")
1417 .world();
1418 let now: Vec<(Vec4, Rotor4)> = (0..world.bodies().len())
1419 .map(|dense| world.bodies().id_at(dense))
1420 .map(|id| world.body(id).expect("dense body"))
1421 .map(|body| (body.position, body.orientation.rotation))
1422 .collect();
1423 assert_eq!(
1424 now, frozen,
1425 "the pile moved after the freeze at frame {frame}"
1426 );
1427 slice_sweep = slice_sweep.max((stage.slice() - W_SLICE).abs());
1428 }
1429 }
1430 assert!(
1431 slice_sweep > 0.9 * SLICE_SWEEP_RANGE,
1432 "the slice swept {slice_sweep}, not {SLICE_SWEEP_RANGE}"
1433 );
1434
1435 let mesh = composed(&mut session);
1436 assert_eq!(mesh.colors.len(), mesh.vertices.len());
1437 let count = mesh.vertices.len() as u32;
1438 assert!(count > 4, "the mesh holds almost nothing");
1439 for tri in &mesh.indices {
1440 for index in tri {
1441 assert!(*index < count, "index {index} past {count} vertices");
1442 }
1443 }
1444 for v in &mesh.vertices {
1445 assert!(v.iter().all(|c| c.is_finite()), "non-finite vertex {v:?}");
1446 }
1447 }
1448}