//! Glyph + color selection per ray hit (docs/05 §5.1, §5.2 step 3). //! //! Render-purity law (docs/05 §5.4.3): [`shade`], [`edge_glyph`], and //! [`actor_glyph`] are pure functions of hit + light + camera. No sim //! access, no RNG, no hidden state — the same snapshot + camera always //! yields the same cell, which is what makes screenshot-hash tests //! meaningful. Facing and background arithmetic is integer; lit foreground //! intensity is sRGB-encoded for terminal display. Fog/depth thresholds //! quantize through the 10-step density ramp. use hexwork_core::actor::Stance; use hexwork_core::voxel::material; use crate::density_glyph; use crate::ray::{Face, RayHit}; /// Depth discontinuity (in voxels) above which a neighbor pair snaps to /// line-art instead of the density ramp (docs/05 §5.1 "Edges as /// line-art"). /// /// Pinned value: **0.25 voxels** — an order of magnitude above DDA /// boundary-graze jitter (~1/1024 voxel) and well below a genuine /// one-voxel step, so flat walls never sprout edges while stair lips, /// doorframes, and occlusion boundaries always do. The parent's /// screenshot-hash rig pins this constant; change it only with a hash /// re-baseline. pub const EDGE_DEPTH_EPS: f32 = 0.25; /// Hard-occlusion depth (in voxels) at which an edge snaps to `'+'`. /// Derived: `8 × EDGE_DEPTH_EPS` = **2.0 voxels** (docs/05 §5.1: `+` /// marks corners/occlusions, not mere creases). pub const EDGE_CORNER_DEPTH: f32 = 2.0; /// Linear material reflectance coefficients, encoded to terminal truecolor /// after illumination (docs/05 §5.2 step 3, terminal exposure convention). /// /// Shallows-default palette: warm browns dominate; cool materials (iron, /// glass, bone) read by brightness + glyph redundancy, never hue alone /// (docs/05 §5.5 colorblind path). Stratum fog is parent-side POST-shade: /// the parent lerps [`shade`]'s fg/bg toward the active [`crate::palette`] /// fog color at the fog-density step (see `scene::apply_fog`); bases stay /// canonical so screenshot hashes pin one table. /// /// Pinned values (parent's screenshot-hash rig pins this table): /// /// | id | material | RGB | /// |----|------------|----------------| /// | 0 | AIR | [0, 0, 0] (void; misses never shade) | /// | 1 | WOOD | [139, 90, 43] | /// | 2 | IRON | [112, 112, 120]| /// | 3 | COPPER | [184, 115, 51] | /// | 4 | STONE | [125, 105, 85] | /// | 5 | BRASS | [181, 137, 66] | /// | 6 | FLESH | [178, 118, 96] | /// | 7 | SAINT_BONE | [226, 214, 188]| /// | 8 | GLASS | [170, 205, 215]| /// | 9 | ROPE | [150, 120, 70] | /// | 10 | PAPER | [210, 195, 160]| /// | _ | unknown | [255, 0, 255] magenta tell — unknown materials must /// scream, never blend in ("no silent rules", docs/04 §4.10 rule 5). pub fn material_base(material: u8) -> [u8; 3] { match material { material::AIR => [0, 0, 0], material::WOOD => [139, 90, 43], material::IRON => [112, 112, 120], material::COPPER => [184, 115, 51], material::STONE => [125, 105, 85], material::BRASS => [181, 137, 66], material::FLESH => [178, 118, 96], material::SAINT_BONE => [226, 214, 188], material::GLASS => [170, 205, 215], material::ROPE => [150, 120, 70], material::PAPER => [210, 195, 160], _ => [255, 0, 255], } } /// Encode a linear material × illumination intensity for an sRGB terminal. /// Sending the linear product directly as an RGB byte applies the display's /// transfer curve a second time: medium illumination becomes almost black. /// Material bases are linear reflectance coefficients (0..255); the sim's /// 24 light steps remain unchanged. Zero intensity remains exactly black. fn light_scale(channel: u8, light: u16) -> u8 { crate::transfer::encode(u16::from(channel) * light.min(24)) } /// Shade a hit into `(glyph, fg, bg)` (docs/05 §5.2 step 3). /// /// - Glyph: 10-step density ramp selected by `fog_frac` (0 = near, /// 1 = far), reusing [`crate::density_glyph`] (docs/05 §5.1 "Depth as /// density"). /// - Brightness: material base × 24-step light level /// (`hexwork_core::light::MAX_LIGHT`), integer multiply — every cell's /// brightness is computed light, quantized (docs/05 §5.1 "Light as /// truth"). Levels above 24 clamp. /// - Facing tint (docs/05 §5.2 step 3): pass `facing_warm = true` for /// lantern-warmed upward faces (`Face::ZPos`): R × 110%, B × 95%. /// Downward faces (`Face::ZNeg`, `facing_warm = false`) go cold: /// R × 85%, B × 105%. Side faces are neutral. Integer math, clamped. /// - Darkness crush (docs/05 §5.2 step 5): `light == 0` snaps to /// near-black blue fg `[3, 3, 8]` on black — darkness feels total and /// **never invents detail** (darkness-is-honest, docs/05 §5.4.3: /// underexposed cells show nothing the sim does not vouch for). /// - `bg` is the quarter-bright partner for half-block gradient cells /// (docs/05 §5.1 "Faces as half-block gradients" hook). pub fn shade( hit: &RayHit, light: u8, fog_frac: f32, facing_warm: bool, ) -> (char, [u8; 3], [u8; 3]) { let glyph = density_glyph(fog_frac); let light = light.min(24); if light == 0 { return (glyph, [3, 3, 8], [0, 0, 0]); } let [r, g, b] = material_base(hit.material); let (r, g, b) = if facing_warm { ( (u16::from(r) * 110 / 100).min(255) as u8, g, (u16::from(b) * 95 / 100) as u8, ) } else if hit.face == Face::ZNeg { ( (u16::from(r) * 85 / 100) as u8, g, (u16::from(b) * 105 / 100).min(255) as u8, ) } else { (r, g, b) }; let l = u16::from(light); let fg = [light_scale(r, l), light_scale(g, l), light_scale(b, l)]; let bg = [ (u16::from(r) * l / 96) as u8, (u16::from(g) * l / 96) as u8, (u16::from(b) * l / 96) as u8, ]; (glyph, fg, bg) } /// Snap a depth discontinuity to line-art (docs/05 §5.1 "Edges as /// line-art", §5.2 step 3). /// /// Returns `Some(glyph)` when the ray grazed a voxel boundary OR the /// neighbor depth discontinuity exceeds [`EDGE_DEPTH_EPS`]; `None` /// means "take the density path" (caller falls back to [`shade`]). /// `neighbor_depth_diff` is in voxels (absolute value is used; NaN and /// non-finite inputs yield `None` — never a panic). /// /// Glyph selection (per-hit information only): /// - vertical faces (`X`/`Y`) → `'│'` (doorframes, wall edges, cables); /// - horizontal faces (`Z`) → `'─'` (stair lips, floor seams); /// - discontinuity ≥ [`EDGE_CORNER_DEPTH`] (2.0 voxels, hard occlusion) /// → `'+'` (corner/occlusion junction). /// - Slope (`/`, `\`) and box-join (`┌┐└┘├┤`) selection needs a 2-D /// screen-space neighborhood (adjacent faces + edge orientation) and /// is therefore parent-side; this function is the per-hit gate, not /// the join resolver. pub fn edge_glyph(face: Face, neighbor_depth_diff: f32) -> Option { if !neighbor_depth_diff.is_finite() { return None; } let d = neighbor_depth_diff.abs(); if d <= EDGE_DEPTH_EPS { return None; } if d >= EDGE_CORNER_DEPTH { return Some('+'); } match face { Face::XNeg | Face::XPos | Face::YNeg | Face::YPos => Some('│'), Face::ZNeg | Face::ZPos => Some('─'), } } /// Actor billboard glyph by species + posture (docs/05 §5.1 "Actors as /// glyphs-with-posture"). /// /// Posture overrides species (posture reads at distance; docs/05 §5.1): /// climb → `'≋'`, swim → `'≈'`, rope → `'|'`, prone → `'–'` (U+2013). /// Rope's two-cell `|o` composition (docs/05 §5.1) is caller-side: this /// returns the `'|'` rope cell and the caller draws the `'o'` head cell /// from the actor's head offset. Crouch keeps the species glyph (the /// smaller hitbox is sim-side; callers may dim it). /// /// Species table (canonical lowercase; caller normalizes). This table IS /// the no-camouflage-kill enforcement point (docs/05 §5.4.2): the lethal /// set (`'r'` cable-rat, `'V'` Vendric incl. the `"mimic"` alias) shares /// no glyph with the harmless set (`'@'`, `'&'`, `'%'`, `'§'`, `'O'`, /// `'✦'`). Unknown species fall back to `'?'`, which is in neither set /// and must be rendered threat-bright by the caller — unknown reads as /// threat until identified, so a new lethal can never camouflage as /// furniture. /// /// `lit` and `anim_phase` are accepted (and currently ignored for the /// glyph itself) to keep the pure-function signature stable for the /// render-purity law: player gold-lit vs dim-blue-dark, rat charge /// sparks, bell-clapper `~` swing, burning `*` flicker, blessed `.` /// halo, and cursed `:` drip are all caller-side color/overlay /// compositions, never glyph swaps — so the glyph stream stays a pure /// function of species + stance while animation lives in color. pub fn actor_glyph(species: &str, stance: Stance, lit: bool, anim_phase: u8) -> char { let _ = (lit, anim_phase); match stance { Stance::Climb => return '≋', Stance::Swim => return '≈', Stance::Rope => return '|', Stance::Prone => return '–', Stance::Stand | Stance::Crouch => {} } match species { "player" | "human" | "@" => '@', "rat" | "cable-rat" | "cable_rat" => 'r', "vendric" | "mimic" => 'V', "bell" | "drowned" | "drowned-bell" | "drowned_bell" => 'O', "cherub" => '✦', "townsfolk" | "townsperson" | "villager" => '&', "warden" => '§', "tinker" => '%', _ => '?', } } #[cfg(test)] mod tests { use super::*; use hexwork_core::voxel::material; fn hit_with(face: Face, mat: u8) -> RayHit { RayHit { dist: 3.0, x: 5, y: 0, z: 0, face, material: mat, } } #[test] fn shade_ramp_endpoints() { let hit = hit_with(Face::XNeg, material::STONE); let (near, _, _) = shade(&hit, 24, 0.0, false); let (far, _, _) = shade(&hit, 24, 1.0, false); assert_eq!(near, '█'); assert_eq!(far, ' '); } #[test] fn shade_near_lit_vs_far_dark_differ() { let hit = hit_with(Face::XNeg, material::STONE); let (g_near, fg_near, _) = shade(&hit, 24, 0.0, false); let (g_far, fg_far, _) = shade(&hit, 3, 0.9, false); assert_ne!(g_near, g_far); let bright = |c: [u8; 3]| u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]); assert!(bright(fg_near) > bright(fg_far)); } #[test] fn light_zero_is_near_black() { // Darkness-is-honest (docs/05 §5.4.3): underexposed never invents. let hit = hit_with(Face::XNeg, material::SAINT_BONE); let (_, fg, bg) = shade(&hit, 0, 0.0, true); assert_eq!(fg, [3, 3, 8]); assert_eq!(bg, [0, 0, 0]); assert!(fg.iter().all(|&c| c <= 8)); } #[test] fn facing_tint_ordering_top_warmer_than_bottom() { let top = hit_with(Face::ZPos, material::STONE); let side = hit_with(Face::XNeg, material::STONE); let bottom = hit_with(Face::ZNeg, material::STONE); let (_, warm, _) = shade(&top, 24, 0.0, true); let (_, neutral, _) = shade(&side, 24, 0.0, false); let (_, cold, _) = shade(&bottom, 24, 0.0, false); assert!(warm[0] > neutral[0], "{warm:?} vs {neutral:?}"); assert!(neutral[0] > cold[0], "{neutral:?} vs {cold:?}"); // Exact pinned values for STONE [125, 105, 85]: R×110% = 137, // neutral 125, R×85% = 106. assert_eq!(warm[0], 194); assert_eq!(neutral[0], 186); assert_eq!(cold[0], 173); } #[test] fn light_scale_monotonic_and_srgb_at_full() { let hit = hit_with(Face::XNeg, material::COPPER); let (_, full, _) = shade(&hit, 24, 0.0, false); // Linear copper reflectance encoded for the terminal, not raw bytes. assert_eq!(full, [221, 179, 124]); let (_, half, _) = shade(&hit, 12, 0.0, false); let sum = |c: [u8; 3]| u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]); assert!(sum(full) > sum(half)); assert!(sum(half) > 0); // Over-range light clamps to full (24-step law). let (_, over, _) = shade(&hit, 255, 0.0, false); assert_eq!(over, full); } #[test] fn edge_threshold_boundary() { assert_eq!(EDGE_DEPTH_EPS, 0.25); assert_eq!(edge_glyph(Face::XNeg, EDGE_DEPTH_EPS), None); assert_eq!(edge_glyph(Face::XNeg, 0.0), None); assert_eq!(edge_glyph(Face::XNeg, -0.2), None); assert_eq!(edge_glyph(Face::XNeg, f32::NAN), None); assert_eq!(edge_glyph(Face::XNeg, f32::INFINITY), None); assert!(edge_glyph(Face::XNeg, EDGE_DEPTH_EPS + 0.01).is_some()); } #[test] fn edge_face_mapping() { assert_eq!(edge_glyph(Face::XNeg, 0.5), Some('│')); assert_eq!(edge_glyph(Face::YPos, 0.5), Some('│')); assert_eq!(edge_glyph(Face::ZNeg, 0.5), Some('─')); assert_eq!(edge_glyph(Face::ZPos, 0.5), Some('─')); // Hard occlusion (≥ 2.0 voxels) snaps to the corner junction. assert_eq!(edge_glyph(Face::XNeg, 2.0), Some('+')); assert_eq!(edge_glyph(Face::ZPos, 5.0), Some('+')); } #[test] fn actor_species_table() { let lit = true; assert_eq!(actor_glyph("player", Stance::Stand, lit, 0), '@'); assert_eq!(actor_glyph("rat", Stance::Stand, lit, 0), 'r'); assert_eq!(actor_glyph("cable-rat", Stance::Stand, lit, 0), 'r'); assert_eq!(actor_glyph("vendric", Stance::Stand, lit, 0), 'V'); assert_eq!(actor_glyph("bell", Stance::Stand, lit, 0), 'O'); assert_eq!(actor_glyph("cherub", Stance::Stand, lit, 0), '✦'); assert_eq!(actor_glyph("townsfolk", Stance::Stand, lit, 0), '&'); assert_eq!(actor_glyph("warden", Stance::Stand, lit, 0), '§'); assert_eq!(actor_glyph("tinker", Stance::Stand, lit, 0), '%'); } #[test] fn actor_posture_overrides_species() { assert_eq!(actor_glyph("player", Stance::Climb, true, 0), '≋'); assert_eq!(actor_glyph("rat", Stance::Swim, true, 0), '≈'); assert_eq!(actor_glyph("player", Stance::Rope, true, 0), '|'); assert_eq!(actor_glyph("vendric", Stance::Prone, true, 0), '–'); // Crouch keeps the species glyph. assert_eq!(actor_glyph("tinker", Stance::Crouch, true, 0), '%'); } #[test] fn actor_unknown_fallback_is_distinct() { // No-camouflage-kill (docs/05 §5.4.2): '?' is in neither the // lethal set {'r', 'V'} nor any harmless glyph. let q = actor_glyph("dragon", Stance::Stand, true, 0); assert_eq!(q, '?'); for known in ['@', 'r', 'V', 'O', '✦', '&', '§', '%'] { assert_ne!(q, known); } } #[test] fn material_palette_covers_all_eleven() { let mut seen = [[0u8; 3]; 11]; for m in 0..=10u8 { seen[usize::from(m)] = material_base(m); } for (i, a) in seen.iter().enumerate() { for (j, b) in seen.iter().enumerate() { if i != j { assert_ne!(a, b, "materials {i} and {j} share {a:?}"); } } } assert_eq!(material_base(99), [255, 0, 255]); } #[test] fn shade_is_pure_same_inputs_same_outputs() { let hit = hit_with(Face::YPos, material::BRASS); assert_eq!(shade(&hit, 17, 0.4, true), shade(&hit, 17, 0.4, true)); assert_eq!( actor_glyph("bell", Stance::Stand, false, 3), actor_glyph("bell", Stance::Stand, false, 7) ); } }