From 9fc255d43cdb40beec9c32745e84572a2bf515d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:52:20 +0000 Subject: [PATCH] feat(pet): draw the Watch whale with the desktop's whale v2 contour The terminal pet's Braille cells were the old 980-dot particle world, while the desktop app paints the v2 contour character. Port that character's Director, rig, props and Braille rasterizer into crates/tui/src/tui/whale_v2 and feed it the same owner truth the desktop reads (EngineOwnerProjection): presence, activity kind, observed span onsets, parallel agent count, completed-turn id. The live view thread now takes its cells from one Director on one clock; the Kitty pixel image and the label still come from the particle frame. Unknown presence and a disconnected producer read as Offline (asleep), never Idle. Listening, Stuck and the failure beat are not ported (the owner emits none of them) and were removed rather than left as dead code. The vendored animation tables live beside the module; the desktop app copy stays the reference. Evidence: a scratch crate built from this module alone passes its 2 tests (owner truth changes the frame; disconnected equals Offline) and renders a recognizable reading whale at 32x16. The full codewhale-tui lib test build was killed (SIGKILL) in this sandbox, so the integrated test run and a live terminal check are still owed. cargo check -p codewhale-tui --lib passes with warnings denied. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gi912QbkMmcL35mcKHcE1m --- crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/pet_watch/live.rs | 16 +- crates/tui/src/tui/whale_v2/acting.rs | 858 +++ crates/tui/src/tui/whale_v2/data.rs | 557 ++ .../tui/src/tui/whale_v2/data/animation.json | 6236 +++++++++++++++++ crates/tui/src/tui/whale_v2/data/mark-data.js | 2 + crates/tui/src/tui/whale_v2/math.rs | 98 + crates/tui/src/tui/whale_v2/mod.rs | 222 + crates/tui/src/tui/whale_v2/props.rs | 502 ++ crates/tui/src/tui/whale_v2/rig.rs | 986 +++ crates/tui/src/tui/whale_v2/scene.rs | 216 + 11 files changed, 9693 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/tui/whale_v2/acting.rs create mode 100644 crates/tui/src/tui/whale_v2/data.rs create mode 100644 crates/tui/src/tui/whale_v2/data/animation.json create mode 100644 crates/tui/src/tui/whale_v2/data/mark-data.js create mode 100644 crates/tui/src/tui/whale_v2/math.rs create mode 100644 crates/tui/src/tui/whale_v2/mod.rs create mode 100644 crates/tui/src/tui/whale_v2/props.rs create mode 100644 crates/tui/src/tui/whale_v2/rig.rs create mode 100644 crates/tui/src/tui/whale_v2/scene.rs diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index d4c2f03c95..32a1f8a562 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -122,6 +122,7 @@ pub mod underwater; pub mod user_input; pub mod views; pub mod vim_mode; +pub mod whale_v2; pub mod whales; pub mod widgets; pub mod window_control; diff --git a/crates/tui/src/tui/pet_watch/live.rs b/crates/tui/src/tui/pet_watch/live.rs index 092dd1d702..0f942332d8 100644 --- a/crates/tui/src/tui/pet_watch/live.rs +++ b/crates/tui/src/tui/pet_watch/live.rs @@ -409,6 +409,7 @@ fn run( ) }); let mut renderer = graphics::Renderer::default(); + let mut whale = crate::tui::whale_v2::Whale::default(); let id = uuid::Uuid::new_v4().to_string(); let mut client = Client::connect()?; @@ -606,13 +607,26 @@ fn run( 1.0 }; renderer.set_appearance(&s.appearance); - let (cells, image) = renderer.render( + let (_, image) = renderer.render( &pose, previous.as_ref().filter(|_| view.motion), fraction, &view, if view.motion { s.time_ms / 1000.0 } else { 0.0 }, )?; + // The Braille cells are the v2 character; the particle world + // still feeds the pixel image and the label. + whale.observe( + &s.source, + s.activity.as_ref(), + s.producer_connected, + !view.motion, + ); + whale.advance(Instant::now()); + let cells = whale.cells( + usize::from(view.width.clamp(1, 512)), + usize::from(view.height.clamp(1, 256)), + ); let bytes = image.as_ref().map_or(0, Vec::len); if let Ok(mut slot) = output.lock() { *slot = Some(Presentation { diff --git a/crates/tui/src/tui/whale_v2/acting.rs b/crates/tui/src/tui/whale_v2/acting.rs new file mode 100644 index 0000000000..f6673aa1e1 --- /dev/null +++ b/crates/tui/src/tui/whale_v2/acting.rs @@ -0,0 +1,858 @@ +//! Port of `acting.js`: presence → acting state → pose over time. +//! +//! ```text +//! pose(t) = spring(base pose of the state) state blends, follow-through +//! + Σ one-shot clips (enter, exit, beats) authored, exact frame timing +//! + loop clip of the state the ongoing performance +//! + ambient (breath, bob, blinks, puffs) alive, never dancing +//! + drag (flukes and fins trail the body) secondary motion +//! ``` +//! +//! Nothing here invents activity: every acting state comes from the owner's +//! presence, every work action from the owner's activity kind, every beat +//! from an observed owner onset. The Director owns no status timer and no +//! telemetry; it is a pure function of its inputs and the frames it steps. + +use super::data::{Act, Ambient, Clip, Emit, N, Pose, PosterParticle, p, tables}; +use super::math::{Rng, TAU, clamp, lerp}; +use super::props::{Particle, ParticleKind}; +use super::rig::{Anchors, MARK_DIRECTION, build}; +use std::collections::{HashSet, VecDeque}; + +pub const FPS: f64 = 30.; + +/// The authoritative owner presence the terminal derives (see `Whale`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Presence { + Offline, + Idle, + Thinking, + Working, + NeedsYou, + Done, +} + +/// One observed foreground span onset reported by the owner. +#[derive(Clone, Debug, PartialEq)] +pub struct Span { + pub kind: String, + pub since_ms: f64, +} + +/// The owner's activity, exactly as reported. `observed` is true only when +/// the owner strictly said so; `parallel` keeps its raw number so a +/// non-integer count is refused rather than rounded. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Activity { + pub kind: Option, + pub observed: bool, + pub parallel: Option, + pub active: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Context { + /// `freshness === 'Live'`. Missing or stale freshness is plain work. + pub live: bool, + /// Stable turn identity; a completed one authorizes one Done flourish. + pub turn_id: Option, + /// The explicit terminal status: completed, failed, error, canceled, + /// interrupted. Never inferred. + pub status: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + Tool { kind: String }, +} + +/// `actingFor()`. +pub fn acting_for(presence: Presence, activity: Option<&Activity>, context: &Context) -> Act { + match presence { + Presence::Offline => Act::Sleep, + Presence::Idle => Act::Rest, + Presence::Thinking => Act::Think, + Presence::NeedsYou => Act::Needs, + Presence::Done => Act::Done, + Presence::Working => match activity { + Some(activity) if activity.observed && context.live => activity + .kind + .as_deref() + .and_then(|kind| tables().act_for_kind(kind)) + .unwrap_or(Act::Busy), + _ => Act::Busy, + }, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClipId { + Enter(Act), + Loop(Act), + Beat(Act), + Exit(Act), + Wake, + Puff, + Blink, +} + +impl ClipId { + pub fn clip(self) -> &'static Clip { + let t = tables(); + match self { + ClipId::Enter(act) => &t.act(act).enter, + ClipId::Loop(act) => &t.act(act).loop_, + ClipId::Beat(act) => t.act(act).beat.as_ref().expect("beat"), + ClipId::Exit(act) => t.act(act).exit.as_ref().expect("exit"), + ClipId::Wake => &t.wake, + ClipId::Puff => &t.puff, + ClipId::Blink => &t.blink, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Shot { + pub clip: ClipId, + pub at: f64, + pub owner: Act, + pub exit: bool, + pub wake: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Calf { + pub value: f64, + pub velocity: f64, + pub target: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Emission { + pub kind: Emit, + pub owner: Act, + pub f: f64, +} + +#[derive(Clone, Copy, Debug)] +struct AmbientClock { + blink_at: f64, + dart_at: f64, + puff_at: f64, + sleep_at: f64, + look_at: f64, + look: (f64, f64), + cur: (f64, f64), +} + +#[derive(Clone, Copy, Debug)] +struct Departure { + at: f64, + offset: Pose, +} + +#[derive(Clone, Debug)] +pub struct Options { + pub seed: u32, + pub reduced: bool, + pub ambient: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + seed: 7, + reduced: false, + ambient: true, + } + } +} + +/// One session's performance. Drop it when the foreground session changes: +/// no pending clip, calf count or completed-turn identity may carry over. +#[derive(Clone, Debug)] +pub struct Director { + rng: Rng, + /// Frame time at 30 fps, floating. + pub f: f64, + pub presence: Presence, + pub activity: Option, + pub context: Context, + pub acting: Act, + pub reduced: bool, + ambient_on: bool, + x: Pose, + v: Pose, + pub shots: Vec, + loops: Vec, + pub particles: Vec, + pub lines: f64, + pub events: VecDeque<(Event, f64)>, + pub emissions: VecDeque, + amb: AmbientClock, + pose_out: Pose, + departure: Option, + changed_at: f64, + from_target: Pose, + delays: Pose, + done_turns: HashSet, + spans: VecDeque, + span_set: HashSet, + pub calves: [Calf; 3], + anchors: Option, +} + +const HELD_PROPS: [usize; 9] = [ + p::page, + p::pad, + p::pencil, + p::lens, + p::wrench, + p::glass, + p::pod, + p::cursor, + p::link, +]; + +impl Director { + pub fn new(options: Options) -> Self { + let seed = if options.seed == 0 { 7 } else { options.seed }; + let mut director = Self { + rng: Rng::new(seed), + f: 0., + presence: Presence::Idle, + activity: None, + context: Context::default(), + acting: Act::Rest, + reduced: options.reduced, + ambient_on: options.ambient, + x: [0.; N], + v: [0.; N], + shots: Vec::new(), + loops: vec![Shot { + clip: ClipId::Loop(Act::Rest), + at: 0., + owner: Act::Rest, + exit: false, + wake: false, + }], + particles: Vec::new(), + lines: 1., + events: VecDeque::new(), + emissions: VecDeque::new(), + amb: AmbientClock { + blink_at: 40., + dart_at: 120., + puff_at: 360., + sleep_at: 0., + look_at: 0., + look: (0., 0.), + cur: (0., 0.), + }, + pose_out: [0.; N], + departure: None, + changed_at: 0., + from_target: [0.; N], + delays: [0.; N], + done_turns: HashSet::new(), + spans: VecDeque::new(), + span_set: HashSet::new(), + calves: [Calf::default(); 3], + anchors: None, + }; + director.x = director.target_for(Act::Rest); + director.pose_out = director.x; + director + } + + pub fn target_for(&self, act: Act) -> Pose { + let mut pose = tables().defaults; + for (param, value) in &tables().act(act).base { + pose[*param] = *value; + } + pose[p::padLines] = if self.lines == 0. { 1. } else { self.lines }; + pose + } + + /// `set(presence, activity, context)`: the only way owner truth enters. + pub fn set(&mut self, presence: Presence, activity: Option, context: Context) { + self.presence = presence; + self.activity = activity; + self.context = context; + let next = acting_for(presence, self.activity.as_ref(), &self.context); + let done_key = (presence == Presence::Done + && self.context.status.as_deref() == Some("completed")) + .then(|| self.context.turn_id.clone()) + .flatten() + .filter(|id| !id.is_empty()); + let celebrate = done_key.is_some_and(|key| self.done_turns.insert(key)); + self.go(next, celebrate, celebrate && next == Act::Done); + let n = match (next, self.activity.as_ref().and_then(|a| a.parallel)) { + (Act::Pod, Some(parallel)) if parallel.fract() == 0. && parallel > 0. => { + parallel.min(3.) as usize + } + _ => 0, + }; + let reduced = self.reduced; + for (i, calf) in self.calves.iter_mut().enumerate() { + calf.target = if i < n { 1. } else { 0. }; + if reduced { + calf.value = calf.target; + calf.velocity = 0.; + } + } + // Observe every onset, including those hidden by owner priority: + // never queue, never replay. + let spans = match &self.activity { + Some(activity) if activity.observed && self.context.live => activity.active.clone(), + _ => Vec::new(), + }; + for span in spans { + if !span.since_ms.is_finite() { + continue; + } + let since = if span.since_ms == 0. { + 0. + } else { + span.since_ms + }; + let key = format!( + "{}|{}|{}", + self.context.turn_id.as_deref().unwrap_or(""), + span.kind, + since + ); + if self.span_set.insert(key.clone()) { + self.spans.push_back(key); + self.event(Event::Tool { kind: span.kind }); + } + } + if self.span_set.len() > 512 { + while self.spans.len() > 256 { + let old = self.spans.pop_front().unwrap(); + self.span_set.remove(&old); + } + } + if self.reduced { + self.pose_out = self.poster(); + } + } + + fn go(&mut self, next: Act, celebrate: bool, force: bool) { + if next == self.acting && !force { + return; + } + let prev = self.acting; + let current = self.pose(); + // Freeze the visible offset, not the old clip's future movement or events. + let mut offset = [0.; N]; + for i in 0..N { + offset[i] = current[i] - self.x[i]; + } + self.departure = Some(Departure { at: self.f, offset }); + let wake_dur = tables().wake.dur; + let f = self.f; + self.shots + .retain(|s| s.wake && f - s.at < wake_dur && next != Act::Sleep); + self.loops.clear(); + self.particles.clear(); + self.acting = next; + self.changed_at = self.f; + self.from_target = self.x; + self.delays = [0.; N]; + self.delays[p::fin] = 3.; + self.delays[p::finFar] = 4.; + self.delays[p::fluke] = 4.; + self.delays[p::curl] = if prev == Act::Sleep { 2. } else { 3. }; + let think = if next == Act::Think { 6. } else { 0. }; + self.delays[p::lookX] = think; + self.delays[p::lookY] = think; + self.delays[p::lid] = if next == Act::Sleep { 6. } else { 0. }; + self.delays[p::glassExt] = if next == Act::Browse { 8. } else { 0. }; + if next == Act::Write { + self.lines = 1.; + } + if self.reduced { + self.x = self.target_for(next); + self.v = [0.; N]; + self.shots.clear(); + self.departure = None; + self.pose_out = self.poster(); + return; + } + let shot = |clip, owner| Shot { + clip, + at: f, + owner, + exit: false, + wake: false, + }; + if tables().act(prev).exit.is_some() { + self.shots.push(Shot { + exit: true, + ..shot(ClipId::Exit(prev), prev) + }); + } + if prev == Act::Sleep && next != Act::Sleep { + self.shots.push(Shot { + wake: true, + ..shot(ClipId::Wake, next) + }); + } + if next != Act::Done || celebrate { + self.shots.push(shot(ClipId::Enter(next), next)); + } + let enter = tables().act(next).enter.dur; + self.loops = vec![Shot { + at: f + (enter * 0.6).min(10.), + ..shot(ClipId::Loop(next), next) + }]; + } + + pub fn set_reduced(&mut self, value: bool) { + if self.reduced == value { + return; + } + self.reduced = value; + self.shots.clear(); + self.loops.clear(); + self.particles.clear(); + self.departure = None; + self.acting = acting_for(self.presence, self.activity.as_ref(), &self.context); + self.x = self.target_for(self.acting); + self.v = [0.; N]; + for calf in &mut self.calves { + calf.value = calf.target; + calf.velocity = 0.; + } + self.pose_out = self.poster(); + if !self.reduced { + self.loops = vec![Shot { + clip: ClipId::Loop(self.acting), + at: self.f, + owner: self.acting, + exit: false, + wake: false, + }]; + } + } + + /// An owner event. A beat plays only for matching, fresh, observed work. + pub fn event(&mut self, event: Event) -> bool { + self.events.push_back((event.clone(), self.f)); + if self.events.len() > 40 { + self.events.pop_front(); + } + let observed = self.activity.as_ref().is_some_and(|a| a.observed); + if self.presence != Presence::Working || !self.context.live || !observed { + return false; + } + let Event::Tool { kind } = &event; + if tables().act_for_kind(kind) != Some(self.acting) { + return false; + } + let clip = tables() + .act(self.acting) + .beat + .is_some() + .then_some(ClipId::Beat(self.acting)); + let Some(clip) = clip else { + return false; + }; + if self.acting == Act::Write { + self.lines = (self.lines + 1.).min(4.); + } + if self.reduced { + self.pose_out = self.poster(); + return true; + } + self.shots.retain(|s| s.clip != clip); + self.shots.push(Shot { + clip, + at: self.f, + owner: self.acting, + exit: false, + wake: false, + }); + true + } + + /// Advance one step of at most a tenth of a second. + pub fn step(&mut self, dt: f64) { + if self.reduced { + self.pose_out = self.poster(); + return; + } + let t = tables(); + let dt = clamp(dt, 0., 1. / 10.); + let df = dt * FPS; + let f0 = self.f; + self.f += df; + let target = self.target_for(self.acting); + let sub = (dt * 120.).ceil().max(1.) as usize; + let h = dt / sub as f64; + for _ in 0..sub { + for (i, &target) in target.iter().enumerate() { + let (w, z) = t.springs[i]; + let goal = if self.f - self.changed_at < self.delays[i] { + self.from_target[i] + } else { + target + }; + self.v[i] += (w * w * (goal - self.x[i]) - 2. * z * w * self.v[i]) * h; + self.x[i] += self.v[i] * h; + } + for calf in &mut self.calves { + calf.velocity += (144. * (calf.target - calf.value) - 24. * calf.velocity) * h; + calf.value = clamp(calf.value + calf.velocity * h, 0., 1.); + } + } + let mut out = [0.; N]; + if let Some(departure) = self.departure { + let w = 1. - clamp((self.f - departure.at) / 8., 0., 1.); + for (out, offset) in out.iter_mut().zip(departure.offset) { + *out = offset * w; + } + if w == 0. { + self.departure = None; + } + } + self.anchors = Some(build(MARK_DIRECTION, &self.pose_out, 0, 24., 1.).anchors); + let f = self.f; + self.shots.retain(|s| f - s.at <= s.clip.clip().dur); + let shots = self.shots.clone(); + for s in &shots { + if f < s.at { + continue; + } + let clip = s.clip.clip(); + clip.eval(f - s.at, &mut out, 1.); + if !s.exit && (s.wake || s.owner == self.acting) { + self.fire(clip, f0 - s.at, f - s.at, s.owner); + } + } + let loops = self.loops.clone(); + for l in &loops { + if f < l.at { + continue; + } + let clip = l.clip.clip(); + let lf = (f - l.at) % clip.dur; + let w = clamp((f - l.at) / 8., 0., 1.); + clip.eval(lf, &mut out, w); + let pf = (f0 - l.at) % clip.dur; + self.fire(clip, if pf <= lf { pf } else { -1. }, lf, l.owner); + } + self.ambient(&mut out); + out[p::fluke] += clamp(-self.v[p::y] * 2.2 - self.v[p::rot] * 0.6, -22., 22.); + out[p::fin] += clamp(-self.v[p::y] * 1.2, -12., 12.); + let mut pose = [0.; N]; + for i in 0..N { + let desired = self.x[i] + out[i]; + let prior = self.pose_out[i]; + let delta = if i == p::wrenchSpin { + angle_delta(desired, prior) + } else { + desired - prior + }; + let rate = t.rates[i]; + pose[i] = prior + clamp(delta, -rate * df, rate * df); + } + for i in [p::lid, p::lidLow, p::mouth, p::yaw, p::pageFlip] { + pose[i] = clamp(pose[i], 0., 1.); + } + for i in HELD_PROPS { + pose[i] = clamp(pose[i], 0., 1.); + } + self.pose_out = pose; + for particle in &mut self.particles { + particle.age += df; + particle.vy += particle.g * df; + particle.vx *= particle.drag.powf(df); + particle.vy *= particle.drag.powf(df); + particle.x += particle.vx * df; + particle.y += particle.vy * df; + } + let acting = self.acting; + self.particles + .retain(|particle| particle.age < particle.life && particle.owner == acting); + } + + fn fire(&mut self, clip: &Clip, a: f64, b: f64, owner: Act) { + if owner != self.acting { + return; + } + for (frame, kind) in &clip.events { + if *frame > a && *frame <= b { + self.emit(*kind); + } + } + } + + fn ambient(&mut self, out: &mut Pose) { + if !self.ambient_on { + return; + } + let a: Ambient = tables().act(self.acting).ambient; + let f = self.f; + // Breath: a slow inhale, a short hold, a longer exhale. + let ph = (f % a.period) / a.period; + let half_pi = std::f64::consts::PI / 2.; + let b = if ph < 0.4 { + (ph / 0.4 * half_pi).sin() + } else if ph < 0.5 { + 1. + } else { + ((ph - 0.5) / 0.5 * half_pi).cos() + }; + let breath = a.breath; + out[p::squash] -= 0.016 * b * breath; + out[p::y] = out[p::y] - 0.55 * b * breath + (f / 210. * TAU).sin() * 1.1 * a.bob; + out[p::fluke] += 3.5 * ((f - 10.) / a.period * TAU).sin() * breath; + out[p::fin] += 2.2 * ((f - 6.) / a.period * TAU).sin() * breath; + if self.acting == Act::Rest { + out[p::rot] += 2.3 * (f / 240. * TAU).sin(); + out[p::x] += 1.8 * (f / 330. * TAU).sin(); + out[p::y] += ((f + 33.) / 210. * TAU).sin(); + out[p::fluke] += 8. * ((f - 24.) / 180. * TAU).sin(); + out[p::head] += 1.5 * ((f - 12.) / 250. * TAU).sin(); + } + let blink = |at: f64| Shot { + clip: ClipId::Blink, + at, + owner: self.acting, + exit: false, + wake: false, + }; + if a.blink > 0. && f >= self.amb.blink_at { + self.shots.push(blink(f)); + if self.rng.draw() < 0.2 { + self.shots.push(blink(f + 9.)); + } + self.amb.blink_at = f + (90. + self.rng.draw() * 150.) / a.blink; + } + if a.dart > 0. { + if f >= self.amb.dart_at { + let x = (self.rng.draw() - 0.5) * 0.9; + let y = (self.rng.draw() - 0.5) * 0.5; + self.amb.look = (x, y); + self.amb.dart_at = f + 150. + self.rng.draw() * 240.; + self.amb.look_at = f; + } + let t = clamp((f - self.amb.look_at) / 4., 0., 1.); + self.amb.cur.0 = lerp(self.amb.cur.0, self.amb.look.0, t); + self.amb.cur.1 = lerp(self.amb.cur.1, self.amb.look.1, t); + out[p::lookX] += self.amb.cur.0; + out[p::lookY] += self.amb.cur.1; + } + if a.puff > 0. && f >= self.amb.puff_at { + self.shots.push(Shot { + clip: ClipId::Puff, + ..blink(f) + }); + self.amb.puff_at = f + 600. + self.rng.draw() * 540.; + } + if a.sleepy > 0. && f >= self.amb.sleep_at { + self.emit(Emit::Sleep); + self.amb.sleep_at = f + 170. + self.rng.draw() * 140.; + } + } + + /// Particles are born at rig anchors, in world design units. + fn emit(&mut self, kind: Emit) { + let Some(a) = self.anchors else { + return; + }; + let owner = self.acting; + self.emissions.push_back(Emission { + kind, + owner, + f: self.f, + }); + if self.emissions.len() > 100 { + self.emissions.pop_front(); + } + let r = &mut self.rng; + let _seed = r.draw(); + let particle = |kind, x, y, vx, vy, radius, life| Particle { + kind, + x, + y, + vx, + vy, + g: 0., + drag: 0.98, + r: radius, + age: 0., + life, + cloud: false, + owner, + }; + match kind { + Emit::Spout { n, big } => { + let source = if big { a.spout } else { a.blowhole }; + for _ in 0..n { + let ang = + -std::f64::consts::PI / 2. + (r.draw() - 0.5) * if big { 1.3 } else { 0.8 }; + let sp = if big { 1.55 } else { 0.9 } * (0.75 + r.draw() * 0.5); + let x = source.x + if big { (r.draw() - 0.5) * 26. } else { 0. }; + let y = source.y - if big { 24. + r.draw() * 3. } else { 1. }; + let vx = ang.cos() * if big { 0.3 } else { sp }; + let vy = if big { -0.35 } else { ang.sin() * sp }; + self.particles.push(Particle { + g: if big { 0.055 } else { 0.075 }, + drag: 0.985, + ..particle( + ParticleKind::Drop, + x, + y, + vx, + vy, + if big { 1.6 } else { 1.3 }, + if big { 35. } else { 26. }, + ) + }); + } + for _ in 0..if big { 3 } else { 2 } { + let x = a.blowhole.x + (r.draw() - 0.5) * 3.; + let y = a.blowhole.y - 5. - r.draw() * 4.; + let vx = (r.draw() - 0.5) * 0.1; + self.particles.push(Particle { + drag: 0.95, + ..particle( + ParticleKind::Mist, + x, + y, + vx, + -0.12, + if big { 4.5 } else { 3.2 }, + 24., + ) + }); + } + } + Emit::Thought => { + let bx = a.blowhole.x + 2.; + let by = a.blowhole.y - 3.; + for (i, (delay, radius, cloud)) in + [(0., 1.3, false), (5., 2.1, false), (10., 5.2, true)] + .into_iter() + .enumerate() + { + self.particles.push(Particle { + age: -delay, + drag: 0.99, + cloud, + ..particle( + ParticleKind::Thought, + bx + [0., 6., 14.][i], + by - i as f64 * 5.5, + 0.06, + -0.05, + radius, + 64. - delay, + ) + }); + } + } + Emit::Speech => { + let m = a.mouth; + for i in 0..3 { + let x = m.x + 4. + r.draw() * 2.; + let vx = 0.26 + r.draw() * 0.15; + let vy = -0.3 - r.draw() * 0.2; + let radius = 1.7 + r.draw() * 1.6; + let _seed = r.draw(); + self.particles.push(Particle { + age: -(i as f64) * 6., + drag: 0.985, + ..particle(ParticleKind::Bubble, x, m.y - 1., vx, vy, radius, 50.) + }); + } + } + Emit::Blub => { + self.particles.push(Particle { + drag: 0.995, + ..particle( + ParticleKind::Bubble, + a.blowhole.x, + a.blowhole.y - 2., + 0.02, + -0.16, + 2.2, + 64., + ) + }); + } + Emit::Sleep => { + self.particles.push(Particle { + drag: 0.997, + ..particle( + ParticleKind::Bubble, + a.blowhole.x, + a.blowhole.y - 1.5, + 0.03, + -0.1, + 1.6, + 90., + ) + }); + } + } + } + + /// Reduced motion: the state's poster pose — no clock, no ambient, no + /// transitions. + pub fn poster(&self) -> Pose { + let mut pose = self.target_for(self.acting); + for (param, value) in &tables().act(self.acting).poster { + pose[*param] = *value; + } + pose[p::padLines] = self.lines; + pose + } + + pub fn poster_particles(&self, anchors: &Anchors) -> Vec { + let owner = self.acting; + let still = |kind, x, y, r, cloud| Particle { + kind, + x, + y, + vx: 0., + vy: 0., + g: 0., + drag: 0.98, + r, + age: 10., + life: 100., + cloud, + owner, + }; + let mut out = Vec::new(); + for kind in &tables().act(self.acting).poster_particles { + match kind { + PosterParticle::ThoughtCloud => { + let bx = anchors.blowhole.x + 2.; + let by = anchors.blowhole.y - 3.; + out.push(still(ParticleKind::Thought, bx, by, 1.3, false)); + out.push(still(ParticleKind::Thought, bx + 6., by - 5.5, 2.1, false)); + out.push(still(ParticleKind::Thought, bx + 14., by - 13., 5.2, true)); + } + PosterParticle::Speech => { + let m = anchors.mouth; + out.push(still(ParticleKind::Bubble, m.x + 6., m.y - 5., 1.9, false)); + out.push(still(ParticleKind::Bubble, m.x + 9., m.y - 10., 1.3, false)); + } + } + } + out + } + + pub fn pose(&self) -> Pose { + self.pose_out + } +} + +pub fn angle_delta(a: f64, b: f64) -> f64 { + ((a - b + 180.) % 360. + 360.) % 360. - 180. +} diff --git a/crates/tui/src/tui/whale_v2/data.rs b/crates/tui/src/tui/whale_v2/data.rs new file mode 100644 index 0000000000..09ffd5c184 --- /dev/null +++ b/crates/tui/src/tui/whale_v2/data.rs @@ -0,0 +1,557 @@ +//! The authored performance tables, read from the vendored handoff export +//! (`vendor/whale-character-v2/handoff/animation.json`) so the Rust Director +//! and the JavaScript oracle share one copy of every pose, clip, spring and +//! rate. The three small clips the export does not carry (the in-work hmm +//! beat, the resting puff and the blink) are transcribed from `acting.js`. + +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Every animated parameter, in the export's order. The pose is a fixed +/// array indexed by this list. +pub const PARAMS: [&str; 48] = [ + "x", + "y", + "rot", + "scale", + "squash", + "tilt", + "yaw", + "curl", + "arch", + "head", + "fluke", + "flukeSpread", + "fin", + "finFar", + "lookX", + "lookY", + "lid", + "lidLow", + "eyeScale", + "brow", + "browTilt", + "mouth", + "smile", + "mouthSide", + "page", + "pageFlip", + "pageRot", + "pageDX", + "pageDY", + "pad", + "padLines", + "pencil", + "scribX", + "scribY", + "lens", + "wrench", + "wrenchSpin", + "wrenchY", + "glass", + "glassExt", + "pod", + "spout", + "splash", + "cursor", + "cursorX", + "cursorY", + "link", + "linkTilt", +]; +pub const N: usize = PARAMS.len(); +pub type Pose = [f64; N]; + +/// Named indices into a [`Pose`]. +#[allow(non_upper_case_globals, dead_code)] +pub mod p { + pub const x: usize = 0; + pub const y: usize = 1; + pub const rot: usize = 2; + pub const scale: usize = 3; + pub const squash: usize = 4; + pub const tilt: usize = 5; + pub const yaw: usize = 6; + pub const curl: usize = 7; + pub const arch: usize = 8; + pub const head: usize = 9; + pub const fluke: usize = 10; + pub const flukeSpread: usize = 11; + pub const fin: usize = 12; + pub const finFar: usize = 13; + pub const lookX: usize = 14; + pub const lookY: usize = 15; + pub const lid: usize = 16; + pub const lidLow: usize = 17; + pub const eyeScale: usize = 18; + pub const brow: usize = 19; + pub const browTilt: usize = 20; + pub const mouth: usize = 21; + pub const smile: usize = 22; + pub const mouthSide: usize = 23; + pub const page: usize = 24; + pub const pageFlip: usize = 25; + pub const pageRot: usize = 26; + pub const pageDX: usize = 27; + pub const pageDY: usize = 28; + pub const pad: usize = 29; + pub const padLines: usize = 30; + pub const pencil: usize = 31; + pub const scribX: usize = 32; + pub const scribY: usize = 33; + pub const lens: usize = 34; + pub const wrench: usize = 35; + pub const wrenchSpin: usize = 36; + pub const wrenchY: usize = 37; + pub const glass: usize = 38; + pub const glassExt: usize = 39; + pub const pod: usize = 40; + pub const spout: usize = 41; + pub const splash: usize = 42; + pub const cursor: usize = 43; + pub const cursorX: usize = 44; + pub const cursorY: usize = 45; + pub const link: usize = 46; + pub const linkTilt: usize = 47; +} + +pub fn param_index(name: &str) -> Option { + PARAMS.iter().position(|p| *p == name) +} + +/// The 17 acting states. One-to-one with the export's `acts`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Act { + Rest, + Listen, + Think, + Busy, + Read, + Search, + Write, + Run, + Browse, + Talk, + Pod, + Needs, + Done, + Hmm, + Computer, + Connect, + Sleep, +} + +impl Act { + pub const ALL: [Act; 17] = [ + Act::Rest, + Act::Listen, + Act::Think, + Act::Busy, + Act::Read, + Act::Search, + Act::Write, + Act::Run, + Act::Browse, + Act::Talk, + Act::Pod, + Act::Needs, + Act::Done, + Act::Hmm, + Act::Computer, + Act::Connect, + Act::Sleep, + ]; + pub fn id(self) -> &'static str { + match self { + Act::Rest => "rest", + Act::Listen => "listen", + Act::Think => "think", + Act::Busy => "busy", + Act::Read => "read", + Act::Search => "search", + Act::Write => "write", + Act::Run => "run", + Act::Browse => "browse", + Act::Talk => "talk", + Act::Pod => "pod", + Act::Needs => "needs", + Act::Done => "done", + Act::Hmm => "hmm", + Act::Computer => "computer", + Act::Connect => "connect", + Act::Sleep => "sleep", + } + } + pub fn from_id(id: &str) -> Option { + Act::ALL.into_iter().find(|act| act.id() == id) + } + fn index(self) -> usize { + Act::ALL.iter().position(|a| *a == self).unwrap() + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Ease { + Linear, + In, + Out, + InOut, + Back, + Step, +} + +impl Ease { + fn parse(value: Option<&str>) -> Ease { + match value { + Some("l") => Ease::Linear, + Some("i") => Ease::In, + Some("o") => Ease::Out, + Some("b") => Ease::Back, + Some("s") => Ease::Step, + _ => Ease::InOut, + } + } + pub fn apply(self, t: f64) -> f64 { + match self { + Ease::Linear => t, + Ease::In => t * t * t, + Ease::Out => 1. - (1. - t).powf(3.), + Ease::InOut => { + if t < 0.5 { + 4. * t * t * t + } else { + 1. - (-2. * t + 2.).powf(3.) / 2. + } + } + Ease::Back => { + let c1 = 1.70158; + let c3 = c1 + 1.; + 1. + c3 * (t - 1.).powf(3.) + c1 * (t - 1.).powf(2.) + } + Ease::Step => { + if t < 1. { + 0. + } else { + 1. + } + } + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Key { + pub frame: f64, + pub value: f64, + pub ease: Ease, +} + +/// A particle emission authored on a clip frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Emit { + Spout { n: u32, big: bool }, + Thought, + Speech, + Blub, + Sleep, +} + +#[derive(Clone, Debug)] +pub struct Clip { + pub dur: f64, + /// Parameter index and its keys, in authored order. + pub tracks: Vec<(usize, Vec)>, + pub events: Vec<(f64, Emit)>, +} + +impl Clip { + /// `track()` from acting.js: keys are additive offsets, omitted easing is + /// cubic in/out. + pub fn track(keys: &[Key], f: f64) -> f64 { + if f <= keys[0].frame { + return keys[0].value; + } + for i in 1..keys.len() { + let k1 = keys[i]; + if f <= k1.frame { + let k0 = keys[i - 1]; + let t = if k1.frame == k0.frame { + 1. + } else { + (f - k0.frame) / (k1.frame - k0.frame) + }; + return k0.value + (k1.value - k0.value) * k1.ease.apply(t); + } + } + keys[keys.len() - 1].value + } + pub fn eval(&self, f: f64, out: &mut Pose, weight: f64) { + for (param, keys) in &self.tracks { + out[*param] += Self::track(keys, f) * weight; + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Ambient { + pub breath: f64, + pub bob: f64, + pub blink: f64, + pub dart: f64, + pub puff: f64, + pub sleepy: f64, + pub period: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PosterParticle { + ThoughtCloud, + Speech, +} + +#[derive(Clone, Debug)] +pub struct ActData { + /// Absolute pose: defaults, then the act's base. + pub base: Vec<(usize, f64)>, + pub enter: Clip, + pub loop_: Clip, + pub beat: Option, + pub exit: Option, + pub ambient: Ambient, + pub poster: Vec<(usize, f64)>, + pub poster_particles: Vec, +} + +pub struct Tables { + pub defaults: Pose, + acts: Vec, + pub springs: [(f64, f64); N], + pub rates: [f64; N], + pub wake: Clip, + pub puff: Clip, + pub blink: Clip, + kind_to_act: HashMap, +} + +impl Tables { + pub fn act(&self, act: Act) -> &ActData { + &self.acts[act.index()] + } + /// `KIND_TO_ACT`: the owner's canonical kind to its action. Unknown or + /// future kinds have no action and fall back to plain work. + pub fn act_for_kind(&self, kind: &str) -> Option { + self.kind_to_act.get(kind).copied() + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Export { + fps: f64, + parameters: Vec, + defaults: HashMap, + acts: HashMap, + springs: HashMap, + rate_limits: HashMap, + wake: Value, + owner_kind_to_action: HashMap, +} + +fn clip(value: &Value) -> Clip { + let dur = value["dur"].as_f64().expect("clip duration"); + let mut tracks = Vec::new(); + if let Some(keys) = value["keys"].as_object() { + for (name, list) in keys { + let param = param_index(name).unwrap_or_else(|| panic!("unknown parameter {name}")); + let keys = list + .as_array() + .expect("keys") + .iter() + .map(|key| Key { + frame: key[0].as_f64().expect("key frame"), + value: key[1].as_f64().expect("key value"), + ease: Ease::parse(key.get(2).and_then(Value::as_str)), + }) + .collect(); + tracks.push((param, keys)); + } + } + let events = value["events"] + .as_array() + .map(|events| { + events + .iter() + .map(|event| { + let frame = event[0].as_f64().expect("event frame"); + let kind = match event[1].as_str().expect("event kind") { + "spout" => Emit::Spout { + n: event[2]["n"].as_u64().unwrap_or(4) as u32, + big: event[2]["big"].as_bool().unwrap_or(false), + }, + "thought" => Emit::Thought, + "speech" => Emit::Speech, + "blub" => Emit::Blub, + "sleep" => Emit::Sleep, + other => panic!("unknown event {other}"), + }; + (frame, kind) + }) + .collect() + }) + .unwrap_or_default(); + Clip { + dur, + tracks, + events, + } +} + +fn pairs(value: &Value) -> Vec<(usize, f64)> { + value + .as_object() + .map(|object| { + object + .iter() + .filter_map(|(name, value)| Some((param_index(name)?, value.as_f64()?))) + .collect() + }) + .unwrap_or_default() +} + +/// One authored key: `[frame, value, ease?]`. +type Authored<'a> = (f64, f64, Option<&'a str>); + +/// A clip written the way acting.js writes it. +fn authored(dur: f64, tracks: &[(&str, &[Authored])], events: Vec<(f64, Emit)>) -> Clip { + Clip { + dur, + tracks: tracks + .iter() + .map(|(name, keys)| { + ( + param_index(name).unwrap(), + keys.iter() + .map(|(frame, value, ease)| Key { + frame: *frame, + value: *value, + ease: Ease::parse(*ease), + }) + .collect(), + ) + }) + .collect(), + events, + } +} + +const EXPORT: &str = include_str!("data/animation.json"); + +pub fn tables() -> &'static Tables { + static TABLES: OnceLock = OnceLock::new(); + TABLES.get_or_init(|| { + let export: Export = serde_json::from_str(EXPORT).expect("whale animation export"); + assert_eq!(export.fps, 30.); + assert_eq!(export.parameters, PARAMS.map(str::to_string)); + let mut defaults = [0.; N]; + for (index, name) in PARAMS.iter().enumerate() { + defaults[index] = export.defaults[*name]; + } + let acts = Act::ALL + .iter() + .map(|act| { + let value = &export.acts[act.id()]; + let ambient = &value["ambient"]; + let number = |key: &str| ambient[key].as_f64().unwrap_or(0.); + let poster_particles = value["poster"]["particles"] + .as_array() + .map(|list| { + list.iter() + .filter_map(|entry| match entry[0].as_str()? { + "thought-cloud" => Some(PosterParticle::ThoughtCloud), + "speech" => Some(PosterParticle::Speech), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + ActData { + base: pairs(&value["base"]), + enter: clip(&value["enter"]), + loop_: clip(&value["loop"]), + beat: value.get("beat").map(clip), + exit: value.get("exit").map(clip), + ambient: Ambient { + breath: number("breath"), + bob: number("bob"), + blink: number("blink"), + dart: number("dart"), + puff: number("puff"), + sleepy: number("sleepy"), + period: number("period"), + }, + poster: pairs(&value["poster"]), + poster_particles, + } + }) + .collect(); + let fallback = export.springs["_"]; + let springs = PARAMS.map(|name| export.springs.get(name).copied().unwrap_or(fallback)); + let rate = export.rate_limits["_"]; + let rates = PARAMS.map(|name| export.rate_limits.get(name).copied().unwrap_or(rate)); + let kind_to_act = export + .owner_kind_to_action + .iter() + .map(|(kind, act)| (kind.clone(), Act::from_id(act).expect("mapped action"))) + .collect(); + // acting.js HMM_BEAT, PUFF and BLINK. + let puff = authored( + 34., + &[ + ( + "y", + &[ + (0., 0., None), + (6., 0.7, None), + (10., -0.6, Some("o")), + (34., 0., Some("io")), + ], + ), + ( + "squash", + &[ + (0., 0., None), + (6., 0.03, None), + (10., -0.02, None), + (20., 0., None), + ], + ), + ], + vec![(9., Emit::Spout { n: 3, big: false })], + ); + let blink = authored( + 7., + &[( + "lid", + &[ + (0., 0., None), + (2., 1., Some("i")), + (3., 1., None), + (7., 0., Some("o")), + ], + )], + Vec::new(), + ); + Tables { + defaults, + acts, + springs, + rates, + wake: clip(&export.wake), + puff, + blink, + kind_to_act, + } + }) +} diff --git a/crates/tui/src/tui/whale_v2/data/animation.json b/crates/tui/src/tui/whale_v2/data/animation.json new file mode 100644 index 0000000000..f3f8f95431 --- /dev/null +++ b/crates/tui/src/tui/whale_v2/data/animation.json @@ -0,0 +1,6236 @@ +{ + "schemaVersion": 1, + "status": "design-proposal", + "fps": 30, + "coordinateSpace": { + "min": -62, + "max": 62, + "yPositive": "down" + }, + "actions": [ + { + "id": "rest", + "label": "Resting", + "presence": "Idle", + "kind": null + }, + { + "id": "listen", + "label": "Listening", + "presence": "Listening", + "kind": null + }, + { + "id": "think", + "label": "Thinking", + "presence": "Thinking", + "kind": null + }, + { + "id": "busy", + "label": "Plain work", + "presence": "Working", + "kind": "unknown" + }, + { + "id": "read", + "label": "Read", + "presence": "Working", + "kind": "reading" + }, + { + "id": "search", + "label": "Search", + "presence": "Working", + "kind": "searching" + }, + { + "id": "write", + "label": "Edit", + "presence": "Working", + "kind": "editing" + }, + { + "id": "run", + "label": "Run", + "presence": "Working", + "kind": "executing" + }, + { + "id": "browse", + "label": "Browse", + "presence": "Working", + "kind": "browsing" + }, + { + "id": "talk", + "label": "Reply", + "presence": "Working", + "kind": "responding" + }, + { + "id": "pod", + "label": "Delegate · 3 agents", + "presence": "Working", + "kind": "delegating" + }, + { + "id": "needs", + "label": "Needs you", + "presence": "NeedsYou", + "kind": null + }, + { + "id": "done", + "label": "Done", + "presence": "Done", + "kind": null + }, + { + "id": "hmm", + "label": "Stuck · proposed", + "presence": "Stuck", + "kind": null + }, + { + "id": "sleep", + "label": "Asleep", + "presence": "Offline", + "kind": null + }, + { + "id": "computer", + "label": "Computer use", + "presence": "Working", + "kind": "computer" + }, + { + "id": "connect", + "label": "Connected app", + "presence": "Working", + "kind": "network" + } + ], + "defaults": { + "x": 0, + "y": 0, + "rot": 0, + "scale": 1, + "squash": 0, + "tilt": 0, + "yaw": 0, + "curl": 0, + "arch": 0, + "head": 0, + "fluke": 6, + "flukeSpread": 1, + "fin": 0, + "finFar": 0, + "lookX": 0, + "lookY": 0, + "lid": 0, + "lidLow": 0, + "eyeScale": 1, + "brow": 0, + "browTilt": 0, + "mouth": 0, + "smile": 0.4, + "mouthSide": 0, + "page": 0, + "pageFlip": 0, + "pageRot": 0, + "pageDX": 0, + "pageDY": 0, + "pad": 0, + "padLines": 0, + "pencil": 0, + "scribX": 0, + "scribY": 0, + "lens": 0, + "wrench": 0, + "wrenchSpin": 0, + "wrenchY": 0, + "glass": 0, + "glassExt": 0, + "pod": 0, + "spout": 0, + "splash": 0, + "cursor": 0, + "cursorX": 0, + "cursorY": 0, + "link": 0, + "linkTilt": 0 + }, + "parameters": [ + "x", + "y", + "rot", + "scale", + "squash", + "tilt", + "yaw", + "curl", + "arch", + "head", + "fluke", + "flukeSpread", + "fin", + "finFar", + "lookX", + "lookY", + "lid", + "lidLow", + "eyeScale", + "brow", + "browTilt", + "mouth", + "smile", + "mouthSide", + "page", + "pageFlip", + "pageRot", + "pageDX", + "pageDY", + "pad", + "padLines", + "pencil", + "scribX", + "scribY", + "lens", + "wrench", + "wrenchSpin", + "wrenchY", + "glass", + "glassExt", + "pod", + "spout", + "splash", + "cursor", + "cursorX", + "cursorY", + "link", + "linkTilt" + ], + "acts": { + "rest": { + "base": { + "head": 0, + "fluke": 6, + "fin": 0, + "lid": 0.04, + "smile": 0.42, + "lookX": 0.15, + "lookY": 0.05 + }, + "enter": { + "dur": 18, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 6, + 0.03 + ], + [ + 18, + 0, + "o" + ] + ], + "y": [ + [ + 0, + 0 + ], + [ + 6, + 0.8 + ], + [ + 18, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 1, + "keys": {}, + "events": [] + }, + "ambient": { + "breath": 1, + "bob": 1, + "blink": 1, + "dart": 1, + "puff": 1, + "period": 150 + }, + "poster": {} + }, + "listen": { + "base": { + "head": 6, + "fluke": 6, + "fin": 10, + "lid": 0, + "smile": 0.55, + "lookX": -0.2, + "lookY": -0.05, + "yaw": 0.5, + "tilt": 8, + "eyeScale": 1.06, + "brow": 0.4, + "scale": 1.04, + "x": 2 + }, + "enter": { + "dur": 14, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 4, + 0.05 + ], + [ + 14, + 0, + "o" + ] + ], + "y": [ + [ + 0, + 0 + ], + [ + 4, + 1.2 + ], + [ + 14, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 96, + "keys": { + "head": [ + [ + 0, + 0 + ], + [ + 64, + 0 + ], + [ + 70, + -4, + "o" + ], + [ + 78, + 1 + ], + [ + 86, + 0, + "io" + ] + ], + "tilt": [ + [ + 0, + 0 + ], + [ + 48, + 0 + ], + [ + 60, + 3 + ], + [ + 80, + 0 + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.8, + "bob": 0.6, + "blink": 1, + "dart": 0, + "puff": 0, + "period": 130 + }, + "poster": {} + }, + "think": { + "base": { + "head": 10, + "fluke": 14, + "fin": 112, + "lid": 0.1, + "smile": 0.22, + "lookX": 0.62, + "lookY": -0.88, + "curl": 0.3, + "brow": 0.55, + "browTilt": 0.45 + }, + "enter": { + "dur": 20, + "keys": { + "lid": [ + [ + 0, + 0 + ], + [ + 3, + 0.95, + "i" + ], + [ + 6, + 0.95 + ], + [ + 11, + 0, + "o" + ] + ], + "lookX": [ + [ + 0, + 0 + ], + [ + 12, + 0 + ] + ], + "lookY": [ + [ + 0, + 0 + ], + [ + 12, + 0 + ] + ] + }, + "events": [ + [ + 16, + "thought" + ] + ] + }, + "loop": { + "dur": 72, + "keys": { + "fin": [ + [ + 0, + 0 + ], + [ + 40, + 0 + ], + [ + 44, + 7, + "o" + ], + [ + 48, + 0 + ], + [ + 52, + 7, + "o" + ], + [ + 56, + 0 + ] + ], + "lookX": [ + [ + 0, + 0 + ], + [ + 24, + 0 + ], + [ + 28, + -0.5, + "o" + ], + [ + 52, + -0.5 + ], + [ + 56, + 0, + "o" + ] + ], + "fluke": [ + [ + 0, + 0 + ], + [ + 36, + 5 + ], + [ + 72, + 0 + ] + ] + }, + "events": [ + [ + 30, + "thought" + ] + ] + }, + "ambient": { + "breath": 0.7, + "bob": 0.5, + "blink": 0.6, + "dart": 0, + "puff": 0, + "period": 130 + }, + "poster": { + "particles": [ + [ + "thought-cloud" + ] + ] + } + }, + "busy": { + "base": { + "head": -2, + "fluke": 6, + "fin": 24, + "lid": 0.16, + "smile": 0.3, + "lookX": 0.5, + "lookY": 0.15, + "rot": -12, + "curl": -0.38 + }, + "enter": { + "dur": 10, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 3, + 0.05 + ], + [ + 10, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 24, + "keys": { + "fluke": [ + [ + 0, + -12 + ], + [ + 12, + 12 + ], + [ + 24, + -12 + ] + ], + "fin": [ + [ + 0, + 8 + ], + [ + 12, + -8 + ], + [ + 24, + 8 + ] + ], + "y": [ + [ + 0, + 0 + ], + [ + 6, + -0.6 + ], + [ + 18, + 0.6 + ], + [ + 24, + 0 + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.5, + "bob": 0.4, + "blink": 0.8, + "dart": 0, + "puff": 0, + "period": 120 + }, + "poster": {} + }, + "read": { + "base": { + "head": -5, + "fluke": 6, + "fin": 132, + "lid": 0.2, + "smile": 0.25, + "lookX": 0.62, + "lookY": 0.45, + "lidLow": 0.06, + "page": 1 + }, + "enter": { + "dur": 14, + "keys": { + "fin": [ + [ + 0, + 0 + ], + [ + 4, + -18, + "o" + ], + [ + 12, + 0, + "o" + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 3, + 0.03 + ], + [ + 10, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 54, + "keys": { + "lookX": [ + [ + 0, + -0.45 + ], + [ + 14, + 0.45, + "l" + ], + [ + 18, + -0.45, + "o" + ], + [ + 32, + 0.45, + "l" + ], + [ + 36, + -0.45, + "o" + ], + [ + 50, + 0.45, + "l" + ], + [ + 54, + -0.45, + "o" + ] + ], + "lookY": [ + [ + 0, + -0.3 + ], + [ + 16, + -0.3 + ], + [ + 18, + 0, + "s" + ], + [ + 34, + 0 + ], + [ + 36, + 0.3, + "s" + ], + [ + 52, + 0.3 + ], + [ + 54, + -0.3, + "s" + ] + ], + "head": [ + [ + 0, + 0.8 + ], + [ + 27, + -0.8 + ], + [ + 54, + 0.8 + ] + ] + }, + "events": [] + }, + "beat": { + "dur": 12, + "keys": { + "pageFlip": [ + [ + 0, + 0 + ], + [ + 6, + 1, + "io" + ], + [ + 12, + 0, + "io" + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 3, + -3, + "o" + ], + [ + 8, + 1 + ], + [ + 12, + 0 + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 10, + "keys": { + "pageDX": [ + [ + 0, + 0 + ], + [ + 10, + -6, + "i" + ] + ], + "pageDY": [ + [ + 0, + 0 + ], + [ + 10, + 4, + "i" + ] + ], + "fin": [ + [ + 0, + 0 + ], + [ + 5, + -20, + "o" + ], + [ + 10, + 0 + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.6, + "bob": 0.5, + "blink": 1, + "dart": 0, + "puff": 0, + "period": 130 + }, + "poster": {} + }, + "search": { + "base": { + "head": -2, + "fluke": 6, + "fin": 168, + "lid": 0.04, + "smile": 0.3, + "lookX": 0.85, + "lookY": 0.05, + "brow": 0.3, + "lens": 1 + }, + "enter": { + "dur": 14, + "keys": { + "fin": [ + [ + 0, + 0 + ], + [ + 4, + -20, + "o" + ], + [ + 12, + 0, + "o" + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 3, + 0.03 + ], + [ + 10, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 64, + "keys": { + "fin": [ + [ + 0, + -16 + ], + [ + 32, + 12 + ], + [ + 64, + -16 + ] + ], + "lookX": [ + [ + 0, + 0.1 + ], + [ + 32, + 0.2 + ], + [ + 64, + 0.1 + ] + ], + "lookY": [ + [ + 0, + 0.45 + ], + [ + 32, + -0.35 + ], + [ + 64, + 0.45 + ] + ], + "head": [ + [ + 0, + -2 + ], + [ + 32, + 2 + ], + [ + 64, + -2 + ] + ] + }, + "events": [] + }, + "beat": { + "dur": 10, + "keys": { + "fin": [ + [ + 0, + 0 + ], + [ + 3, + 8, + "o" + ], + [ + 10, + 0 + ] + ], + "eyeScale": [ + [ + 0, + 0 + ], + [ + 3, + 0.08 + ], + [ + 10, + 0 + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 10, + "keys": { + "fin": [ + [ + 0, + 0 + ], + [ + 6, + -26, + "o" + ], + [ + 10, + 0 + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.6, + "bob": 0.5, + "blink": 0.8, + "dart": 0, + "puff": 0, + "period": 130 + }, + "poster": {} + }, + "write": { + "base": { + "head": -9, + "fluke": 6, + "fin": 118, + "lid": 0.3, + "smile": 0.15, + "lookX": 0.8, + "lookY": 0.85, + "lidLow": 0.08, + "mouthSide": 0.45, + "pad": 1, + "pencil": 1 + }, + "enter": { + "dur": 14, + "keys": { + "fin": [ + [ + 0, + 0 + ], + [ + 4, + -16, + "o" + ], + [ + 12, + 0, + "o" + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 3, + 0.03 + ], + [ + 10, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 18, + "keys": { + "scribX": [ + [ + 0, + -3 + ], + [ + 3, + 3, + "l" + ], + [ + 6, + -2, + "l" + ], + [ + 9, + 3.5, + "l" + ], + [ + 12, + -1, + "l" + ], + [ + 15, + 2.5, + "l" + ], + [ + 18, + -3, + "l" + ] + ], + "scribY": [ + [ + 0, + 0 + ], + [ + 3, + -0.8, + "l" + ], + [ + 6, + 0.6, + "l" + ], + [ + 9, + -0.7, + "l" + ], + [ + 12, + 0.5, + "l" + ], + [ + 15, + -0.4, + "l" + ], + [ + 18, + 0, + "l" + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 9, + -1.5 + ], + [ + 18, + 0 + ] + ], + "fluke": [ + [ + 0, + -5 + ], + [ + 9, + 5 + ], + [ + 18, + -5 + ] + ] + }, + "events": [] + }, + "beat": { + "dur": 12, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 2, + 0.03 + ], + [ + 8, + 0 + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 10, + "keys": { + "fin": [ + [ + 0, + 0 + ], + [ + 5, + -18, + "o" + ], + [ + 10, + 0 + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.5, + "bob": 0.4, + "blink": 0.8, + "dart": 0, + "puff": 0, + "period": 120 + }, + "poster": {} + }, + "run": { + "base": { + "head": 7, + "fluke": -24, + "fin": 0, + "lid": 0.1, + "smile": 0.35, + "lookX": -0.35, + "lookY": -0.95, + "curl": 0.64, + "brow": 0.15, + "wrench": 1 + }, + "enter": { + "dur": 20, + "keys": { + "curl": [ + [ + 0, + 0 + ], + [ + 6, + -0.12, + "o" + ], + [ + 20, + 0, + "o" + ] + ], + "fluke": [ + [ + 0, + 0 + ], + [ + 6, + -12 + ], + [ + 20, + 0 + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 5, + 0.05 + ], + [ + 14, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 30, + "keys": { + "wrenchSpin": [ + [ + 0, + 0 + ], + [ + 10, + 42, + "io" + ], + [ + 16, + 42 + ], + [ + 26, + 0, + "o" + ], + [ + 30, + 0 + ] + ], + "rot": [ + [ + 0, + 0 + ], + [ + 10, + 2 + ], + [ + 26, + 0 + ] + ], + "fluke": [ + [ + 0, + 0 + ], + [ + 10, + 6 + ], + [ + 26, + 0 + ] + ] + }, + "events": [] + }, + "beat": { + "dur": 12, + "keys": { + "wrenchY": [ + [ + 0, + 0 + ], + [ + 6, + -8, + "o" + ], + [ + 11, + 0, + "i" + ], + [ + 12, + 0 + ] + ], + "wrenchSpin": [ + [ + 0, + 0 + ], + [ + 11, + 360, + "io" + ], + [ + 12, + 360 + ] + ], + "fluke": [ + [ + 0, + 0 + ], + [ + 2, + -7 + ], + [ + 5, + 5 + ], + [ + 12, + 0 + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 2, + 0.04 + ], + [ + 5, + -0.025 + ], + [ + 10, + 0.03 + ], + [ + 12, + 0 + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 12, + "keys": { + "wrenchY": [ + [ + 0, + 0 + ], + [ + 12, + -6, + "o" + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.5, + "bob": 0.4, + "blink": 0.9, + "dart": 0, + "puff": 0, + "period": 120 + }, + "poster": {} + }, + "browse": { + "base": { + "head": 7, + "fluke": 6, + "fin": 122, + "lid": 0.06, + "smile": 0.3, + "lookX": 0.9, + "lookY": -0.1, + "y": -3, + "glass": 1, + "glassExt": 1 + }, + "enter": { + "dur": 20, + "keys": { + "y": [ + [ + 0, + 0 + ], + [ + 5, + 1.5, + "o" + ], + [ + 14, + 0, + "o" + ] + ], + "glassExt": [ + [ + 0, + 0 + ], + [ + 10, + 0 + ], + [ + 18, + 0 + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 80, + "keys": { + "rot": [ + [ + 0, + -3 + ], + [ + 40, + 3 + ], + [ + 80, + -3 + ] + ], + "head": [ + [ + 0, + -1 + ], + [ + 40, + 1.5 + ], + [ + 80, + -1 + ] + ] + }, + "events": [] + }, + "beat": { + "dur": 10, + "keys": { + "glassExt": [ + [ + 0, + 0 + ], + [ + 4, + 0.18, + "o" + ], + [ + 10, + 0 + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 10, + "keys": { + "glassExt": [ + [ + 0, + 0 + ], + [ + 8, + -0.9, + "i" + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.6, + "bob": 0.5, + "blink": 0.7, + "dart": 0, + "puff": 0, + "period": 130 + }, + "poster": {} + }, + "talk": { + "base": { + "head": 3, + "fluke": 6, + "fin": 36, + "lid": 0, + "smile": 0.5, + "lookX": -0.3, + "lookY": 0, + "yaw": 0.3, + "eyeScale": 1.02 + }, + "enter": { + "dur": 12, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 3, + 0.04 + ], + [ + 12, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 84, + "keys": { + "mouth": [ + [ + 0, + 0.05 + ], + [ + 4, + 0.62 + ], + [ + 8, + 0.18 + ], + [ + 13, + 0.72 + ], + [ + 17, + 0.14 + ], + [ + 21, + 0.52 + ], + [ + 26, + 0.08 + ], + [ + 30, + 0.66 + ], + [ + 36, + 0.12 + ], + [ + 40, + 0.46 + ], + [ + 44, + 0.05 + ], + [ + 52, + 0.05 + ], + [ + 56, + 0.64 + ], + [ + 61, + 0.16 + ], + [ + 66, + 0.58 + ], + [ + 72, + 0.1 + ], + [ + 78, + 0.44 + ], + [ + 84, + 0.05 + ] + ], + "fin": [ + [ + 0, + 0 + ], + [ + 44, + 0 + ], + [ + 52, + 32, + "o" + ], + [ + 64, + 20 + ], + [ + 74, + 0 + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 8, + 1.5 + ], + [ + 22, + -1 + ], + [ + 36, + 1 + ], + [ + 50, + 0 + ], + [ + 84, + 0 + ] + ] + }, + "events": [ + [ + 18, + "speech" + ], + [ + 60, + "speech" + ] + ] + }, + "ambient": { + "breath": 0.6, + "bob": 0.5, + "blink": 1, + "dart": 0, + "puff": 0, + "period": 120 + }, + "poster": { + "particles": [ + [ + "speech" + ] + ] + } + }, + "pod": { + "base": { + "head": 2, + "fluke": 6, + "fin": 0, + "lid": 0.04, + "smile": 0.45, + "lookX": -0.55, + "lookY": -0.1, + "pod": 1 + }, + "enter": { + "dur": 16, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 4, + 0.04 + ], + [ + 14, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 120, + "keys": { + "lookX": [ + [ + 0, + 0 + ], + [ + 50, + 0 + ], + [ + 56, + 0.9, + "o" + ], + [ + 90, + 0.9 + ], + [ + 96, + 0, + "o" + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 60, + 0 + ], + [ + 66, + -3, + "o" + ], + [ + 72, + 0 + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.8, + "bob": 0.8, + "blink": 1, + "dart": 0, + "puff": 0, + "period": 140 + }, + "poster": {} + }, + "needs": { + "base": { + "head": 4, + "fluke": 22, + "fin": 0, + "lid": 0, + "smile": 0.42, + "lookX": 0.1, + "lookY": -0.25, + "yaw": 1, + "rot": -24, + "x": 2, + "y": -3, + "scale": 1.06, + "finFar": 0, + "eyeScale": 1.06, + "brow": 0.3 + }, + "enter": { + "dur": 42, + "keys": { + "rot": [ + [ + 0, + 0 + ], + [ + 4, + 6, + "o" + ], + [ + 12, + -4, + "o" + ], + [ + 22, + 1 + ], + [ + 36, + 0, + "io" + ] + ], + "y": [ + [ + 0, + 0 + ], + [ + 5, + 1.5 + ], + [ + 12, + -3, + "o" + ], + [ + 24, + 0.6 + ], + [ + 40, + 0, + "io" + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 4, + 0.045 + ], + [ + 10, + -0.045, + "o" + ], + [ + 22, + 0.012 + ], + [ + 36, + 0 + ] + ], + "fluke": [ + [ + 0, + 0 + ], + [ + 8, + -8 + ], + [ + 16, + 26, + "o" + ], + [ + 24, + -8 + ], + [ + 34, + 5 + ], + [ + 42, + 0, + "io" + ] + ], + "eyeScale": [ + [ + 0, + 0 + ], + [ + 8, + 0 + ], + [ + 13, + 0.05, + "o" + ], + [ + 28, + 0, + "io" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 360, + "keys": { + "rot": [ + [ + 0, + 0 + ], + [ + 200, + 0 + ], + [ + 230, + 3, + "io" + ], + [ + 260, + 3 + ], + [ + 300, + 0, + "io" + ] + ], + "fluke": [ + [ + 0, + 0 + ], + [ + 210, + 0 + ], + [ + 240, + 8, + "io" + ], + [ + 300, + 0, + "io" + ] + ], + "lid": [ + [ + 0, + 0 + ], + [ + 140, + 0 + ], + [ + 144, + 0.9, + "i" + ], + [ + 152, + 0, + "o" + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 12, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 4, + 0.025 + ], + [ + 12, + 0, + "o" + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.65, + "bob": 0.25, + "blink": 0.4, + "dart": 0, + "puff": 0, + "period": 150 + }, + "poster": {} + }, + "done": { + "base": { + "head": 5, + "fluke": 10, + "fin": 22, + "lid": 0, + "smile": 0.9, + "lookX": 0.15, + "lookY": 0.05, + "lidLow": 0.62 + }, + "enter": { + "dur": 80, + "keys": { + "squash": [ + [ + 0, + 0 + ], + [ + 6, + 0.1, + "o" + ], + [ + 10, + -0.08, + "o" + ], + [ + 22, + 0.02 + ], + [ + 30, + 0, + "o" + ] + ], + "y": [ + [ + 0, + 0 + ], + [ + 6, + 3 + ], + [ + 16, + -7, + "o" + ], + [ + 32, + -5 + ], + [ + 70, + 0, + "io" + ] + ], + "rot": [ + [ + 0, + 0 + ], + [ + 6, + 3 + ], + [ + 16, + -10, + "o" + ], + [ + 32, + -8 + ], + [ + 70, + 0, + "io" + ] + ], + "spout": [ + [ + 0, + 0 + ], + [ + 10, + 0 + ], + [ + 18, + 1, + "o" + ], + [ + 34, + 1 + ], + [ + 62, + 0, + "io" + ] + ], + "splash": [ + [ + 0, + 0 + ], + [ + 6, + 0.5 + ], + [ + 14, + 1, + "o" + ], + [ + 38, + 0.8 + ], + [ + 70, + 0, + "io" + ] + ], + "fluke": [ + [ + 0, + 0 + ], + [ + 8, + -14 + ], + [ + 14, + 30, + "o" + ], + [ + 24, + -8 + ], + [ + 34, + 0, + "o" + ] + ], + "curl": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ], + [ + 48, + 0.5, + "io" + ], + [ + 60, + 0.5 + ], + [ + 78, + 0, + "io" + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ], + [ + 48, + -6, + "io" + ], + [ + 60, + -6 + ], + [ + 78, + 0, + "io" + ] + ], + "lidLow": [ + [ + 0, + 0 + ], + [ + 8, + 0 + ], + [ + 12, + 0.25, + "o" + ], + [ + 30, + 0.2 + ], + [ + 48, + 0.3 + ], + [ + 78, + 0 + ] + ] + }, + "events": [ + [ + 18, + "spout", + { + "n": 5, + "big": true + } + ] + ] + }, + "loop": { + "dur": 1, + "keys": {}, + "events": [] + }, + "ambient": { + "breath": 0.9, + "bob": 0.8, + "blink": 0, + "dart": 0, + "puff": 0, + "period": 150 + }, + "poster": { + "rot": -10, + "y": -4, + "head": 7, + "spout": 1, + "splash": 1 + } + }, + "hmm": { + "base": { + "head": -10, + "fluke": 34, + "fin": 0, + "lid": 0.46, + "smile": 0.1, + "lookX": -0.4, + "lookY": 0.5, + "rot": 22, + "x": -3, + "y": 2, + "scale": 0.96, + "yaw": 0.45, + "curl": 0.7, + "mouthSide": 0.45 + }, + "enter": { + "dur": 30, + "keys": { + "x": [ + [ + 0, + 0 + ], + [ + 7, + -4, + "o" + ], + [ + 24, + 0, + "io" + ] + ], + "rot": [ + [ + 0, + 0 + ], + [ + 8, + 5, + "o" + ], + [ + 30, + 0, + "io" + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 3, + 4, + "o" + ], + [ + 10, + 0, + "io" + ] + ], + "lid": [ + [ + 0, + 0 + ], + [ + 2, + 0.7 + ], + [ + 5, + 0.7 + ], + [ + 9, + 0, + "o" + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 120, + "keys": { + "head": [ + [ + 0, + 0 + ], + [ + 60, + 0 + ], + [ + 76, + 3, + "io" + ], + [ + 96, + 0, + "io" + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.4, + "bob": 0.1, + "blink": 0.6, + "dart": 0, + "puff": 0, + "period": 140 + }, + "poster": {} + }, + "computer": { + "base": { + "head": 4, + "fluke": 6, + "fin": 0, + "lid": 0.12, + "smile": 0.42, + "lookX": 0.8, + "lookY": -0.8, + "cursor": 1 + }, + "enter": { + "dur": 18, + "keys": { + "head": [ + [ + 0, + 0 + ], + [ + 5, + -3 + ], + [ + 18, + 0 + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 4, + 0.025 + ], + [ + 14, + 0 + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 96, + "keys": { + "cursorX": [ + [ + 0, + -1 + ], + [ + 28, + 1.5 + ], + [ + 56, + 1.5 + ], + [ + 76, + -1 + ], + [ + 96, + -1 + ] + ], + "cursorY": [ + [ + 0, + 0 + ], + [ + 28, + -2 + ], + [ + 56, + 1 + ], + [ + 76, + 0 + ], + [ + 96, + 0 + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 28, + 2 + ], + [ + 56, + -1 + ], + [ + 96, + 0 + ] + ] + }, + "events": [] + }, + "beat": { + "dur": 10, + "keys": { + "cursorY": [ + [ + 0, + 0 + ], + [ + 3, + 1.5, + "o" + ], + [ + 10, + 0 + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 3, + -2 + ], + [ + 10, + 0 + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 10, + "keys": { + "cursorY": [ + [ + 0, + 0 + ], + [ + 10, + -4 + ] + ] + }, + "events": [] + }, + "ambient": { + "breath": 0.6, + "bob": 0.3, + "blink": 0.8, + "dart": 0, + "puff": 0, + "period": 130 + }, + "poster": {} + }, + "connect": { + "base": { + "head": 3, + "fluke": 6, + "fin": 0, + "lid": 0.1, + "smile": 0.42, + "lookX": 0.8, + "lookY": -0.55, + "link": 1 + }, + "enter": { + "dur": 16, + "keys": { + "head": [ + [ + 0, + 0 + ], + [ + 5, + -2 + ], + [ + 16, + 0 + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 80, + "keys": { + "linkTilt": [ + [ + 0, + -4 + ], + [ + 40, + 4 + ], + [ + 80, + -4 + ] + ], + "head": [ + [ + 0, + 0 + ], + [ + 40, + 1.5 + ], + [ + 80, + 0 + ] + ] + }, + "events": [] + }, + "beat": { + "dur": 10, + "keys": { + "linkTilt": [ + [ + 0, + 0 + ], + [ + 4, + 10 + ], + [ + 10, + 0 + ] + ] + }, + "events": [] + }, + "exit": { + "dur": 10, + "keys": {}, + "events": [] + }, + "ambient": { + "breath": 0.6, + "bob": 0.3, + "blink": 0.8, + "dart": 0, + "puff": 0, + "period": 130 + }, + "poster": {} + }, + "sleep": { + "base": { + "head": -8, + "fluke": 6, + "fin": -12, + "lid": 1, + "smile": 0.28, + "lookX": 0, + "lookY": 0, + "curl": 0.92 + }, + "enter": { + "dur": 40, + "keys": { + "mouth": [ + [ + 0, + 0 + ], + [ + 8, + 0.85, + "o" + ], + [ + 16, + 0.85 + ], + [ + 22, + 0, + "io" + ] + ], + "lid": [ + [ + 0, + 0 + ], + [ + 6, + 0 + ], + [ + 24, + 0 + ] + ], + "fin": [ + [ + 0, + 0 + ], + [ + 8, + 44, + "o" + ], + [ + 18, + 44 + ], + [ + 26, + 0 + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 8, + -0.07, + "o" + ], + [ + 18, + -0.07 + ], + [ + 26, + 0 + ] + ], + "curl": [ + [ + 0, + 0 + ], + [ + 18, + 0 + ], + [ + 40, + 0 + ] + ] + }, + "events": [] + }, + "loop": { + "dur": 1, + "keys": {}, + "events": [] + }, + "ambient": { + "breath": 1.7, + "bob": 0.5, + "blink": 0, + "dart": 0, + "puff": 0, + "sleepy": 1, + "period": 210 + }, + "poster": {} + } + }, + "springs": { + "_": [ + 9, + 0.8 + ], + "yaw": [ + 8, + 0.72 + ], + "x": [ + 7, + 0.8 + ], + "y": [ + 7, + 0.72 + ], + "rot": [ + 8, + 0.65 + ], + "scale": [ + 10, + 0.7 + ], + "squash": [ + 14, + 0.45 + ], + "curl": [ + 6.5, + 0.8 + ], + "arch": [ + 7, + 0.8 + ], + "head": [ + 9, + 0.7 + ], + "tilt": [ + 8, + 0.65 + ], + "fluke": [ + 6, + 0.45 + ], + "flukeSpread": [ + 8, + 0.7 + ], + "fin": [ + 9, + 0.55 + ], + "finFar": [ + 9, + 0.55 + ], + "lookX": [ + 22, + 0.9 + ], + "lookY": [ + 22, + 0.9 + ], + "lid": [ + 26, + 1 + ], + "lidLow": [ + 16, + 0.9 + ], + "eyeScale": [ + 12, + 0.5 + ], + "brow": [ + 12, + 0.8 + ], + "browTilt": [ + 12, + 0.8 + ], + "mouth": [ + 22, + 1 + ], + "smile": [ + 10, + 0.85 + ], + "mouthSide": [ + 10, + 0.85 + ], + "page": [ + 12, + 0.5 + ], + "pad": [ + 12, + 0.55 + ], + "pencil": [ + 12, + 0.5 + ], + "lens": [ + 12, + 0.5 + ], + "wrench": [ + 12, + 0.5 + ], + "glass": [ + 12, + 0.55 + ], + "pod": [ + 6, + 0.8 + ], + "glassExt": [ + 10, + 0.6 + ], + "cursor": [ + 12, + 0.55 + ], + "link": [ + 12, + 0.55 + ] + }, + "rateLimits": { + "_": 0.16, + "x": 2, + "y": 2, + "rot": 5, + "tilt": 5, + "head": 5, + "fin": 12, + "finFar": 12, + "fluke": 10, + "arch": 5, + "wrenchSpin": 100, + "wrenchY": 4, + "pageRot": 6, + "pageDX": 2, + "pageDY": 2, + "scribX": 4, + "scribY": 2, + "padLines": 0.3, + "lid": 0.6, + "lidLow": 0.35, + "lookX": 0.5, + "lookY": 0.5, + "squash": 0.08, + "scale": 0.06, + "yaw": 0.11, + "curl": 0.1, + "mouth": 0.4, + "cursorX": 2, + "cursorY": 2, + "linkTilt": 6 + }, + "wake": { + "dur": 26, + "keys": { + "lid": [ + [ + 0, + 0 + ], + [ + 5, + 0 + ], + [ + 8, + 0.45 + ], + [ + 12, + 0 + ], + [ + 16, + 0 + ] + ], + "squash": [ + [ + 0, + 0 + ], + [ + 10, + -0.09, + "o" + ], + [ + 18, + 0.03 + ], + [ + 26, + 0, + "o" + ] + ], + "lookX": [ + [ + 0, + 0 + ], + [ + 14, + -0.6 + ], + [ + 20, + 0.6 + ], + [ + 26, + 0 + ] + ] + }, + "events": [] + }, + "ownerKindToAction": { + "reading": "read", + "files": "read", + "memory": "read", + "searching": "search", + "editing": "write", + "executing": "run", + "testing": "run", + "browsing": "browse", + "network": "connect", + "computer": "computer", + "responding": "talk", + "communicating": "talk", + "delegating": "pod", + "thinking": "think", + "tool": "busy", + "unknown": "busy", + "waiting": "busy", + "error": "busy" + }, + "directions": { + "mark": { + "key": "mark", + "name": "01 / Emblem", + "uncurl": 0, + "frontWidth": 1 + }, + "cruise": { + "key": "cruise", + "name": "02 / Cruise", + "uncurl": 0.56, + "frontWidth": 1.08 + }, + "open": { + "key": "open", + "name": "03 / Open C", + "uncurl": 0.25, + "frontWidth": 0.94 + }, + "calf": { + "key": "mark", + "name": "01 / Emblem", + "uncurl": 0, + "frontWidth": 1 + } + }, + "sourceContours": { + "body": [ + [ + "M", + 215.77, + 40.96 + ], + [ + "C", + 208.55, + 41.41, + 204.88, + 41.74, + 201.43, + 42.19 + ], + [ + "C", + 197, + 42.77, + 191.11, + 43.68, + 190.66, + 43.83 + ], + [ + "C", + 190.48, + 43.9, + 189.58, + 44.05, + 188.65, + 44.2 + ], + [ + "C", + 185.38, + 44.71, + 171.46, + 48.1, + 167.61, + 49.36 + ], + [ + "C", + 166.23, + 49.81, + 163.99, + 50.54, + 162.68, + 50.97 + ], + [ + "C", + 157.57, + 52.61, + 150.43, + 55.35, + 142.93, + 58.55 + ], + [ + "C", + 140.59, + 59.53, + 130.65, + 64.41, + 130.1, + 64.84 + ], + [ + "C", + 129.97, + 64.94, + 128.72, + 65.62, + 127.33, + 66.34 + ], + [ + "C", + 125.95, + 67.07, + 123.23, + 68.63, + 121.29, + 69.82 + ], + [ + "C", + 119.36, + 71, + 117.09, + 72.36, + 116.29, + 72.84 + ], + [ + "C", + 98.57, + 83.3, + 78.57, + 101.12, + 63.12, + 120.19 + ], + [ + "C", + 61.59, + 122.07, + 58.34, + 126.28, + 57.87, + 126.98 + ], + [ + "C", + 57.54, + 127.46, + 56.88, + 128.41, + 56.38, + 129.12 + ], + [ + "C", + 55.6, + 130.2, + 54.37, + 132.01, + 50.39, + 137.92 + ], + [ + "C", + 48.61, + 140.57, + 47.68, + 142.13, + 44.71, + 147.36 + ], + [ + "C", + 43.02, + 150.33, + 41.49, + 153.05, + 41.29, + 153.4 + ], + [ + "C", + 40.78, + 154.25, + 36.13, + 163.69, + 35.12, + 165.85 + ], + [ + "C", + 33.76, + 168.82, + 33.66, + 169.05, + 32.61, + 171.64 + ], + [ + "C", + 30.62, + 176.47, + 29.99, + 178.08, + 28.86, + 181.45 + ], + [ + "C", + 28.45, + 182.68, + 27.85, + 184.34, + 27.55, + 185.1 + ], + [ + "C", + 27.27, + 185.85, + 26.79, + 187.19, + 26.54, + 188.04 + ], + [ + "C", + 26.27, + 188.92, + 25.81, + 190.38, + 25.54, + 191.31 + ], + [ + "C", + 24.6, + 194.28, + 22.89, + 200.62, + 22.52, + 202.38 + ], + [ + "C", + 22.42, + 202.99, + 22.14, + 204.04, + 21.96, + 204.77 + ], + [ + "C", + 21.61, + 206.03, + 21.18, + 208.05, + 20.05, + 213.53 + ], + [ + "C", + 18.84, + 219.37, + 17.71, + 226.51, + 17.26, + 231.14 + ], + [ + "C", + 17.11, + 232.6, + 16.93, + 233.96, + 16.88, + 234.16 + ], + [ + "C", + 16.58, + 235.19, + 16, + 249.13, + 16, + 255.14 + ], + [ + "C", + 16.03, + 272.78, + 18.54, + 293.84, + 22.06, + 306.09 + ], + [ + "C", + 22.34, + 306.97, + 22.54, + 307.88, + 22.54, + 308.11 + ], + [ + "C", + 22.54, + 308.33, + 22.77, + 309.36, + 23.07, + 310.4 + ], + [ + "C", + 23.37, + 311.43, + 23.82, + 313.06, + 24.08, + 314.04 + ], + [ + "C", + 25.38, + 318.77, + 27.47, + 325.06, + 30.44, + 333.29 + ], + [ + "C", + 30.67, + 333.92, + 30.94, + 334.65, + 31.07, + 334.93 + ], + [ + "C", + 31.22, + 335.2, + 31.67, + 336.29, + 32.1, + 337.32 + ], + [ + "C", + 32.53, + 338.35, + 33.03, + 339.53, + 33.23, + 339.96 + ], + [ + "C", + 33.41, + 340.36, + 34.12, + 341.95, + 34.77, + 343.48 + ], + [ + "C", + 36, + 346.37, + 41.29, + 357.09, + 42.42, + 358.95 + ], + [ + "C", + 42.82, + 359.63, + 45.54, + 364.34, + 45.84, + 364.87 + ], + [ + "C", + 46.57, + 366.2, + 47.93, + 368.31, + 49.09, + 369.9 + ], + [ + "C", + 50.27, + 371.56, + 50.77, + 372.29, + 53.11, + 375.71 + ], + [ + "C", + 57.92, + 382.83, + 65.01, + 391.39, + 71.73, + 398.1 + ], + [ + "C", + 75.96, + 402.35, + 76.56, + 402.83, + 80.66, + 405.53 + ], + [ + "C", + 93.59, + 414.03, + 103.63, + 418.51, + 118.65, + 422.48 + ], + [ + "C", + 130.05, + 425.5, + 149.5, + 425.93, + 163.06, + 423.46 + ], + [ + "C", + 171.06, + 422, + 181.4, + 418.81, + 189.98, + 415.19 + ], + [ + "C", + 203.24, + 409.58, + 217.93, + 399.56, + 228.68, + 388.82 + ], + [ + "C", + 232.6, + 384.89, + 231.34, + 387.84, + 225.25, + 396.9 + ], + [ + "C", + 220.07, + 404.62, + 210.76, + 414.18, + 202.06, + 420.72 + ], + [ + "C", + 184.52, + 433.88, + 166.28, + 441.63, + 143.56, + 445.53 + ], + [ + "C", + 140.89, + 445.98, + 140.09, + 446.61, + 141.12, + 447.47 + ], + [ + "C", + 141.82, + 448.05, + 148.82, + 451.39, + 153.62, + 453.43 + ], + [ + "C", + 188.6, + 468.3, + 224.17, + 474.06, + 262.32, + 471.04 + ], + [ + "C", + 283.05, + 469.38, + 301.29, + 466.14, + 318.42, + 461 + ], + [ + "C", + 328.64, + 457.96, + 329.89, + 457.56, + 337.42, + 454.76 + ], + [ + "C", + 359.08, + 446.76, + 381.9, + 434.33, + 402.3, + 419.41 + ], + [ + "C", + 414.38, + 410.61, + 431.87, + 394.53, + 440.75, + 384.09 + ], + [ + "C", + 443.26, + 381.15, + 444.93, + 379.18, + 445.98, + 377.95 + ], + [ + "C", + 446.46, + 377.37, + 447.37, + 376.29, + 447.99, + 375.56 + ], + [ + "C", + 449.63, + 373.62, + 456.3, + 364.67, + 459.57, + 360.04 + ], + [ + "C", + 460.85, + 358.22, + 462.13, + 356.24, + 465.76, + 350.53 + ], + [ + "C", + 466.61, + 349.17, + 466.74, + 348.97, + 467.37, + 347.88 + ], + [ + "C", + 467.72, + 347.31, + 468.12, + 346.63, + 468.25, + 346.4 + ], + [ + "C", + 468.98, + 345.24, + 471.44, + 341.04, + 472.12, + 339.76 + ], + [ + "C", + 472.55, + 338.98, + 473.26, + 337.67, + 473.71, + 336.86 + ], + [ + "C", + 474.14, + 336.08, + 475.14, + 334.17, + 475.92, + 332.66 + ], + [ + "C", + 476.73, + 331.13, + 477.78, + 329.09, + 478.29, + 328.13 + ], + [ + "C", + 478.82, + 327.15, + 479.8, + 325.06, + 480.48, + 323.48 + ], + [ + "C", + 481.18, + 321.89, + 481.89, + 320.23, + 482.09, + 319.83 + ], + [ + "C", + 482.26, + 319.4, + 482.72, + 318.35, + 483.09, + 317.44 + ], + [ + "C", + 483.45, + 316.53, + 483.85, + 315.58, + 483.97, + 315.3 + ], + [ + "C", + 484.1, + 315.03, + 484.5, + 314.07, + 484.85, + 313.16 + ], + [ + "C", + 485.23, + 312.26, + 485.73, + 311.05, + 486.01, + 310.45 + ], + [ + "C", + 486.26, + 309.87, + 486.49, + 309.26, + 486.49, + 309.11 + ], + [ + "C", + 486.49, + 308.96, + 486.64, + 308.48, + 486.84, + 308.06 + ], + [ + "C", + 487.04, + 307.6, + 487.5, + 306.45, + 487.87, + 305.49 + ], + [ + "C", + 488.23, + 304.51, + 488.7, + 303.33, + 488.9, + 302.85 + ], + [ + "C", + 489.46, + 301.46, + 491.5, + 294.92, + 492.5, + 291.15 + ], + [ + "C", + 492.78, + 290.17, + 493.28, + 288.33, + 493.63, + 287.02 + ], + [ + "C", + 498.42, + 269.79, + 499.55, + 245.46, + 496, + 236.55 + ], + [ + "C", + 492.08, + 226.66, + 484.23, + 221.93, + 467.32, + 219.22 + ], + [ + "C", + 461.05, + 218.21, + 437.15, + 217.78, + 426.99, + 218.51 + ], + [ + "C", + 403.97, + 220.17, + 389.25, + 222.19, + 369.12, + 226.49 + ], + [ + "C", + 367.46, + 226.84, + 364.99, + 227.34, + 363.66, + 227.62 + ], + [ + "C", + 359.26, + 228.53, + 340.61, + 233.36, + 329.49, + 236.48 + ], + [ + "C", + 320.36, + 239.02, + 302.29, + 244.7, + 290.87, + 248.63 + ], + [ + "C", + 286.59, + 250.11, + 282.62, + 251.45, + 282.07, + 251.62 + ], + [ + "C", + 281.26, + 251.9, + 268.98, + 256.35, + 265.33, + 257.71 + ], + [ + "C", + 264.86, + 257.89, + 263.37, + 258.44, + 262.06, + 258.92 + ], + [ + "C", + 260.76, + 259.4, + 258.21, + 260.35, + 256.4, + 261.08 + ], + [ + "C", + 254.62, + 261.79, + 252.73, + 262.52, + 252.25, + 262.69 + ], + [ + "C", + 251.77, + 262.87, + 249.91, + 263.6, + 248.1, + 264.33 + ], + [ + "C", + 246.31, + 265.03, + 244.1, + 265.91, + 243.19, + 266.24 + ], + [ + "C", + 242.29, + 266.57, + 235.22, + 269.36, + 227.47, + 272.43 + ], + [ + "C", + 203.49, + 281.94, + 201.33, + 282.74, + 197.65, + 283.85 + ], + [ + "C", + 197.03, + 284.03, + 195.57, + 284.53, + 194.38, + 284.93 + ], + [ + "C", + 193.2, + 285.34, + 191.24, + 285.99, + 189.98, + 286.37 + ], + [ + "C", + 188.75, + 286.72, + 187.26, + 287.2, + 186.71, + 287.37 + ], + [ + "C", + 185.75, + 287.7, + 183.04, + 288.46, + 179.16, + 289.51 + ], + [ + "C", + 178.21, + 289.76, + 176.32, + 290.22, + 175.01, + 290.52 + ], + [ + "C", + 173.7, + 290.8, + 171.87, + 291.2, + 170.93, + 291.43 + ], + [ + "C", + 170.03, + 291.63, + 168.27, + 291.98, + 167.03, + 292.15 + ], + [ + "C", + 165.83, + 292.36, + 164.09, + 292.63, + 163.19, + 292.76 + ], + [ + "C", + 157.6, + 293.66, + 155.64, + 293.84, + 150.1, + 294.04 + ], + [ + "C", + 143.94, + 294.27, + 136.06, + 294.02, + 133.92, + 293.56 + ], + [ + "C", + 133.35, + 293.44, + 131.91, + 293.19, + 130.73, + 293.04 + ], + [ + "C", + 129.55, + 292.88, + 127.96, + 292.58, + 127.21, + 292.36 + ], + [ + "C", + 123.89, + 291.43, + 119.56, + 289.99, + 118.22, + 289.36 + ], + [ + "C", + 117.42, + 289.01, + 115.91, + 288.31, + 114.88, + 287.83 + ], + [ + "C", + 107.28, + 284.3, + 99.61, + 277.64, + 94.47, + 270.14 + ], + [ + "C", + 92.39, + 267.1, + 89.89, + 262.29, + 88.84, + 259.32 + ], + [ + "C", + 87.43, + 255.35, + 86.02, + 249.91, + 85.19, + 245.36 + ], + [ + "C", + 85.06, + 244.65, + 84.79, + 241.43, + 84.54, + 238.16 + ], + [ + "C", + 84.08, + 231.92, + 84.06, + 232.63, + 84.94, + 222.46 + ], + [ + "C", + 85.57, + 215.34, + 87.66, + 205.86, + 90.07, + 199.31 + ], + [ + "C", + 90.45, + 198.33, + 91.03, + 196.7, + 91.38, + 195.67 + ], + [ + "C", + 92.13, + 193.55, + 93.04, + 191.26, + 93.77, + 189.73 + ], + [ + "C", + 94.02, + 189.18, + 94.25, + 188.65, + 94.25, + 188.55 + ], + [ + "C", + 94.25, + 188.45, + 94.7, + 187.36, + 95.28, + 186.16 + ], + [ + "C", + 104.84, + 165.65, + 118.7, + 148.84, + 137.65, + 134.78 + ], + [ + "C", + 139.74, + 133.22, + 145.27, + 129.5, + 146.45, + 128.84 + ], + [ + "C", + 146.73, + 128.69, + 148.27, + 127.81, + 149.85, + 126.85 + ], + [ + "C", + 157.6, + 122.3, + 167.84, + 117.85, + 176.14, + 115.43 + ], + [ + "C", + 187.26, + 112.21, + 194.28, + 110.98, + 203.32, + 110.65 + ], + [ + "C", + 215, + 92, + 218, + 59, + 215.77, + 40.96 + ], + [ + "Z" + ] + ], + "pouch": [ + [ + "M", + 478.24, + 268.66 + ], + [ + "C", + 482.39, + 268.96, + 483.29, + 269.21, + 483.42, + 270.06 + ], + [ + "C", + 483.55, + 270.92, + 482.46, + 276.33, + 480.58, + 284.48 + ], + [ + "C", + 480.1, + 286.54, + 478.89, + 290.8, + 477.56, + 295.05 + ], + [ + "C", + 476.7, + 297.79, + 475.8, + 300.36, + 474.87, + 302.72 + ], + [ + "C", + 474.59, + 303.4, + 474.16, + 304.48, + 473.91, + 305.11 + ], + [ + "C", + 473.66, + 305.74, + 472.93, + 307.48, + 472.27, + 309.01 + ], + [ + "C", + 471.65, + 310.52, + 471.02, + 312.01, + 470.89, + 312.28 + ], + [ + "C", + 470.76, + 312.56, + 470.39, + 313.47, + 470.01, + 314.3 + ], + [ + "C", + 469.66, + 315.13, + 469.15, + 316.26, + 468.93, + 316.79 + ], + [ + "C", + 468.4, + 317.99, + 461.41, + 331.96, + 460.52, + 333.54 + ], + [ + "C", + 460.17, + 334.17, + 459.64, + 335.13, + 459.32, + 335.68 + ], + [ + "C", + 458.99, + 336.23, + 458.61, + 336.91, + 458.46, + 337.19 + ], + [ + "C", + 458.13, + 337.77, + 457.61, + 338.63, + 456.32, + 340.59 + ], + [ + "C", + 455.82, + 341.34, + 455.37, + 342.07, + 455.29, + 342.22 + ], + [ + "C", + 455.22, + 342.37, + 454.71, + 343.15, + 454.18, + 343.98 + ], + [ + "C", + 453.63, + 344.81, + 452.37, + 346.73, + 451.39, + 348.26 + ], + [ + "C", + 448.85, + 352.19, + 448.95, + 352.04, + 446.94, + 354.83 + ], + [ + "C", + 444.7, + 357.9, + 444.2, + 358.58, + 438.96, + 365.19 + ], + [ + "C", + 422.56, + 385.9, + 400.72, + 405.35, + 378.38, + 419.11 + ], + [ + "C", + 368.84, + 424.97, + 367.31, + 425.85, + 358.93, + 430.03 + ], + [ + "C", + 348.61, + 435.19, + 343.58, + 437.35, + 331.5, + 441.86 + ], + [ + "C", + 318.65, + 446.64, + 307.28, + 449.76, + 291.75, + 452.78 + ], + [ + "C", + 275.8, + 455.84, + 262.62, + 457.08, + 245.08, + 457.08 + ], + [ + "C", + 235.17, + 457.08, + 234.19, + 456.8, + 238.49, + 455.19 + ], + [ + "C", + 240.53, + 454.46, + 244.68, + 452.32, + 249.74, + 449.4 + ], + [ + "C", + 267.9, + 438.99, + 287.3, + 419.56, + 305.97, + 393.05 + ], + [ + "C", + 306.8, + 391.86, + 309.04, + 388.72, + 310.95, + 386.05 + ], + [ + "C", + 312.86, + 383.41, + 315.91, + 379.13, + 317.74, + 376.59 + ], + [ + "C", + 322.9, + 369.37, + 328.46, + 362.05, + 334.4, + 354.63 + ], + [ + "C", + 344.79, + 341.67, + 362.33, + 323.81, + 374.18, + 314.17 + ], + [ + "C", + 384.89, + 305.44, + 385.65, + 304.86, + 392.52, + 299.95 + ], + [ + "C", + 396.87, + 296.83, + 403.11, + 292.61, + 405.47, + 291.17 + ], + [ + "C", + 406.1, + 290.8, + 407.41, + 289.99, + 408.37, + 289.39 + ], + [ + "C", + 410.43, + 288.1, + 414.48, + 285.74, + 414.63, + 285.74 + ], + [ + "C", + 414.68, + 285.74, + 415.84, + 285.11, + 417.2, + 284.33 + ], + [ + "C", + 436.75, + 273.18, + 459.59, + 267.32, + 478.24, + 268.66 + ], + [ + "Z" + ] + ], + "pleat-1": [ + [ + "M", + 446.33, + 308.56 + ], + [ + "C", + 446.23, + 308.94, + 445.96, + 309.67, + 445.73, + 310.14 + ], + [ + "C", + 445.48, + 310.62, + 445.05, + 311.58, + 444.75, + 312.28 + ], + [ + "C", + 441.96, + 318.6, + 441.08, + 320.31, + 436.22, + 328.89 + ], + [ + "C", + 433.73, + 333.29, + 433.3, + 334, + 430.16, + 338.7 + ], + [ + "C", + 428.85, + 340.64, + 427.34, + 342.9, + 426.76, + 343.73 + ], + [ + "C", + 426.21, + 344.56, + 425.12, + 346.07, + 424.37, + 347.05 + ], + [ + "C", + 423.59, + 348.06, + 422.53, + 349.47, + 422, + 350.2 + ], + [ + "C", + 412.14, + 363.78, + 398.08, + 378.73, + 385.22, + 389.32 + ], + [ + "C", + 382.15, + 391.84, + 375.76, + 396.87, + 374.1, + 398.05 + ], + [ + "C", + 373.17, + 398.73, + 371.99, + 399.61, + 371.51, + 399.96 + ], + [ + "C", + 370.68, + 400.59, + 360.84, + 407.19, + 357.92, + 409.07 + ], + [ + "C", + 352.04, + 412.85, + 345.62, + 416.44, + 337.29, + 420.57 + ], + [ + "C", + 333.34, + 422.53, + 329.84, + 424.29, + 329.49, + 424.47 + ], + [ + "C", + 328.61, + 424.95, + 321.34, + 427.99, + 318.04, + 429.28 + ], + [ + "C", + 316.53, + 429.85, + 312.11, + 431.36, + 308.23, + 432.65 + ], + [ + "C", + 304.36, + 433.91, + 301.01, + 435.04, + 300.81, + 435.19 + ], + [ + "C", + 299, + 436.32, + 306.92, + 434.94, + 315.28, + 432.65 + ], + [ + "C", + 317.36, + 432.07, + 319.88, + 431.39, + 320.89, + 431.14 + ], + [ + "C", + 321.89, + 430.86, + 324.96, + 429.85, + 327.68, + 428.87 + ], + [ + "C", + 338.02, + 425.17, + 340.24, + 424.22, + 349.87, + 419.41 + ], + [ + "C", + 358.68, + 415.04, + 364.31, + 411.79, + 371.13, + 407.19 + ], + [ + "C", + 378.3, + 402.35, + 382.71, + 399.06, + 386.63, + 395.59 + ], + [ + "C", + 387.26, + 395.01, + 388.82, + 393.65, + 390.13, + 392.54 + ], + [ + "C", + 394.63, + 388.72, + 399.54, + 383.94, + 404.12, + 378.98 + ], + [ + "C", + 405.63, + 377.32, + 407.03, + 375.79, + 407.24, + 375.58 + ], + [ + "C", + 408.57, + 374.15, + 414.73, + 366.7, + 416.32, + 364.62 + ], + [ + "C", + 419.84, + 359.94, + 426.71, + 350.17, + 427.19, + 349.14 + ], + [ + "C", + 427.29, + 348.94, + 427.87, + 347.96, + 428.47, + 347 + ], + [ + "C", + 429.1, + 346.02, + 429.96, + 344.59, + 430.36, + 343.78 + ], + [ + "C", + 430.79, + 342.98, + 431.34, + 342.02, + 431.59, + 341.64 + ], + [ + "C", + 432.3, + 340.59, + 432.57, + 340.08, + 433.53, + 338.2 + ], + [ + "C", + 434.03, + 337.22, + 434.71, + 335.98, + 435.04, + 335.43 + ], + [ + "C", + 435.79, + 334.12, + 442.21, + 321.34, + 442.21, + 321.11 + ], + [ + "C", + 442.21, + 321.01, + 442.51, + 320.28, + 442.86, + 319.5 + ], + [ + "C", + 443.77, + 317.49, + 444.1, + 316.76, + 444.72, + 315.18 + ], + [ + "C", + 445.03, + 314.42, + 445.5, + 313.31, + 445.76, + 312.71 + ], + [ + "C", + 446.01, + 312.13, + 446.23, + 311.48, + 446.23, + 311.25 + ], + [ + "C", + 446.23, + 311.05, + 446.38, + 310.57, + 446.59, + 310.19 + ], + [ + "C", + 447.06, + 309.26, + 447.19, + 308.08, + 446.81, + 307.96 + ], + [ + "C", + 446.64, + 307.88, + 446.44, + 308.16, + 446.33, + 308.56 + ], + [ + "Z" + ] + ], + "pleat-2": [ + [ + "M", + 402.33, + 331.71 + ], + [ + "C", + 392.11, + 346.95, + 379.91, + 360.64, + 366.48, + 371.91 + ], + [ + "C", + 365.17, + 373.02, + 363.28, + 374.63, + 362.28, + 375.48 + ], + [ + "C", + 357.8, + 379.36, + 342.95, + 390, + 335.91, + 394.4 + ], + [ + "C", + 333.67, + 395.81, + 328.66, + 398.61, + 325.29, + 400.34 + ], + [ + "C", + 323.18, + 401.42, + 321.44, + 402.41, + 321.44, + 402.51 + ], + [ + "C", + 321.44, + 402.86, + 322.85, + 402.78, + 323.76, + 402.38 + ], + [ + "C", + 324.21, + 402.18, + 325.16, + 401.8, + 325.84, + 401.52 + ], + [ + "C", + 332.44, + 398.81, + 336.59, + 396.87, + 340.94, + 394.48 + ], + [ + "C", + 346.63, + 391.33, + 348.29, + 390.35, + 352.59, + 387.46 + ], + [ + "C", + 372.06, + 374.4, + 390.71, + 353.95, + 403.84, + 331.25 + ], + [ + "C", + 407.34, + 325.19, + 406.56, + 325.44, + 402.33, + 331.71 + ], + [ + "Z" + ] + ], + "jaw-band": [ + [ + "M", + 141.12, + 447.47 + ], + [ + "C", + 141.82, + 448.05, + 148.82, + 451.39, + 153.62, + 453.43 + ], + [ + "C", + 188.6, + 468.3, + 224.17, + 474.06, + 262.32, + 471.04 + ], + [ + "C", + 283.05, + 469.38, + 301.29, + 466.14, + 318.42, + 461 + ], + [ + "C", + 328.64, + 457.96, + 329.89, + 457.56, + 337.42, + 454.76 + ], + [ + "C", + 359.08, + 446.76, + 381.9, + 434.33, + 402.3, + 419.41 + ], + [ + "C", + 414.38, + 410.61, + 431.87, + 394.53, + 440.75, + 384.09 + ], + [ + "C", + 443.26, + 381.15, + 444.93, + 379.18, + 445.98, + 377.95 + ], + [ + "C", + 446.46, + 377.37, + 447.37, + 376.29, + 447.99, + 375.56 + ], + [ + "C", + 449.63, + 373.62, + 456.3, + 364.67, + 459.57, + 360.04 + ], + [ + "C", + 460.85, + 358.22, + 462.13, + 356.24, + 465.76, + 350.53 + ], + [ + "C", + 466.61, + 349.17, + 466.74, + 348.97, + 467.37, + 347.88 + ], + [ + "C", + 467.72, + 347.31, + 468.12, + 346.63, + 468.25, + 346.4 + ], + [ + "C", + 468.98, + 345.24, + 471.44, + 341.04, + 472.12, + 339.76 + ], + [ + "C", + 472.55, + 338.98, + 473.26, + 337.67, + 473.71, + 336.86 + ], + [ + "C", + 474.14, + 336.08, + 475.14, + 334.17, + 475.92, + 332.66 + ], + [ + "C", + 476.73, + 331.13, + 477.78, + 329.09, + 478.29, + 328.13 + ], + [ + "C", + 478.82, + 327.15, + 479.8, + 325.06, + 480.48, + 323.48 + ], + [ + "C", + 481.18, + 321.89, + 481.89, + 320.23, + 482.09, + 319.83 + ], + [ + "C", + 482.26, + 319.4, + 482.72, + 318.35, + 483.09, + 317.44 + ], + [ + "C", + 483.45, + 316.53, + 483.85, + 315.58, + 483.97, + 315.3 + ], + [ + "C", + 484.1, + 315.03, + 484.5, + 314.07, + 484.85, + 313.16 + ], + [ + "C", + 485.23, + 312.26, + 485.73, + 311.05, + 486.01, + 310.45 + ], + [ + "C", + 486.26, + 309.87, + 486.49, + 309.26, + 486.49, + 309.11 + ], + [ + "C", + 486.49, + 308.96, + 486.64, + 308.48, + 486.84, + 308.06 + ], + [ + "C", + 487.04, + 307.6, + 487.5, + 306.45, + 487.87, + 305.49 + ], + [ + "C", + 488.23, + 304.51, + 488.7, + 303.33, + 488.9, + 302.85 + ], + [ + "C", + 489.46, + 301.46, + 491.5, + 294.92, + 492.5, + 291.15 + ], + [ + "C", + 492.78, + 290.17, + 493.28, + 288.33, + 493.63, + 287.02 + ], + [ + "C", + 498.42, + 269.79, + 499.55, + 245.46, + 496, + 236.55 + ], + [ + "C", + 497, + 249, + 486, + 265, + 483.42, + 270.06 + ], + [ + "C", + 480.1, + 286.54, + 478.89, + 290.8, + 477.56, + 295.05 + ], + [ + "C", + 476.7, + 297.79, + 475.8, + 300.36, + 474.87, + 302.72 + ], + [ + "C", + 474.59, + 303.4, + 474.16, + 304.48, + 473.91, + 305.11 + ], + [ + "C", + 473.66, + 305.74, + 472.93, + 307.48, + 472.27, + 309.01 + ], + [ + "C", + 471.65, + 310.52, + 471.02, + 312.01, + 470.89, + 312.28 + ], + [ + "C", + 470.76, + 312.56, + 470.39, + 313.47, + 470.01, + 314.3 + ], + [ + "C", + 469.66, + 315.13, + 469.15, + 316.26, + 468.93, + 316.79 + ], + [ + "C", + 468.4, + 317.99, + 461.41, + 331.96, + 460.52, + 333.54 + ], + [ + "C", + 460.17, + 334.17, + 459.64, + 335.13, + 459.32, + 335.68 + ], + [ + "C", + 458.99, + 336.23, + 458.61, + 336.91, + 458.46, + 337.19 + ], + [ + "C", + 458.13, + 337.77, + 457.61, + 338.63, + 456.32, + 340.59 + ], + [ + "C", + 455.82, + 341.34, + 455.37, + 342.07, + 455.29, + 342.22 + ], + [ + "C", + 455.22, + 342.37, + 454.71, + 343.15, + 454.18, + 343.98 + ], + [ + "C", + 453.63, + 344.81, + 452.37, + 346.73, + 451.39, + 348.26 + ], + [ + "C", + 448.85, + 352.19, + 448.95, + 352.04, + 446.94, + 354.83 + ], + [ + "C", + 444.7, + 357.9, + 444.2, + 358.58, + 438.96, + 365.19 + ], + [ + "C", + 422.56, + 385.9, + 400.72, + 405.35, + 378.38, + 419.11 + ], + [ + "C", + 368.84, + 424.97, + 367.31, + 425.85, + 358.93, + 430.03 + ], + [ + "C", + 348.61, + 435.19, + 343.58, + 437.35, + 331.5, + 441.86 + ], + [ + "C", + 318.65, + 446.64, + 307.28, + 449.76, + 291.75, + 452.78 + ], + [ + "C", + 275.8, + 455.84, + 262.62, + 457.08, + 245.08, + 457.08 + ], + [ + "C", + 235.17, + 457.08, + 234.19, + 456.8, + 238.49, + 455.19 + ], + [ + "C", + 209, + 463, + 169, + 458, + 141.12, + 447.47 + ], + [ + "Z" + ] + ], + "eye": [ + [ + "M", + 297.59, + 326.98 + ], + [ + "C", + 306.77, + 328.86, + 310.47, + 337.17, + 304.89, + 343.38 + ], + [ + "C", + 298.9, + 350.05, + 286.17, + 349.02, + 281.79, + 341.47 + ], + [ + "C", + 280.68, + 339.58, + 280.71, + 335.38, + 281.81, + 333.24 + ], + [ + "C", + 284.36, + 328.26, + 291, + 325.62, + 297.59, + 326.98 + ], + [ + "Z" + ] + ], + "eyelid": [ + [ + "M", + 312, + 337 + ], + [ + "C", + 312, + 345.28427125, + 303.9411255, + 352, + 294, + 352 + ], + [ + "C", + 284.0588745, + 352, + 276, + 345.28427125, + 276, + 337 + ], + [ + "C", + 276, + 328.71572875, + 284.0588745, + 322, + 294, + 322 + ], + [ + "C", + 303.9411255, + 322, + 312, + 328.71572875, + 312, + 337 + ], + [ + "Z" + ] + ], + "flipper": [ + [ + "M", + 228.68, + 388.82 + ], + [ + "C", + 232.6, + 384.89, + 231.34, + 387.84, + 225.25, + 396.9 + ], + [ + "C", + 220.07, + 404.62, + 210.76, + 414.18, + 202.06, + 420.72 + ], + [ + "C", + 184.52, + 433.88, + 166.28, + 441.63, + 143.56, + 445.53 + ], + [ + "C", + 140.89, + 445.98, + 140.09, + 446.61, + 141.12, + 447.47 + ], + [ + "C", + 141.82, + 448.05, + 148.82, + 451.39, + 153.62, + 453.43 + ], + [ + "C", + 188.6, + 468.3, + 224.17, + 474.06, + 262.32, + 471.04 + ], + [ + "C", + 273, + 445, + 250, + 405, + 228.68, + 388.82 + ], + [ + "Z" + ] + ], + "flukes": [ + [ + "M", + 203.32, + 110.65 + ], + [ + "C", + 216.73, + 110.2, + 227.07, + 111.76, + 237.05, + 115.73 + ], + [ + "C", + 238.84, + 116.44, + 243.9, + 119.08, + 244.48, + 119.58 + ], + [ + "C", + 244.68, + 119.78, + 244.95, + 119.94, + 245.06, + 119.94 + ], + [ + "C", + 245.94, + 119.94, + 250.11, + 123.89, + 253.46, + 127.84 + ], + [ + "C", + 258.06, + 133.32, + 264.13, + 138.73, + 267.17, + 140.09 + ], + [ + "C", + 267.47, + 140.21, + 268.23, + 140.67, + 268.86, + 141.07 + ], + [ + "C", + 270.22, + 141.93, + 275.42, + 144.54, + 277.54, + 145.4 + ], + [ + "C", + 280.15, + 146.48, + 281.66, + 146.96, + 286.97, + 148.47 + ], + [ + "C", + 300.13, + 152.19, + 319.86, + 150.53, + 332.89, + 144.59 + ], + [ + "C", + 335, + 143.64, + 338.73, + 141.77, + 339.68, + 141.2 + ], + [ + "C", + 340.24, + 140.87, + 341.37, + 140.19, + 342.2, + 139.69 + ], + [ + "C", + 349.09, + 135.56, + 358, + 126.35, + 362.28, + 118.95 + ], + [ + "C", + 364.92, + 114.4, + 364.69, + 112.64, + 361.47, + 112.64 + ], + [ + "C", + 360.79, + 112.64, + 358.5, + 112.26, + 353.9, + 111.36 + ], + [ + "C", + 348.76, + 110.37, + 343.83, + 109.12, + 336.29, + 106.9 + ], + [ + "C", + 333.82, + 106.17, + 324.96, + 103.1, + 321.69, + 101.82 + ], + [ + "C", + 320.13, + 101.22, + 318.14, + 100.46, + 316.28, + 99.78 + ], + [ + "C", + 315.6, + 99.53, + 314.25, + 99.03, + 313.26, + 98.68 + ], + [ + "C", + 312.31, + 98.32, + 311.1, + 97.9, + 310.62, + 97.77 + ], + [ + "C", + 310.14, + 97.62, + 309.39, + 97.34, + 308.94, + 97.14 + ], + [ + "C", + 308.51, + 96.94, + 308.03, + 96.79, + 307.9, + 96.79 + ], + [ + "C", + 307.75, + 96.79, + 306.42, + 96.39, + 304.91, + 95.88 + ], + [ + "C", + 297.14, + 93.29, + 288.28, + 91.86, + 280.56, + 91.91 + ], + [ + "C", + 276.45, + 91.93, + 267.35, + 92.76, + 265.71, + 93.27 + ], + [ + "C", + 265.31, + 93.39, + 264.4, + 93.57, + 263.7, + 93.64 + ], + [ + "C", + 263.02, + 93.74, + 261.08, + 94.05, + 259.42, + 94.32 + ], + [ + "C", + 255.8, + 94.93, + 249.66, + 94.88, + 246.21, + 94.2 + ], + [ + "C", + 245.03, + 93.97, + 243.39, + 93.69, + 242.59, + 93.59 + ], + [ + "C", + 239.97, + 93.24, + 240.55, + 92.56, + 244.07, + 91.86 + ], + [ + "C", + 249.16, + 90.85, + 258.62, + 87.4, + 262.77, + 85.04 + ], + [ + "C", + 274.12, + 78.55, + 283.55, + 69.57, + 289.19, + 59.9 + ], + [ + "C", + 292.21, + 54.72, + 291.85, + 53.36, + 287.1, + 51.6 + ], + [ + "C", + 274.17, + 46.9, + 260.65, + 43.7, + 246.09, + 41.96 + ], + [ + "C", + 237.33, + 40.91, + 223.64, + 40.46, + 215.77, + 40.96 + ], + [ + "C", + 209.62, + 64.19, + 205.47, + 87.41999999999999, + 203.32, + 110.65 + ], + [ + "Z" + ] + ], + "eye-far": [ + [ + "M", + 297.59, + 326.98 + ], + [ + "C", + 306.77, + 328.86, + 310.47, + 337.17, + 304.89, + 343.38 + ], + [ + "C", + 298.9, + 350.05, + 286.17, + 349.02, + 281.79, + 341.47 + ], + [ + "C", + 280.68, + 339.58, + 280.71, + 335.38, + 281.81, + 333.24 + ], + [ + "C", + 284.36, + 328.26, + 291, + 325.62, + 297.59, + 326.98 + ], + [ + "Z" + ] + ] + }, + "easing": { + "l": "t", + "i": "t^3", + "o": "1-(1-t)^3", + "io": "t<0.5 ? 4*t^3 : 1-(-2*t+2)^3/2", + "b": "c1=1.70158; c3=c1+1; 1+c3*(t-1)^3+c1*(t-1)^2", + "s": "t<1 ? 0 : 1" + }, + "scheduling": { + "heroActiveHz": 30, + "heroRestHz": 8, + "smallActiveHz": 15, + "smallRestHz": 2, + "brailleActiveHz": 6, + "brailleRestHz": 2, + "reducedHz": 0, + "hiddenHz": 0 + }, + "braille": { + "bits": [ + [ + 1, + 8 + ], + [ + 2, + 16 + ], + [ + 4, + 32 + ], + [ + 64, + 128 + ] + ], + "cellAspect": 0.5, + "minimumCells": [ + 16, + 8 + ], + "recommendedCells": [ + 32, + 16 + ], + "opacityThreshold": 0.5, + "curveSteps": 10 + }, + "geometryAuthority": "rig.js + props.js; do not interpolate exported SVG strings or switch poster SVGs to animate", + "ownerAuthority": "presence.rs and the foreground Engine; computer kind needs owner integration. See TOOL-COVERAGE.md." +} diff --git a/crates/tui/src/tui/whale_v2/data/mark-data.js b/crates/tui/src/tui/whale_v2/data/mark-data.js new file mode 100644 index 0000000000..223ff7a6be --- /dev/null +++ b/crates/tui/src/tui/whale_v2/data/mark-data.js @@ -0,0 +1,2 @@ +// Exact cubic contours from reference/codewhale-mark-gradient.svg. Do not trace from a bitmap. +(function(root){root.WhaleMark={"contours":[[["M",215.77,40.96],["C",208.55,41.41,204.88,41.74,201.43,42.19],["C",197.0,42.77,191.11,43.68,190.66,43.83],["C",190.48,43.9,189.58,44.05,188.65,44.2],["C",185.38,44.71,171.46,48.1,167.61,49.36],["C",166.23,49.81,163.99,50.54,162.68,50.97],["C",157.57,52.61,150.43,55.35,142.93,58.55],["C",140.59,59.53,130.65,64.41,130.1,64.84],["C",129.97,64.94,128.72,65.62,127.33,66.34],["C",125.95,67.07,123.23,68.63,121.29,69.82],["C",119.36,71.0,117.09,72.36,116.29,72.84],["C",98.57,83.3,78.57,101.12,63.12,120.19],["C",61.59,122.07,58.34,126.28,57.87,126.98],["C",57.54,127.46,56.88,128.41,56.38,129.12],["C",55.6,130.2,54.37,132.01,50.39,137.92],["C",48.61,140.57,47.68,142.13,44.71,147.36],["C",43.02,150.33,41.49,153.05,41.29,153.4],["C",40.78,154.25,36.13,163.69,35.12,165.85],["C",33.76,168.82,33.66,169.05,32.61,171.64],["C",30.62,176.47,29.99,178.08,28.86,181.45],["C",28.45,182.68,27.85,184.34,27.55,185.1],["C",27.27,185.85,26.79,187.19,26.54,188.04],["C",26.27,188.92,25.81,190.38,25.54,191.31],["C",24.6,194.28,22.89,200.62,22.52,202.38],["C",22.42,202.99,22.14,204.04,21.96,204.77],["C",21.61,206.03,21.18,208.05,20.05,213.53],["C",18.84,219.37,17.71,226.51,17.26,231.14],["C",17.11,232.6,16.93,233.96,16.88,234.16],["C",16.58,235.19,16.0,249.13,16.0,255.14],["C",16.03,272.78,18.54,293.84,22.06,306.09],["C",22.34,306.97,22.54,307.88,22.54,308.11],["C",22.54,308.33,22.77,309.36,23.07,310.4],["C",23.37,311.43,23.82,313.06,24.08,314.04],["C",25.38,318.77,27.47,325.06,30.44,333.29],["C",30.67,333.92,30.94,334.65,31.07,334.93],["C",31.22,335.2,31.67,336.29,32.1,337.32],["C",32.53,338.35,33.03,339.53,33.23,339.96],["C",33.41,340.36,34.12,341.95,34.77,343.48],["C",36.0,346.37,41.29,357.09,42.42,358.95],["C",42.82,359.63,45.54,364.34,45.84,364.87],["C",46.57,366.2,47.93,368.31,49.09,369.9],["C",50.27,371.56,50.77,372.29,53.11,375.71],["C",57.92,382.83,65.01,391.39,71.73,398.1],["C",75.96,402.35,76.56,402.83,80.66,405.53],["C",93.59,414.03,103.63,418.51,118.65,422.48],["C",130.05,425.5,149.5,425.93,163.06,423.46],["C",171.06,422.0,181.4,418.81,189.98,415.19],["C",203.24,409.58,217.93,399.56,228.68,388.82],["C",232.6,384.89,231.34,387.84,225.25,396.9],["C",220.07,404.62,210.76,414.18,202.06,420.72],["C",184.52,433.88,166.28,441.63,143.56,445.53],["C",140.89,445.98,140.09,446.61,141.12,447.47],["C",141.82,448.05,148.82,451.39,153.62,453.43],["C",188.6,468.3,224.17,474.06,262.32,471.04],["C",283.05,469.38,301.29,466.14,318.42,461.0],["C",328.64,457.96,329.89,457.56,337.42,454.76],["C",359.08,446.76,381.9,434.33,402.3,419.41],["C",414.38,410.61,431.87,394.53,440.75,384.09],["C",443.26,381.15,444.93,379.18,445.98,377.95],["C",446.46,377.37,447.37,376.29,447.99,375.56],["C",449.63,373.62,456.3,364.67,459.57,360.04],["C",460.85,358.22,462.13,356.24,465.76,350.53],["C",466.61,349.17,466.74,348.97,467.37,347.88],["C",467.72,347.31,468.12,346.63,468.25,346.4],["C",468.98,345.24,471.44,341.04,472.12,339.76],["C",472.55,338.98,473.26,337.67,473.71,336.86],["C",474.14,336.08,475.14,334.17,475.92,332.66],["C",476.73,331.13,477.78,329.09,478.29,328.13],["C",478.82,327.15,479.8,325.06,480.48,323.48],["C",481.18,321.89,481.89,320.23,482.09,319.83],["C",482.26,319.4,482.72,318.35,483.09,317.44],["C",483.45,316.53,483.85,315.58,483.97,315.3],["C",484.1,315.03,484.5,314.07,484.85,313.16],["C",485.23,312.26,485.73,311.05,486.01,310.45],["C",486.26,309.87,486.49,309.26,486.49,309.11],["C",486.49,308.96,486.64,308.48,486.84,308.06],["C",487.04,307.6,487.5,306.45,487.87,305.49],["C",488.23,304.51,488.7,303.33,488.9,302.85],["C",489.46,301.46,491.5,294.92,492.5,291.15],["C",492.78,290.17,493.28,288.33,493.63,287.02],["C",498.42,269.79,499.55,245.46,496.0,236.55],["C",492.08,226.66,484.23,221.93,467.32,219.22],["C",461.05,218.21,437.15,217.78,426.99,218.51],["C",403.97,220.17,389.25,222.19,369.12,226.49],["C",367.46,226.84,364.99,227.34,363.66,227.62],["C",359.26,228.53,340.61,233.36,329.49,236.48],["C",320.36,239.02,302.29,244.7,290.87,248.63],["C",286.59,250.11,282.62,251.45,282.07,251.62],["C",281.26,251.9,268.98,256.35,265.33,257.71],["C",264.86,257.89,263.37,258.44,262.06,258.92],["C",260.76,259.4,258.21,260.35,256.4,261.08],["C",254.62,261.79,252.73,262.52,252.25,262.69],["C",251.77,262.87,249.91,263.6,248.1,264.33],["C",246.31,265.03,244.1,265.91,243.19,266.24],["C",242.29,266.57,235.22,269.36,227.47,272.43],["C",203.49,281.94,201.33,282.74,197.65,283.85],["C",197.03,284.03,195.57,284.53,194.38,284.93],["C",193.2,285.34,191.24,285.99,189.98,286.37],["C",188.75,286.72,187.26,287.2,186.71,287.37],["C",185.75,287.7,183.04,288.46,179.16,289.51],["C",178.21,289.76,176.32,290.22,175.01,290.52],["C",173.7,290.8,171.87,291.2,170.93,291.43],["C",170.03,291.63,168.27,291.98,167.03,292.15],["C",165.83,292.36,164.09,292.63,163.19,292.76],["C",157.6,293.66,155.64,293.84,150.1,294.04],["C",143.94,294.27,136.06,294.02,133.92,293.56],["C",133.35,293.44,131.91,293.19,130.73,293.04],["C",129.55,292.88,127.96,292.58,127.21,292.36],["C",123.89,291.43,119.56,289.99,118.22,289.36],["C",117.42,289.01,115.91,288.31,114.88,287.83],["C",107.28,284.3,99.61,277.64,94.47,270.14],["C",92.39,267.1,89.89,262.29,88.84,259.32],["C",87.43,255.35,86.02,249.91,85.19,245.36],["C",85.06,244.65,84.79,241.43,84.54,238.16],["C",84.08,231.92,84.06,232.63,84.94,222.46],["C",85.57,215.34,87.66,205.86,90.07,199.31],["C",90.45,198.33,91.03,196.7,91.38,195.67],["C",92.13,193.55,93.04,191.26,93.77,189.73],["C",94.02,189.18,94.25,188.65,94.25,188.55],["C",94.25,188.45,94.7,187.36,95.28,186.16],["C",104.84,165.65,118.7,148.84,137.65,134.78],["C",139.74,133.22,145.27,129.5,146.45,128.84],["C",146.73,128.69,148.27,127.81,149.85,126.85],["C",157.6,122.3,167.84,117.85,176.14,115.43],["C",187.26,112.21,194.28,110.98,203.32,110.65],["C",216.73,110.2,227.07,111.76,237.05,115.73],["C",238.84,116.44,243.9,119.08,244.48,119.58],["C",244.68,119.78,244.95,119.94,245.06,119.94],["C",245.94,119.94,250.11,123.89,253.46,127.84],["C",258.06,133.32,264.13,138.73,267.17,140.09],["C",267.47,140.21,268.23,140.67,268.86,141.07],["C",270.22,141.93,275.42,144.54,277.54,145.4],["C",280.15,146.48,281.66,146.96,286.97,148.47],["C",300.13,152.19,319.86,150.53,332.89,144.59],["C",335.0,143.64,338.73,141.77,339.68,141.2],["C",340.24,140.87,341.37,140.19,342.2,139.69],["C",349.09,135.56,358.0,126.35,362.28,118.95],["C",364.92,114.4,364.69,112.64,361.47,112.64],["C",360.79,112.64,358.5,112.26,353.9,111.36],["C",348.76,110.37,343.83,109.12,336.29,106.9],["C",333.82,106.17,324.96,103.1,321.69,101.82],["C",320.13,101.22,318.14,100.46,316.28,99.78],["C",315.6,99.53,314.25,99.03,313.26,98.68],["C",312.31,98.32,311.1,97.9,310.62,97.77],["C",310.14,97.62,309.39,97.34,308.94,97.14],["C",308.51,96.94,308.03,96.79,307.9,96.79],["C",307.75,96.79,306.42,96.39,304.91,95.88],["C",297.14,93.29,288.28,91.86,280.56,91.91],["C",276.45,91.93,267.35,92.76,265.71,93.27],["C",265.31,93.39,264.4,93.57,263.7,93.64],["C",263.02,93.74,261.08,94.05,259.42,94.32],["C",255.8,94.93,249.66,94.88,246.21,94.2],["C",245.03,93.97,243.39,93.69,242.59,93.59],["C",239.97,93.24,240.55,92.56,244.07,91.86],["C",249.16,90.85,258.62,87.4,262.77,85.04],["C",274.12,78.55,283.55,69.57,289.19,59.9],["C",292.21,54.72,291.85,53.36,287.1,51.6],["C",274.17,46.9,260.65,43.7,246.09,41.96],["C",237.33,40.91,223.64,40.46,215.77,40.96],["Z"]],[["M",478.24,268.66],["C",482.39,268.96,483.29,269.21,483.42,270.06],["C",483.55,270.92,482.46,276.33,480.58,284.48],["C",480.1,286.54,478.89,290.8,477.56,295.05],["C",476.7,297.79,475.8,300.36,474.87,302.72],["C",474.59,303.4,474.16,304.48,473.91,305.11],["C",473.66,305.74,472.93,307.48,472.27,309.01],["C",471.65,310.52,471.02,312.01,470.89,312.28],["C",470.76,312.56,470.39,313.47,470.01,314.3],["C",469.66,315.13,469.15,316.26,468.93,316.79],["C",468.4,317.99,461.41,331.96,460.52,333.54],["C",460.17,334.17,459.64,335.13,459.32,335.68],["C",458.99,336.23,458.61,336.91,458.46,337.19],["C",458.13,337.77,457.61,338.63,456.32,340.59],["C",455.82,341.34,455.37,342.07,455.29,342.22],["C",455.22,342.37,454.71,343.15,454.18,343.98],["C",453.63,344.81,452.37,346.73,451.39,348.26],["C",448.85,352.19,448.95,352.04,446.94,354.83],["C",444.7,357.9,444.2,358.58,438.96,365.19],["C",422.56,385.9,400.72,405.35,378.38,419.11],["C",368.84,424.97,367.31,425.85,358.93,430.03],["C",348.61,435.19,343.58,437.35,331.5,441.86],["C",318.65,446.64,307.28,449.76,291.75,452.78],["C",275.8,455.84,262.62,457.08,245.08,457.08],["C",235.17,457.08,234.19,456.8,238.49,455.19],["C",240.53,454.46,244.68,452.32,249.74,449.4],["C",267.9,438.99,287.3,419.56,305.97,393.05],["C",306.8,391.86,309.04,388.72,310.95,386.05],["C",312.86,383.41,315.91,379.13,317.74,376.59],["C",322.9,369.37,328.46,362.05,334.4,354.63],["C",344.79,341.67,362.33,323.81,374.18,314.17],["C",384.89,305.44,385.65,304.86,392.52,299.95],["C",396.87,296.83,403.11,292.61,405.47,291.17],["C",406.1,290.8,407.41,289.99,408.37,289.39],["C",410.43,288.1,414.48,285.74,414.63,285.74],["C",414.68,285.74,415.84,285.11,417.2,284.33],["C",436.75,273.18,459.59,267.32,478.24,268.66],["Z"]],[["M",297.59,326.98],["C",306.77,328.86,310.47,337.17,304.89,343.38],["C",298.9,350.05,286.17,349.02,281.79,341.47],["C",280.68,339.58,280.71,335.38,281.81,333.24],["C",284.36,328.26,291.0,325.62,297.59,326.98],["Z"]],[["M",446.33,308.56],["C",446.23,308.94,445.96,309.67,445.73,310.14],["C",445.48,310.62,445.05,311.58,444.75,312.28],["C",441.96,318.6,441.08,320.31,436.22,328.89],["C",433.73,333.29,433.3,334.0,430.16,338.7],["C",428.85,340.64,427.34,342.9,426.76,343.73],["C",426.21,344.56,425.12,346.07,424.37,347.05],["C",423.59,348.06,422.53,349.47,422.0,350.2],["C",412.14,363.78,398.08,378.73,385.22,389.32],["C",382.15,391.84,375.76,396.87,374.1,398.05],["C",373.17,398.73,371.99,399.61,371.51,399.96],["C",370.68,400.59,360.84,407.19,357.92,409.07],["C",352.04,412.85,345.62,416.44,337.29,420.57],["C",333.34,422.53,329.84,424.29,329.49,424.47],["C",328.61,424.95,321.34,427.99,318.04,429.28],["C",316.53,429.85,312.11,431.36,308.23,432.65],["C",304.36,433.91,301.01,435.04,300.81,435.19],["C",299.0,436.32,306.92,434.94,315.28,432.65],["C",317.36,432.07,319.88,431.39,320.89,431.14],["C",321.89,430.86,324.96,429.85,327.68,428.87],["C",338.02,425.17,340.24,424.22,349.87,419.41],["C",358.68,415.04,364.31,411.79,371.13,407.19],["C",378.3,402.35,382.71,399.06,386.63,395.59],["C",387.26,395.01,388.82,393.65,390.13,392.54],["C",394.63,388.72,399.54,383.94,404.12,378.98],["C",405.63,377.32,407.03,375.79,407.24,375.58],["C",408.57,374.15,414.73,366.7,416.32,364.62],["C",419.84,359.94,426.71,350.17,427.19,349.14],["C",427.29,348.94,427.87,347.96,428.47,347.0],["C",429.1,346.02,429.96,344.59,430.36,343.78],["C",430.79,342.98,431.34,342.02,431.59,341.64],["C",432.3,340.59,432.57,340.08,433.53,338.2],["C",434.03,337.22,434.71,335.98,435.04,335.43],["C",435.79,334.12,442.21,321.34,442.21,321.11],["C",442.21,321.01,442.51,320.28,442.86,319.5],["C",443.77,317.49,444.1,316.76,444.72,315.18],["C",445.03,314.42,445.5,313.31,445.76,312.71],["C",446.01,312.13,446.23,311.48,446.23,311.25],["C",446.23,311.05,446.38,310.57,446.59,310.19],["C",447.06,309.26,447.19,308.08,446.81,307.96],["C",446.64,307.88,446.44,308.16,446.33,308.56],["Z"]],[["M",402.33,331.71],["C",392.11,346.95,379.91,360.64,366.48,371.91],["C",365.17,373.02,363.28,374.63,362.28,375.48],["C",357.8,379.36,342.95,390.0,335.91,394.4],["C",333.67,395.81,328.66,398.61,325.29,400.34],["C",323.18,401.42,321.44,402.41,321.44,402.51],["C",321.44,402.86,322.85,402.78,323.76,402.38],["C",324.21,402.18,325.16,401.8,325.84,401.52],["C",332.44,398.81,336.59,396.87,340.94,394.48],["C",346.63,391.33,348.29,390.35,352.59,387.46],["C",372.06,374.4,390.71,353.95,403.84,331.25],["C",407.34,325.19,406.56,325.44,402.33,331.71],["Z"]]],"svg":"\n \n \n \n \n \n \n \n\n"};})(typeof window!=="undefined"?window:globalThis); diff --git a/crates/tui/src/tui/whale_v2/math.rs b/crates/tui/src/tui/whale_v2/math.rs new file mode 100644 index 0000000000..2a9fa20d8d --- /dev/null +++ b/crates/tui/src/tui/whale_v2/math.rs @@ -0,0 +1,98 @@ +//! The few JavaScript numeric semantics the oracle depends on, so the port +//! reproduces its arithmetic rather than approximating it. + +pub const TAU: f64 = std::f64::consts::PI * 2.; +pub const D2R: f64 = std::f64::consts::PI / 180.; + +pub fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +/// `Math.max(a, Math.min(b, v))`, NaN-propagating like JavaScript. +pub fn clamp(v: f64, a: f64, b: f64) -> f64 { + if v.is_nan() { + return v; + } + let low = if b < v { b } else { v }; + if a > low { a } else { low } +} + +pub fn smooth(a: f64, b: f64, v: f64) -> f64 { + let t = clamp((v - a) / (b - a), 0., 1.); + t * t * (3. - 2. * t) +} + +/// V8's `Math.hypot`: scale by the largest magnitude, then a compensated sum. +pub fn hypot(values: &[f64]) -> f64 { + let mut max = 0f64; + for value in values { + let abs = value.abs(); + if abs.is_nan() { + return f64::NAN; + } + if abs > max { + max = abs; + } + } + if max == f64::INFINITY { + return f64::INFINITY; + } + if max == 0. { + return 0.; + } + let mut sum = 0.; + let mut compensation = 0.; + for value in values { + let n = value.abs() / max; + let summand = n * n - compensation; + let preliminary = sum + summand; + compensation = (preliminary - sum) - summand; + sum = preliminary; + } + sum.sqrt() * max +} + +pub fn hypot2(a: f64, b: f64) -> f64 { + hypot(&[a, b]) +} + +/// `Math.round`: halves round toward positive infinity. +pub fn round(v: f64) -> f64 { + let floor = v.floor(); + if v - floor >= 0.5 { floor + 1. } else { floor } +} + +/// mulberry32, bit-exact with acting.js. +#[derive(Clone, Debug)] +pub struct Rng(u32); + +impl Rng { + pub fn new(seed: u32) -> Self { + Self(seed) + } + pub fn draw(&mut self) -> f64 { + self.0 = self.0.wrapping_add(0x6D2B_79F5); + let a = self.0; + let mut t = (a ^ (a >> 15)).wrapping_mul(1 | a); + t = (t.wrapping_add((t ^ (t >> 7)).wrapping_mul(61 | t))) ^ t; + f64::from(t ^ (t >> 14)) / 4_294_967_296. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn javascript_numeric_semantics() { + assert_eq!(round(2.5), 3.); + assert_eq!(round(-2.5), -2.); + assert_eq!(round(-2.6), -3.); + assert_eq!(hypot2(3., 4.), 5.); + assert_eq!(clamp(5., 0., 1.), 1.); + // mulberry32(11), first draws, from node. + let mut rng = Rng::new(11); + let first = rng.draw(); + assert!((0. ..1.).contains(&first)); + } +} diff --git a/crates/tui/src/tui/whale_v2/mod.rs b/crates/tui/src/tui/whale_v2/mod.rs new file mode 100644 index 0000000000..5bf24624d3 --- /dev/null +++ b/crates/tui/src/tui/whale_v2/mod.rs @@ -0,0 +1,222 @@ +//! The Whale character v2 in the terminal: the Rust port of the executable +//! reference (`acting`, `rig`, `props`, `scene`) that the Codewhale desktop +//! app paints, rasterized to Braille cells for the Watch/habitat tank. +//! +//! Replaces the particle splat that `pet_watch::graphics::Renderer::render` +//! used to turn into cells: the owner's frames still carry the old 980-point +//! world, but the cells now come from one [`Director`] fed by the same owner +//! truth the desktop whale reads (`EngineOwnerProjection`). No tool, caption +//! or argument is classified here, and there is no second clock: [`Whale`] +//! steps the Director in at most 1/30 s slices from wall time. +//! +//! Known limitations: +//! - The Kitty pixel image path (`CODEWHALE_PET_GRAPHICS`) still draws the +//! particle world; only the Braille cells are v2. +//! - The owner has no Listening or Stuck state, so the whale never performs +//! them. `Unknown` presence and a disconnected producer read as Offline +//! (asleep), never as Idle. +//! - No cove or calf-free habitat: this is the bare character, as on the +//! desktop's compact surfaces. +//! - Geometry, tables and the conformance vectors are vendored from +//! `codewhale-app/vendor/whale-character-v2`; the app copy stays the +//! reference until both consume one crate. + +mod acting; +mod data; +mod math; +mod props; +mod rig; +mod scene; + +use acting::{Activity, Context, Director, Options, Presence, Span}; +use codewhale_protocol::engine_owner::{ + EngineOwnerProjection, OwnerActivityKind, OwnerFreshness, OwnerPresence, +}; +use std::time::{Duration, Instant}; + +/// Longest catch-up after a stalled interval: resume, never replay. +const MAX_CATCH_UP: Duration = Duration::from_secs(1); +const SAME_FRAME: Duration = Duration::from_millis(4); +const STEP: f64 = 1. / 30.; + +#[derive(Clone, Debug, PartialEq)] +struct Inputs { + presence: Presence, + activity: Option, + context: Context, +} + +/// One Director for the foreground session, on one wall clock. +pub struct Whale { + session: Option, + director: Director, + inputs: Option, + clock: Option, +} + +impl Default for Whale { + fn default() -> Self { + Self { + session: None, + director: Director::new(Options::default()), + inputs: None, + clock: None, + } + } +} + +impl Whale { + /// Apply owner truth for `session`. A different session replaces the + /// Director outright: no pending clip, calf count or completed-turn + /// identity carries across. + pub fn observe( + &mut self, + session: &str, + owner: Option<&EngineOwnerProjection>, + producer_connected: bool, + reduced: bool, + ) { + if self.session.as_deref() != Some(session) { + self.session = Some(session.to_string()); + self.director = Director::new(Options { + reduced, + ..Options::default() + }); + self.inputs = None; + self.clock = None; + } + self.director.set_reduced(reduced); + let inputs = inputs(owner, producer_connected); + if self.inputs.as_ref() != Some(&inputs) { + self.director.set( + inputs.presence, + inputs.activity.clone(), + inputs.context.clone(), + ); + self.inputs = Some(inputs); + } + } + + /// Advance the clock to `now` in steps of at most 1/30 s. + pub fn advance(&mut self, now: Instant) { + let Some(last) = self.clock else { + self.clock = Some(now); + return; + }; + let elapsed = now.saturating_duration_since(last); + if elapsed < SAME_FRAME { + return; + } + self.clock = Some(now); + let mut remaining = elapsed.min(MAX_CATCH_UP).as_secs_f64(); + while remaining > 1e-9 { + let dt = remaining.min(STEP); + self.director.step(dt); + remaining -= dt; + } + } + + /// The packed Braille cells (`cols × rows`, row-major) the widget paints. + pub fn cells(&self, cols: usize, rows: usize) -> Vec { + scene::braille(&self.director, cols, rows).cells + } +} + +fn inputs(owner: Option<&EngineOwnerProjection>, producer_connected: bool) -> Inputs { + let Some(owner) = owner.filter(|_| producer_connected) else { + return Inputs { + presence: Presence::Offline, + activity: None, + context: Context::default(), + }; + }; + let presence = match owner.authoritative_presence { + OwnerPresence::Unknown => Presence::Offline, + OwnerPresence::Idle => Presence::Idle, + OwnerPresence::NeedsYou => Presence::NeedsYou, + OwnerPresence::Done => Presence::Done, + // Reasoning with no foreground tool is Thinking; anything else is work. + OwnerPresence::Working if owner.activity_kind == Some(OwnerActivityKind::Thinking) => { + Presence::Thinking + } + OwnerPresence::Working => Presence::Working, + }; + Inputs { + presence, + activity: Some(Activity { + kind: owner.activity_kind.map(|kind| kind.as_str().to_string()), + observed: owner.observed, + parallel: Some(f64::from(owner.parallel_agent_count)), + active: owner + .active_spans + .iter() + .map(|span| Span { + kind: span.activity_kind.as_str().to_string(), + since_ms: span.started_at_ms, + }) + .collect(), + }), + context: Context { + live: owner.freshness == OwnerFreshness::Fresh, + turn_id: owner.turn_id.clone(), + status: owner.turn_outcome.map(|status| status.as_str().to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn owner(presence: &str, kind: Option<&str>) -> EngineOwnerProjection { + serde_json::from_value(json!({ + "schemaVersion": 1, "sessionId": "s", "cursor": 1, "observed": true, + "freshness": "fresh", "authoritativePresence": presence, + "activityKind": kind, "observedAtMs": 1000.0, "parallelAgentCount": 0, + "activeSpans": [], "turnId": null, "turnOutcome": null, + "doneEffectId": null, "failedToolAge": null, + })) + .expect("owner projection") + } + + fn frame(owner: Option<&EngineOwnerProjection>, connected: bool) -> Vec { + let mut whale = Whale::default(); + whale.observe("s", owner, connected, false); + let start = Instant::now(); + whale.advance(start); + // Two seconds of owner time, in the 33 ms cadence the view thread uses. + for i in 1..=60 { + whale.advance(start + Duration::from_millis(33 * i)); + } + whale.cells(32, 16) + } + + #[test] + fn owner_truth_changes_the_braille_whale() { + let resting = frame(Some(&owner("idle", None)), true); + let reading = frame(Some(&owner("working", Some("reading"))), true); + let offline = frame(Some(&owner("working", Some("reading"))), false); + assert!(resting.iter().any(|b| *b != 0), "the whale is drawn"); + assert_ne!(resting, reading, "work reads differently from rest"); + assert_eq!( + offline, + frame(None, false), + "a disconnected producer is Offline" + ); + assert_ne!(offline, reading); + for line in 0..16 { + let row: String = reading[line * 32..(line + 1) * 32] + .iter() + .map(|b| { + if *b == 0 { + ' ' + } else { + char::from_u32(0x2800 + u32::from(*b)).unwrap() + } + }) + .collect(); + eprintln!("{row}"); + } + } +} diff --git a/crates/tui/src/tui/whale_v2/props.rs b/crates/tui/src/tui/whale_v2/props.rs new file mode 100644 index 0000000000..73ea7a094c --- /dev/null +++ b/crates/tui/src/tui/whale_v2/props.rs @@ -0,0 +1,502 @@ +//! Port of `props.js`: flat physical props and particles, placed at rig +//! anchors so a held prop follows the whale under any transform. + +use super::data::{Pose, p}; +use super::math::{D2R, clamp}; +use super::rig::{Cmd, Parts, Path, Point, Role, Shape, ellipse, parse}; +use std::sync::OnceLock; + +fn poly(pts: &[[f64; 2]]) -> Path { + let mut a = vec![Cmd::M(pts[0])]; + for i in 1..=pts.len() { + let p = pts[(i - 1) % pts.len()]; + let q = pts[i % pts.len()]; + a.push(Cmd::C([p[0], p[1], q[0], q[1], q[0], q[1]])); + } + a.push(Cmd::Z); + a +} + +pub fn transform(path: &Path, x: f64, y: f64, a: f64, s: f64) -> Path { + let (c, sn) = (a.cos(), a.sin()); + path.iter() + .map(|cmd| { + let src = cmd.coords(); + let mut out = [0.; 6]; + let mut i = 0; + while i < src.len() { + out[i] = x + (src[i] * c - src[i + 1] * sn) * s; + out[i + 1] = y + (src[i] * sn + src[i + 1] * c) * s; + i += 2; + } + match cmd { + Cmd::M(_) => Cmd::M([out[0], out[1]]), + Cmd::C(_) => Cmd::C(out), + Cmd::Z => Cmd::Z, + } + }) + .collect() +} + +struct Library { + page: Path, + fold: Path, + rules: [Path; 2], + pad: Path, + binding: Path, + pad_lines: [Path; 4], + pencil: Path, + pencil_tip: Path, + lens: Path, + lens_handle: Path, + wrench: Path, + spyglass: Path, + spyglass_end: Path, + cursor: Path, + links: [Path; 2], + jet: Path, + drop: Path, + water_back: Path, + water_front: Path, + splashes: [Path; 2], + particle_drop: Path, + cloud: Path, + bubble: Path, +} + +fn library() -> &'static Library { + static LIBRARY: OnceLock = OnceLock::new(); + LIBRARY.get_or_init(|| { + let splash = parse("M0 0 C-2 -8 -8 -12 -11 -15 C-5 -15 2 -8 3 0 C2 2 1 2 0 0 Z"); + let mirror = |side: f64| -> Path { + splash + .iter() + .map(|c| c.map(|i, v| if i % 2 == 1 { v } else { -side * v })) + .collect() + }; + let link = |side: f64| -> Path { + let mut path = ellipse(side * 2.3, side * 5.5, 4.5, 8.); + path.extend(ellipse(side * 2.3, side * 5.5, 2.1, 5.3)); + path + }; + let mut lens = ellipse(0., 0., 9., 9.); + lens.extend(ellipse(0., 0., 5.8, 5.8)); + Library { + page: parse("M-9 -13 C-5 -14 0 -14 4 -13 C6 -11 7 -9 9 -8 C9 -1 9 6 8 13 C3 12 -3 13 -9 14 C-8 5 -8 -4 -9 -13 Z"), + fold: poly(&[[4., -13.], [4., -8.], [9., -8.]]), + rules: [0., 1.].map(|i| { + poly(&[ + [-5., -4. + i * 6.], + [5., -4. + i * 6.], + [5., -2. + i * 6.], + [-5., -2. + i * 6.], + ]) + }), + pad: poly(&[[-11., -5.], [11., -5.], [11., 5.], [-11., 5.]]), + binding: poly(&[[-8.5, -3.], [8.5, -3.], [8.5, -1.5], [-8.5, -1.5]]), + pad_lines: [0., 1., 2., 3.].map(|i| { + poly(&[ + [-8. + i * 4., 0.], + [-6. + i * 4., 0.], + [-6. + i * 4., 1.2], + [-8. + i * 4., 1.2], + ]) + }), + pencil: poly(&[[-2., -5.], [2., -5.], [2., 6.], [0., 10.], [-2., 6.]]), + pencil_tip: poly(&[[-1.3, 6.5], [1.3, 6.5], [0., 10.]]), + lens, + lens_handle: poly(&[[-1.7, 7.], [1.7, 7.], [1.7, 17.], [-1.7, 17.]]), + wrench: poly(&[ + [-2., 9.], + [-2., -8.], + [-6., -11.], + [-6., -18.], + [-3., -21.], + [-3., -14.], + [3., -14.], + [3., -21.], + [6., -18.], + [6., -11.], + [2., -8.], + [2., 9.], + [5., 12.], + [5., 17.], + [2., 20.], + [2., 14.], + [-2., 14.], + [-2., 20.], + [-5., 17.], + [-5., 12.], + ]), + spyglass: poly(&[ + [0., -2.5], + [6., -2.5], + [6., -3.5], + [13., -3.5], + [13., -4.5], + [19., -4.5], + [19., 4.5], + [13., 4.5], + [13., 3.5], + [6., 3.5], + [6., 2.5], + [0., 2.5], + ]), + spyglass_end: poly(&[[17., -4.5], [20., -4.5], [20., 4.5], [17., 4.5]]), + cursor: poly(&[[-8., -12.], [10., 1.6], [1.6, 2.8], [-2.2, 10.4]]), + links: [link(-1.), link(1.)], + jet: parse("M-2 0 C-3 -12 -9 -28 -17 -29 C-22 -29 -23 -24 -19 -21 C-25 -21 -27 -28 -23 -32 C-16 -39 -5 -32 0 -16 C5 -33 16 -38 23 -31 C27 -26 23 -20 19 -21 C23 -25 20 -29 16 -28 C8 -26 4 -12 2 0 C1 1 -1 1 -2 0 Z"), + drop: parse("M0 -3 C3 0 3 3 0 3 C-3 3 -3 0 0 -3 Z"), + water_back: parse("M-43 0 C-28 -7 -12 -4 0 -1 C14 2 28 -7 43 -2 C29 -1 15 7 0 3 C-15 -1 -28 -3 -43 0 Z"), + water_front: parse("M-29 0 C-11 -2 8 4 28 0 C13 8 -11 4 -29 0 Z"), + splashes: [mirror(-1.), mirror(1.)], + particle_drop: parse("M0 -2 C1 -1 2 1 0 2 C-2 1 -1 -1 0 -2 Z"), + cloud: parse("M-5 1 C-8 0 -7 -4 -4 -4 C-3 -8 2 -8 4 -5 C8 -5 9 0 5 2 C4 6 -3 6 -5 1 Z"), + bubble: ellipse(0., 0., 1., 1.), + } + }) +} + +fn visible(v: f64) -> f64 { + clamp(if v.is_nan() { 0. } else { v }, 0., 1.) +} + +/// `shapes()`: every prop, in the reference's paint order. +pub fn shapes(q: &Pose, parts: &Parts, small_view: bool) -> Vec { + let lib = library(); + let boost = if small_view { 1.15 } else { 1. } * parts.unit_scale; + let mut out = Vec::with_capacity(32); + let mut add = |id: String, path: &Path, role: Role, x: f64, y: f64, a: f64, s: f64, v: f64| { + out.push(Shape { + id, + path: transform(path, x, y, a, s), + role, + opacity: visible(v), + }); + }; + let hand = parts.anchors.fin_tip; + let angle = parts.anchors.fin_ang; + let offset = |anchor: Point, x: f64, y: f64, a: f64| Point { + x: anchor.x + (x * a.cos() - y * a.sin()) * boost, + y: anchor.y + (x * a.sin() + y * a.cos()) * boost, + }; + let pc = offset(hand, 22. + q[p::pageDX], -31. + q[p::pageDY], angle); + let pa = angle - 0.08 + q[p::pageRot] * D2R; + add( + "prop-page".into(), + &lib.page, + Role::Tool, + pc.x, + pc.y, + pa, + boost, + q[p::page], + ); + add( + "prop-page-fold".into(), + &lib.fold, + Role::Cutout, + pc.x, + pc.y, + pa, + boost, + q[p::page], + ); + for (i, rule) in lib.rules.iter().enumerate() { + add( + format!("prop-page-rule-{i}"), + rule, + Role::Cutout, + pc.x, + pc.y, + pa, + boost, + q[p::page], + ); + } + add( + "prop-page-turn".into(), + &lib.page, + Role::Tool, + pc.x + 2. * boost, + pc.y, + pa, + boost * (q[p::pageFlip] * std::f64::consts::PI / 2.).sin() * 0.75, + q[p::page] * q[p::pageFlip], + ); + let pad = offset(hand, 21., -16., angle); + add( + "prop-pad".into(), + &lib.pad, + Role::Tool, + pad.x, + pad.y, + angle - 0.05, + boost, + q[p::pad], + ); + add( + "prop-pad-binding".into(), + &lib.binding, + Role::Cutout, + pad.x, + pad.y, + angle - 0.05, + boost, + q[p::pad], + ); + for (i, line) in lib.pad_lines.iter().enumerate() { + add( + format!("prop-pad-line-{i}"), + line, + Role::Cutout, + pad.x, + pad.y, + angle - 0.05, + boost, + q[p::pad] * clamp(q[p::padLines] - i as f64, 0., 1.), + ); + } + let pen = offset( + hand, + 16.4 + q[p::scribX] * 0.35, + -23.3 + q[p::scribY] * 0.35, + angle, + ); + add( + "prop-pencil".into(), + &lib.pencil, + Role::Accent, + pen.x, + pen.y, + angle - 0.48, + boost, + q[p::pencil], + ); + add( + "prop-pencil-tip".into(), + &lib.pencil_tip, + Role::Tool, + pen.x, + pen.y, + angle - 0.48, + boost, + q[p::pencil], + ); + let la = angle + 0.55; + let lc = offset(hand, 3., -42., la); + add( + "prop-lens".into(), + &lib.lens, + Role::Tool, + lc.x, + lc.y, + la, + boost, + q[p::lens], + ); + add( + "prop-lens-handle".into(), + &lib.lens_handle, + Role::Tool, + lc.x, + lc.y, + la, + boost, + q[p::lens], + ); + let wc = offset(parts.anchors.tail, 40., -5. + q[p::wrenchY], angle); + add( + "prop-wrench".into(), + &lib.wrench, + Role::Tool, + wc.x, + wc.y, + parts.anchors.tail_ang + 1.1 + q[p::wrenchSpin] * D2R, + if small_view { 0.78 } else { 0.7 } * parts.unit_scale, + q[p::wrench], + ); + let ga = angle - 0.22; + let gs = boost * (0.95 + 0.3 * clamp(q[p::glassExt], 0., 1.)); + let gc = offset(hand, 12., -33., ga); + add( + "prop-spyglass".into(), + &lib.spyglass, + Role::Accent, + gc.x, + gc.y, + ga, + gs, + q[p::glass], + ); + add( + "prop-spyglass-end".into(), + &lib.spyglass_end, + Role::Tool, + gc.x, + gc.y, + ga, + gs, + q[p::glass], + ); + let cursor = offset( + parts.anchors.spout, + 11. + q[p::cursorX], + -31. + q[p::cursorY], + angle, + ); + add( + "prop-cursor".into(), + &lib.cursor, + Role::Pointer, + cursor.x, + cursor.y, + angle, + boost, + q[p::cursor], + ); + let link = offset(parts.anchors.spout, 16., -34., angle); + let link_angle = angle + (0.55 + q[p::linkTilt] * D2R); + for (index, side) in [-1, 1].iter().enumerate() { + add( + format!("prop-link-{side}"), + &lib.links[index], + Role::Pointer, + link.x, + link.y, + link_angle, + boost, + q[p::link], + ); + } + let spout = clamp(q[p::spout], 0., 1.); + let surface = clamp(q[p::splash], 0., 1.); + let origin = parts.anchors.spout; + let unit = parts.unit_scale; + add( + "prop-spout".into(), + &lib.jet, + Role::Water, + origin.x, + origin.y, + 0., + (0.35 + 0.65 * spout) * unit, + spout, + ); + for side in [-1., 1.] { + add( + format!("prop-spout-drop-{}", side as i32), + &lib.drop, + Role::Water, + origin.x + side * 25. * unit, + origin.y - 35. * unit, + -side * 0.35, + unit * spout, + spout, + ); + } + add( + "prop-water-back".into(), + &lib.water_back, + Role::Water, + 0., + 43., + 0., + 0.8 + 0.2 * surface, + surface, + ); + add( + "prop-water-front".into(), + &lib.water_front, + Role::Water, + 0., + 50., + 0., + 0.8 + 0.2 * surface, + surface, + ); + for (index, side) in [-1., 1.].iter().enumerate() { + add( + format!("prop-water-splash-{}", *side as i32), + &lib.splashes[index], + Role::Water, + side * 35., + 39., + 0., + 0.5 + 0.5 * surface, + surface, + ); + } + out +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParticleKind { + Drop, + Mist, + Thought, + Bubble, +} + +#[derive(Clone, Debug)] +pub struct Particle { + pub kind: ParticleKind, + pub x: f64, + pub y: f64, + pub vx: f64, + pub vy: f64, + pub g: f64, + pub drag: f64, + pub r: f64, + pub age: f64, + pub life: f64, + pub cloud: bool, + /// The acting state that emitted it; a particle never outlives it. + pub owner: super::data::Act, +} + +/// `particles()`: drops and bubbles as filled water shapes. +pub fn particles(list: &[Particle], small_view: bool) -> Vec { + let lib = library(); + let mut out = Vec::new(); + for (i, particle) in list.iter().enumerate() { + if particle.age < 0. + || particle.kind == ParticleKind::Mist + || (small_view && particle.kind == ParticleKind::Bubble) + { + continue; + } + let fade = clamp( + (particle.life - particle.age) / (particle.life * 0.2), + 0., + 1., + ); + let r = particle.r * if small_view { 1.65 } else { 1. }; + let (mut x, mut y) = (particle.x, particle.y); + if small_view && particle.kind == ParticleKind::Thought { + x += 4.; + y -= 6.; + } + if particle.kind == ParticleKind::Drop { + out.push(Shape { + id: format!("spout-{i}"), + path: transform(&lib.particle_drop, x, y, 0., r), + role: Role::Water, + opacity: fade, + }); + } else { + let (path, scale) = if particle.cloud { + (&lib.cloud, r / 6.) + } else { + (&lib.bubble, r) + }; + out.push(Shape { + id: format!("bubble-{i}"), + path: transform(path, x, y, 0., scale), + role: Role::Water, + opacity: fade, + }); + } + } + out +} diff --git a/crates/tui/src/tui/whale_v2/rig.rs b/crates/tui/src/tui/whale_v2/rig.rs new file mode 100644 index 0000000000..a4f67c7173 --- /dev/null +++ b/crates/tui/src/tui/whale_v2/rig.rs @@ -0,0 +1,986 @@ +//! Port of `rig.js`: the mark is the rig. Profile contours are partitioned +//! from the official mark's cubic anchors; every pose keeps each named +//! part's command topology. Only move, cubic and close commands exist. + +use super::data::{Pose, p, tables}; +use super::math::{D2R, clamp, hypot, hypot2, lerp, round, smooth}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Cmd { + M([f64; 2]), + C([f64; 6]), + Z, +} + +impl Cmd { + pub fn coords(&self) -> &[f64] { + match self { + Cmd::M(c) => c, + Cmd::C(c) => c, + Cmd::Z => &[], + } + } + pub fn coords_mut(&mut self) -> &mut [f64] { + match self { + Cmd::M(c) => c, + Cmd::C(c) => c, + Cmd::Z => &mut [], + } + } + /// `c.slice(-2)`: the command's end point. + pub fn end(&self) -> [f64; 2] { + let c = self.coords(); + [c[c.len() - 2], c[c.len() - 1]] + } + /// The same command with every coordinate mapped; `i` is the index + /// within the command's coordinates, so `i % 2` picks x or y. + pub fn map(&self, mut f: impl FnMut(usize, f64) -> f64) -> Cmd { + let mut out = *self; + for (i, v) in out.coords_mut().iter_mut().enumerate() { + *v = f(i, *v); + } + out + } + /// Rebuild a command of this kind from coordinates. + fn with(&self, coords: &[f64]) -> Cmd { + match self { + Cmd::M(_) => Cmd::M([coords[0], coords[1]]), + Cmd::C(_) => Cmd::C([ + coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], + ]), + Cmd::Z => Cmd::Z, + } + } +} + +pub type Path = Vec; + +/// `parse()`: `M`, `C` and `Z` with plain decimal numbers. +pub fn parse(d: &str) -> Path { + let bytes = d.as_bytes(); + let mut tokens: Vec> = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if matches!(b, b'M' | b'C' | b'Z') { + tokens.push(Ok(b)); + i += 1; + } else if b == b'-' || b == b'.' || b.is_ascii_digit() { + let start = i; + i += 1; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + i += 1; + } + tokens.push(Err(d[start..i].parse().expect("number"))); + } else { + i += 1; + } + } + let mut path = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let op = tokens[i].expect("command"); + i += 1; + let n = match op { + b'M' => 2, + b'C' => 6, + _ => 0, + }; + let nums: Vec = tokens[i..i + n].iter().map(|t| t.unwrap_err()).collect(); + i += n; + path.push(match op { + b'M' => Cmd::M([nums[0], nums[1]]), + b'C' => Cmd::C([nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]]), + _ => Cmd::Z, + }); + } + path +} + +pub fn ellipse(x: f64, y: f64, rx: f64, ry: f64) -> Path { + let k = 0.55228475; + vec![ + Cmd::M([x + rx, y]), + Cmd::C([x + rx, y + ry * k, x + rx * k, y + ry, x, y + ry]), + Cmd::C([x - rx * k, y + ry, x - rx, y + ry * k, x - rx, y]), + Cmd::C([x - rx, y - ry * k, x - rx * k, y - ry, x, y - ry]), + Cmd::C([x + rx * k, y - ry, x + rx, y - ry * k, x + rx, y]), + Cmd::Z, + ] +} + +fn bezier(a: [f64; 2], c: &[f64; 6], t: f64) -> [f64; 2] { + let q = 1. - t; + [ + q * q * q * a[0] + 3. * q * q * t * c[0] + 3. * q * t * t * c[2] + t * t * t * c[4], + q * q * q * a[1] + 3. * q * q * t * c[1] + 3. * q * t * t * c[3] + t * t * t * c[5], + ] +} + +fn points(path: &Path) -> Vec<[f64; 2]> { + let mut out = Vec::new(); + let mut last = [0., 0.]; + for c in path { + match c { + Cmd::M(m) => { + last = *m; + out.push(last); + } + Cmd::C(c) => { + for j in 1..=10 { + out.push(bezier(last, c, j as f64 / 10.)); + } + last = [c[4], c[5]]; + } + Cmd::Z => {} + } + } + out +} + +pub fn signed_area(path: &Path) -> f64 { + let p = points(path); + let mut sum = 0.; + for (i, a) in p.iter().enumerate() { + let b = p[(i + 1) % p.len()]; + sum += a[0] * b[1] - b[0] * a[1]; + } + sum / 2. +} + +struct Sample { + l: f64, + p: [f64; 2], + d: [f64; 2], +} +struct Knot { + l0: f64, + l1: f64, + h0: f64, + h1: f64, +} +struct Measure { + length: f64, + knots: Vec, + samples: Vec, +} + +fn or_one(v: f64) -> f64 { + if v == 0. || v.is_nan() { 1. } else { v } +} + +fn measure(path: &Path) -> Measure { + let (mut last, mut length) = ([0., 0.], 0.); + let (mut knots, mut samples) = (Vec::new(), Vec::new()); + for c in path { + match c { + Cmd::M(m) => { + last = *m; + samples.push(Sample { + l: 0., + p: last, + d: [1., 0.], + }); + } + Cmd::C(c) => { + let a = last; + let l0 = length; + let mut prev = a; + for j in 1..=30 { + let t = j as f64 / 30.; + let q = 1. - t; + let p = bezier(a, c, t); + let d = [ + 3. * q * q * (c[0] - a[0]) + + 6. * q * t * (c[2] - c[0]) + + 3. * t * t * (c[4] - c[2]), + 3. * q * q * (c[1] - a[1]) + + 6. * q * t * (c[3] - c[1]) + + 3. * t * t * (c[5] - c[3]), + ]; + length += hypot2(p[0] - prev[0], p[1] - prev[1]); + samples.push(Sample { l: length, p, d }); + prev = p; + } + let seg = length - l0; + knots.push(Knot { + l0, + l1: length, + h0: hypot2(c[0] - a[0], c[1] - a[1]) / or_one(seg), + h1: hypot2(c[2] - c[4], c[3] - c[5]) / or_one(seg), + }); + last = [c[4], c[5]]; + } + Cmd::Z => {} + } + } + samples[0].d = samples[1].d; + Measure { + length, + knots, + samples, + } +} + +/// `match()`: resample `target` onto `source`'s command topology. +fn fit(source: &Path, target: &Path) -> Path { + let src = measure(source); + let dst = measure(target); + let at = |u: f64| { + let l = clamp(u, 0., 1.) * dst.length; + let mut i = 1; + while i < dst.samples.len() - 1 && dst.samples[i].l < l { + i += 1; + } + let (a, b) = (&dst.samples[i - 1], &dst.samples[i]); + let t = (l - a.l) / or_one(b.l - a.l); + let d = [lerp(a.d[0], b.d[0], t), lerp(a.d[1], b.d[1], t)]; + let n = or_one(hypot(&d)); + ( + [lerp(a.p[0], b.p[0], t), lerp(a.p[1], b.p[1], t)], + [d[0] / n, d[1] / n], + ) + }; + let mut out = vec![Cmd::M(at(0.).0)]; + for k in &src.knots { + let (ap, ad) = at(k.l0 / src.length); + let (bp, bd) = at(k.l1 / src.length); + let span = (k.l1 - k.l0) / src.length * dst.length; + let h0 = span * k.h0.min(0.6); + let h1 = span * k.h1.min(0.6); + out.push(Cmd::C([ + ap[0] + ad[0] * h0, + ap[1] + ad[1] * h0, + bp[0] - bd[0] * h1, + bp[1] - bd[1] * h1, + bp[0], + bp[1], + ])); + } + out.push(Cmd::Z); + out +} + +fn orient_like(source: &Path, path: &Path) -> Path { + if signed_area(source) * signed_area(path) >= 0. { + return path.clone(); + } + let start = path[0].end(); + let mut at = start; + let mut segments: Vec<([f64; 2], [f64; 6])> = Vec::new(); + for c in path { + if let Cmd::C(c) = c { + segments.push((at, *c)); + at = [c[4], c[5]]; + } + } + if hypot2(at[0] - start[0], at[1] - start[1]) > 0.01 { + segments.push((at, [at[0], at[1], start[0], start[1], start[0], start[1]])); + } + let mut out = vec![Cmd::M(start)]; + for (at, c) in segments.iter().rev() { + out.push(Cmd::C([c[2], c[3], c[0], c[1], at[0], at[1]])); + } + out.push(Cmd::Z); + out +} + +/// Match anatomical landmarks section by section. +fn pieces(source: &Path, stops: &[usize], targets: &[Path]) -> Path { + let mut begin = 0; + let mut result: Path = Vec::new(); + for (i, &last) in stops.iter().enumerate() { + let section: Path = if begin == 0 { + source[..=last].to_vec() + } else { + let mut section = vec![Cmd::M(source[begin].end())]; + section.extend_from_slice(&source[begin + 1..=last]); + section + }; + let fitted = fit(§ion, &targets[i]); + let from = if i > 0 { 1 } else { 0 }; + result.extend_from_slice(&fitted[from..fitted.len() - 1]); + begin = last; + } + result.push(Cmd::Z); + result +} + +pub const PART_IDS: [&str; 10] = [ + "body", "pouch", "pleat-1", "pleat-2", "jaw-band", "eye", "eyelid", "flipper", "flukes", + "eye-far", +]; + +pub struct Rig { + pub src: HashMap<&'static str, Path>, + pub front: HashMap<&'static str, Path>, + small_grid: HashMap<&'static str, Path>, + small: Mutex>>>, +} + +const MARK: &str = include_str!("data/mark-data.js"); + +fn mark_contours() -> Vec { + let start = MARK.find("root.WhaleMark=").expect("mark data") + "root.WhaleMark=".len(); + let end = MARK.rfind(";})").expect("mark data end"); + let value: serde_json::Value = serde_json::from_str(&MARK[start..end]).expect("mark json"); + value["contours"] + .as_array() + .unwrap() + .iter() + .map(|contour| { + contour + .as_array() + .unwrap() + .iter() + .map(|c| { + let nums: Vec = c.as_array().unwrap()[1..] + .iter() + .map(|n| n.as_f64().unwrap()) + .collect(); + match c[0].as_str().unwrap() { + "M" => Cmd::M([nums[0], nums[1]]), + "C" => Cmd::C([nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]]), + _ => Cmd::Z, + } + }) + .collect() + }) + .collect() +} + +fn index_at(path: &Path, x: f64, y: f64) -> usize { + path.iter() + .position(|c| !matches!(c, Cmd::Z) && c.end() == [x, y]) + .expect("landmark") +} + +pub fn rig() -> &'static Rig { + static RIG: OnceLock = OnceLock::new(); + RIG.get_or_init(|| { + let contours = mark_contours(); + let (outer, mut pouch, eye, pleat1, pleat2) = ( + contours[0].clone(), + contours[1].clone(), + contours[2].clone(), + contours[3].clone(), + contours[4].clone(), + ); + let mut body = outer[..125].to_vec(); + body.push(Cmd::C([215., 92., 218., 59., 215.77, 40.96])); + body.push(Cmd::Z); + let mut flipper = vec![Cmd::M([228.68, 388.82])]; + flipper.extend_from_slice(&outer[48..54]); + flipper.push(Cmd::C([273., 445., 250., 405., 228.68, 388.82])); + flipper.push(Cmd::Z); + let mut flukes = vec![Cmd::M([203.32, 110.65])]; + flukes.extend_from_slice(&outer[125..outer.len() - 1]); + flukes.push(Cmd::Z); + let ji = index_at(&outer, 141.12, 447.47); + let je = index_at(&outer, 496., 236.55); + let pe = index_at(&pouch, 238.49, 455.19); + let mut jaw = vec![Cmd::M([141.12, 447.47])]; + jaw.extend_from_slice(&outer[ji + 1..=je]); + jaw.push(Cmd::C([497., 249., 486., 265., 483.42, 270.06])); + jaw.extend_from_slice(&pouch[3..=pe]); + jaw.push(Cmd::C([209., 463., 169., 458., 141.12, 447.47])); + jaw.push(Cmd::Z); + let front_body = parse("M244 70 C147 65 78 114 76 228 C75 279 76 303 94 342 C112 370 127 380 151 378 C172 450 250 475 286 465 C384 453 436 399 442 341 C442 320 440 310 431 310 C353 269 270 274 215 288 C176 300 138 314 121 294 C101 269 112 222 127 192 C153 145 203 127 244 128 C244 108 244 87 244 70 Z"); + let front_pouch = parse("M416 353 C405 398 362 431 288 443 C234 448 184 410 155 352 C204 368 241 374 281 374 C328 374 375 365 416 353 Z"); + let front_flukes = parse("M244 128 C270 130 274 154 302 151 C324 149 343 126 346 108 C321 107 314 94 302 100 C279 95 263 97 250 96 C265 86 282 67 285 52 C267 49 249 64 244 70 C244 96 244 116 244 128 Z"); + let front_fin = parse("M165 360 C147 376 139 397 124 404 C147 408 163 402 180 390 C183 378 174 367 165 360 Z"); + let front_jaw = parse("M155 352 C184 410 234 448 288 443 C362 431 405 398 416 353 C424 354 435 350 442 341 C436 399 384 453 286 465 C230 470 180 435 151 378 C150 368 152 359 155 352 Z"); + let front_pleat1 = parse("M330 384 C329 405 315 424 294 434 C314 430 336 405 330 384 Z"); + let front_pleat2 = parse("M247 386 C247 404 261 423 277 433 C257 426 242 403 247 386 Z"); + // SRC in the JavaScript key order; pouch is the mark contour itself. + let mut src_list: Vec<(&'static str, Path)> = Vec::new(); + src_list.push(("body", body)); + // Jaw was built from the pouch before the seam pass mutates it. + let jaw_band = jaw; + pouch = pouch.clone(); + src_list.push(("pouch", pouch)); + src_list.push(("pleat-1", pleat1)); + src_list.push(("pleat-2", pleat2)); + src_list.push(("jaw-band", jaw_band)); + src_list.push(("eye", eye.clone())); + src_list.push(("eyelid", ellipse(294., 337., 18., 15.))); + src_list.push(("flipper", flipper)); + src_list.push(("flukes", flukes)); + src_list.push(("eye-far", eye)); + let target: HashMap<&str, Path> = HashMap::from([ + ("body", front_body), + ("pouch", front_pouch), + ("pleat-1", front_pleat1), + ("pleat-2", front_pleat2), + ("jaw-band", front_jaw), + ("eye", ellipse(200., 330., 13., 8.)), + ("eyelid", ellipse(200., 330., 18., 15.)), + ("flipper", front_fin), + ("flukes", front_flukes.clone()), + ("eye-far", ellipse(365., 330., 13., 8.)), + ]); + // Close any open seam with a straight cubic. + for (_, path) in src_list.iter_mut() { + let first = path[0].end(); + let last = path[path.len() - 2].end(); + if hypot2(first[0] - last[0], first[1] - last[1]) > 0.01 { + let at = path.len() - 1; + path.insert( + at, + Cmd::C([ + lerp(last[0], first[0], 1. / 3.), + lerp(last[1], first[1], 1. / 3.), + lerp(last[0], first[0], 2. / 3.), + lerp(last[1], first[1], 2. / 3.), + first[0], + first[1], + ]), + ); + } + } + let mut src: HashMap<&'static str, Path> = src_list.into_iter().collect(); + let mut front: HashMap<&'static str, Path> = HashMap::new(); + for id in PART_IDS { + front.insert(id, fit(&src[id], &orient_like(&src[id], &target[id]))); + } + front.insert( + "body", + pieces( + &src["body"], + &[47, 53, 80, 124, 125], + &[ + parse("M244 70 C147 65 78 114 76 228 C75 279 98 325 128 335 C142 347 144 363 151 378"), + parse("M151 378 C164 410 187 434 210 449 C231 462 261 468 286 465"), + parse("M286 465 C384 453 436 399 442 341 C442 320 440 310 431 310"), + parse("M431 310 C353 269 270 274 215 288 C176 300 138 314 121 294 C101 269 112 222 127 192 C153 145 203 127 244 128"), + parse("M244 128 C244 108 244 87 244 70"), + ], + ), + ); + // Keep the tapered throat and parallel pleats in the attentive turn. + for id in ["pouch", "pleat-1", "pleat-2"] { + let scaled = src[id] + .iter() + .map(|c| { + c.map(|i, v| { + if i % 2 == 1 { + 355. + (v - 355.) * 0.65 + } else { + 300. + (v - 300.) * 0.9 + } + }) + }) + .collect(); + front.insert(id, scaled); + } + front.insert( + "flipper", + pieces( + &src["flipper"], + &[4, 7], + &[ + parse("M165 360 C147 376 139 397 124 404"), + parse("M124 404 C147 408 163 402 180 390 C183 378 174 367 165 360"), + ], + ), + ); + let flukes_src = &src["flukes"]; + let mut open_src = flukes_src[..flukes_src.len() - 2].to_vec(); + open_src.push(Cmd::Z); + let mut open_target = front_flukes[..front_flukes.len() - 2].to_vec(); + open_target.push(Cmd::Z); + let tail_open = fit(&open_src, &open_target); + let mut front_flukes_fit = tail_open[..tail_open.len() - 1].to_vec(); + front_flukes_fit.extend_from_slice(&front_flukes[front_flukes.len() - 2..]); + front.insert("flukes", front_flukes_fit); + // Overlap only the internal closing seam. + for path in [src.get_mut("flukes").unwrap(), front.get_mut("flukes").unwrap()] { + let at = path.len() - 2; + // `path.at(-2)[1]` and `[3]` in `['C', x1, y1, x2, y2, x, y]`: + // the two control-point x coordinates. + let c = path[at].coords_mut(); + c[0] -= 2.; + c[2] -= 2.; + } + let small_grid = HashMap::from([ + ("body", parse("M10.5 3.5 C6 3.5 3 7 3 12 C3 16.5 5 18.5 8 18.5 C9.5 18.5 10.5 18 11 17 C10.5 18.5 9 19.5 8 19.5 C9 20.5 11 20.5 12.5 20 C17 20 21 16 21.5 11.5 C22 8.5 15 10.5 12 12 C6.5 15 5.5 13 6 10 C6 8 8 6.5 10 6.5 C10.5 5.5 10.5 4.5 10.5 3.5 Z")), + ("flukes", parse("M10 6.5 C11.5 6.5 11.5 8 13.5 8 C15 8 16.5 7 17 6.5 C14 6 13 5 12 6 C13 5.5 14 4.5 14.5 4 C13 3.5 12 3.5 10.5 3.5 C10.5 4.5 10.5 5.5 10 6.5 Z")), + ("pouch", parse("M20.5 12.5 C20 17 16 20 11.5 19.5 C15 18.5 15.5 13 20.5 12.5 Z")), + ("flipper", parse("M11 17 C10.5 18.5 9 19.5 8 19.5 C9 20.5 11 20.5 12.5 20 C13 19.5 12 18 11 17 Z")), + ("jaw-band", parse("M11 20 C17 20 21 16 22 12 C21 17 17 21 11 21 C10 21 10 20 11 20 Z")), + ("eye", ellipse(14., 15., 0.65, 0.46)), + ("eye-far", ellipse(14., 15., 0.65, 0.46)), + ("eyelid", ellipse(14., 15., 1., 0.7)), + ]); + Rig { + src, + front, + small_grid, + small: Mutex::new(HashMap::new()), + } + }) +} + +impl Rig { + /// Optical-fit sources for a 16/24/32-cell icon; authored on a 24-cell + /// grid, not downsampled from the hero. + fn small_source(&self, size: f64) -> Arc> { + let mut cache = self.small.lock().unwrap(); + if let Some(found) = cache.get(&size.to_bits()) { + return found.clone(); + } + let mut out = HashMap::new(); + for id in PART_IDS { + let Some(grid) = self.small_grid.get(id) else { + out.insert(id, self.src[id].clone()); + continue; + }; + let fitted: Path = grid + .iter() + .map(|c| { + c.map(|_, v| { + let cell = round(v / 24. * size * 2.) / 2.; + (cell / size - 0.5) * 634.88 + 256. + }) + }) + .collect(); + out.insert(id, fit(&self.src[id], &orient_like(&self.src[id], &fitted))); + } + let out = Arc::new(out); + cache.insert(size.to_bits(), out.clone()); + out + } +} + +fn rotate(x: f64, y: f64, cx: f64, cy: f64, a: f64) -> [f64; 2] { + let (c, s) = (a.cos(), a.sin()); + [ + cx + (x - cx) * c - (y - cy) * s, + cy + (x - cx) * s + (y - cy) * c, + ] +} + +/// Morph a blade in its shoulder-to-tip frame. +fn morph_blade(a: &Path, b: &Path, t: f64) -> Path { + let aa = a[0].end(); + let bb = b[0].end(); + let at = a[4].end(); + let bt = b[4].end(); + let mut angle = (bt[1] - bb[1]).atan2(bt[0] - bb[0]) - (at[1] - aa[1]).atan2(at[0] - aa[0]); + angle = angle.sin().atan2(angle.cos()); + a.iter() + .enumerate() + .map(|(i, c)| { + let src = c.coords(); + let dst = b[i].coords(); + let mut out = Vec::with_capacity(src.len()); + let mut j = 0; + while j < src.len() { + let target = rotate(dst[j] - bb[0], dst[j + 1] - bb[1], 0., 0., -angle); + let p = rotate( + lerp(src[j] - aa[0], target[0], t), + lerp(src[j + 1] - aa[1], target[1], t), + 0., + 0., + angle * t, + ); + out.push(p[0] + lerp(aa[0], bb[0], t)); + out.push(p[1] + lerp(aa[1], bb[1], t)); + j += 2; + } + c.with(&out) + }) + .collect() +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Direction { + pub uncurl: f64, + pub front_width: f64, +} + +/// `01 / Emblem`, the product direction. +pub const MARK_DIRECTION: Direction = Direction { + uncurl: 0., + front_width: 1., +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Body, + Hole, + Cutout, + Tool, + Accent, + Pointer, + Water, +} + +#[derive(Clone, Debug)] +pub struct Shape { + pub id: String, + pub path: Path, + pub role: Role, + pub opacity: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Anchors { + pub blowhole: Point, + pub spout: Point, + pub mouth: Point, + pub fin_tip: Point, + pub tail: Point, + pub tail_ang: f64, + pub fin_ang: f64, +} + +#[derive(Clone, Debug)] +pub struct Parts { + pub shapes: Vec, + pub anchors: Anchors, + pub unit_scale: f64, +} + +/// The rig's own defaults (`POSE`) with props at zero — what a partial pose +/// such as a calf's resolves to. +pub fn rig_pose() -> Pose { + tables().defaults +} + +/// `build()`: the named contour parts and anchors for one pose. +pub fn build(dir: Direction, pose: &Pose, lod: u8, size: f64, dpr: f64) -> Parts { + let rig = rig(); + let q = pose; + let yaw = clamp(q[p::yaw], 0., 1.); + let head_yaw = smooth(0., 0.82, yaw) * 0.18; + let tail_yaw = smooth(0.24, 1., yaw) * 0.12; + let uncurl = dir.uncurl * (1. - clamp(q[p::curl], 0., 1.)) + (-q[p::curl]).max(0.) * 0.9 + - q[p::curl].max(0.) * 0.08; + let small = lod >= 2; + let tool_weight = clamp(q[p::page] + q[p::pencil] + q[p::lens] + q[p::glass], 0., 1.); + let targets = [ + (p::page, 392., 222., 132.), + (p::pencil, 394., 218., 118.), + (p::lens, 380., 240., 168.), + (p::glass, 401., 255., 122.), + ]; + let mut tx = 335. * (1. - tool_weight); + let mut ty = 386. * (1. - tool_weight); + let mut total = 0.; + for (key, x, y, neutral) in targets { + let weight = q[key]; + total += weight; + tx += (x + (q[p::fin] - neutral) * 0.38) * weight; + ty += (y + (q[p::fin] - neutral) * 0.18) * weight; + } + if total > 1. { + tx /= total; + ty /= total; + } + let lift = smooth(30., 98., q[p::fin]); + let attention = smooth(0.65, 1., yaw) * (1. - tool_weight); + let tw = tool_weight; + let held_fin = pieces( + &rig.src["flipper"], + &[4, 7], + &[ + vec![ + Cmd::M([230., 398.]), + Cmd::C([ + 238., + 408., + lerp(252., 250., tw), + lerp(427., 425., tw), + lerp(262., 255., tw), + lerp(438., 435., tw), + ]), + Cmd::C([ + lerp(290., 240., tw), + lerp(416., 350., tw), + tx - lerp(10., 35., tw), + ty + lerp(18., 5., tw), + tx, + ty, + ]), + ], + vec![ + Cmd::M([tx, ty]), + Cmd::C([tx - 66., ty + 20., 230., 357., 230., 398.]), + ], + ], + ); + let small_src = small.then(|| rig.small_source(size)); + let source = |id: &str| -> &Path { + match &small_src { + Some(s) => &s[id], + None => &rig.src[id], + } + }; + let profile_fin = morph_blade(source("flipper"), &held_fin, lift); + let posed_fin = morph_blade(&profile_fin, &rig.front["flipper"], head_yaw); + let transform = |id: &str, x: f64, y: f64| -> [f64; 2] { + let (mut x, mut y) = (x, y); + let w = (1. - smooth(165., 295., y)) * smooth(315., 135., x); + x -= uncurl * 48. * w; + y += uncurl * 60. * w; + if id == "flukes" { + let hinge = lerp(220., 246., tail_yaw); + let weight = smooth(hinge, hinge + 75., x); + [x, y] = rotate(x, y, hinge, 110., (q[p::fluke] - 6.) * 0.48 * D2R * weight); + } + if y > 285. { + let jaw_weight = smooth(320., 470., y); + y += q[p::mouth] * 22. * jaw_weight; + if id == "pouch" { + y += (q[p::smile] - 0.4) * 7. * ((x - 230.) / 260. * std::f64::consts::PI).sin(); + y += q[p::mouthSide] * 10. * smooth(280., 480., x) * (1. - smooth(400., 460., y)); + } + } + let head_weight = smooth(185., 470., x) * smooth(150., 300., y); + [x, y] = rotate(x, y, 290., 305., -q[p::head] * 0.34 * D2R * head_weight); + x = (x - 256.) / 5.12; + y = (y - 256.) / 5.12; + x *= q[p::scale] * (1. + q[p::squash] * 0.5); + y *= q[p::scale] * (1. - q[p::squash]); + [x, y] = rotate(x, y, 0., 0., (q[p::rot] + q[p::tilt] * 0.28) * D2R); + if small { + x *= 0.9; + y *= 0.9; + } + [x + q[p::x], y + q[p::y]] + }; + let mut shapes: Vec = Vec::with_capacity(12); + for id in [ + "flukes", "body", "jaw-band", "pouch", "pleat-1", "pleat-2", "flipper", "eye", "eye-far", + "eyelid", + ] { + let a = source(id); + let b = &rig.front[id]; + let t = if id == "flukes" { tail_yaw } else { head_yaw }; + let mut opacity = 1.; + if id == "flipper" { + opacity = lift * (1. - attention); + } + if id == "jaw-band" { + opacity = 0.; + } + if id.starts_with("pleat") && small { + opacity = 0.; + } + if id.starts_with("eye") && small && 26. / 512. * size * dpr < 2. { + opacity = 0.; + } + if id == "eyelid" || id == "eye-far" { + opacity = 0.; + } + let path: Path = a + .iter() + .enumerate() + .map(|(i, c)| { + if matches!(c, Cmd::Z) { + return Cmd::Z; + } + let src = c.coords(); + let dst = b[i].coords(); + let mut out = Vec::with_capacity(src.len()); + let mut j = 0; + while j < src.len() { + let blend = if id == "body" { + lerp(head_yaw, tail_yaw, 1. - smooth(140., 260., src[j + 1])) + } else { + t + }; + let mut x = lerp(src[j], dst[j], blend); + let mut y = lerp(src[j + 1], dst[j + 1], blend); + if id == "body" { + x -= 8. + * (yaw * std::f64::consts::PI).sin() + * smooth(90., 0., hypot2(src[j] - 160., src[j + 1] - 442.)); + } + if id == "flipper" { + let fin = posed_fin[i].coords(); + x = fin[j]; + y = fin[j + 1]; + } + if yaw > 0. { + x = 256. + (x - 256.) * lerp(1., dir.front_width, head_yaw); + } + if id == "eye" || id == "eye-far" { + let cx = if id == "eye" { + lerp(294., 200., head_yaw) + } else { + lerp(294., 365., head_yaw) + }; + let cy = lerp(337., 330., head_yaw); + let visible = if id == "eye-far" { 0. } else { 1. }; + x = cx + (x - cx) * q[p::eyeScale] * visible; + y = cy + + (y - cy) + * q[p::eyeScale] + * (1. - q[p::lid] * 0.97 - q[p::lidLow] * 0.64) + * visible; + y += q[p::lidLow] * 3. * (((x - cx) / 13.).powf(2.) - 1.); + x += q[p::lookX] * 2.; + y += q[p::lookY] * 2.; + } + let xy = transform(id, x, y); + let grid = 124. / (size * dpr); + if small { + out.push(round(xy[0] / grid * 2.) / 2. * grid); + out.push(round(xy[1] / grid * 2.) / 2. * grid); + } else { + out.extend_from_slice(&xy); + } + j += 2; + } + c.with(&out) + }) + .collect(); + let role = if matches!(id, "pouch" | "eye" | "eye-far") { + Role::Hole + } else { + Role::Body + }; + shapes.push(Shape { + id: id.to_string(), + path, + role, + opacity, + }); + } + // The legacy named lid geometry; closing the eye narrows its aperture. + let cy = lerp(337., 328., head_yaw); + let lid_path: Path = rig.src["eyelid"] + .iter() + .enumerate() + .map(|(i, c)| { + if matches!(c, Cmd::Z) { + return Cmd::Z; + } + let src = c.coords(); + let dst = rig.front["eyelid"][i].coords(); + let mut out = Vec::new(); + let mut j = 0; + while j < src.len() { + let x = lerp(src[j], dst[j], head_yaw); + let y = lerp(src[j + 1], dst[j + 1], head_yaw); + out.extend_from_slice(&transform( + "eye", + x, + cy - 13. + (y - cy + 15.) * q[p::lid] * 0.40, + )); + j += 2; + } + c.with(&out) + }) + .collect(); + let lid = shapes.iter_mut().find(|s| s.id == "eyelid").unwrap(); + lid.opacity = 0.; + lid.path = lid_path; + let anchor = |x: f64, y: f64| { + let z = transform("body", x, y); + Point { x: z[0], y: z[1] } + }; + let tip = shapes.iter().find(|s| s.id == "flipper").unwrap().path[4].end(); + // The mark's own flipper separation, carried up with the arm. + let left = [[230., 398.], [230., 357.], [tx - 66., ty + 20.], [tx, ty]]; + let mix = |a: [f64; 2], b: [f64; 2], t: f64| [lerp(a[0], b[0], t), lerp(a[1], b[1], t)]; + let q1 = mix(left[0], left[1], 0.42); + let mid = mix(left[1], left[2], 0.42); + let q2 = mix(q1, mid, 0.42); + let q3 = mix(q2, mix(mid, mix(left[2], left[3], 0.42), 0.42), 0.42); + let point = |pt: [f64; 2], dx: f64, dy: f64| transform("body", pt[0] + dx, pt[1] + dy); + let cat = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| [a[0], a[1], b[0], b[1], c[0], c[1]]; + let start = point(left[0], -6., -4.); + let gap = Shape { + id: "flipper-gap".into(), + path: vec![ + Cmd::M(start), + Cmd::C(cat( + point(q1, -7., -2.), + point(q2, -5., -1.), + point(q3, 0., 0.), + )), + Cmd::C(cat( + point(q2, 0., 0.), + point(q1, 0., 0.), + point(left[0], -6., -4.), + )), + Cmd::Z, + ], + role: Role::Hole, + opacity: lift * (1. - head_yaw) * (1. - attention), + }; + let at = shapes.iter().position(|s| s.id == "flipper").unwrap(); + shapes.insert(at, gap); + let tail = transform( + "flukes", + lerp(289., 296., tail_yaw), + lerp(113., 121., tail_yaw), + ); + let anchors = Anchors { + blowhole: anchor(lerp(325., 278., head_yaw), lerp(234., 278., head_yaw)), + spout: anchor(410., 222.), + mouth: anchor(lerp(480., 426., head_yaw), lerp(300., 353., head_yaw)), + fin_tip: Point { + x: tip[0], + y: tip[1], + }, + tail: Point { + x: tail[0], + y: tail[1], + }, + tail_ang: (q[p::rot] + q[p::tilt] * 0.28 + (q[p::fluke] - 6.) * 0.35) * D2R, + fin_ang: (q[p::rot] + q[p::tilt] * 0.28) * D2R, + }; + Parts { + shapes, + anchors, + unit_scale: q[p::scale] * if small { 0.9 } else { 1. }, + } +} + +/// `holesFor()`: the compound holes painted with a shape. In one ink the +/// body subtracts its apertures; designated prop details are always holes. +pub fn holes_for(id: &str, shapes: &[Shape], mono: bool) -> Vec { + let prefix = if id.len() >= 4 { + &id[..id.len() - 4] + } else { + "" + }; + shapes + .iter() + .filter(|h| { + (mono + && id.ends_with("body") + && h.role == Role::Hole + && [ + format!("{prefix}pouch"), + format!("{prefix}eye"), + format!("{prefix}eye-far"), + ] + .contains(&h.id)) + || (mono && h.id == "flipper-gap" && id == "body") + || (h.role == Role::Cutout + && (id == "prop-page" || id == "prop-pad") + && h.id.starts_with(&format!("{id}-"))) + }) + .map(|h| { + let origin = h.path[0].end(); + let w = h.opacity; + h.path + .iter() + .map(|c| c.map(|i, v| lerp(origin[i % 2], v, w))) + .collect() + }) + .collect() +} diff --git a/crates/tui/src/tui/whale_v2/scene.rs b/crates/tui/src/tui/whale_v2/scene.rs new file mode 100644 index 0000000000..98655e4eb7 --- /dev/null +++ b/crates/tui/src/tui/whale_v2/scene.rs @@ -0,0 +1,216 @@ +//! `scene()` and `braille.js`: one evaluated performance, composed for a +//! view. A surface may simplify geometry and paint less often, but it never +//! classifies tools or advances a second clock — it only reads the Director. + +use super::acting::Director; +use super::data::p; +use super::props; +use super::rig::{ + Cmd, Direction, MARK_DIRECTION, Parts, Path, Role, Shape, build, holes_for, rig_pose, +}; + +#[derive(Clone, Copy, Debug)] +pub struct View { + /// Allocated square in device-independent pixels (drives optical sizes). + pub size: f64, + pub dpr: f64, + /// 0 is the full contour rig; 2 is the optical-fit small icon. + pub lod: u8, + pub dir: Direction, +} + +impl View { + pub fn hero(size: f64, dpr: f64) -> Self { + Self { + size, + dpr, + lod: 0, + dir: MARK_DIRECTION, + } + } +} + +/// `scene()`: calves, whale, props and particles, in paint order. +pub fn scene(director: &Director, view: View) -> Parts { + scene_posed(director, view, &[]) +} + +/// `scene()` with a surface's `view.pose` overrides (the cove's gaze), +/// applied before the pod adjustment exactly as the reference merges them. +pub fn scene_posed(director: &Director, view: View, overrides: &[(usize, f64)]) -> Parts { + let mut pose = director.pose(); + for (param, value) in overrides { + pose[*param] = *value; + } + let pod = director + .calves + .iter() + .map(|c| c.value) + .fold(f64::NEG_INFINITY, f64::max); + pose[p::scale] *= 1. - 0.16 * pod; + pose[p::x] += 10. * pod; + let mut parts = build(view.dir, &pose, view.lod, view.size, view.dpr); + let mut calves = Vec::new(); + for (i, calf) in director.calves.iter().enumerate() { + let vis = calf.value; + if vis < 0.002 { + continue; + } + let i = i as f64; + let phase = if director.reduced { + 0. + } else { + director.f / 30. * (0.8 + i * 0.13) + i * 2.1 + }; + let mut calf_pose = rig_pose(); + calf_pose[p::x] = -44. - (1. - vis) * 10.; + calf_pose[p::y] = -30. + i * 30.; + calf_pose[p::scale] = 0.18 * vis; + calf_pose[p::fluke] = 6. + phase.sin() * 8.; + calf_pose[p::lid] = 0.; + let lod = if view.lod >= 2 { 2 } else { 0 }; + let cp = build(view.dir, &calf_pose, lod, view.size, view.dpr); + calves.extend(cp.shapes.into_iter().map(|s| Shape { + id: format!("calf-{}-{}", i as usize + 1, s.id), + opacity: s.opacity * vis, + ..s + })); + } + let small = view.lod >= 2; + let prop_shapes = props::shapes(&pose, &parts, small); + let whale = std::mem::take(&mut parts.shapes); + parts.shapes = calves; + parts.shapes.extend(whale); + parts.shapes.extend(prop_shapes); + let particles = if director.reduced { + director.poster_particles(&parts.anchors) + } else { + director.particles.clone() + }; + parts.shapes.extend(props::particles(&particles, small)); + parts +} + +/// Top-to-bottom bit layout of one 2×4 Braille cell. +pub const BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]; + +pub(super) fn segments(path: &Path) -> Vec<[f64; 4]> { + let mut edges = Vec::new(); + let mut pen: Option<[f64; 2]> = None; + let mut start = [0., 0.]; + let mut line = |pen: &mut Option<[f64; 2]>, q: [f64; 2]| { + if let Some(from) = *pen { + edges.push([from[0], from[1], q[0], q[1]]); + } + *pen = Some(q); + }; + for c in path { + match c { + Cmd::M(m) => { + pen = Some(*m); + start = *m; + } + Cmd::C(c) => { + let from = pen.unwrap_or([0., 0.]); + for i in 1..=10 { + let t = i as f64 / 10.; + let u = 1. - t; + line( + &mut pen, + [ + u * u * u * from[0] + + 3. * u * u * t * c[0] + + 3. * u * t * t * c[2] + + t * t * t * c[4], + u * u * u * from[1] + + 3. * u * u * t * c[1] + + 3. * u * t * t * c[3] + + t * t * t * c[5], + ], + ); + } + } + Cmd::Z => line(&mut pen, start), + } + } + edges +} + +/// Packed row-major Braille cells, as `render_grid` consumes them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Grid { + pub cols: usize, + pub rows: usize, + pub cells: Vec, +} + +/// `rasterize()`: even-odd scan conversion of the one-ink scene into dots. +pub fn rasterize(parts: &Parts, cols: usize, rows: usize, cell_aspect: f64) -> Grid { + let width = cols * 2; + let height = rows * 4; + let mut dots = vec![false; width * height]; + let scale = (width as f64 * cell_aspect * 2.).min(height as f64) / 124.; + let sx = scale / (cell_aspect * 2.); + let sy = scale; + let (hw, hh) = (width as f64 / 2., height as f64 / 2.); + for shape in &parts.shapes { + if shape.opacity < 0.5 || shape.role == Role::Hole || shape.role == Role::Cutout { + continue; + } + let mut paths = vec![shape.path.clone()]; + paths.extend(holes_for(&shape.id, &parts.shapes, true)); + let edges: Vec<[f64; 4]> = paths + .iter() + .flat_map(segments) + .map(|[x1, y1, x2, y2]| [hw + x1 * sx, hh + y1 * sy, hw + x2 * sx, hh + y2 * sy]) + .collect(); + let mut cross = Vec::new(); + for y in 0..height { + let cy = y as f64 + 0.5; + cross.clear(); + for [x1, y1, x2, y2] in &edges { + if (*y1 > cy) != (*y2 > cy) { + cross.push(x1 + (cy - y1) * (x2 - x1) / (y2 - y1)); + } + } + cross.sort_by(f64::total_cmp); + let mut i = 0; + while i + 1 < cross.len() { + let left = (cross[i] - 0.5).ceil().max(0.); + let right = (cross[i + 1] - 0.5).ceil().min(width as f64); + let mut x = left; + while x < right { + dots[y * width + x as usize] = true; + x += 1.; + } + i += 2; + } + } + } + let mut cells = vec![0u8; cols * rows]; + for y in 0..height { + for x in 0..width { + if dots[y * width + x] { + cells[(y / 4) * cols + x / 2] |= BITS[y % 4][x % 2]; + } + } + } + Grid { cols, rows, cells } +} + +/// `frame()`: the terminal still at `cols × rows`, via the small rig. +pub fn braille(director: &Director, cols: usize, rows: usize) -> Grid { + let size = (cols * 2).min(rows * 4) as f64; + rasterize( + &scene( + director, + View { + lod: 2, + ..View::hero(size, 1.) + }, + ), + cols, + rows, + 0.5, + ) +}