Skip to main content

hero/
hero.rs

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    // xorshift64*, Vigna 2016 ยง4.
155    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, &params).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}