From 414f74362c1c3a40bed7ef1333aae6f143dd2f8d Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:04:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(pocket-widget):=20chorus=20diorama=20w?= =?UTF-8?q?idget=20=E2=80=94=20a=20drag-orbit=20dream=20corridor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third pocket-widget runtime shape: a scene-only WidgetGame (no guest, no EmbeddedUi) recreating the airbrush-chrome record-sleeve look as a living diorama. A framed tunnel-book box recedes wing by wing — cut-metal curtains carrying faces in profile, a striped wall, a red-lit brick room with a purple cube, orange dunes — to a starburst deep inside, with fog spilling out over the desktop and a chrome-lidded eye below the frame. Everything is procedural at load time: geometry in `diorama` (non-uniform Catmull-Rom face profiles extruded as thick cutout slabs whose gradient bands hug the silhouette), painting in `paint` (multi-stop smoothstepped ramps + deterministic LCG grain), all instances unlit so the airbrush shading is exactly what was painted. The starburst is beams + additive sprites; the fog is drifting additive puffs. Interactions: drag through the frame opening to orbit (the frame itself moves the window), scroll to dolly, double-click to dive inside, arrows nudge, R recenters. `--screenshot/--yaw/--pitch/--dist` render headless composites; `--still` freezes the star and fog, and the governor receipt then shows 2 rendered frames over a 3 s windowed run (vs 164 animated). Co-Authored-By: Claude Fable 5 --- WIDGET.md | 6 + pocket3d/Cargo.lock | 14 + pocket3d/Cargo.toml | 1 + pocket3d/examples/chorus/Cargo.toml | 17 + pocket3d/examples/chorus/src/diorama.rs | 886 ++++++++++++++++++++++++ pocket3d/examples/chorus/src/main.rs | 465 +++++++++++++ pocket3d/examples/chorus/src/paint.rs | 513 ++++++++++++++ 7 files changed, 1902 insertions(+) create mode 100644 pocket3d/examples/chorus/Cargo.toml create mode 100644 pocket3d/examples/chorus/src/diorama.rs create mode 100644 pocket3d/examples/chorus/src/main.rs create mode 100644 pocket3d/examples/chorus/src/paint.rs diff --git a/WIDGET.md b/WIDGET.md index 9253254b..d8401669 100644 --- a/WIDGET.md +++ b/WIDGET.md @@ -314,6 +314,12 @@ Landed in this repo: `--density=N` builds; and the second runtime — `examples/note-widget` over `demos/note` (markdown view/edit/menu, autosave, tested in `test/note.test.ts`). +6. `examples/chorus`: the scene-only data point — a `WidgetGame` with no + guest and no `EmbeddedUi`, an airbrush-chrome diorama (procedural + geometry + painted ramps, everything unlit) you orbit by dragging + through the frame opening. It proves the 3D shell stands alone as an + ambient-art form and honors the governor: `--still` renders 2 frames + over a 3 s windowed run. Still ahead: the golden-specs wiring (§8), density-2 screens for the 3D form (§5 — the flat form ships them), the `widget` surface (§7), diff --git a/pocket3d/Cargo.lock b/pocket3d/Cargo.lock index e37bedcc..7bb7968c 100644 --- a/pocket3d/Cargo.lock +++ b/pocket3d/Cargo.lock @@ -326,6 +326,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chorus" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger", + "glam", + "log", + "pocket-widget", + "pocket3d", + "wgpu", + "winit", +] + [[package]] name = "codespan-reporting" version = "0.12.0" diff --git a/pocket3d/Cargo.toml b/pocket3d/Cargo.toml index 4fe4877e..e4b930ac 100644 --- a/pocket3d/Cargo.toml +++ b/pocket3d/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/pocket-ui-wgpu", "crates/pocket-vrm", "crates/pocket-widget", + "examples/chorus", "examples/handheld", "examples/note-widget", "examples/uihost", diff --git a/pocket3d/examples/chorus/Cargo.toml b/pocket3d/examples/chorus/Cargo.toml new file mode 100644 index 00000000..387bbcef --- /dev/null +++ b/pocket3d/examples/chorus/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "chorus" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "A dream-corridor diorama widget: airbrush chrome profiles receding to a starburst, drag to peer inside" + +[dependencies] +pocket3d = { workspace = true } +pocket-widget = { workspace = true } +wgpu = { workspace = true } +winit = { workspace = true } +glam = { workspace = true, features = ["std"] } +anyhow = { workspace = true } +log = { workspace = true } +env_logger = { workspace = true } diff --git a/pocket3d/examples/chorus/src/diorama.rs b/pocket3d/examples/chorus/src/diorama.rs new file mode 100644 index 00000000..ace50680 --- /dev/null +++ b/pocket3d/examples/chorus/src/diorama.rs @@ -0,0 +1,886 @@ +//! The set: a framed box whose interior is dressed like a theater stage — +//! wing after wing of cut-metal curtains, each carrying a face in profile, +//! receding to a glow. Everything is authored procedurally at load time; +//! the airbrush shading is painted by `paint` and the geometry's only job +//! is to give those ramps silhouettes to hug. +//! +//! Units are scene millimeters-ish; the frame opening is 84×84 centered at +//! the origin, front face +Z, the corridor runs to z = -125. + +use std::sync::Arc; + +use glam::{Mat4, Vec2, Vec3}; +use pocket3d::gpu::Gpu; +use pocket3d::model::{ModelAsset, ModelInstance, ModelVertex}; +use pocket3d::renderer::Renderer; +use pocket3d::scene::Scene; + +use crate::paint::{self, Img}; + +/// Half-size of the frame opening (the hole in the cream face). +pub const OPEN: f32 = 44.0; +/// Half-size of the frame's outer edge. +pub const OUTER: f32 = 54.0; +/// Half-size of the box interior — the mat bevel closes 44 → 42, so the +/// blue reveal is visible on every side like a gallery mat. +pub const BOX: f32 = 42.0; +/// Where the bevel lands and the interior begins. +pub const BEVEL_Z: f32 = -6.0; +/// The back wall. +pub const BACK_Z: f32 = -125.0; +/// Where the light lives — in front of the deep folds, so its spikes lie +/// over their edges the way the sleeve's starburst does. +pub const STAR: Vec3 = Vec3::new(0.0, -4.0, -86.0); + +// --------------------------------------------------------------------------- +// Mesh scaffolding +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct MeshBuf { + v: Vec, + i: Vec, +} + +impl MeshBuf { + fn vert(pos: Vec3, uv: [f32; 2]) -> ModelVertex { + ModelVertex { + pos: pos.to_array(), + normal: [0.0, 0.0, 1.0], + uv, + joints: [0; 4], + weights: [1.0, 0.0, 0.0, 0.0], + } + } + + /// Emit a quad wound so it faces `hint` (the renderer culls back faces). + fn quad(&mut self, p: [Vec3; 4], uv: [[f32; 2]; 4], hint: Vec3) { + let n = (p[1] - p[0]).cross(p[2] - p[0]); + let base = self.v.len() as u32; + if n.dot(hint) >= 0.0 { + for k in 0..4 { + self.v.push(Self::vert(p[k], uv[k])); + } + } else { + for k in [0usize, 3, 2, 1] { + self.v.push(Self::vert(p[k], uv[k])); + } + } + self.i + .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + + /// Emit a quad exactly as wound. + fn quad_raw(&mut self, p: [Vec3; 4], uv: [[f32; 2]; 4]) { + let base = self.v.len() as u32; + for k in 0..4 { + self.v.push(Self::vert(p[k], uv[k])); + } + self.i + .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + + /// Emit a quad visible from both sides (rims, free-floating bands). + fn quad_ds(&mut self, p: [Vec3; 4], uv: [[f32; 2]; 4]) { + self.quad_raw(p, uv); + self.quad_raw([p[3], p[2], p[1], p[0]], [uv[3], uv[2], uv[1], uv[0]]); + } + + fn build( + self, + gpu: &Gpu, + renderer: &Renderer, + label: &str, + img: Option<&Img>, + ) -> Arc { + ModelAsset::from_geometry( + gpu, + &renderer.model_material_layout, + &renderer.samplers, + label, + &self.v, + &self.i, + img.map(|m| (m.w, m.h, m.px.as_slice())), + ) + } +} + +fn push_unlit(scene: &mut Scene, asset: Arc, transform: Mat4) { + let mut inst = ModelInstance::new(asset); + inst.transform = transform; + inst.lit = 0.0; + scene.models.push(inst); +} + +// --------------------------------------------------------------------------- +// Curves +// --------------------------------------------------------------------------- + +/// Non-uniform Catmull-Rom through (t, value) controls — C1 profiles, no +/// polyline corners for the chrome bands to kink on. +fn spline(controls: &[(f32, f32)], t: f32) -> f32 { + let n = controls.len(); + if t <= controls[0].0 { + return controls[0].1; + } + if t >= controls[n - 1].0 { + return controls[n - 1].1; + } + let mut k = 0; + while k + 2 < n && controls[k + 1].0 < t { + k += 1; + } + let p1 = controls[k]; + let p2 = controls[k + 1]; + let p0 = if k > 0 { controls[k - 1] } else { p1 }; + let p3 = if k + 2 < n { controls[k + 2] } else { p2 }; + let h = (p2.0 - p1.0).max(1e-5); + let s = ((t - p1.0) / h).clamp(0.0, 1.0); + let m1 = (p2.1 - p0.1) / (p2.0 - p0.0).max(1e-5) * h; + let m2 = (p3.1 - p1.1) / (p3.0 - p1.0).max(1e-5) * h; + let (s2, s3) = (s * s, s * s * s); + (2.0 * s3 - 3.0 * s2 + 1.0) * p1.1 + + (s3 - 2.0 * s2 + s) * m1 + + (-2.0 * s3 + 3.0 * s2) * p2.1 + + (s3 - s2) * m2 +} + +/// A face in profile, top of head → neck, as (t, reach) controls where +/// reach 1.0 is the nose tip. Variants so the cast aren't clones; features +/// are kept broad — small wiggles read as noise at curtain scale, not lips. +fn face_controls(variant: u32) -> Vec<(f32, f32)> { + match variant { + 0 => vec![ + (0.0, 0.04), + (0.1, 0.3), + (0.2, 0.38), + (0.27, 0.26), + (0.36, 0.5), + (0.42, 1.0), + (0.455, 0.52), + (0.5, 0.62), + (0.54, 0.48), + (0.585, 0.66), + (0.64, 0.4), + (0.72, 0.56), + (0.82, 0.24), + (1.0, 0.02), + ], + 1 => vec![ + (0.0, 0.03), + (0.12, 0.34), + (0.22, 0.42), + (0.29, 0.3), + (0.38, 0.56), + (0.45, 1.0), + (0.49, 0.44), + (0.545, 0.64), + (0.6, 0.4), + (0.66, 0.6), + (0.73, 0.34), + (0.8, 0.44), + (0.88, 0.18), + (1.0, 0.02), + ], + 2 => vec![ + (0.0, 0.05), + (0.09, 0.26), + (0.18, 0.34), + (0.25, 0.24), + (0.33, 0.46), + (0.39, 1.0), + (0.43, 0.46), + (0.49, 0.6), + (0.55, 0.42), + (0.61, 0.58), + (0.68, 0.34), + (0.76, 0.46), + (0.86, 0.2), + (1.0, 0.02), + ], + // Minimal: forehead, nose, one lip, chin — for the smallest wings, + // where finer features would alias into wiggles. + _ => vec![ + (0.0, 0.05), + (0.14, 0.3), + (0.26, 0.24), + (0.36, 0.52), + (0.43, 1.0), + (0.48, 0.46), + (0.56, 0.62), + (0.64, 0.34), + (0.75, 0.48), + (0.86, 0.18), + (1.0, 0.04), + ], + } +} + +// --------------------------------------------------------------------------- +// Curtains +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +pub enum Attach { + Left, + Right, + Top, + Bottom, +} + +pub struct Curtain { + pub attach: Attach, + /// (s, reach) controls: how far the free edge reaches from the attach + /// wall, in world units, along the span. + pub controls: Vec<(f32, f32)>, + /// World range along the span axis (y top→bottom for Left/Right, + /// x left→right for Top/Bottom). + pub span: (f32, f32), + /// Front face z; the slab extends `thickness` behind it. + pub z: f32, + pub thickness: f32, +} + +/// A curtain is a thick cutout slab: a front face whose gradient bands hug +/// the free edge (offset columns, denser near the edge), plus a shaded rim +/// along the cut. The attach side melts into its wall. +fn build_curtain( + gpu: &Gpu, + renderer: &Renderer, + label: &str, + c: &Curtain, + stops: &[(f32, [f32; 3])], + seed: u32, +) -> Arc { + const SAMPLES: usize = 160; + const COLS: usize = 14; + let img = paint::chrome_ramp(512, 64, stops, seed); + let mut m = MeshBuf::default(); + + let point = |s: f32, reach: f32| -> Vec2 { + let a = c.span.0 + (c.span.1 - c.span.0) * s; + match c.attach { + Attach::Left => Vec2::new(-BOX + reach, a), + Attach::Right => Vec2::new(BOX - reach, a), + Attach::Top => Vec2::new(a, BOX - reach), + Attach::Bottom => Vec2::new(a, -BOX + reach), + } + }; + + // Sample the reach curve, then relax it a little — the spline is C1 + // but tight feature clusters can still micro-overshoot, and airbrush + // art has no corners anywhere. + let mut reach: Vec = (0..=SAMPLES) + .map(|k| spline(&c.controls, k as f32 / SAMPLES as f32)) + .collect(); + for _ in 0..2 { + let prev = reach.clone(); + for k in 1..SAMPLES { + reach[k] = prev[k - 1] * 0.25 + prev[k] * 0.5 + prev[k + 1] * 0.25; + } + } + let edge: Vec<(f32, Vec2)> = reach + .iter() + .enumerate() + .map(|(k, &r)| { + let s = k as f32 / SAMPLES as f32; + (s, point(s, r)) + }) + .collect(); + let span_len = (c.span.1 - c.span.0).abs(); + + // Front face: columns lerp edge → wall, packed toward the edge so the + // first bands run parallel to the silhouette. + let col_pos = |k: usize, s: f32, f: f32| -> Vec2 { + let e = edge[k].1; + let w = point(s, 0.0); + e + (w - e) * f + }; + for j in 0..COLS { + let f0 = (j as f32 / COLS as f32).powf(0.55); + let f1 = ((j + 1) as f32 / COLS as f32).powf(0.55); + for k in 0..SAMPLES { + let (s0, s1) = (edge[k].0, edge[k + 1].0); + let a = col_pos(k, s0, f0).extend(c.z); + let b = col_pos(k, s0, f1).extend(c.z); + let d = col_pos(k + 1, s1, f0).extend(c.z); + let e2 = col_pos(k + 1, s1, f1).extend(c.z); + let (v0, v1) = (s0 * span_len / 44.0, s1 * span_len / 44.0); + m.quad( + [a, b, e2, d], + [[f0, v0], [f1, v0], [f1, v1], [f0, v1]], + Vec3::Z, + ); + } + } + + // Rim along the free edge: bright lip at the front, falling into the + // ramp's first shadow at the back of the cut. + for k in 0..SAMPLES { + let (s0, p0) = edge[k]; + let (s1, p1) = edge[k + 1]; + let (v0, v1) = (s0 * span_len / 44.0, s1 * span_len / 44.0); + m.quad_ds( + [ + p0.extend(c.z), + p1.extend(c.z), + p1.extend(c.z - c.thickness), + p0.extend(c.z - c.thickness), + ], + [[0.005, v0], [0.005, v1], [0.12, v1], [0.12, v0]], + ); + } + + m.build(gpu, renderer, label, Some(&img)) +} + +// --------------------------------------------------------------------------- +// The set +// --------------------------------------------------------------------------- + +/// Build the whole diorama into `scene.models`. Instances are static; all +/// motion in the widget is camera, sprites and beams. +pub fn build(gpu: &Gpu, renderer: &Renderer, scene: &mut Scene) { + // --- frame ----------------------------------------------------------- + { + let img = paint::frame_face(64, 256, 11); + let mut m = MeshBuf::default(); + let ring = [ + // top band, bottom band, left band, right band + [ + Vec3::new(-OUTER, OPEN, 0.0), + Vec3::new(OUTER, OPEN, 0.0), + Vec3::new(OUTER, OUTER, 0.0), + Vec3::new(-OUTER, OUTER, 0.0), + ], + [ + Vec3::new(-OUTER, -OUTER, 0.0), + Vec3::new(OUTER, -OUTER, 0.0), + Vec3::new(OUTER, -OPEN, 0.0), + Vec3::new(-OUTER, -OPEN, 0.0), + ], + [ + Vec3::new(-OUTER, -OPEN, 0.0), + Vec3::new(-OPEN, -OPEN, 0.0), + Vec3::new(-OPEN, OPEN, 0.0), + Vec3::new(-OUTER, OPEN, 0.0), + ], + [ + Vec3::new(OPEN, -OPEN, 0.0), + Vec3::new(OUTER, -OPEN, 0.0), + Vec3::new(OUTER, OPEN, 0.0), + Vec3::new(OPEN, OPEN, 0.0), + ], + ]; + for p in ring { + let uv = [[0.2, 0.1], [0.8, 0.1], [0.8, 0.9], [0.2, 0.9]]; + m.quad(p, uv, Vec3::Z); + } + // Outer edge slab so the frame reads as a body from an angle. + let rims: [([Vec3; 2], Vec3); 4] = [ + ( + [Vec3::new(-OUTER, OUTER, 0.0), Vec3::new(OUTER, OUTER, 0.0)], + Vec3::Y, + ), + ( + [ + Vec3::new(OUTER, -OUTER, 0.0), + Vec3::new(-OUTER, -OUTER, 0.0), + ], + -Vec3::Y, + ), + ( + [ + Vec3::new(-OUTER, -OUTER, 0.0), + Vec3::new(-OUTER, OUTER, 0.0), + ], + -Vec3::X, + ), + ( + [Vec3::new(OUTER, OUTER, 0.0), Vec3::new(OUTER, -OUTER, 0.0)], + Vec3::X, + ), + ]; + for ([a, b], n) in rims { + let (a2, b2) = (a - Vec3::Z * 5.0, b - Vec3::Z * 5.0); + m.quad( + [a, b, b2, a2], + [[0.3, 0.85], [0.7, 0.85], [0.7, 0.98], [0.3, 0.98]], + n, + ); + } + // Exterior shell: the box itself, so orbiting shows a cream tunnel + // book from the side instead of a view through culled backfaces. + let shell: [([Vec3; 4], Vec3); 5] = [ + ( + [ + Vec3::new(-OUTER, OUTER, 0.0), + Vec3::new(OUTER, OUTER, 0.0), + Vec3::new(OUTER, OUTER, BACK_Z - 2.0), + Vec3::new(-OUTER, OUTER, BACK_Z - 2.0), + ], + Vec3::Y, + ), + ( + [ + Vec3::new(-OUTER, -OUTER, 0.0), + Vec3::new(OUTER, -OUTER, 0.0), + Vec3::new(OUTER, -OUTER, BACK_Z - 2.0), + Vec3::new(-OUTER, -OUTER, BACK_Z - 2.0), + ], + -Vec3::Y, + ), + ( + [ + Vec3::new(-OUTER, -OUTER, 0.0), + Vec3::new(-OUTER, OUTER, 0.0), + Vec3::new(-OUTER, OUTER, BACK_Z - 2.0), + Vec3::new(-OUTER, -OUTER, BACK_Z - 2.0), + ], + -Vec3::X, + ), + ( + [ + Vec3::new(OUTER, -OUTER, 0.0), + Vec3::new(OUTER, OUTER, 0.0), + Vec3::new(OUTER, OUTER, BACK_Z - 2.0), + Vec3::new(OUTER, -OUTER, BACK_Z - 2.0), + ], + Vec3::X, + ), + ( + [ + Vec3::new(-OUTER, -OUTER, BACK_Z - 2.0), + Vec3::new(OUTER, -OUTER, BACK_Z - 2.0), + Vec3::new(OUTER, OUTER, BACK_Z - 2.0), + Vec3::new(-OUTER, OUTER, BACK_Z - 2.0), + ], + -Vec3::Z, + ), + ]; + for (p, n) in shell { + m.quad(p, [[0.3, 0.2], [0.7, 0.2], [0.7, 0.8], [0.3, 0.8]], n); + } + let asset = m.build(gpu, renderer, "frame", Some(&img)); + push_unlit(scene, asset, Mat4::IDENTITY); + } + + // --- bevel (the lit blue reveal, opening 42 splaying to 44) ---------- + { + let img = paint::bevel_ramp(256, 64, 12); + let mut m = MeshBuf::default(); + // Mat bevel: the opening closes 44 → 42 going back, so every band + // tilts toward the viewer (hint +Z resolves all four windings). + let splay: [[Vec3; 4]; 4] = [ + [ + Vec3::new(-OPEN, OPEN, 0.0), + Vec3::new(OPEN, OPEN, 0.0), + Vec3::new(BOX, BOX, BEVEL_Z), + Vec3::new(-BOX, BOX, BEVEL_Z), + ], + [ + Vec3::new(-OPEN, -OPEN, 0.0), + Vec3::new(OPEN, -OPEN, 0.0), + Vec3::new(BOX, -BOX, BEVEL_Z), + Vec3::new(-BOX, -BOX, BEVEL_Z), + ], + [ + Vec3::new(-OPEN, -OPEN, 0.0), + Vec3::new(-OPEN, OPEN, 0.0), + Vec3::new(-BOX, BOX, BEVEL_Z), + Vec3::new(-BOX, -BOX, BEVEL_Z), + ], + [ + Vec3::new(OPEN, -OPEN, 0.0), + Vec3::new(OPEN, OPEN, 0.0), + Vec3::new(BOX, BOX, BEVEL_Z), + Vec3::new(BOX, -BOX, BEVEL_Z), + ], + ]; + for p in splay { + m.quad(p, [[0.0, 0.1], [0.0, 0.9], [1.0, 0.9], [1.0, 0.1]], Vec3::Z); + } + let asset = m.build(gpu, renderer, "bevel", Some(&img)); + push_unlit(scene, asset, Mat4::IDENTITY); + } + + // --- interior walls -------------------------------------------------- + let wall = |gpu: &Gpu, + renderer: &Renderer, + scene: &mut Scene, + label: &str, + img: Img, + p: [Vec3; 4], + uv: [[f32; 2]; 4], + hint: Vec3| { + let mut m = MeshBuf::default(); + m.quad(p, uv, hint); + let asset = m.build(gpu, renderer, label, Some(&img)); + push_unlit(scene, asset, Mat4::IDENTITY); + }; + + let d0 = BEVEL_Z; + // Ceiling: stripes recede with depth. + wall( + gpu, + renderer, + scene, + "ceiling", + paint::ceiling_stripes(512, 128, 21), + [ + Vec3::new(-BOX, BOX, d0), + Vec3::new(BOX, BOX, d0), + Vec3::new(BOX, BOX, BACK_Z), + Vec3::new(-BOX, BOX, BACK_Z), + ], + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + -Vec3::Y, + ); + // Floor: fog gradient into the glow. + wall( + gpu, + renderer, + scene, + "floor", + paint::fog_floor(512, 128, 22), + [ + Vec3::new(-BOX, -BOX, d0), + Vec3::new(BOX, -BOX, d0), + Vec3::new(BOX, -BOX, BACK_Z), + Vec3::new(-BOX, -BOX, BACK_Z), + ], + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + Vec3::Y, + ); + // Left wall: the dark room with the ember grid. + wall( + gpu, + renderer, + scene, + "left wall", + paint::grid_wall(512, 256, 23), + [ + Vec3::new(-BOX, BOX, d0), + Vec3::new(-BOX, -BOX, d0), + Vec3::new(-BOX, -BOX, BACK_Z), + Vec3::new(-BOX, BOX, BACK_Z), + ], + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + Vec3::X, + ); + // Right wall: the pixel city. + wall( + gpu, + renderer, + scene, + "right wall", + paint::block_wall(512, 256, 24), + [ + Vec3::new(BOX, BOX, d0), + Vec3::new(BOX, -BOX, d0), + Vec3::new(BOX, -BOX, BACK_Z), + Vec3::new(BOX, BOX, BACK_Z), + ], + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + -Vec3::X, + ); + // Back wall: the glow. + let cx = (STAR.x + BOX) / (2.0 * BOX); + let cy = (BOX - STAR.y) / (2.0 * BOX); + wall( + gpu, + renderer, + scene, + "back wall", + paint::glow_wall(256, 256, cx, cy, 25), + [ + Vec3::new(-BOX, BOX, BACK_Z), + Vec3::new(BOX, BOX, BACK_Z), + Vec3::new(BOX, -BOX, BACK_Z), + Vec3::new(-BOX, -BOX, BACK_Z), + ], + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + Vec3::Z, + ); + + // --- the cast: curtains front to back -------------------------------- + // Reach = base + profile × amp: `base` sets how far the whole wing + // stands from its wall, `amp` how far features breathe beyond that. + // Each deeper wing reaches further toward the center than the one in + // front of it, so they interleave like theater flats and the aperture + // narrows to the star. + let face = |variant: u32, base: f32, amp: f32, lift: f32| -> Vec<(f32, f32)> { + face_controls(variant) + .into_iter() + .map(|(t, o)| ((t * 0.84 + lift).clamp(0.0, 1.0), base + o * amp)) + .collect() + }; + + let curtains: [(&str, Curtain, paint::Ramp, u32); 7] = [ + ( + "valance", + Curtain { + attach: Attach::Top, + controls: vec![ + (0.0, 28.0), + (0.18, 14.0), + (0.4, 23.0), + (0.6, 9.0), + (0.8, 17.0), + (1.0, 7.0), + ], + span: (-BOX, BOX), + z: -16.0, + thickness: 7.0, + }, + paint::ramp_valance(), + 31, + ), + ( + "black profile", + Curtain { + attach: Attach::Left, + controls: face(0, 14.0, 18.0, 0.10), + span: (BOX, -BOX), + z: -24.0, + thickness: 7.0, + }, + paint::ramp_black_profile(), + 32, + ), + ( + "sage profile", + Curtain { + attach: Attach::Right, + controls: face(0, 14.0, 18.0, 0.04), + span: (BOX, -BOX), + z: -40.0, + thickness: 8.0, + }, + paint::ramp_sage(), + 33, + ), + ( + "pink profile", + Curtain { + attach: Attach::Left, + controls: face(2, 20.0, 16.0, 0.16), + span: (BOX, -BOX), + z: -58.0, + thickness: 7.0, + }, + paint::ramp_pink(), + 34, + ), + ( + "teal profile", + Curtain { + attach: Attach::Right, + controls: face(3, 20.0, 15.0, 0.22), + span: (BOX, -BOX), + z: -74.0, + thickness: 6.0, + }, + paint::ramp_teal(), + 35, + ), + ( + "fold left", + Curtain { + attach: Attach::Left, + controls: vec![ + (0.0, 36.0), + (0.14, 42.0), + (0.3, 37.0), + (0.45, 43.0), + (0.6, 38.0), + (0.74, 44.0), + (0.88, 38.0), + (1.0, 41.0), + ], + span: (BOX, -BOX), + z: -90.0, + thickness: 5.0, + }, + paint::ramp_lavender(), + 36, + ), + ( + "fold right", + Curtain { + attach: Attach::Right, + controls: vec![ + (0.0, 38.0), + (0.16, 44.0), + (0.32, 38.0), + (0.5, 45.0), + (0.66, 39.0), + (0.82, 45.0), + (1.0, 40.0), + ], + span: (BOX, -BOX), + z: -102.0, + thickness: 5.0, + }, + paint::ramp_warm_fold(), + 37, + ), + ]; + for (label, c, stops, seed) in &curtains { + let asset = build_curtain(gpu, renderer, label, c, stops, *seed); + push_unlit(scene, asset, Mat4::IDENTITY); + } + + // --- the dunes ------------------------------------------------------- + let dunes = [ + ( + "dune", + Curtain { + attach: Attach::Bottom, + controls: vec![ + (0.0, 0.0), + (0.22, 13.0), + (0.4, 8.0), + (0.62, 26.0), + (0.8, 17.0), + (1.0, 22.0), + ], + span: (-8.0, BOX), + z: -34.0, + thickness: 10.0, + }, + 42u32, + ), + ( + "dune far", + Curtain { + attach: Attach::Bottom, + controls: vec![(0.0, 0.0), (0.4, 18.0), (0.75, 10.0), (1.0, 14.0)], + span: (2.0, BOX), + z: -52.0, + thickness: 8.0, + }, + 43, + ), + ]; + for (label, c, seed) in &dunes { + let asset = build_curtain(gpu, renderer, label, c, &paint::ramp_dune(), *seed); + push_unlit(scene, asset, Mat4::IDENTITY); + } + + // --- the purple cube in the dark room -------------------------------- + { + let img = paint::cube_faces(64, 96, 51); + let mut m = MeshBuf::default(); + let s = 5.5; + let f = |band: f32| -> [[f32; 2]; 4] { + let v0 = band / 3.0 + 0.04; + let v1 = (band + 1.0) / 3.0 - 0.04; + [[0.1, v0], [0.9, v0], [0.9, v1], [0.1, v1]] + }; + let c = [ + Vec3::new(-s, -s, -s), + Vec3::new(s, -s, -s), + Vec3::new(s, s, -s), + Vec3::new(-s, s, -s), + Vec3::new(-s, -s, s), + Vec3::new(s, -s, s), + Vec3::new(s, s, s), + Vec3::new(-s, s, s), + ]; + m.quad([c[7], c[6], c[2], c[3]], f(0.0), Vec3::Y); // top + m.quad([c[4], c[5], c[6], c[7]], f(1.0), Vec3::Z); // front + m.quad([c[5], c[1], c[2], c[6]], f(2.0), Vec3::X); // right + m.quad([c[0], c[4], c[7], c[3]], f(2.0), -Vec3::X); // left + let asset = m.build(gpu, renderer, "cube", Some(&img)); + push_unlit( + scene, + asset, + Mat4::from_translation(Vec3::new(-25.0, -37.5, -50.0)) * Mat4::from_rotation_y(0.6), + ); + } + + // --- the eye under the fog, out front -------------------------------- + { + let img = paint::iris(128, 61); + let mut m = MeshBuf::default(); + let r = 5.8; + let center = Vec3::new(-14.0, -57.5, 6.0); + m.quad_ds( + [ + center + Vec3::new(-r, -r, 0.0), + center + Vec3::new(r, -r, 0.0), + center + Vec3::new(r, r, 0.0), + center + Vec3::new(-r, r, 0.0), + ], + [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]], + ); + let asset = m.build(gpu, renderer, "iris", Some(&img)); + push_unlit(scene, asset, Mat4::IDENTITY); + } + { + // The lids: chrome waves — the upper one droops over the iris, the + // lower one cradles it. u runs top→bottom of each band so the + // bright lip lands on the lash line. + let img = paint::chrome_ramp(256, 64, &paint::ramp_lid(), 62); + let mut m = MeshBuf::default(); + const N: usize = 48; + let (x0, x1) = (-34.0f32, 6.0f32); + let upper_top = -48.0f32; + let upper = |t: f32| -> f32 { + -52.0 - 1.6 * (t * std::f32::consts::PI).sin() - 1.3 * t + + 0.6 * (t * std::f32::consts::TAU * 1.7).sin() + }; + let lower_bot = -65.5f32; + let lower = |t: f32| -> f32 { + -62.5 + 1.4 * (t * std::f32::consts::PI).sin() + 0.6 * t + - 0.5 * (t * std::f32::consts::TAU * 1.3 + 0.8).sin() + }; + for k in 0..N { + let (t0, t1) = (k as f32 / N as f32, (k + 1) as f32 / N as f32); + let (xa, xb) = (x0 + (x1 - x0) * t0, x0 + (x1 - x0) * t1); + let z = 8.0; + // Upper lid: band + under-rim. + m.quad_ds( + [ + Vec3::new(xa, upper_top, z), + Vec3::new(xb, upper_top, z), + Vec3::new(xb, upper(t1), z), + Vec3::new(xa, upper(t0), z), + ], + [ + [0.85, t0 * 1.4], + [0.85, t1 * 1.4], + [0.02, t1 * 1.4], + [0.02, t0 * 1.4], + ], + ); + m.quad_ds( + [ + Vec3::new(xa, upper(t0), z), + Vec3::new(xb, upper(t1), z), + Vec3::new(xb, upper(t1), z - 2.5), + Vec3::new(xa, upper(t0), z - 2.5), + ], + [[0.02, t0], [0.02, t1], [0.25, t1], [0.25, t0]], + ); + // Lower lid. + m.quad_ds( + [ + Vec3::new(xa, lower(t0), z), + Vec3::new(xb, lower(t1), z), + Vec3::new(xb, lower_bot, z), + Vec3::new(xa, lower_bot, z), + ], + [ + [0.02, t0 * 1.4], + [0.02, t1 * 1.4], + [0.6, t1 * 1.4], + [0.6, t0 * 1.4], + ], + ); + } + let asset = m.build(gpu, renderer, "lids", Some(&img)); + push_unlit(scene, asset, Mat4::IDENTITY); + } +} diff --git a/pocket3d/examples/chorus/src/main.rs b/pocket3d/examples/chorus/src/main.rs new file mode 100644 index 00000000..0c3205fd --- /dev/null +++ b/pocket3d/examples/chorus/src/main.rs @@ -0,0 +1,465 @@ +//! chorus — a dream-corridor diorama that lives on the desktop. +//! +//! A pocket-widget study of the airbrush-chrome record-sleeve look: a +//! framed box recedes wing by wing — cut-metal curtains carrying faces in +//! profile — to a starburst deep inside. The whole set is procedural +//! (geometry in `diorama`, painted ramps in `paint`, everything unlit so +//! the airbrush shading is exactly what you painted), and the window is a +//! borderless, transparent, always-on-top widget. +//! +//! cargo run -p chorus +//! cargo run -p chorus -- --screenshot out.png --yaw 18 --pitch -8 +//! +//! Interactions: drag inside the frame to orbit and peer around the +//! curtains, scroll to move closer, double-click to dive between the home +//! and close framings, drag the frame itself to move the window, arrows +//! nudge, R recenters, Esc quits. `--still` freezes the star and fog, and +//! the widget then renders zero frames at rest. + +mod diorama; +mod paint; + +use std::path::PathBuf; + +use anyhow::{Result, anyhow}; +use glam::{Mat3, Vec2, Vec3}; +use pocket_widget::shell::{WidgetConfig, WidgetGame}; +use pocket3d::camera::Camera; +use pocket3d::gpu::{Gpu, OFFSCREEN_FORMAT, OffscreenTarget}; +use pocket3d::hud::Hud; +use pocket3d::input::Input; +use pocket3d::renderer::Renderer; +use pocket3d::scene::{Beam, Scene, Sprite}; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; + +use diorama::{OPEN, STAR}; + +/// Window size, logical px. +const WIN_W: u32 = 400; +const WIN_H: u32 = 460; + +/// The camera orbits this point; the whole interaction is spherical +/// coordinates around it. +const PIVOT: Vec3 = Vec3::new(0.0, -4.0, -30.0); +const HOME_DIST: f32 = 310.0; +const PEER_DIST: f32 = 150.0; +const DIST_RANGE: (f32, f32) = (130.0, 420.0); +const YAW_MAX: f32 = 0.55; +const PITCH_MAX: f32 = 0.38; +/// Double-click window in ticks (60 Hz). +const DOUBLE_CLICK_TICKS: u64 = 21; +/// Framing ease duration in ticks. +const EASE_TICKS: f32 = 24.0; + +/// Fog puffs: (base position, size, alpha, drift phase, drift amplitude). +/// Inside along the floor, on the sill, and spilling out over the desk. +const FOG: [([f32; 3], f32, f32, f32, f32); 16] = [ + // deep interior + ([-20.0, -34.0, -70.0], 34.0, 0.10, 0.0, 2.0), + ([12.0, -36.0, -55.0], 30.0, 0.12, 1.3, 2.5), + ([-4.0, -33.0, -42.0], 26.0, 0.10, 2.1, 2.0), + ([26.0, -35.0, -30.0], 28.0, 0.11, 3.2, 2.2), + ([-28.0, -36.0, -26.0], 30.0, 0.12, 4.0, 2.6), + // the sill + ([-14.0, -43.0, -6.0], 30.0, 0.14, 0.7, 3.0), + ([14.0, -45.0, -2.0], 34.0, 0.15, 1.9, 3.2), + ([34.0, -49.0, 2.0], 26.0, 0.08, 2.8, 2.4), + ([-38.0, -48.0, 0.0], 26.0, 0.08, 3.9, 2.6), + // the spill, out front and below + ([-26.0, -54.0, 8.0], 40.0, 0.13, 0.4, 3.6), + ([4.0, -58.0, 12.0], 44.0, 0.12, 1.6, 4.0), + ([30.0, -55.0, 9.0], 34.0, 0.11, 2.5, 3.0), + ([-46.0, -50.0, 6.0], 30.0, 0.09, 3.4, 2.8), + ([46.0, -50.0, 5.0], 28.0, 0.08, 4.4, 2.6), + // the wisp climbing the left frame edge + ([-50.0, -8.0, 3.0], 18.0, 0.05, 5.1, 2.0), + ([-46.0, 10.0, 2.0], 14.0, 0.04, 5.9, 1.6), +]; + +struct ChorusGame { + scene: Scene, + camera: Camera, + hud: Hud, + window_px: (u32, u32), + ticks: u64, + + /// Orbit state (user-controlled part of the camera). + yaw: f32, + pitch: f32, + dist: f32, + /// Cursor grab: (cursor at press, yaw at press, pitch at press). + grab: Option<(Vec2, f32, f32)>, + /// Peer framing: dist eases toward PEER_DIST while 1. + peer: bool, + blend: f32, + /// Dist as set by scrolling in the current framing. + dist_home: f32, + last_open_click: Option, + + animate: bool, + dirty: bool, + exit: bool, + quit_after: Option, +} + +impl ChorusGame { + fn new(animate: bool) -> Self { + let scene = Scene { + transparent_clear: true, + ..Default::default() + }; + let camera = Camera { + pos: PIVOT + Vec3::new(0.0, 0.0, HOME_DIST), + fov_y: 28f32.to_radians(), + znear: 20.0, + zfar: 1500.0, + ..Default::default() + }; + Self { + scene, + camera, + hud: Hud::default(), + window_px: (WIN_W, WIN_H), + ticks: 0, + yaw: 0.0, + pitch: 0.0, + dist: HOME_DIST, + grab: None, + peer: false, + blend: 0.0, + dist_home: HOME_DIST, + last_open_click: None, + animate, + dirty: true, + exit: false, + quit_after: None, + } + } + + /// Does a window ray pass through the frame opening? (The z=0 plane is + /// the frame face; anything through the hole is scene interaction, + /// anything on or outside the frame is a window gesture.) + fn through_opening(&self, cursor: Vec2) -> bool { + let (origin, dir) = self + .camera + .screen_ray(cursor, (self.window_px.0 as f32, self.window_px.1 as f32)); + if dir.z.abs() < 1e-5 { + return false; + } + let t = -origin.z / dir.z; + if t <= 0.0 { + return false; + } + let hit = origin + dir * t; + hit.x.abs() < OPEN && hit.y.abs() < OPEN + } + + /// The star and the fog — rebuilt every tick, a couple dozen quads. + fn dress(&mut self, t: f32) { + let (tw, drift) = if self.animate { + ( + 1.0 + 0.08 * (t * 2.3).sin() + 0.04 * (t * 3.9 + 1.7).sin(), + 1.0, + ) + } else { + (1.0, 0.0) + }; + self.scene.sprites.clear(); + self.scene.beams.clear(); + + // Starburst: a hot core, two halos, long thin spikes. + let star = |v: Vec3| STAR + v; + self.scene.sprites.push(Sprite { + pos: STAR, + size: 7.0 * tw, + color: [1.0, 1.0, 1.0, 1.0], + }); + self.scene.sprites.push(Sprite { + pos: STAR, + size: 18.0 * tw, + color: [0.8, 0.95, 1.0, 0.5], + }); + self.scene.sprites.push(Sprite { + pos: STAR, + size: 40.0, + color: [0.6, 0.8, 1.0, 0.2], + }); + let spike = |a: Vec3, b: Vec3, w: f32, alpha: f32| Beam { + a: star(a), + b: star(b), + width: w, + color: [0.88, 0.96, 1.0, alpha], + }; + let (h, v, d) = (30.0 * tw, 20.0 * tw, 10.0 * tw); + self.scene.beams.push(spike( + Vec3::new(-h, 0.0, 0.0), + Vec3::new(h, 0.0, 0.0), + 1.5, + 0.85, + )); + self.scene.beams.push(spike( + Vec3::new(0.0, -v, 0.0), + Vec3::new(0.0, v, 0.0), + 1.5, + 0.85, + )); + self.scene.beams.push(spike( + Vec3::new(-d, -d, 0.0), + Vec3::new(d, d, 0.0), + 1.0, + 0.5, + )); + self.scene.beams.push(spike( + Vec3::new(-d, d, 0.0), + Vec3::new(d, -d, 0.0), + 1.0, + 0.5, + )); + + // Fog: additive pale blue, drifting on staggered phases. + for (base, size, alpha, phase, amp) in FOG { + let p = Vec3::from(base) + + Vec3::new( + (t * 0.35 + phase).sin() * amp * drift, + (t * 0.22 + phase * 1.7).sin() * amp * 0.35 * drift, + 0.0, + ); + self.scene.sprites.push(Sprite { + pos: p, + size, + color: [0.55, 0.68, 0.9, alpha], + }); + } + } +} + +impl WidgetGame for ChorusGame { + fn init(&mut self, gpu: &Gpu, renderer: &mut Renderer) -> Result<()> { + diorama::build(gpu, renderer, &mut self.scene); + log::info!( + "chorus: {} set pieces, {} fog puffs", + self.scene.models.len(), + FOG.len() + ); + Ok(()) + } + + fn tick(&mut self, _dt: f32, input: &Input, window_px: (u32, u32)) -> Result<()> { + self.window_px = window_px; + if input.key_pressed(KeyCode::Escape) + || self.quit_after.is_some_and(|limit| self.ticks >= limit) + { + self.exit = true; + } + + // --- orbit: drag inside the opening ------------------------------- + let cursor = input.cursor(); + if let Some(c) = cursor { + if input.mouse_button_pressed(MouseButton::Left) && self.through_opening(c) { + self.grab = Some((c, self.yaw, self.pitch)); + let double = self + .last_open_click + .is_some_and(|at| self.ticks - at <= DOUBLE_CLICK_TICKS); + self.last_open_click = Some(self.ticks); + if double { + self.peer = !self.peer; + } + } + if let Some((c0, yaw0, pitch0)) = self.grab { + let (w, h) = (window_px.0 as f32, window_px.1 as f32); + let yaw = (yaw0 + (c.x - c0.x) / w * 1.6).clamp(-YAW_MAX, YAW_MAX); + let pitch = (pitch0 + (c.y - c0.y) / h * 1.2).clamp(-PITCH_MAX, PITCH_MAX); + if yaw != self.yaw || pitch != self.pitch { + self.yaw = yaw; + self.pitch = pitch; + self.dirty = true; + } + } + } + if !input.mouse_button_down(MouseButton::Left) { + self.grab = None; + } + + // --- keyboard nudge + reset --------------------------------------- + let nudge = 0.014; + for (code, dy, dp) in [ + (KeyCode::ArrowLeft, -nudge, 0.0), + (KeyCode::ArrowRight, nudge, 0.0), + (KeyCode::ArrowUp, 0.0, nudge), + (KeyCode::ArrowDown, 0.0, -nudge), + ] { + if input.key_down(code) { + self.yaw = (self.yaw + dy).clamp(-YAW_MAX, YAW_MAX); + self.pitch = (self.pitch + dp).clamp(-PITCH_MAX, PITCH_MAX); + self.dirty = true; + } + } + if input.key_pressed(KeyCode::KeyR) { + self.yaw = 0.0; + self.pitch = 0.0; + self.dist_home = HOME_DIST; + self.peer = false; + self.dirty = true; + } + + // --- dolly: scroll ------------------------------------------------ + let scroll = input.scroll().y; + if scroll != 0.0 { + self.dist_home = (self.dist_home - scroll * 0.7).clamp(DIST_RANGE.0, DIST_RANGE.1); + self.dirty = true; + } + + // --- peer framing ease -------------------------------------------- + let target = if self.peer { 1.0 } else { 0.0 }; + if self.blend != target { + let step = 1.0 / EASE_TICKS; + self.blend = if self.blend < target { + (self.blend + step).min(target) + } else { + (self.blend - step).max(target) + }; + self.dirty = true; + } + + if self.animate { + self.dirty = true; // the star breathes, the fog drifts + } + self.ticks += 1; + Ok(()) + } + + fn take_dirty(&mut self) -> bool { + std::mem::take(&mut self.dirty) + } + + fn prepare(&mut self, _gpu: &Gpu) -> Result<()> { + Ok(()) + } + + fn compose(&mut self, time: f32, _size: (u32, u32)) -> (&Scene, &Camera, &Hud) { + self.dress(time); + // Idle sway on top of the user's orbit — the diorama breathes. + let (sway_y, sway_p) = if self.animate && self.grab.is_none() { + ( + (time * 0.45).sin() * 0.02, + (time * 0.31 + 1.0).sin() * 0.014, + ) + } else { + (0.0, 0.0) + }; + let ease = self.blend * self.blend * (3.0 - 2.0 * self.blend); + let dist = self.dist_home + (PEER_DIST - self.dist_home) * ease; + self.dist = dist; + let rot = Mat3::from_rotation_y(self.yaw + sway_y) + * Mat3::from_rotation_x(-(self.pitch + sway_p)); + self.camera.pos = PIVOT + rot * Vec3::new(0.0, 0.0, dist); + self.camera.look_at(PIVOT); + self.scene.time = time; + (&self.scene, &self.camera, &self.hud) + } + + fn drag_at(&mut self, cursor: Vec2) -> bool { + // The frame (and everything outside it) is a handle; the view + // through the opening is the scene. + !self.through_opening(cursor) + } + + fn wants_exit(&self) -> bool { + self.exit + } +} + +// --------------------------------------------------------------------------- +// CLI + headless +// --------------------------------------------------------------------------- + +struct Args { + screenshot: Option, + frames: u32, + yaw: f32, + pitch: f32, + dist: f32, + still: bool, + auto_quit: Option, +} + +fn parse_args() -> Result { + let mut args = Args { + screenshot: None, + frames: 30, + yaw: 0.0, + pitch: 0.0, + dist: HOME_DIST, + still: false, + auto_quit: None, + }; + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + let mut val = |name: &str| -> Result { + it.next().ok_or_else(|| anyhow!("{name} needs a value")) + }; + match a.as_str() { + "--screenshot" => args.screenshot = Some(PathBuf::from(val("--screenshot")?)), + "--frames" => args.frames = val("--frames")?.parse()?, + "--yaw" => args.yaw = val("--yaw")?.parse::()?.to_radians(), + "--pitch" => args.pitch = val("--pitch")?.parse::()?.to_radians(), + "--dist" => args.dist = val("--dist")?.parse()?, + "--still" => args.still = true, + "--auto-quit" => args.auto_quit = Some(val("--auto-quit")?.parse()?), + other => return Err(anyhow!("unknown flag {other}")), + } + } + Ok(args) +} + +fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let args = parse_args()?; + let mut game = ChorusGame::new(!args.still); + game.yaw = args.yaw.clamp(-YAW_MAX, YAW_MAX); + game.pitch = args.pitch.clamp(-PITCH_MAX, PITCH_MAX); + game.dist_home = args.dist.clamp(DIST_RANGE.0, DIST_RANGE.1); + game.quit_after = args.auto_quit.map(|s| (s * 60.0) as u64); + if let Some(out) = args.screenshot.clone() { + headless(game, &args, &out) + } else { + pocket_widget::run( + WidgetConfig { + title: "Pocket Chorus".into(), + size: (WIN_W, WIN_H), + ..Default::default() + }, + game, + ) + } +} + +/// N fixed ticks, one composite PNG at 2× — the alpha channel is the real +/// window transparency, so the spill fog glows over whatever it lands on. +fn headless(mut game: ChorusGame, args: &Args, out: &std::path::Path) -> Result<()> { + let (w, h) = (WIN_W * 2, WIN_H * 2); + let gpu = Gpu::new_headless()?; + let mut renderer = Renderer::new(&gpu, OFFSCREEN_FORMAT)?; + game.init(&gpu, &mut renderer)?; + + let input = Input::default(); + for _ in 0..args.frames { + game.tick(1.0 / 60.0, &input, (w, h))?; + } + game.take_dirty(); + game.prepare(&gpu)?; + let target = OffscreenTarget::new(&gpu, w, h); + let (scene, camera, hud) = game.compose(args.frames as f32 / 60.0, (w, h)); + renderer.render(&gpu, &target.view, (w, h), scene, camera, hud); + target.save_png(&gpu, out)?; + println!( + "chorus: wrote {} after {} frames (yaw {:.1}°, pitch {:.1}°, dist {:.0})", + out.display(), + args.frames, + args.yaw.to_degrees(), + args.pitch.to_degrees(), + game.dist + ); + Ok(()) +} diff --git a/pocket3d/examples/chorus/src/paint.rs b/pocket3d/examples/chorus/src/paint.rs new file mode 100644 index 00000000..fe31cedc --- /dev/null +++ b/pocket3d/examples/chorus/src/paint.rs @@ -0,0 +1,513 @@ +//! Procedural airbrush textures. Everything the diorama wears is painted +//! here at load time — multi-stop chrome ramps for the curtain layers, the +//! striped ceiling, the red-grid dark room, the pixel-city wall, the glow +//! wall behind everything. Deterministic (LCG grain), no assets on disk. + +/// A painted RGBA8 image. +pub struct Img { + pub w: u32, + pub h: u32, + pub px: Vec, +} + +impl Img { + pub fn new(w: u32, h: u32) -> Self { + Self { + w, + h, + px: vec![0; (w * h * 4) as usize], + } + } + + pub fn set(&mut self, x: u32, y: u32, c: [f32; 3]) { + let i = ((y * self.w + x) * 4) as usize; + self.px[i] = (c[0].clamp(0.0, 1.0) * 255.0) as u8; + self.px[i + 1] = (c[1].clamp(0.0, 1.0) * 255.0) as u8; + self.px[i + 2] = (c[2].clamp(0.0, 1.0) * 255.0) as u8; + self.px[i + 3] = 255; + } +} + +/// Tiny deterministic noise source (screenshots stay byte-stable). +pub struct Lcg(pub u32); + +impl Lcg { + pub fn next(&mut self) -> f32 { + self.0 = self.0.wrapping_mul(1664525).wrapping_add(1013904223); + (self.0 >> 8) as f32 / 16_777_216.0 + } + /// Uniform in [-1, 1]. + pub fn signed(&mut self) -> f32 { + self.next() * 2.0 - 1.0 + } +} + +pub fn rgb(hex: u32) -> [f32; 3] { + [ + ((hex >> 16) & 0xff) as f32 / 255.0, + ((hex >> 8) & 0xff) as f32 / 255.0, + (hex & 0xff) as f32 / 255.0, + ] +} + +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} + +fn smooth(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Sample a multi-stop gradient at u∈[0,1]. Stops are (position, color), +/// positions ascending; interpolation is smoothstepped so bands stay soft — +/// the airbrush look lives in the easing. +pub fn grad(stops: &[(f32, [f32; 3])], u: f32) -> [f32; 3] { + let u = u.clamp(0.0, 1.0); + let mut prev = stops[0]; + for &s in stops { + if u <= s.0 { + let span = (s.0 - prev.0).max(1e-5); + return lerp3(prev.1, s.1, smooth((u - prev.0) / span)); + } + prev = s; + } + prev.1 +} + +/// Horizontal chrome ramp: u runs across (edge → wall), v gets faint +/// large-scale luminance sway + fine grain so flats never read sterile. +pub fn chrome_ramp(w: u32, h: u32, stops: &[(f32, [f32; 3])], seed: u32) -> Img { + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + let grain: Vec = (0..(w * h)).map(|_| noise.signed() * 0.014).collect(); + for y in 0..h { + let v = y as f32 / (h - 1).max(1) as f32; + let sway = (v * std::f32::consts::TAU * 1.5).sin() * 0.02; + for x in 0..w { + let u = x as f32 / (w - 1) as f32; + let mut c = grad(stops, u); + let g = grain[(y * w + x) as usize] + sway; + c = [c[0] + g, c[1] + g, c[2] + g]; + img.set(x, y, c); + } + } + img +} + +/// The ceiling: red/orange/green airbrush stripes running along x, receding +/// with depth (u = depth 0 front → 1 back), melting into the glow near the +/// back wall. +pub fn ceiling_stripes(w: u32, h: u32, seed: u32) -> Img { + let stripes = [ + (0.00, rgb(0x2A1714)), + (0.08, rgb(0x8E2418)), + (0.16, rgb(0xD96A35)), + (0.24, rgb(0xC3391F)), + (0.32, rgb(0x1F3A28)), + (0.40, rgb(0xD3542A)), + (0.48, rgb(0x27492F)), + (0.56, rgb(0xE0854A)), + (0.64, rgb(0x7E1F14)), + (0.72, rgb(0xD96A35)), + (0.80, rgb(0x1F3A28)), + (0.88, rgb(0xB93A20)), + (1.00, rgb(0xE8D9BF)), + ]; + let glow = rgb(0xF2EDE0); + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + let grain: Vec = (0..(w * h)).map(|_| noise.signed() * 0.012).collect(); + for y in 0..h { + for x in 0..w { + let u = x as f32 / (w - 1) as f32; + let mut c = grad(&stripes, u); + // Melt into the back glow over the last stretch. + let melt = smooth((u - 0.72) / 0.28); + c = lerp3(c, glow, melt * 0.85); + let g = grain[(y * w + x) as usize]; + img.set(x, y, [c[0] + g, c[1] + g, c[2] + g]); + } + } + img +} + +/// The left wall, two registers like the sleeve's left side: red/green +/// airbrush stripes across the top, and below them the dark room with a +/// red-lit brick grid smoldering in it. u = depth (front → back), +/// v = height (top → bottom). +pub fn grid_wall(w: u32, h: u32, seed: u32) -> Img { + let stripes = [ + (0.00, rgb(0x7E1F14)), + (0.07, rgb(0xC3391F)), + (0.14, rgb(0xE0854A)), + (0.2, rgb(0x27492F)), + (0.27, rgb(0xD3542A)), + (0.33, rgb(0x1F3A28)), + (0.4, rgb(0x8E2418)), + ]; + let base_top = rgb(0x131A2C); + let base_bot = rgb(0x181F33); + let ember = rgb(0xD8502E); + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + let grain: Vec = (0..(w * h)).map(|_| noise.signed() * 0.012).collect(); + // Per-cell ember intensity. + let (cw, ch) = (14u32, 9u32); + let mut cell_noise = Lcg(seed ^ 0x5bd1); + let cells: Vec = (0..((w / cw + 2) * (h / ch + 2))) + .map(|_| cell_noise.next()) + .collect(); + let cells_per_row = w / cw + 2; + for y in 0..h { + let v = y as f32 / (h - 1) as f32; + for x in 0..w { + let u = x as f32 / (w - 1) as f32; + let mut c = lerp3(base_top, base_bot, v); + // Upper register: stripes, dimming as the room takes over. + if v < 0.46 { + let s = grad(&stripes, v); + let hand_off = smooth((v - 0.3) / 0.16); + // Stripes glow a little brighter deeper in (toward u=1). + let lit = lerp3(s, [s[0] * 1.25, s[1] * 1.25, s[2] * 1.25], u * 0.6); + c = lerp3(lit, c, hand_off); + } + // The grid lives in the lower middle of the room and dies out + // toward the back and the floor. + let zone = smooth((v - 0.42) / 0.16) + * (1.0 - smooth((u - 0.55) / 0.3)) + * (1.0 - smooth((v - 0.92) / 0.08)); + if zone > 0.01 { + let (gx, gy) = (x % cw, y % ch); + let cell = cells[((y / ch) * cells_per_row + x / cw) as usize]; + let line = gx < 2 || gy < 2; + if line { + let heat = zone * (0.25 + 0.75 * cell); + c = lerp3(c, ember, heat); + } else { + // Bricks catch a little of the glow. + c = lerp3(c, rgb(0x3A1A1E), zone * 0.4 * cell); + } + } + let g = grain[(y * w + x) as usize]; + img.set(x, y, [c[0] + g, c[1] + g, c[2] + g]); + } + } + img +} + +/// The pixel city: an indigo→periwinkle wall with washed rectangular blocks +/// like far-off lit windows. u = depth, v = height (top → bottom). +pub fn block_wall(w: u32, h: u32, seed: u32) -> Img { + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + let vert = [ + (0.0, rgb(0x3A3E86)), + (0.45, rgb(0x7A86C6)), + (0.8, rgb(0xAEBBE4)), + (1.0, rgb(0x8E9AD0)), + ]; + for y in 0..h { + let v = y as f32 / (h - 1) as f32; + for x in 0..w { + let u = x as f32 / (w - 1) as f32; + let mut c = grad(&vert, v); + // Brighter toward the deep end where the glow lives. + c = lerp3(c, rgb(0xD9E0F2), smooth((u - 0.6) / 0.4) * 0.5); + let g = noise.signed() * 0.008; + img.set(x, y, [c[0] + g, c[1] + g, c[2] + g]); + } + } + // Washed blocks, denser mid-wall. + let mut rects = Lcg(seed ^ 0x9e37); + for _ in 0..60 { + let bw = (10.0 + rects.next() * 34.0) as u32; + let bh = (8.0 + rects.next() * 22.0) as u32; + let bx = (rects.next() * (w - bw - 1) as f32) as u32; + let by = (rects.next() * (h - bh - 1) as f32) as u32; + let tint = lerp3(rgb(0xC9D3F2), rgb(0xF2F5FC), rects.next()); + let a = 0.28 + rects.next() * 0.4; + for y in by..by + bh { + for x in bx..bx + bw { + // Feather the block edges — hard rects at grazing angles + // alias into streaks. + let edge = (x - bx + 1) + .min(bx + bw - x) + .min(y - by + 1) + .min(by + bh - y) as f32 + / 3.0; + let i = ((y * img.w + x) * 4) as usize; + let c = [ + img.px[i] as f32 / 255.0, + img.px[i + 1] as f32 / 255.0, + img.px[i + 2] as f32 / 255.0, + ]; + img.set(x, y, lerp3(c, tint, a * edge.min(1.0))); + } + } + } + img +} + +/// The back wall: a warm white core (behind the star) breathing out to +/// lavender corners, with faint vertical fold shadows suggesting curtains +/// all the way down. (cx, cy) is the glow center in UV. +pub fn glow_wall(w: u32, h: u32, cx: f32, cy: f32, seed: u32) -> Img { + let radial = [ + (0.0, rgb(0xFFFDF2)), + (0.22, rgb(0xF6F0DF)), + (0.5, rgb(0xD5D4E4)), + (0.8, rgb(0xA7AACE)), + (1.0, rgb(0x8F93BE)), + ]; + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + for y in 0..h { + let v = y as f32 / (h - 1) as f32; + for x in 0..w { + let u = x as f32 / (w - 1) as f32; + let r = (((u - cx) * (u - cx) + (v - cy) * (v - cy)).sqrt() * 1.35).min(1.0); + let mut c = grad(&radial, r); + // Vertical fold hints, strongest away from the core. + let fold = (u * std::f32::consts::TAU * 7.0).sin() * 0.05 * smooth((r - 0.25) / 0.5); + let g = noise.signed() * 0.008 + fold; + c = [c[0] + g, c[1] + g, c[2] + g]; + img.set(x, y, c); + } + } + img +} + +/// The floor: dusty blue fog brightening into the glow at the deep end. +/// u = depth (front → back), v across. +pub fn fog_floor(w: u32, h: u32, seed: u32) -> Img { + let depth = [ + (0.0, rgb(0x5F739E)), + (0.35, rgb(0x8CA2C8)), + (0.7, rgb(0xC2D2E8)), + (1.0, rgb(0xEDF2F8)), + ]; + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + for y in 0..h { + let v = y as f32 / (h - 1) as f32; + for x in 0..w { + let u = x as f32 / (w - 1) as f32; + let mut c = grad(&depth, u); + let billow = ((u * 9.0 + v * 4.0).sin() + (u * 4.0 - v * 7.0).cos()) * 0.012; + let g = noise.signed() * 0.01 + billow; + c = [c[0] + g, c[1] + g, c[2] + g]; + img.set(x, y, c); + } + } + img +} + +/// Frame front: warm gallery cream, barely shaded. +pub fn frame_face(w: u32, h: u32, seed: u32) -> Img { + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + for y in 0..h { + let v = y as f32 / (h - 1) as f32; + for x in 0..w { + let c = lerp3(rgb(0xF0ECE1), rgb(0xE2DDCE), v); + let g = noise.signed() * 0.008; + img.set(x, y, [c[0] + g, c[1] + g, c[2] + g]); + } + } + img +} + +/// Frame bevel: the lit blue windowsill between the cream face and the box. +pub fn bevel_ramp(w: u32, h: u32, seed: u32) -> Img { + chrome_ramp( + w, + h, + &[ + (0.0, rgb(0xD8DFF2)), + (0.35, rgb(0xA9B6E0)), + (0.75, rgb(0x7C8CC8)), + (1.0, rgb(0x6474B4)), + ], + seed, + ) +} + +/// The eye: pale sclera, rust ring, dark pupil, one glint. Radial, drawn +/// straight into a square texture. +pub fn iris(size: u32, seed: u32) -> Img { + let stops = [ + (0.0, rgb(0x0A0A12)), + (0.24, rgb(0x14090E)), + (0.3, rgb(0x6E1F1A)), + (0.42, rgb(0xC24A2C)), + (0.52, rgb(0x7E2A3E)), + (0.6, rgb(0xB8BEDC)), + (0.72, rgb(0xE8ECF6)), + (0.9, rgb(0xD5DEF0)), + (1.0, rgb(0xC5D0E8)), + ]; + let mut img = Img::new(size, size); + let mut noise = Lcg(seed); + for y in 0..size { + let v = y as f32 / (size - 1) as f32 * 2.0 - 1.0; + for x in 0..size { + let u = x as f32 / (size - 1) as f32 * 2.0 - 1.0; + let r = (u * u + v * v).sqrt().min(1.0); + let mut c = grad(&stops, r); + // Glint upper-left of the pupil. + let gd = ((u + 0.12) * (u + 0.12) + (v + 0.16) * (v + 0.16)).sqrt(); + c = lerp3(c, [1.0, 1.0, 1.0], (1.0 - smooth(gd / 0.1)) * 0.9); + let g = noise.signed() * 0.01; + img.set(x, y, [c[0] + g, c[1] + g, c[2] + g]); + } + } + img +} + +// --------------------------------------------------------------------------- +// Curtain palettes — the cast of the corridor, front to back. +// --------------------------------------------------------------------------- + +// Every ramp opens with a bright lip at u=0 (the lit profile edge — it is +// what makes the silhouettes read as cut metal) and settles toward its +// body color at the wall side. + +/// One gradient: (position, color) stops across a curtain, edge → wall. +pub type Ramp = Vec<(f32, [f32; 3])>; + +pub fn ramp_black_profile() -> Ramp { + vec![ + (0.0, rgb(0xEDE7D6)), + (0.02, rgb(0x4A4C54)), + (0.06, rgb(0x181A24)), + (0.3, rgb(0x0C0E16)), + (0.7, rgb(0x121420)), + (1.0, rgb(0x0A0C14)), + ] +} + +pub fn ramp_sage() -> Ramp { + vec![ + (0.0, rgb(0xF7F3E2)), + (0.045, rgb(0xA8A489)), + (0.1, rgb(0xEFEACD)), + (0.18, rgb(0x6E6F58)), + (0.28, rgb(0xDDD8BB)), + (0.4, rgb(0x7C7D63)), + (0.55, rgb(0xCFC9AE)), + (0.72, rgb(0x8B8A70)), + (1.0, rgb(0x5E6050)), + ] +} + +pub fn ramp_pink() -> Ramp { + vec![ + (0.0, rgb(0xF9F1E4)), + (0.05, rgb(0xC9A9A2)), + (0.12, rgb(0xF1DDD2)), + (0.22, rgb(0x96716F)), + (0.34, rgb(0xE7C9BE)), + (0.5, rgb(0xA5807C)), + (0.7, rgb(0xD9B9AE)), + (1.0, rgb(0x7E625F)), + ] +} + +pub fn ramp_teal() -> Ramp { + vec![ + (0.0, rgb(0xF1F7EF)), + (0.06, rgb(0x9FBAAE)), + (0.14, rgb(0xE2EEE5)), + (0.26, rgb(0x6E948C)), + (0.4, rgb(0xD2E4DA)), + (0.6, rgb(0x7FA49B)), + (1.0, rgb(0x5C8078)), + ] +} + +pub fn ramp_lavender() -> Ramp { + vec![ + (0.0, rgb(0xF6F4F0)), + (0.08, rgb(0xC3C4DA)), + (0.2, rgb(0xECECF4)), + (0.35, rgb(0x9FA3C6)), + (0.55, rgb(0xDEDFED)), + (1.0, rgb(0x8E93B8)), + ] +} + +pub fn ramp_warm_fold() -> Ramp { + vec![ + (0.0, rgb(0xFAF6E8)), + (0.08, rgb(0xD8CDB4)), + (0.2, rgb(0xF4EEDA)), + (0.38, rgb(0xB3A98E)), + (0.6, rgb(0xE8E0C8)), + (1.0, rgb(0x9C9278)), + ] +} + +pub fn ramp_valance() -> Ramp { + vec![ + (0.0, rgb(0xF5F0DC)), + (0.05, rgb(0x9A9A7A)), + (0.12, rgb(0xE4DFC0)), + (0.24, rgb(0x6E7054)), + (0.4, rgb(0xCEC9A8)), + (0.6, rgb(0x7E8062)), + (1.0, rgb(0x5A5C48)), + ] +} + +pub fn ramp_dune() -> Ramp { + vec![ + (0.0, rgb(0xFFEDCC)), + (0.06, rgb(0xF0B265)), + (0.16, rgb(0xC87A35)), + (0.3, rgb(0xE89A4E)), + (0.5, rgb(0xB96F2F)), + (0.75, rgb(0xD98A40)), + (1.0, rgb(0xA85F28)), + ] +} + +pub fn ramp_lid() -> Ramp { + vec![ + (0.0, rgb(0xF2EFE2)), + (0.06, rgb(0x8A8EA0)), + (0.16, rgb(0xE6E7EE)), + (0.32, rgb(0x525A74)), + (0.55, rgb(0xC4C9D8)), + (0.8, rgb(0x646C88)), + (1.0, rgb(0x3A4058)), + ] +} + +/// Cube faces: glossy purple, one ramp per face packed vertically. +pub fn cube_faces(w: u32, h: u32, seed: u32) -> Img { + let faces = [ + [(0.0, rgb(0xC9B4F0)), (1.0, rgb(0x9A7ED8))], // top + [(0.0, rgb(0x7A5EC8)), (1.0, rgb(0x503CA0))], // front + [(0.0, rgb(0x453394)), (1.0, rgb(0x2C2068))], // side + ]; + let mut img = Img::new(w, h); + let mut noise = Lcg(seed); + let band = h / 3; + for y in 0..h { + let f = ((y / band) as usize).min(2); + let v = (y % band) as f32 / band.max(1) as f32; + for x in 0..w { + let u = x as f32 / (w - 1) as f32; + let mut c = grad(&faces[f], (u + v) * 0.5); + let g = noise.signed() * 0.01; + c = [c[0] + g, c[1] + g, c[2] + g]; + img.set(x, y, c); + } + } + img +} From d450c7451650cfc7fe602ec2a7fedfc52da1738e Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:30:38 +0800 Subject: [PATCH 2/2] fix(pocket-widget): widget windows live on every Space, over fullscreen apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A widget launched from a fullscreen terminal opened on the desktop Space and appeared to not exist — macOS never switches Spaces for a CLI-spawned window, and normal windows can't join a fullscreen Space at all. Set NSWindow.collectionBehavior to CanJoinAllSpaces | Stationary | IgnoresCycle | FullScreenAuxiliary on the shell's window (winit exposes no API for this, so it goes through the raw AppKit handle; objc2 is already in the tree via winit). On by default for both widget shapes as `WidgetConfig::all_spaces` — a desktop widget that vanishes when you switch Spaces isn't ambient — and a no-op off macOS. Co-Authored-By: Claude Fable 5 --- pocket3d/Cargo.lock | 1 + pocket3d/crates/pocket-widget/Cargo.toml | 5 ++ pocket3d/crates/pocket-widget/src/embed.rs | 2 +- pocket3d/crates/pocket-widget/src/pick.rs | 8 ++- pocket3d/crates/pocket-widget/src/shell.rs | 63 +++++++++++++++++----- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/pocket3d/Cargo.lock b/pocket3d/Cargo.lock index 7bb7968c..9d90720b 100644 --- a/pocket3d/Cargo.lock +++ b/pocket3d/Cargo.lock @@ -1659,6 +1659,7 @@ dependencies = [ "anyhow", "glam", "log", + "objc2", "pocket-ui-wgpu", "pocket3d", "pocketjs-core", diff --git a/pocket3d/crates/pocket-widget/Cargo.toml b/pocket3d/crates/pocket-widget/Cargo.toml index dd7716df..56c69bfe 100644 --- a/pocket3d/crates/pocket-widget/Cargo.toml +++ b/pocket3d/crates/pocket-widget/Cargo.toml @@ -15,3 +15,8 @@ winit = { workspace = true } glam = { workspace = true, features = ["std"] } anyhow = { workspace = true } log = { workspace = true } + +# Widget windows set NSWindow.collectionBehavior (all Spaces, over +# fullscreen apps) — winit doesn't expose it. Same objc2 winit pins. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" diff --git a/pocket3d/crates/pocket-widget/src/embed.rs b/pocket3d/crates/pocket-widget/src/embed.rs index 9669cab0..950be8d3 100644 --- a/pocket3d/crates/pocket-widget/src/embed.rs +++ b/pocket3d/crates/pocket-widget/src/embed.rs @@ -7,8 +7,8 @@ //! re-renders nothing, which is the heart of the shell's demand rendering. use anyhow::Result; -use pocket3d::gpu::{Gpu, OFFSCREEN_FORMAT, OffscreenTarget}; use pocket_ui_wgpu::{UiRenderer, UiSurface}; +use pocket3d::gpu::{Gpu, OFFSCREEN_FORMAT, OffscreenTarget}; pub struct EmbeddedUi { surface: UiSurface, diff --git a/pocket3d/crates/pocket-widget/src/pick.rs b/pocket3d/crates/pocket-widget/src/pick.rs index 355f7981..08213830 100644 --- a/pocket3d/crates/pocket-widget/src/pick.rs +++ b/pocket3d/crates/pocket-widget/src/pick.rs @@ -70,16 +70,14 @@ mod tests { fn t_is_world_scale_under_rotation_and_translation() { // Box rotated 45° around Y and pushed to x=10: a ray down -X from // (20, 0, 0) hits the corner-on silhouette at 10 - sqrt(2). - let m = Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0)) - * Mat4::from_rotation_y(FRAC_PI_4); + let m = + Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0)) * Mat4::from_rotation_y(FRAC_PI_4); let t = ray_obb(Vec3::new(20.0, 0.0, 0.0), Vec3::NEG_X, &m, BOX).unwrap(); assert!((t - (10.0 - 2.0f32.sqrt())).abs() < 1e-3, "t = {t}"); } #[test] fn parallel_ray_outside_slab_misses() { - assert!( - ray_obb(Vec3::new(0.0, 2.0, 5.0), Vec3::NEG_Z, &Mat4::IDENTITY, BOX).is_none() - ); + assert!(ray_obb(Vec3::new(0.0, 2.0, 5.0), Vec3::NEG_Z, &Mat4::IDENTITY, BOX).is_none()); } } diff --git a/pocket3d/crates/pocket-widget/src/shell.rs b/pocket3d/crates/pocket-widget/src/shell.rs index 29b83635..36d8f138 100644 --- a/pocket3d/crates/pocket-widget/src/shell.rs +++ b/pocket3d/crates/pocket-widget/src/shell.rs @@ -60,6 +60,11 @@ pub struct WidgetConfig { /// `ime_events` stream; the game reports its caret rect through /// `ime_cursor_area` so candidate windows dock next to the text. pub ime: bool, + /// Live on every Space, including over fullscreen apps (macOS: NSWindow + /// collectionBehavior CanJoinAllSpaces | FullScreenAuxiliary | + /// Stationary). Without this a widget launched from a fullscreen + /// terminal opens on the desktop Space and appears to not exist. + pub all_spaces: bool, } impl Default for WidgetConfig { @@ -75,10 +80,42 @@ impl Default for WidgetConfig { resizable: false, min_size: (160, 120), ime: false, + all_spaces: true, } } } +/// Make the window a real desktop widget: visible on every Space, allowed +/// over fullscreen apps, unaffected by Spaces gestures. winit has no API +/// for `NSWindow.collectionBehavior`, so this reaches through the raw +/// handle. No-op off macOS. +#[cfg(target_os = "macos")] +fn apply_all_spaces(window: &Window) { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let Ok(handle) = window.window_handle() else { + return; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return; + }; + // NSWindowCollectionBehavior: CanJoinAllSpaces | Stationary | + // IgnoresCycle | FullScreenAuxiliary. + const BEHAVIOR: usize = (1 << 0) | (1 << 4) | (1 << 6) | (1 << 8); + unsafe { + let ns_view = appkit.ns_view.as_ptr() as *mut AnyObject; + let ns_window: *mut AnyObject = msg_send![&*ns_view, window]; + if !ns_window.is_null() { + let _: () = msg_send![&*ns_window, setCollectionBehavior: BEHAVIOR]; + } + } +} + +#[cfg(not(target_os = "macos"))] +fn apply_all_spaces(_window: &Window) {} + /// What the widget loop needs from a 3D product. pub trait WidgetGame { /// Called once after the GPU exists — build assets, boot guests. @@ -132,12 +169,7 @@ pub trait FlatWidget { fn take_dirty(&mut self) -> bool; /// Draw into the swapchain view (submit your own encoder). Called only /// on frames that render. - fn render( - &mut self, - gpu: &Gpu, - view: &wgpu::TextureView, - window_px: (u32, u32), - ) -> Result<()>; + fn render(&mut self, gpu: &Gpu, view: &wgpu::TextureView, window_px: (u32, u32)) -> Result<()>; /// Left-press policy: OS window drag (move) at this cursor position? fn drag_at(&mut self, cursor: Vec2) -> bool { let _ = cursor; @@ -163,7 +195,13 @@ pub trait FlatWidget { /// Run a 3D widget (scene + camera + demand rendering). pub fn run(config: WidgetConfig, game: impl WidgetGame) -> Result<()> { - run_driver(config, SceneDriver { game, renderer: None }) + run_driver( + config, + SceneDriver { + game, + renderer: None, + }, + ) } /// Run a 2D widget (the window is the surface). @@ -379,6 +417,9 @@ impl WidgetApp { if self.config.ime { window.set_ime_allowed(true); } + if self.config.all_spaces { + apply_all_spaces(&window); + } let instance = Gpu::new_instance(); let surface = instance.create_surface(window.clone())?; let gpu = Gpu::from_instance_for_surface(instance, &surface)?; @@ -505,12 +546,8 @@ impl WidgetApp { .texture .create_view(&wgpu::TextureViewDescriptor::default()); let size = (state.surface_config.width, state.surface_config.height); - self.driver.render( - &state.gpu, - &view, - size, - state.start.elapsed().as_secs_f32(), - )?; + self.driver + .render(&state.gpu, &view, size, state.start.elapsed().as_secs_f32())?; state.window.pre_present_notify(); frame.present();