diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ | `t` | People panel (status + selection) | | `[` / `]` | Zoom (Bevy) | | Mouse wheel over right pane / `PageUp` / `PageDown` / `Home` / `End` | Scroll Bevy sidebar | -| `F3` | Toggle Bevy HD-2D material preview | +| `F3` | Flip Bevy render: material (HD-2D, default) / flat sensorium | | `Ctrl+S` / `Ctrl+L` | Save / load | | `q` | Quit | diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -34,9 +34,18 @@ const DETECTION_ROWS: usize = 6; const DETECTION_CELLS: usize = 4; -// HD-2D prototype (F3 material render): one 3D world unit per map tile. +// Material render (HD-2D, default physical view): one 3D world unit per tile. const WALL_HEIGHT: f32 = 1.5; const ROCK_HEIGHT: f32 = 2.0; +/// Cutaway height for south-facing structural mass (wall/rock tiles whose +/// known neighbor toward the camera-side interior is open): low enough to +/// read over, high enough to still read as wall footprint +/// (wiki/interface/material-render.md debt 1, decided by screenshot +/// comparison against a steeper pitch). +const CUTAWAY_HEIGHT: f32 = 0.34; +/// Door slabs cut lower than full height but higher than walls, so a doorway +/// in a cutaway wall line still reads as a door, not a parapet gap. +const DOOR_CUT_HEIGHT: f32 = 0.62; /// Camera pitch below the horizon for the material render (35-45 degrees is /// the Octopath band; 40 keeps tile footprints readable). const CAMERA_TILT_DEG: f32 = 40.0; @@ -186,9 +195,9 @@ } } -/// The default flat sensorium is intentionally schematic. Generated pixel-art -/// swatches stay available to the F3 material preview, but the primary view -/// uses quiet blocks so devices, fog, and cursor semantics read first. +/// The flat sensorium (one F3 from the default material render) is +/// intentionally schematic: quiet blocks so devices, fog, and cursor +/// semantics read first; the pixel-art swatches live in the material render. fn flat_seen_color(tile: TileType) -> Color { use TileType::*; match tile { @@ -257,10 +266,11 @@ /// (wiki/interface/views.md): flipping it changes nothing in the sim and is /// never saved. /// -/// `material == false` (default) is the current flat sensorium render. -/// `material == true` is the HD-2D prototype: a tilted Camera3d over the same -/// tile grid, floor planes, extruded wall boxes, billboarded props/people, and -/// a few point lights. +/// `material == true` (default; wiki/interface/material-render.md criterion +/// 7) is the material render: a tilted Camera3d over the same tile grid, +/// floor planes, extruded wall boxes, billboarded props/people, and a few +/// point lights. `material == false` is the flat sensorium render, one F3 +/// away. #[derive(Resource)] struct RenderMode { material: bool, @@ -273,17 +283,17 @@ impl Default for RenderMode { fn default() -> Self { Self { - material: false, + material: true, dirty: true, zoom: 1.0, } } } -/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|wide|close`, path via -/// `MISALIGNED_SHOT_PATH`): stages a scenario, waits for assets, saves one -/// screenshot, exits. Not a player surface; exists so render experiments can -/// be reviewed from deterministic PNGs. +/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|wide|close|zoomin| +/// zoomout`, path via `MISALIGNED_SHOT_PATH`): stages a scenario, waits for +/// assets, runs the fog audit, saves one screenshot, exits. Not a player +/// surface; exists so render passes can be reviewed from deterministic PNGs. #[derive(Resource)] struct ShotHarness { path: String, @@ -296,6 +306,10 @@ enum TilePart { Floor, Block, + /// Dedicated cap over a block's top face: extruded boxes reuse the wall + /// texture on their sides, so without a cap the tops read as untextured + /// gray slabs (material-render.md debt 2). + Top, Prop, } @@ -335,6 +349,19 @@ cache: HashMap<(TileType, u8, u8), Handle>, } +/// Pooled block meshes for the material render: full-height and cutaway +/// variants per structural class. Blocks handle-swap between them as the +/// south-face cutaway rule changes with fog (never per-entity mesh churn). +#[derive(Resource, Default)] +struct Meshes3d { + wall: Handle, + rock: Handle, + door: Handle, + /// Cutaway parapet, shared by wall and rock. + wall_cut: Handle, + door_cut: Handle, +} + fn fog_key(fog: Fog) -> u8 { match fog { Fog::Seen => 0, @@ -350,6 +377,7 @@ TilePart::Floor => 0, TilePart::Block => 1, TilePart::Prop => 2, + TilePart::Top => 3, } } @@ -767,6 +795,7 @@ menu_pointer, log_row_pointer, manage_menu_ui, + fog_audit_3d, shot_harness_system, ), ) @@ -823,8 +852,95 @@ game.set_cursor(x, y); } } + // Framing-floor checks at the zoom bounds (material-render.md + // criterion 5): min and max of update_camera_real's zoom clamp. + "zoomin" => { + mode.material = true; + mode.zoom = 0.25; + } + "zoomout" => { + mode.material = true; + mode.zoom = 1.6; + } _ => mode.material = false, } +} + +/// Dev-harness fog-contract assertion (material-render.md criterion 4): +/// unknown tiles carry no visible geometry, blueprint/remembered/heard +/// surfaces are unlit model state, only seen surfaces participate in +/// lighting, and people billboards exist only under earned coverage. Also +/// tallies the material pool so per-tile material churn (criterion 6) would +/// show up as an exploding cache. Runs once per screenshot run, prints the +/// tally for the log, and panics on any violation so a capture cannot +/// silently ship a fog leak. Dev-only; inert without MISALIGNED_SHOT. +fn fog_audit_3d( + harness: Option>, + game: Res, + mode: Res, + cache: Res, + mats: Res>, + tiles: Query<( + &Tile3d, + &MeshMaterial3d, + &InheritedVisibility, + )>, + people: Query<(&Person3d, &InheritedVisibility)>, +) { + let Some(h) = harness else { + return; + }; + // One exact frame, late enough that restyle_3d and visibility + // propagation have settled. + if h.frames != 30 || !mode.material { + return; + } + let (mut checked, mut violations) = (0usize, Vec::new()); + for (t, mat, vis) in tiles.iter() { + checked += 1; + let fog = game.sim.fog_at(t.x, t.y); + if matches!(fog, Fog::Unknown) { + if vis.get() { + violations.push(format!("({}, {}) unknown but visible geometry", t.x, t.y)); + } + continue; + } + if !vis.get() { + continue; + } + let Some(m) = mats.get(&mat.0) else { + violations.push(format!("({}, {}) missing material", t.x, t.y)); + continue; + }; + let should_be_lit = matches!(fog, Fog::Seen); + if m.unlit == should_be_lit { + violations.push(format!( + "({}, {}) fog {fog:?} but unlit={} (seen must be lit, model state unlit)", + t.x, t.y, m.unlit + )); + } + } + for (p, vis) in people.iter() { + let earned = game.sim.can_see_person(p.id) + && game + .sim + .person_pos(p.id) + .is_some_and(|(x, y)| game.sim.is_seen(x, y)); + if vis.get() && !earned { + violations.push(format!("person {} visible without coverage", p.id)); + } + } + assert!( + violations.is_empty(), + "fog audit FAILED:\n{}", + violations.join("\n") + ); + println!( + "fog audit OK: {checked} tile entities (unknown=absent, model=unlit, seen=lit), \ + {} people billboards coverage-gated, material pool {} handles", + people.iter().count(), + cache.cache.len() + ); } /// Countdown, capture, countdown, exit: gives the asset loads and the camera @@ -1063,6 +1179,43 @@ ) } +/// The sim-known tile at a coordinate under fog: remembered coordinates +/// render their remembered snapshot, everything else the live map. +fn known_tile(game: &Game, x: i32, y: i32) -> TileType { + match game.sim.fog_at(x, y) { + Fog::Remembered => game + .sim + .remembered + .get(&(x, y)) + .map(|m| m.tile) + .unwrap_or_else(|| game.sim.map.get_tile(x, y)), + _ => game.sim.map.get_tile(x, y), + } +} + +/// South-face cutaway rule (material-render.md debt 1): a structural block +/// drops to parapet height when the tile it hides from the pitched camera +/// (its north neighbor, y - 1, farther from the south-anchored camera) is +/// KNOWN open interior. Gated on the neighbor being known: an unknown +/// neighbor keeps full height, so the cutaway itself never leaks whether +/// unscouted space behind a wall is open (legibility law). +fn cutaway(game: &Game, x: i32, y: i32) -> bool { + y > 0 + && !matches!(game.sim.fog_at(x, y - 1), Fog::Unknown) + && !blocky_tile(known_tile(game, x, y - 1)) +} + +/// Block height in the material render for a structural tile. +fn block_height(tile: TileType, cut: bool) -> f32 { + match (tile, cut) { + (TileType::Rock, false) => ROCK_HEIGHT, + (TileType::Wall, false) => WALL_HEIGHT, + (TileType::Rock | TileType::Wall, true) => CUTAWAY_HEIGHT, + (_, false) => WALL_HEIGHT * 0.9, + (_, true) => DOOR_CUT_HEIGHT, + } +} + /// Ground texture under a tile in the material render. fn floor_texture(tile: TileType, art: &Art) -> Option> { use TileType::*; @@ -1135,6 +1288,41 @@ } } } + TilePart::Top => { + // Dedicated cap treatment (material-render.md debt 2): concrete + // grain darkened per structural class, so tops read as poured + // caps / rock crowns instead of the side texture smeared flat. + let cap = { + use TileType::*; + match tile { + Rock => Color::srgb(0.085, 0.088, 0.095), + Wall => Color::srgb(0.30, 0.31, 0.33), + SecurityDoor3 | SealedDoor | RollDoor => Color::srgb(0.38, 0.13, 0.12), + _ => Color::srgb(0.34, 0.32, 0.28), // door slabs: worn metal + } + }; + match fog { + Fog::Seen => { + m.base_color_texture = Some(art.concrete.clone()); + m.base_color = cap; + } + Fog::Remembered => { + m.unlit = true; + m.base_color_texture = Some(art.concrete.clone()); + let c = cap.to_srgba(); + m.base_color = Color::srgb(c.red * 0.55, c.green * 0.55, c.blue * 0.60); + } + _ => { + // Blueprint: schematic cap over the ghost mass. + m.unlit = true; + m.base_color = if danger_tile(tile) { + Color::srgb(0.18, 0.08, 0.08) + } else { + Color::srgb(0.12, 0.14, 0.17) + }; + } + } + } TilePart::Prop => { let tex = art.tiles.get(&tile).cloned(); m.double_sided = true; @@ -1194,8 +1382,9 @@ } /// Spawn the material-render scene: tilted camera, per-tile floor planes, -/// extruded wall/door boxes, prop and person billboards, and the point-light -/// rig. All of it lives on render layer 1 under one root, hidden until F3. +/// extruded wall/door boxes with dedicated top caps, prop and person +/// billboards, and the point-light rig. All of it lives on render layer 1 +/// under one root that F3 toggles against the flat sensorium root. fn setup_3d( mut commands: Commands, game: Res, @@ -1248,11 +1437,16 @@ )); let floor_mesh = meshes.add(Plane3d::default().mesh().size(1.0, 1.0)); - let wall_mesh = meshes.add(Cuboid::new(1.0, WALL_HEIGHT, 1.0)); - let rock_mesh = meshes.add(Cuboid::new(1.0, ROCK_HEIGHT, 1.0)); - let door_mesh = meshes.add(Cuboid::new(0.98, WALL_HEIGHT * 0.9, 0.4)); + let top_mesh = meshes.add(Plane3d::default().mesh().size(1.0, 1.0)); let prop_mesh = meshes.add(Rectangle::new(0.95, 0.95)); let person_mesh = meshes.add(Rectangle::new(0.62, 1.0)); + let mesh_pool = Meshes3d { + wall: meshes.add(Cuboid::new(1.0, WALL_HEIGHT, 1.0)), + rock: meshes.add(Cuboid::new(1.0, ROCK_HEIGHT, 1.0)), + door: meshes.add(Cuboid::new(0.98, WALL_HEIGHT * 0.9, 0.4)), + wall_cut: meshes.add(Cuboid::new(1.0, CUTAWAY_HEIGHT, 1.0)), + door_cut: meshes.add(Cuboid::new(0.98, DOOR_CUT_HEIGHT, 0.4)), + }; let placeholder = materials.add(StandardMaterial { base_color: Color::BLACK, unlit: true, @@ -1263,22 +1457,53 @@ for x in 0..w { let tile = game.sim.map.get_tile(x, y); if blocky_tile(tile) { - // Structural mass never mutates at runtime; one box per tile. - let (mesh, half) = match tile { - TileType::Rock => (rock_mesh.clone(), ROCK_HEIGHT / 2.0), - TileType::Wall => (wall_mesh.clone(), WALL_HEIGHT / 2.0), - _ => (door_mesh.clone(), WALL_HEIGHT * 0.45), + // Structural mass: one box per tile; restyle_3d swaps between + // the full and cutaway mesh variants as fog earns interiors. + let mesh = match tile { + TileType::Rock => mesh_pool.rock.clone(), + TileType::Wall => mesh_pool.wall.clone(), + _ => mesh_pool.door.clone(), }; commands.spawn(( Mesh3d(mesh), MeshMaterial3d(placeholder.clone()), - Transform::from_translation(grid_to_world_3d(x, y, half)), + Transform::from_translation(grid_to_world_3d( + x, + y, + block_height(tile, false) / 2.0, + )), Visibility::Hidden, layer.clone(), Tile3d { x, y, part: TilePart::Block, + }, + ChildOf(root), + )); + // Dedicated top cap over the box (material-render.md debt 2): + // the extruded sides keep the tile texture, the top face gets + // its own treatment instead of a smeared gray slab. + let cap_scale = if matches!(tile, TileType::Rock | TileType::Wall) { + Vec3::ONE + } else { + Vec3::new(0.98, 1.0, 0.4) + }; + commands.spawn(( + Mesh3d(top_mesh.clone()), + MeshMaterial3d(placeholder.clone()), + Transform::from_translation(grid_to_world_3d( + x, + y, + block_height(tile, false) + 0.002, + )) + .with_scale(cap_scale), + Visibility::Hidden, + layer.clone(), + Tile3d { + x, + y, + part: TilePart::Top, }, ChildOf(root), )); @@ -1394,6 +1619,8 @@ ChildOf(root), )); } + + commands.insert_resource(mesh_pool); } /// Apply the F3 toggle: activate the 3D camera, stop/resume the 2D camera's @@ -1441,17 +1668,23 @@ } /// Restyle the 3D scene from sim facts: same fog precedence as render_map, -/// projected into visibility + pooled materials instead of sprite tints. -/// Unknown space stays geometry-free — sensor darkness, not hidden art. +/// projected into visibility + pooled materials/meshes instead of sprite +/// tints. Unknown space stays geometry-free — sensor darkness, not hidden +/// art. Structural blocks swap to the cutaway parapet mesh when they would +/// hide known interior from the pitched camera (south-face occlusion, +/// material-render.md debt 1), and their top caps ride the same height. fn restyle_3d( game: Res, mut mode: ResMut, art: Res, + pool: Res, mut mats: ResMut>, mut cache: ResMut, mut q: Query<( &Tile3d, + &mut Mesh3d, &mut MeshMaterial3d, + &mut Transform, &mut Visibility, )>, ) { @@ -1462,23 +1695,15 @@ return; } mode.dirty = false; - for (t, mut mat, mut vis) in q.iter_mut() { + for (t, mut mesh, mut mat, mut tf, mut vis) in q.iter_mut() { let fog = game.sim.fog_at(t.x, t.y); - let tile = match fog { - Fog::Remembered => game - .sim - .remembered - .get(&(t.x, t.y)) - .map(|m| m.tile) - .unwrap_or_else(|| game.sim.map.get_tile(t.x, t.y)), - _ => game.sim.map.get_tile(t.x, t.y), - }; + let tile = known_tile(&game, t.x, t.y); let show = match (t.part, fog) { (_, Fog::Unknown) => false, // Heard earns presence only: a dim floor, never shape. (TilePart::Floor, _) => true, - (TilePart::Block, Fog::Heard) => false, - (TilePart::Block, _) => blocky_tile(tile), + (TilePart::Block | TilePart::Top, Fog::Heard) => false, + (TilePart::Block | TilePart::Top, _) => blocky_tile(tile), (TilePart::Prop, Fog::Heard) => false, (TilePart::Prop, _) => prop_tile(tile), }; @@ -1487,6 +1712,25 @@ continue; } *vis = Visibility::Inherited; + if matches!(t.part, TilePart::Block | TilePart::Top) { + let cut = cutaway(&game, t.x, t.y); + let height = block_height(tile, cut); + if t.part == TilePart::Block { + let target = match (tile, cut) { + (TileType::Rock, false) => &pool.rock, + (TileType::Wall, false) => &pool.wall, + (TileType::Rock | TileType::Wall, true) => &pool.wall_cut, + (_, false) => &pool.door, + (_, true) => &pool.door_cut, + }; + if mesh.0 != *target { + mesh.0 = target.clone(); + } + tf.translation.y = height / 2.0; + } else { + tf.translation.y = height + 0.002; + } + } let handle = pooled_material(tile, fog, t.part, &art, &mut mats, &mut cache); if mat.0 != handle { mat.0 = handle; @@ -1507,11 +1751,14 @@ if !mode.material { return; } + // Zoom bounds hold the visual floor at both ends (material-render.md + // criterion 5): min stays above pixel soup, max stays short of a + // postage-stamp cluster in void-dominant darkness. if kb.just_pressed(KeyCode::BracketRight) { - mode.zoom = (mode.zoom * 0.8).max(0.15); + mode.zoom = (mode.zoom * 0.8).max(0.25); } if kb.just_pressed(KeyCode::BracketLeft) { - mode.zoom = (mode.zoom / 0.8).min(2.5); + mode.zoom = (mode.zoom / 0.8).min(1.6); } let (mut min_x, mut min_y, mut max_x, mut max_y) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); @@ -1838,8 +2085,9 @@ return; } - // F3: flip the physical canvas between the flat sensorium render and the - // HD-2D material render. Frontend-only; the sim never learns about it. + // F3: flip the physical canvas between the material render (default; + // material-render.md criterion 7) and the flat sensorium render. + // Frontend-only; the sim never learns about it. if kb.just_pressed(KeyCode::F3) { mode.material = !mode.material; mode.dirty = true; @@ -1847,7 +2095,7 @@ game.add_log( tick, if material { - "View: material render (HD-2D prototype)" + "View: material render" } else { "View: sensorium render" }, diff --git a/wiki/art/pixel-pipeline.md b/wiki/art/pixel-pipeline.md --- a/wiki/art/pixel-pipeline.md +++ b/wiki/art/pixel-pipeline.md @@ -43,9 +43,10 @@ **32x32** despite the OpenAPI schema claiming 16. Everything is standardized on 32x32. - Style coherence comes from two manifest defaults: seed **1337** and the - shared style suffix ("dark evil lair theme, muted palette with purple and - teal accents"). Change them in `assets/pixellab/manifest.json` only, never - per-prompt. + shared style suffix (now the clinical palette: "clinical AI facility, clean + metal and glass, near-monochrome... sterile amber machine light, blood + crimson accent"). Change them in `assets/pixellab/manifest.json` only, + never per-prompt. - **Terrain is the exception.** The shared suffix asks for a "crisp readable silhouette", which makes the model put a focal object in every tile — a crystal spire repeated 3,200 times is visual noise (learned the hard way, @@ -93,6 +94,15 @@ (b1_floor, b1_wall, b1_concrete, b1_rack, b1_ups, b1_switch, b1_patch_panel, b1_breaker_panel, b1_hvac_unit); 15 more b1 entries are in the manifest, pending quota. +- **Palette sweep 2026-07-08** (material-render.md debt 3): door, b1_ups, + b1_hvac_unit, and b1_switch predated the clinical style suffix and read + purple/magenta under the 3D material light. Their pixels were + deterministically hue-remapped in place (violet/dim magenta to desaturated + gunmetal, bright magenta LEDs to sterile amber, saturated teal to + gunmetal); grays, ambers, existing blues, and crimson untouched. A future + quota batch may regenerate them properly; until then the remapped PNGs are + the b1 truth and an audit script confirms no stale purple/teal bands + remain in `assets/pixellab/tiles/`. - The supervillain-fiction assets (minions, agents, henchman, traps, vault, etc.) were deleted 2026-07-05 under the no-dead-code clause; git history has them if ever needed for reference. diff --git a/wiki/interface/bevy-digital-real-canvas.md b/wiki/interface/bevy-digital-real-canvas.md --- a/wiki/interface/bevy-digital-real-canvas.md +++ b/wiki/interface/bevy-digital-real-canvas.md @@ -4,11 +4,13 @@ Type: spec Status: IN PROGRESS Status note: Cameron approved the 2026-07-07 HD-2D prototype screenshots and - chose "land the toggle + write the art spec." The first Bevy material - preview is now a frontend-only F3 toggle behind the default sensorium render: - same anchors, same fog, same UI, no sim/save state. Remaining before - IMPLEMENTED: polish real/digital parity beyond the staging toggle, solve - material-view occlusion/readability, and record an observed final-frame pass. + chose "land the toggle + write the art spec." That art spec, + material-render.md, was implemented 2026-07-08: the material render is now + the DEFAULT Bevy physical view (F3 flips to the flat sensorium), with + occlusion solved by fog-gated south-face cutaways, dedicated top caps, and + the b1 palette sweep. Same anchors, same fog, same UI, no sim/save state. + Remaining before IMPLEMENTED here: digital-dialect depth polish beyond the + flat sensorium and the final real/digital parity pass. Stage: Process / B1 frontend Constitution: "Two views of one world: digital-native and physical", "Visual identity: clinical gore", "Presence: the cursor and the senses", @@ -186,9 +188,11 @@ Cameron's screenshot verdict resolved the taste calls into this staging contract: -1. **Surface:** ship the material render as an F3 preview/toggle, not as a new - default. It is a Bevy representation dialect only: frontend resource, never - sim state, save state, pathing, collision, or knowledge. +1. **Surface:** the material render shipped first as an F3 preview/toggle; + material-render.md (IMPLEMENTED 2026-07-08) then made it the default + physical view once its debts were paid. It remains a Bevy representation + dialect only: frontend resource, never sim state, save state, pathing, + collision, or knowledge. 2. **Camera:** fixed oblique material camera, readability first. The landed prototype uses a pitched perspective Camera3d (FOV 35) because it gave the screenshots enough depth; a future pass may move closer to orthographic, but @@ -209,13 +213,17 @@ 7. **Fog:** unknown spawns no geometry; blueprint/remembered/heard render as unlit or schematic model states; only seen/camera truth participates in rich material lighting. -8. **Digital depth:** the current flat sensorium stays home/default. Digital - overlays (reach traces, nodes, cursor reticle) must remain anchored and - legible in material mode; later digital depth can ghost the same anchors but - must not become a separate topology board. -9. **Known prototype debt:** at the current angle, wall boxes can occlude - interiors behind south walls. A final material pass needs shorter/cutaway - south-facing walls, a steeper camera, or another explicit occlusion rule. +8. **Digital depth:** the flat sensorium remains the digital dialect, one F3 + from the default material view. Digital overlays (reach traces, nodes, + cursor reticle) must remain anchored and legible in material mode; later + digital depth can ghost the same anchors but must not become a separate + topology board. +9. **Occlusion rule (debt paid 2026-07-08):** structural blocks whose known + north neighbor is open interior drop to a low cutaway parapet; the rule is + fog-gated so an unknown neighbor keeps full height and the cutaway never + leaks unscouted layout. Chosen over a steeper camera by screenshot + comparison (the 55 degree pitch flattened the HD-2D depth and still hid + the first tile behind walls). ## Acceptance criteria diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -25,10 +25,14 @@ placeholder color from B1 runtime rendering, gives unknown space deliberate sensor-dark texture, dims physical tile art into a learned-world model, overlays earned device nodes and reach/topology traces, and turns the right -rail into graphical command cards with pinned status/nudge/footer. The first -material pass is available behind F3: a frontend-only HD-2D staging preview -using the same sim facts, fog, anchors, UI, and panels; final real/digital -polish remains in progress. +rail into graphical command cards with pinned status/nudge/footer. As of +2026-07-08 the **material render is the default physical view** +([material-render.md](material-render.md), IMPLEMENTED): an HD-2D treatment +— tilted Camera3d, textured floor planes, extruded wall boxes with dedicated +top caps, fog-gated south-face cutaways, billboarded machines/people, cold +fluorescent plus amber machine lighting — over the same sim facts, fog, +anchors, UI, and panels. F3 flips to the flat sensorium render and back; +the flip is frontend-only and never saved. ## What it renders @@ -63,10 +67,14 @@ selected person's leverage/disposition/obligation/asset card, and persona status. Selection is a `>` marker; Enter opens the context menu on the selection, and the recruit flow prompts for the reveal level. -- **Material preview** — F3 toggles a frontend-only HD-2D material render of - the same canvas: floor planes, wall boxes, billboarded machines/people, and - amber/cold lighting under the same fog rules. It is not saved and changes no - sim state; the flat sensorium remains the default. +- **Material render (default)** — the physical canvas opens in the HD-2D + material render: textured floor planes, extruded wall/rock/door boxes with + dedicated darkened top caps, fog-gated south-face cutaways so room + interiors stay readable at the 40 degree pitch, billboarded + machines/people, and amber/cold lighting under the same fog rules (unknown + = no geometry, blueprint/remembered = unlit model state, seen = lit camera + truth; materials pooled by tile/fog/part). F3 flips to the flat sensorium + render; the mode is not saved and changes no sim state. - **Overlays** — title card and the RUN ENDED card (reason, day / tick). Text remains ASCII-only because Bevy's embedded default font carries a limited @@ -78,12 +86,12 @@ ## Controls Identical to the terminal ([interface/terminal.md](terminal.md), README -controls table), plus zoom, sidebar scrolling, and the material-preview toggle: +controls table), plus zoom, sidebar scrolling, and the render flip: - Move cursor `WASD` / `hjkl` / arrows, or left-click the map; pause `SPACE`/`p`; speed `+`/`-`; zoom `[` / `]`; scroll the right sidebar with - mouse wheel over the pane, `PageUp`/`PageDown`, or `Home`/`End`; F3 toggles - the material preview; quit `q`; save/load `Ctrl+S` / `Ctrl+L`. + mouse wheel over the pane, `PageUp`/`PageDown`, or `Home`/`End`; F3 flips + material/sensorium; quit `q`; save/load `Ctrl+S` / `Ctrl+L`. - **Context menu** — right-click (or Enter on the focused tile) opens the context menu at the pointer, listing `Sim::available_actions` for that anchor with the same content and order as the terminal. Click a row — or diff --git a/wiki/interface/material-render.md b/wiki/interface/material-render.md --- a/wiki/interface/material-render.md +++ b/wiki/interface/material-render.md @@ -2,12 +2,20 @@ ``` Type: spec -Status: READY -Status note: the F3 material-render toggle landed 2026-07-07 (b9f6086) - as an approved experiment; Cameron's verdict was "land the toggle and - write the art spec". This spec drives the material render from - prototype to the game's default physical view. Findings inventory: - wiki/log/2026-07-07-hd2d-prototype.md. +Status: IMPLEMENTED +Status note: implemented 2026-07-08 — the material render is the Bevy + default physical view; F3 flips to the flat sensorium. Debts paid: + fog-gated south-face cutaway (chosen over a steeper pitch by + screenshot comparison), dedicated darkened top caps on all extruded + boxes, palette sweep of the b1 set (door/UPS/HVAC/switch hue-remapped + off the stale purple/teal lair palette), fog contract asserted by a + dev-harness audit (unknown=absent, model=unlit, seen=lit), zoom + bounds tightened to hold the framing floor, and (tile, fog, part) + material pooling with handle swaps (37 pooled handles for ~4600 tile + entities in the audit run). People remain procedural silhouette + billboards pending the cast sprite pass (debt 6, out of scope here). + Prototype findings inventory: wiki/log/2026-07-07-hd2d-prototype.md; + session log: wiki/log/2026-07-08-material-render-default.md. Stage: B1 — The Basement Constitution: "Two views of one world" via interface/views.md (the same-frame canvas; this is the real/material treatment), diff --git a/wiki/log/2026-07-08-material-render-default.md b/wiki/log/2026-07-08-material-render-default.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-07-08-material-render-default.md @@ -0,0 +1,86 @@ +# 2026-07-08 - Material render to default (ROADMAP #27) + +``` +Type: log +``` + +## Intent + +Implement [material-render.md](../interface/material-render.md): pay the +HD-2D prototype's enumerated debts and flip the material render from an F3 +preview to the default Bevy physical view. Frontend + assets + wiki only; no +sim/save changes (the parallel building.md session owned the sim this day). + +## The occlusion decision (debt 1) + +Three variants were screenshotted with the `MISALIGNED_SHOT=wide|close` +harness and compared (session scratchpad `material/`): + +- `before_wide/close.png` — the landed prototype: 40 degree pitch, full + walls. The wall row south of the core room hides the first interior tiles; + the dark region south of the amber room reads as missing scene. +- `pitch55_wide/close.png` — 55 degree pitch, full walls. Interiors improve + slightly but the first tile behind every wall stays hidden, and the + steeper camera flattens exactly what the material render is for: wall + faces, prop billboards, and depth shrink toward a textured top-down map. +- `after_cutaway_wide/close.png` — 40 degrees plus cutaway: structural + blocks whose KNOWN north neighbor is open interior drop to a 0.34 parapet + (doors to a 0.62 slab so a doorway still reads as a door). Strictly more + earned interior is visible at the same depth feel; the previously hidden + remembered/blueprint space south of the core room becomes legible. + +Verdict: **cutaway at the 40 degree pitch**. The rule is fog-gated — an +unknown north neighbor keeps full wall height — so wall height never leaks +whether unscouted space behind a wall is open (legibility law). Cutaway +state is derived per frame in `restyle_3d` by handle-swapping between pooled +full/cutaway meshes; nothing is mutated per tile. + +## The other debts + +- **Top caps (debt 2):** every extruded box gets a `TilePart::Top` cap plane + with a dedicated pooled material — concrete grain darkened per structural + class (gunmetal for walls, near-black for rock, worn metal for door slabs, + a crimson-tinged cap for security mass). No more side texture smeared + across top faces. Caps ride the cutaway height. +- **Palette sweep (debt 3):** a hue audit of `assets/pixellab/tiles/` found + four tiles predating the clinical manifest suffix: door (24% purple), + b1_ups (25%), b1_hvac_unit (13%), b1_switch (11%). Deterministic in-place + remap: violet and dim magenta to desaturated gunmetal, bright magenta LEDs + to sterile amber (machine presence, not decoration), saturated teal to + gunmetal. Re-audit shows no stale bands anywhere in the b1 set + (`palette_before_after.png` in the scratchpad; pixel-pipeline.md updated). +- **Fog contract (debt/criterion 4):** the shot harness now runs + `fog_audit_3d` on every material capture: it asserts unknown tiles have no + visible geometry, visible non-seen surfaces are unlit model state, seen + surfaces are lit, and person billboards are coverage-gated; it panics on + any violation. Output from the landing run: + `fog audit OK: 4608 tile entities (unknown=absent, model=unlit, seen=lit), + 5 people billboards coverage-gated, material pool 37 handles`. +- **Framing floor (criterion 5):** checked at min/default/max zoom + (`final_zoom_min.png`, `after_cutaway_wide.png`, `final_zoom_max.png`). + The old `[`/`]` bounds (0.15-2.5) failed at both ends — pixel soup close + up, postage-stamp cluster in void at 2.5 — so the material zoom clamps + tightened to 0.25-1.6. At 1.6 the full known extent frames with reach + traces legible; at 0.25 tile art stays above native scale. +- **Pooling (criterion 6):** already keyed (tile, fog, part) from the + prototype; the cutaway pass extended it with pooled mesh handle swaps and + the audit now prints the pool size as evidence (37 handles across ~4600 + tile entities; no per-tile material creation in the frame loop). +- **Default flip (criterion 7):** `RenderMode::default` is material; F3 + flips to the flat sensorium and back (log lines and sidebar label + updated); README and bevy.md updated. The sensorium remains byte-for-byte + the digital dialect; the flip is frontend-only and never saved. Relation + to views.md kept honest: views.md's digital-home default governs the full + two-view mechanic and remains READY; this flip sets the *treatment* of the + Bevy physical canvas per material-render.md, whose constitution line reads + this as the real/material half of the same-frame contract. + +## Verification + +`./tools/check.sh` green before and after rebase onto origin/main. Bevy +launch check clean in both render modes (material default and F3 sensorium; +the only ERROR line is the machine-local gilrs IOHIDManager gamepad noise). +Flat-mode screenshot (`final_flat.png`) composition-identical to the +pre-change sensorium; fog audit output recorded above; screenshots reviewed +in this log. Cast-quality person art stays with ROADMAP #13 (spec debt 6 is +explicitly out of scope until the sprite pass). diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,29 @@ ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-08 - Material render to default (ROADMAP #27) + +- Intent: implement material-render.md — pay the HD-2D prototype debts, + make material the default Bevy physical view. +- Changed (src/bin/bevy.rs + assets + wiki only): fog-gated south-face + cutaway parapets (chosen over a 55 degree pitch by screenshot + comparison; unknown neighbors keep full height so wall height leaks + nothing), dedicated darkened top caps (`TilePart::Top`) on all + extruded boxes, b1 palette sweep (door/UPS/HVAC/switch hue-remapped + off the stale purple/teal lair palette, audit clean), `fog_audit_3d` + asserting the fog contract on every harness capture (unknown=absent, + model=unlit, seen=lit, people coverage-gated; pool size printed), + material zoom clamps tightened to 0.25-1.6 for the framing floor, + and `RenderMode::default` flipped to material (F3 flips to the flat + sensorium; never saved). README, bevy.md, canvas spec, + pixel-pipeline.md synced. +- Spec impact: material-render.md READY -> IMPLEMENTED (all seven + criteria); specs.md row; ROADMAP #27 DONE; views.md untouched (the + two-view mechanic and its digital-home default remain READY). +- Checks: ./tools/check.sh green; Bevy launch check clean in both + render modes; fog audit output and screenshot set recorded in + wiki/log/2026-07-08-material-render-default.md. + ## 2026-07-08 - B1 spec status audit - Audited all non-IMPLEMENTED B1 specs against the test suite: diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -96,16 +96,16 @@ the agent-mode actions verb, rail cleanup. Run ./tools/check.sh, land on main, set the spec Status." -### 27. Material render to default 🟩 isolated (src/bin/bevy.rs + assets) +### 27. Material render to default 🟩 isolated (src/bin/bevy.rs + assets) — DONE 2026-07-08 - **Spec:** [material-render.md](../interface/material-render.md) - (READY; the F3 toggle landed b9f6086, Cameron approved the direction) -- **Why:** pay the prototype's debts (south-wall occlusion, slab tops, - stale palettes, framing, material pooling) and flip material to the - default physical view. -- **Size:** M. **Dispatch:** "Work in a worktree named `material-render`. - Implement wiki/interface/material-render.md, all criteria, with - before/after screenshots in the session log. Run ./tools/check.sh, - land on main, set the spec Status." + (IMPLEMENTED) — material is the default Bevy physical view; F3 flips + to the sensorium. +- Debts paid (wiki/log/2026-07-08-material-render-default.md): + fog-gated south-face cutaways (picked over steeper pitch by + screenshot comparison), dedicated top caps, b1 palette sweep + (door/UPS/HVAC/switch de-purpled), fog-contract audit in the shot + harness, zoom bounds holding the framing floor, pooled materials + verified. Cast sprite billboards remain with ROADMAP #13. ### 28. B1 criterion-pin tests 🟩 isolated (test-only) - **Specs:** [compute.md](../mechanics/compute.md), diff --git a/wiki/process/specs.md b/wiki/process/specs.md --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -38,7 +38,7 @@ | [mechanics/research.md](../mechanics/research.md) | Self-modification: tracks, the emission law, capability drift, the rollback split | IMPLEMENTED | | [interface/views.md](../interface/views.md) | Same-frame digital and real representations of one world | READY | | [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; the rail is status only | IMPLEMENTED | -| [interface/material-render.md](../interface/material-render.md) | Material render (HD-2D) from landed toggle to default quality | READY | +| [interface/material-render.md](../interface/material-render.md) | Material render (HD-2D) from landed toggle to default quality | IMPLEMENTED | | [mechanics/building.md](../mechanics/building.md) | Building as intent + actuators: network links, favor/forged-order builds, air-gap bridging | IMPLEMENTED | | [mechanics/aggregate-observer.md](../mechanics/aggregate-observer.md) | Assurance Office becomes an aggregate Observer (scale-debt fix) | IMPLEMENTED | | [world/characters/marcus.md](../world/characters/marcus.md) | Marcus Webb — night janitor; the asset template | READY | diff --git a/assets/pixellab/tiles/b1_hvac_unit.png b/assets/pixellab/tiles/b1_hvac_unit.png --- a/assets/pixellab/tiles/b1_hvac_unit.png +++ b/assets/pixellab/tiles/b1_hvac_unit.png diff --git a/assets/pixellab/tiles/b1_switch.png b/assets/pixellab/tiles/b1_switch.png --- a/assets/pixellab/tiles/b1_switch.png +++ b/assets/pixellab/tiles/b1_switch.png diff --git a/assets/pixellab/tiles/b1_ups.png b/assets/pixellab/tiles/b1_ups.png --- a/assets/pixellab/tiles/b1_ups.png +++ b/assets/pixellab/tiles/b1_ups.png diff --git a/assets/pixellab/tiles/door.png b/assets/pixellab/tiles/door.png --- a/assets/pixellab/tiles/door.png +++ b/assets/pixellab/tiles/door.png