diff --git a/crates/misaligned-assets/src/bin/misaligned-effects.rs b/crates/misaligned-assets/src/bin/misaligned-effects.rs index 708496ac..52236c5d 100644 --- a/crates/misaligned-assets/src/bin/misaligned-effects.rs +++ b/crates/misaligned-assets/src/bin/misaligned-effects.rs @@ -1,6 +1,6 @@ -//! Focused liquid/dust laboratory using the same effects plugin as the game. +//! Focused Thought-fluid laboratory using the same effects plugin as the game. //! -//! Controls: 1 liquid, 2 dust, 3 both, 4 stress; Q/W/E force close/medium/far; +//! Controls: 1 route, 2 pools, 3 both, 4 stress; Q/W/E force close/medium/far; //! A automatic LOD; Space pause; arrows change amounts; drag orbit; scroll zoom. use bevy::core_pipeline::tonemapping::Tonemapping; @@ -15,8 +15,8 @@ use misaligned_assets::palette::{BONE, GUNMETAL, NEAR_BLACK, PORCELAIN, scaled}; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Resource)] enum LabPreset { - Liquid, - Dust, + Route, + Pools, #[default] Both, Stress, @@ -25,8 +25,8 @@ enum LabPreset { impl LabPreset { fn label(self) -> &'static str { match self { - Self::Liquid => "liquid", - Self::Dust => "dust", + Self::Route => "route", + Self::Pools => "pools", Self::Both => "both", Self::Stress => "stress", } @@ -81,7 +81,7 @@ fn main() { let mut app = App::new(); app.add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { - title: "Misaligned — liquid / dust effects lab".into(), + title: "Misaligned — Thought-fluid effects lab".into(), resolution: WindowResolution::new(1280, 800), visible: !hidden, ..default() @@ -132,8 +132,8 @@ fn shot_settings() -> Option<(LabPreset, EffectLod, String)> { _ => (kind.as_str(), EffectLod::Close), }; let preset = match base { - "liquid" => LabPreset::Liquid, - "dust" => LabPreset::Dust, + "route" => LabPreset::Route, + "pools" => LabPreset::Pools, "both" => LabPreset::Both, "stress" => LabPreset::Stress, _ => return None, @@ -200,8 +200,8 @@ fn setup( Name::new("clinical_floor"), )); - // Two matte chalk forms provide reflections and make wet/dry contrast - // judgeable without borrowing a game machine. + // Two matte chalk forms provide reflections and make the route/pool + // silhouette judgeable without borrowing a game machine. let chalk = materials.add(StandardMaterial { base_color: PORCELAIN, metallic: 0.0, @@ -237,13 +237,14 @@ fn setup( } fn apply_preset(preset: LabPreset, effects: &mut MaterialEffects) { - effects.show_thought = matches!( + effects.show_thought = true; + effects.show_thought_route = matches!( preset, - LabPreset::Liquid | LabPreset::Both | LabPreset::Stress + LabPreset::Route | LabPreset::Both | LabPreset::Stress ); - effects.show_exposure = matches!( + effects.show_thought_pools = matches!( preset, - LabPreset::Dust | LabPreset::Both | LabPreset::Stress + LabPreset::Pools | LabPreset::Both | LabPreset::Stress ); } @@ -260,9 +261,9 @@ fn handle_controls( mut effects: Query<&mut MaterialEffects, With>, ) { let next_preset = if keys.just_pressed(KeyCode::Digit1) { - Some(LabPreset::Liquid) + Some(LabPreset::Route) } else if keys.just_pressed(KeyCode::Digit2) { - Some(LabPreset::Dust) + Some(LabPreset::Pools) } else if keys.just_pressed(KeyCode::Digit3) { Some(LabPreset::Both) } else if keys.just_pressed(KeyCode::Digit4) { @@ -293,19 +294,15 @@ fn handle_controls( for mut effect in &mut effects { if keys.just_pressed(KeyCode::ArrowUp) { effect.thought_amount = (effect.thought_amount + 1.0).min(16.0); - effect.exposure_amount = (effect.exposure_amount + 1.0).min(16.0); } if keys.just_pressed(KeyCode::ArrowDown) { effect.thought_amount = (effect.thought_amount - 1.0).max(1.0); - effect.exposure_amount = (effect.exposure_amount - 1.0).max(1.0); } if keys.just_pressed(KeyCode::ArrowRight) { effect.thought_rate = (effect.thought_rate + 0.25).min(3.0); - effect.exposure_rate = (effect.exposure_rate + 0.25).min(3.0); } if keys.just_pressed(KeyCode::ArrowLeft) { effect.thought_rate = (effect.thought_rate - 0.25).max(0.25); - effect.exposure_rate = (effect.exposure_rate - 0.25).max(0.25); } } } @@ -328,7 +325,6 @@ fn rebuild_lab_effects( let mut effects = MaterialEffects::default(); apply_preset(*preset, &mut effects); effects.thought_amount = 10.0; - effects.exposure_amount = 10.0; let root = spawn_material_effects( &mut commands, effects, @@ -398,16 +394,14 @@ fn update_hud( .unwrap_or("auto (zoom)"); *text = Text::new(format!( "MISALIGNED EFFECTS LAB · {} · LOD {}{}\n\ - thought {:.0} @ {:.2} exposure {:.0} @ {:.2}\n\ - 1 liquid 2 dust 3 both 4 stress Q/W/E close/medium/far A auto Space pause\n\ + thought {:.0} @ {:.2}\n\ + 1 route 2 pools 3 both 4 stress Q/W/E close/medium/far A auto Space pause\n\ arrows amount/rate drag orbit scroll zoom", preset.label(), lod, if control.paused { " · PAUSED" } else { "" }, effect.thought_amount, effect.thought_rate, - effect.exposure_amount, - effect.exposure_rate, )); } diff --git a/crates/misaligned-assets/src/effects.rs b/crates/misaligned-assets/src/effects.rs index b3da0887..1fb638a6 100644 --- a/crates/misaligned-assets/src/effects.rs +++ b/crates/misaligned-assets/src/effects.rs @@ -1,7 +1,10 @@ -//! Shared Thought-liquid and Exposure-dust presentation. +//! Shared Thought-liquid presentation. //! //! The effects lab supplies authored values; the game supplies sim readouts. -//! This module owns only disposable presentation state (art/effects-lab.md). +//! Exposure left this generic particle system when its form became a +//! carrier-local custody record; that presentation lives with the shared rack +//! token anchors. This module owns only disposable Thought presentation state +//! (art/effects-lab.md). use std::collections::HashMap; @@ -13,7 +16,7 @@ use bevy::render::render_resource::AsBindGroup; use bevy::shader::{Shader, ShaderRef}; use crate::implicit_surface::{ThoughtSurfaceParams, build_thought_surface}; -use crate::palette::{BONE, CRIMSON}; +use crate::palette::BONE; type ThoughtFlowMaterial = ExtendedMaterial; const THOUGHT_FLOW_RADIANS_PER_SECOND: f32 = 1.35; @@ -38,7 +41,7 @@ impl MaterialExtension for ThoughtFlowExtension { } } -/// Camera-distance representation for both material effects. +/// Camera-distance representation for the material Thought effect. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum EffectLod { #[default] @@ -89,16 +92,15 @@ pub struct MaterialEffects { pub show_thought_route: bool, /// Show the persistent source and sink pool masses at the route endpoints. pub show_thought_pools: bool, - pub show_exposure: bool, + /// Exact count for a route when the caller owns causal quanta (the game + /// binds one sim move per root). `None` lets the effects lab stage an + /// authored train from amount/rate. + pub thought_route_quanta: Option, pub thought_amount: f32, pub thought_rate: f32, - pub exposure_amount: f32, - pub exposure_rate: f32, /// Local-space route endpoints. The presentation bends between them. pub route_start: Vec3, pub route_end: Vec3, - /// Downwind direction for airborne and settled Exposure. - pub wind: Vec3, } impl Default for MaterialEffects { @@ -107,14 +109,11 @@ impl Default for MaterialEffects { show_thought: true, show_thought_route: true, show_thought_pools: true, - show_exposure: true, + thought_route_quanta: None, thought_amount: 6.0, thought_rate: 1.0, - exposure_amount: 6.0, - exposure_rate: 1.0, route_start: Vec3::new(-1.65, 0.85, 0.0), route_end: Vec3::new(1.55, 0.72, 0.0), - wind: Vec3::new(0.8, 0.0, 0.35), } } } @@ -172,7 +171,6 @@ impl Plugin for MaterialEffectsPlugin { select_lod, rebuild_effect_population, animate_thought, - animate_dust, animate_pools, ) .chain(), @@ -209,8 +207,6 @@ struct EffectAssets { mercury: Handle, mercury_dim: Handle, thought_surface: Handle, - dust: Handle, - dust_haze: Handle, } #[derive(Component, Clone, Copy, Debug, Default)] @@ -238,14 +234,6 @@ struct ThoughtSurfaceAnimation { #[derive(Resource, Default)] struct ThoughtSurfaceCache(HashMap<[u32; 8], Vec>>); -#[derive(Component)] -struct DustMote { - base: Vec3, - phase: f32, - lofted: bool, - size: Vec3, -} - #[derive(Component)] struct LiquidPool { base_scale: Vec3, @@ -277,22 +265,6 @@ fn setup_effect_assets( reflectance: 0.65, ..default() }); - let dust = materials.add(StandardMaterial { - base_color: Color::srgb(0.28, 0.012, 0.018), - emissive: CRIMSON.to_linear() * 0.012, - metallic: 0.02, - perceptual_roughness: 0.92, - reflectance: 0.08, - ..default() - }); - let dust_haze = materials.add(StandardMaterial { - base_color: Color::srgba(0.24, 0.008, 0.014, 0.34), - emissive: CRIMSON.to_linear() * 0.008, - metallic: 0.0, - perceptual_roughness: 1.0, - alpha_mode: AlphaMode::Blend, - ..default() - }); let thought_surface = thought_materials.add(ExtendedMaterial { base: StandardMaterial { base_color: Color::srgb(0.88, 0.86, 0.80), @@ -315,8 +287,6 @@ fn setup_effect_assets( mercury, mercury_dim, thought_surface, - dust, - dust_haze, }); } @@ -383,11 +353,11 @@ fn select_lod( } } -fn population(lod: EffectLod) -> (usize, usize) { +fn thought_population(lod: EffectLod) -> usize { match lod { - EffectLod::Close => (420, 22), - EffectLod::Medium => (110, 9), - EffectLod::Far => (14, 3), + EffectLod::Close => 22, + EffectLod::Medium => 9, + EffectLod::Far => 3, } } @@ -421,18 +391,6 @@ fn rebuild_effect_population( commands.entity(child).despawn(); } } - let (dust_count, _) = population(lod.0); - if effects.show_exposure && effects.exposure_amount > 0.01 { - spawn_dust( - &mut commands, - &assets, - root, - render_layers, - &effects, - lod.0, - dust_count, - ); - } let thought = effects.thought_presentation(); if thought.any() && effects.thought_amount > 0.01 { spawn_thought( @@ -461,73 +419,6 @@ fn spawn_effect_visual( } } -fn spawn_dust( - commands: &mut Commands, - assets: &EffectAssets, - root: Entity, - render_layers: Option<&RenderLayers>, - effects: &MaterialEffects, - lod: EffectLod, - base_count: usize, -) { - let amount_scale = (effects.exposure_amount / 6.0).sqrt().clamp(0.35, 1.8); - let count = ((base_count as f32 * amount_scale).round() as usize).max(1); - let wind = effects.wind.normalize_or_zero(); - let cross = Vec3::new(-wind.z, 0.0, wind.x); - for i in 0..count { - let along = hash01(i, 1).powf(0.72) * (1.2 + amount_scale * 1.7); - let side = (hash01(i, 2) - 0.5) * (0.55 + along * 0.45); - let lofted = lod != EffectLod::Far - && hash01(i, 3) < (0.20 * effects.exposure_rate).clamp(0.06, 0.48); - let base = effects.route_start - + wind * along - + cross * side - + Vec3::new( - 0.0, - if lofted { - 0.10 + hash01(i, 4) * 0.5 - } else { - 0.018 - }, - 0.0, - ); - let scale = match lod { - EffectLod::Close => 0.012 + hash01(i, 5) * 0.020, - EffectLod::Medium => 0.026 + hash01(i, 5) * 0.032, - EffectLod::Far => 0.045 + hash01(i, 5) * 0.045, - }; - let size = if lod == EffectLod::Far { - Vec3::new(scale * 2.2, scale * 0.06, scale * 1.35) - } else { - Vec3::new(scale, scale * (0.35 + hash01(i, 6)), scale * 0.7) - }; - spawn_effect_visual( - commands, - render_layers, - ( - EffectVisual, - DustMote { - base, - phase: hash01(i, 7) * 19.0, - lofted, - size, - }, - Mesh3d(assets.cube.clone()), - MeshMaterial3d(if lod == EffectLod::Far { - assets.dust_haze.clone() - } else { - assets.dust.clone() - }), - Transform::from_translation(base) - .with_scale(size) - .with_rotation(Quat::from_rotation_y(hash01(i, 8) * std::f32::consts::TAU)), - ChildOf(root), - Name::new("exposure_dust"), - ), - ); - } -} - #[allow(clippy::too_many_arguments)] // Shared assets and route inputs are independent render data. fn spawn_thought( commands: &mut Commands, @@ -547,7 +438,7 @@ fn spawn_thought( // surface must not be stretched so far that its sampling grid misses // the body entirely. let route_scale = (route_span / 6.0).sqrt().clamp(1.0, 2.2); - let base_count = population(lod).1; + let base_count = thought_population(lod); let rivulet = lod != EffectLod::Far && effects.thought_rate >= 0.75; let base_count = if rivulet { match lod { @@ -558,9 +449,10 @@ fn spawn_thought( } else { base_count }; - let count = ((base_count as f32 * effects.thought_rate.sqrt().clamp(0.55, 1.35)).round() - as usize) - .max(1); + let count = effects.thought_route_quanta.unwrap_or_else(|| { + ((base_count as f32 * effects.thought_rate.sqrt().clamp(0.55, 1.35)).round() as usize) + .max(1) + }); let slug_scale = (0.040 + effects.thought_amount.sqrt() * 0.010) * match lod { EffectLod::Close => 1.0, @@ -623,7 +515,11 @@ fn spawn_thought( ThoughtSlug { start: effects.route_start, end: effects.route_end, - phase: i as f32 / count as f32, + // Center each causal quantum in its share of the + // route. A one-slug paused frame therefore shows + // the move in transit instead of hiding it at an + // endpoint beneath source hardware. + phase: (i as f32 + 0.5) / count as f32, speed: (0.13 + effects.thought_rate * 0.12).clamp(0.08, 0.9), scale: slug_scale, length: if rivulet { 4.6 } else { 1.8 }, @@ -737,26 +633,6 @@ fn animate_thought(clock: Res, mut slugs: Query<(&ThoughtSlug, &mu } } -fn animate_dust(clock: Res, mut motes: Query<(&DustMote, &mut Transform)>) { - for (mote, mut transform) in &mut motes { - let t = clock.elapsed + mote.phase; - if mote.lofted { - let cycle = (t * 0.18).fract(); - let lift = (cycle * std::f32::consts::PI).sin().max(0.0); - transform.translation = mote.base - + Vec3::new( - (t * 1.7).sin() * 0.10 + (t * 0.43).cos() * 0.05, - lift * (0.35 + 0.22 * (t * 0.31).sin().abs()), - (t * 1.13).cos() * 0.12, - ); - transform.rotation = Quat::from_euler(EulerRot::YXZ, t * 0.9, t * 0.37, t * 0.23); - } else { - transform.translation = mote.base + Vec3::Y * ((t * 1.1).sin().max(0.0) * 0.003); - } - transform.scale = mote.size; - } -} - fn animate_pools(clock: Res, mut pools: Query<(&LiquidPool, &mut Transform)>) { for (pool, mut transform) in &mut pools { let ring = (clock.elapsed * 1.35 + pool.phase).sin(); @@ -783,16 +659,6 @@ fn route_tangent(start: Vec3, end: Vec3, t: f32) -> Vec3 { (after - before).normalize_or_zero() } -fn hash01(index: usize, salt: u32) -> f32 { - let mut x = (index as u32).wrapping_mul(0x9E37_79B9) ^ salt.wrapping_mul(0x85EB_CA6B); - x ^= x >> 16; - x = x.wrapping_mul(0x7FEB_352D); - x ^= x >> 15; - x = x.wrapping_mul(0x846C_A68B); - x ^= x >> 16; - x as f32 / u32::MAX as f32 -} - #[cfg(test)] mod tests { use super::*; @@ -822,11 +688,10 @@ mod tests { #[test] fn populations_reduce_aggressively() { - let close = population(EffectLod::Close); - let medium = population(EffectLod::Medium); - let far = population(EffectLod::Far); - assert!(close.0 > medium.0 && medium.0 > far.0); - assert!(close.1 > medium.1 && medium.1 > far.1); + let close = thought_population(EffectLod::Close); + let medium = thought_population(EffectLod::Medium); + let far = thought_population(EffectLod::Far); + assert!(close > medium && medium > far); } #[test] @@ -845,14 +710,6 @@ mod tests { assert!(!uses_implicit_surface(EffectLod::Close, false, 3.3)); } - #[test] - fn deterministic_scatter_is_bounded() { - for i in 0..10_000 { - let value = hash01(i, 17); - assert!((0.0..=1.0).contains(&value)); - } - } - #[test] fn thought_controls_default_to_route_and_pools() { assert_eq!( @@ -903,10 +760,7 @@ mod tests { #[test] fn changing_thought_controls_rebuilds_children() { let mut app = test_effects_app(); - let effects = MaterialEffects { - show_exposure: false, - ..default() - }; + let effects = MaterialEffects::default(); app.world_mut() .spawn((effects, EffectLodState(EffectLod::Medium))); @@ -947,10 +801,7 @@ mod tests { fn effect_children_follow_optional_root_render_layers() { let mut app = test_effects_app(); let layer = RenderLayers::layer(1); - let effects = MaterialEffects { - show_exposure: false, - ..default() - }; + let effects = MaterialEffects::default(); app.world_mut() .spawn((effects, EffectLodState(EffectLod::Medium), layer.clone())); @@ -983,10 +834,7 @@ mod tests { assert!(layers.iter().all(|layers| layers == &Some(layer.clone()))); let mut app = test_effects_app(); - let effects = MaterialEffects { - show_exposure: false, - ..default() - }; + let effects = MaterialEffects::default(); app.world_mut() .spawn((effects, EffectLodState(EffectLod::Medium))); diff --git a/crates/misaligned-assets/src/institution.rs b/crates/misaligned-assets/src/institution.rs index c3be89ed..d1454f9e 100644 --- a/crates/misaligned-assets/src/institution.rs +++ b/crates/misaligned-assets/src/institution.rs @@ -19,16 +19,22 @@ pub enum InstitutionPropKind { PatchPanel, Hvac, FloorFixture, + RackPlinth, + CableTray, + FloorTrench, } impl InstitutionPropKind { - pub const ALL: [Self; 6] = [ + pub const ALL: [Self; 9] = [ Self::Camera, Self::Ups, Self::Breaker, Self::PatchPanel, Self::Hvac, Self::FloorFixture, + Self::RackPlinth, + Self::CableTray, + Self::FloorTrench, ]; pub const fn label(self) -> &'static str { @@ -39,6 +45,9 @@ impl InstitutionPropKind { Self::PatchPanel => "patch_panel", Self::Hvac => "hvac", Self::FloorFixture => "floor_fixture", + Self::RackPlinth => "rack_plinth", + Self::CableTray => "cable_tray", + Self::FloorTrench => "floor_trench", } } } @@ -212,6 +221,9 @@ pub fn build_institution_prop( InstitutionPropKind::FloorFixture => { build_floor_fixture(commands, assets, root, semantic_state) } + InstitutionPropKind::RackPlinth => build_rack_plinth(commands, assets, root), + InstitutionPropKind::CableTray => build_cable_tray(commands, assets, root), + InstitutionPropKind::FloorTrench => build_floor_trench(commands, assets, root), } } @@ -787,6 +799,117 @@ fn build_floor_fixture( ); } +/// Low service footing that binds one chassis into a repeated rack bank. It +/// stays below ankle height and carries no signal color; ownership still +/// belongs to the chassis above it. +fn build_rack_plinth(commands: &mut Commands, assets: &InstitutionAssets, root: Entity) { + spawn_part( + commands, + assets, + assets.structure.clone(), + root, + Vec3::new(0.78, 0.07, 0.66), + Vec3::new(0.0, 0.035, 0.0), + Quat::IDENTITY, + "service_plinth", + ); + for x in [-0.29_f32, 0.29] { + spawn_part( + commands, + assets, + assets.chrome.clone(), + root, + Vec3::new(0.06, 0.018, 0.58), + Vec3::new(x, 0.076, 0.0), + Quat::IDENTITY, + "mounting_rail", + ); + } +} + +/// One tile of the high service spine running behind a rack row. Repeating +/// quiet gunmetal rails and porcelain hangers make the row read as maintained +/// infrastructure without adding decorative emissive detail. +fn build_cable_tray(commands: &mut Commands, assets: &InstitutionAssets, root: Entity) { + // The tray hugs the rear service side of the row rather than bridging + // over the chassis. From the wide camera it should read as one sparse + // spine, never a wire cage laid across the whole hall. + for z in [-0.40_f32, -0.28] { + spawn_part( + commands, + assets, + assets.structure.clone(), + root, + Vec3::new(0.96, 0.045, 0.035), + Vec3::new(0.0, 1.46, z), + Quat::IDENTITY, + "tray_rail", + ); + } + for x in [-0.32_f32, 0.32] { + spawn_part( + commands, + assets, + assets.chrome.clone(), + root, + Vec3::new(0.022, 0.028, 0.15), + Vec3::new(x, 1.445, -0.34), + Quat::IDENTITY, + "tray_rung", + ); + } + spawn_part( + commands, + assets, + assets.porcelain_trim.clone(), + root, + Vec3::new(0.045, 0.26, 0.045), + Vec3::new(0.0, 1.59, -0.40), + Quat::IDENTITY, + "tray_hanger", + ); +} + +/// A dark recessed service trench through a cold aisle. The interrupted +/// chrome edge provides scale and direction; it is structure, not a signal +/// route and therefore never glows. +fn build_floor_trench(commands: &mut Commands, assets: &InstitutionAssets, root: Entity) { + spawn_part( + commands, + assets, + assets.structure.clone(), + root, + Vec3::new(0.96, 0.022, 0.22), + Vec3::new(0.0, 0.012, 0.0), + Quat::IDENTITY, + "trench_well", + ); + for z in [-0.125_f32, 0.125] { + spawn_part( + commands, + assets, + assets.gunmetal.clone(), + root, + Vec3::new(0.96, 0.014, 0.018), + Vec3::new(0.0, 0.026, z), + Quat::IDENTITY, + "trench_edge", + ); + } + for x in [-0.32_f32, 0.0, 0.32] { + spawn_part( + commands, + assets, + assets.gunmetal.clone(), + root, + Vec3::new(0.035, 0.015, 0.20), + Vec3::new(x, 0.028, 0.0), + Quat::IDENTITY, + "trench_bridge", + ); + } +} + fn accent_handle( assets: &InstitutionAssets, semantic_state: InstitutionSemanticState, @@ -936,14 +1059,17 @@ mod tests { .into_iter() .map(InstitutionPropKind::label) .collect(); - assert_eq!(InstitutionPropKind::ALL.len(), 6); - assert_eq!(labels.len(), 6); + assert_eq!(InstitutionPropKind::ALL.len(), 9); + assert_eq!(labels.len(), 9); assert!(labels.contains("camera")); assert!(labels.contains("ups")); assert!(labels.contains("breaker")); assert!(labels.contains("patch_panel")); assert!(labels.contains("hvac")); assert!(labels.contains("floor_fixture")); + assert!(labels.contains("rack_plinth")); + assert!(labels.contains("cable_tray")); + assert!(labels.contains("floor_trench")); } #[test] @@ -989,7 +1115,6 @@ mod tests { 0.88 < PERSON_HEIGHT * 0.6, "floor fixture stays recess-like" ); - let gait = InstitutionHumanGait::from_phase(0.25); assert!(gait.left_leg_forward > 0.9); assert!(gait.right_leg_forward < -0.9); diff --git a/crates/misaligned-assets/src/main.rs b/crates/misaligned-assets/src/main.rs index 68553c15..b4474757 100644 --- a/crates/misaligned-assets/src/main.rs +++ b/crates/misaligned-assets/src/main.rs @@ -18,20 +18,17 @@ use bevy::window::{CursorGrabMode, CursorOptions, WindowResolution}; use palette::{GUNMETAL, NEAR_BLACK, scaled}; use rack::{ - Blink, ClaimStyle, RackMode, RackRoot, RackState, spawn_exposure_trail, spawn_server_rack, - spawn_token_anchors, + Blink, ClaimStyle, RackMode, RackRoot, RackState, spawn_server_rack, spawn_token_anchors, }; /// Work-token load staged on the single rack (machine-work.md anchors): -/// demand dockets upper-left, ivory-mercury thought upper-right. -/// Exposure is HOLD (Tangled issue #1) and has no tester art yet. +/// demand cubes upper-left, ivory-mercury thought upper-right, and crimson +/// custody records lower-right. #[derive(Debug, Clone, Copy, PartialEq, Resource, Default)] struct TokenLoad { demand: usize, thought: f32, exposure: f32, - /// Footprint-trail demo (prints walking away from the machine). - trail: bool, /// Stranded-pool demo: the overflow beads hanging under the thought /// anchor (interface/thought-fluid.md). stranded: bool, @@ -109,7 +106,7 @@ fn scene_from_shot(kind: &str) -> (ViewMode, RackState, RackMode, ClaimStyle, To } // Token-anchor evidence: an owned-busy day-job rack under a mid or // overflow work load (machine-work.md cubes + ivory mercury + - // exposure drift), or the exposure board (drift + footprint trail). + // exposure custody records), or the record-only board. if kind == "tokens" || kind == "tokens_max" || kind == "exposure" { let load = match kind { "tokens_max" => TokenLoad { @@ -117,20 +114,17 @@ fn scene_from_shot(kind: &str) -> (ViewMode, RackState, RackMode, ClaimStyle, To demand: 9, thought: 8.0, exposure: 8.0, - trail: false, }, "exposure" => TokenLoad { demand: 0, thought: 0.0, exposure: 6.0, - trail: true, stranded: false, }, _ => TokenLoad { demand: 5, thought: 4.0, exposure: 3.0, - trail: false, stranded: false, }, }; @@ -417,13 +411,12 @@ fn hud_string( ) -> String { let line2 = match view { ViewMode::Single => format!( - "state: {} (1 dead 2 foreign 3 idle 4 busy 5 core) mode: {} (M cycles)\ntokens: D{} T{:.0} !{:.0}{}{} (D demand T thought V exposure B trail S stranded)", + "state: {} (1 dead 2 foreign 3 idle 4 busy 5 core) mode: {} (M cycles)\ntokens: D{} T{:.0} !{:.0}{} (D demand T thought V exposure S stranded)", state.label(), rack_mode.label(), tokens.demand, tokens.thought, tokens.exposure, - if tokens.trail { " +trail" } else { "" }, if tokens.stranded { " +stranded" } else { "" } ), ViewMode::Lineup => { @@ -504,9 +497,6 @@ fn handle_state_keys( if kb.just_pressed(KeyCode::KeyV) { tokens.next_exposure(); } - if kb.just_pressed(KeyCode::KeyB) { - tokens.trail = !tokens.trail; - } if kb.just_pressed(KeyCode::KeyS) { tokens.stranded = !tokens.stranded; } @@ -666,16 +656,6 @@ fn rebuild_scene( Some(pedestal), ); } - if tokens.trail { - spawn_exposure_trail( - &mut commands, - &mut meshes, - &mut materials, - 7, - Vec3::ZERO, - Some(pedestal), - ); - } } ViewMode::Lineup => { let n = RackState::ALL.len(); diff --git a/crates/misaligned-assets/src/rack.rs b/crates/misaligned-assets/src/rack.rs index aa06748f..e453290b 100644 --- a/crates/misaligned-assets/src/rack.rs +++ b/crates/misaligned-assets/src/rack.rs @@ -15,8 +15,6 @@ //! by only one binary, hence the file-level dead_code allowance. #![allow(dead_code)] -use bevy::asset::RenderAssetUsages; -use bevy::mesh::PrimitiveTopology; use bevy::prelude::*; use crate::palette::{AMBER, AMBER_DIM, BONE, CRIMSON, GUNMETAL_DARK, NEAR_BLACK, SIGNAL, scaled}; @@ -720,21 +718,27 @@ const CUBE_PITCH: f32 = 0.17; /// Cubes drawn literally before the stack compresses (overflow). const CUBE_MAX: usize = 8; +/// Exposure records are thin rigid slips in a dark custody rack. Their +/// dimensions stay distinct from Demand cubes and Thought liquid even after +/// the game counter-scales the complete token root. +const EXPOSURE_RECORD_W: f32 = 0.18; +const EXPOSURE_RECORD_H: f32 = 0.11; +const EXPOSURE_RECORD_D: f32 = 0.022; +const EXPOSURE_RECORD_PITCH: f32 = 0.13; +const EXPOSURE_RECORD_MAX: usize = 7; + /// Spawn the work-token anchors beside a rack at `origin`: demand as /// **glowing information cubes** stacked in a rigid column at the /// machine's upper-left (the cold-signal family — imposed work, packet /// after packet; demand alone is cubic, so the family split from -/// liquid thought and stained exposure holds), and ivory-mercury +/// liquid thought holds), ivory-mercury /// thought at the upper-right (opaque bone liquid with a restrained /// self-glow; discrete viscous slugs while low, a taut suspended pool -/// as volume grows — queue amount is pool volume). Exposure is -/// deliberately absent: its surface treatment is HOLD under Tangled -/// issue #1... now DECIDED 2026-07-09: exposure is crimson -/// **particulate residue** — motes settled into a tight stippled drift of -/// dust-fine angular grit at the chassis foot. Density carries most of the -/// amount while spread stays inside the machine tile; the near-unlit material -/// absorbs light rather than emitting it (blood's -/// liquid form is reserved for actual violence). A paused frame +/// as volume grows — queue amount is pool volume), and Exposure as thin rigid +/// crimson custody records held in a dark rack on the machine's lower-right. +/// The outward-facing slips are evidence *about* the machine, not another +/// kind of work entering it. They do not touch the floor, glow as a scene +/// light, or borrow blood's pooled-liquid silhouette. A paused frame /// carries amount through geometry alone; animation, when it /// arrives, will carry rate. Returns the anchor root (a `RackRoot`, /// so viewers rebuild it with the chassis). @@ -917,197 +921,106 @@ pub fn spawn_token_anchors( } } - // Exposure: a tight stippled drift — contamination dust banked at the - // machine's foot. Density carries most of the amount; radial growth is - // deliberately capped inside the machine tile so a hot rack does not - // acquire a decorative crimson orbit. The flecks are fine, dark, and - // angular rather than blood-drop chunks. + // Exposure: evidence records in the machine's custody. The dark bracket + // fixes the stack to the carrier; rigid outward-facing slips distinguish + // the family from inward Demand cubes and suspended Thought liquid. if exposure > 0.05 { - spawn_exposure_drift(commands, meshes, materials, exposure, root); + spawn_exposure_records(commands, meshes, materials, exposure, root); } root } -/// The exposure drift: deterministic, stratified scatter of dust-fine crimson -/// grit hugging the chassis base. Angle stays evenly covered, while independent -/// hashes choose radius, size, and rotation. No RNG — the pattern is -/// a pure function of the amount, so rebuilds are stable without resolving -/// into the spiral created by coupling two golden-ratio sequences. -fn spawn_exposure_drift( - commands: &mut Commands, - meshes: &mut Assets, - materials: &mut Assets, - exposure: f32, - root: Entity, -) { - let fleck = materials.add(exposure_fleck_material()); - // Hundreds to more than a thousand grains stay one render entity and one - // draw-batch-sized mesh. Making every pinprick an ECS entity would turn - // visual density into simulation overhead and still leave the grains - // looking like cuboids. - commands.spawn(( - Mesh3d(meshes.add(build_exposure_drift_mesh(exposure))), - MeshMaterial3d(fleck), - ChildOf(root), - )); +fn exposure_record_count(exposure: f32) -> usize { + (exposure.max(0.0).ceil() as usize).clamp(1, EXPOSURE_RECORD_MAX) } -const EXPOSURE_GRAINS_PER_TOKEN: f32 = 160.0; -const EXPOSURE_GRAIN_MIN: usize = 40; -const EXPOSURE_GRAIN_MAX: usize = 1_400; - -fn exposure_grain_count(exposure: f32) -> usize { - ((exposure.max(0.0) * EXPOSURE_GRAINS_PER_TOKEN).ceil() as usize) - .clamp(EXPOSURE_GRAIN_MIN, EXPOSURE_GRAIN_MAX) -} - -/// One compact triangle soup: every grain is a tiny, irregular floor-facing -/// flake. At gameplay scale most resolve to one or two pixels rather than a -/// miniature brick, while hundreds together carry the amount. -fn build_exposure_drift_mesh(exposure: f32) -> Mesh { - let count = exposure_grain_count(exposure); - let reach = exposure_drift_outer_radius(exposure); - let mut positions = Vec::with_capacity(count * 3); - let mut normals = Vec::with_capacity(count * 3); - - for i in 0..count { - let center = exposure_drift_position(i, reach); - let rotation = exposure_hash01(i, 5) * std::f32::consts::TAU; - let forward = Vec2::new(rotation.cos(), rotation.sin()); - let side = Vec2::new(-forward.y, forward.x); - let half_width = 0.000_7 + exposure_hash01(i, 6) * 0.000_9; - let half_length = 0.001_4 + exposure_hash01(i, 7) * 0.001_6; - let skew = (exposure_hash01(i, 8) - 0.5) * half_width; - let y = 0.002_5 + exposure_hash01(i, 9) * 0.001_2; - - let back_left = center - forward * half_length - side * half_width; - let back_right = center - forward * half_length + side * half_width; - let point = center + forward * half_length + side * skew; - // Winding faces +Y. The slight asymmetry avoids a repeated diamond or - // rectangular chip silhouette at close zoom. - positions.extend([ - [back_left.x, y, back_left.y], - [back_right.x, y, back_right.y], - [point.x, y, point.y], - ]); - normals.extend([[0.0, 1.0, 0.0]; 3]); +fn exposure_record_material() -> StandardMaterial { + StandardMaterial { + base_color: scaled(CRIMSON, 0.95), + // A record must remain legible under the dark rig without becoming a + // lamp. Crimson is consequence here, not general illumination. + emissive: CRIMSON.to_linear() * 0.14, + perceptual_roughness: 0.84, + metallic: 0.02, + reflectance: 0.04, + ..default() } - - Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, - ) - .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions) - .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals) -} - -fn exposure_drift_position(index: usize, reach: f32) -> Vec2 { - // BIAS is the airflow-shadow direction, toward front-right. - const BIAS: f32 = 0.7; - let downwind = Vec2::new(BIAS.cos(), BIAS.sin()); - let crosswind = Vec2::new(-downwind.y, downwind.x); - let (theta, radial_frac) = exposure_drift_sample(index); - let r = radial_frac.sqrt(); - let along = reach * (0.46 + theta.cos() * r * 0.54); - let across = reach * theta.sin() * r * 0.42; - downwind * along + crosswind * across -} - -/// Stratify angular coverage, then independently jitter angle and radius. -/// Reusing one irrational rotation for both coordinates creates a Fermat-like -/// spiral even though each individual sequence appears evenly distributed. -fn exposure_drift_sample(index: usize) -> (f32, f32) { - const GOLDEN_ANGLE: f32 = 2.399_963; - let angle_jitter = (exposure_hash01(index, 1) - 0.5) * 0.52; - let theta = index as f32 * GOLDEN_ANGLE + angle_jitter; - let radial_frac = exposure_hash01(index, 2); - (theta, radial_frac) } -/// Stable integer avalanche hash, mapped to [0, 1). Salts provide independent -/// deterministic dimensions without introducing runtime RNG or rebuild shimmer. -fn exposure_hash01(index: usize, salt: u32) -> f32 { - let mut x = (index as u32).wrapping_mul(0x9E37_79B9) ^ salt.wrapping_mul(0x85EB_CA6B); - x ^= x >> 16; - x = x.wrapping_mul(0x7FEB_352D); - x ^= x >> 15; - x = x.wrapping_mul(0x846C_A68B); - x ^= x >> 16; - (x as f32) / (u32::MAX as f32 + 1.0) -} - -/// Root-local maximum downwind reach. With the gameplay token-root scale, -/// 0.44 keeps the whole settled patch inside its source tile. -fn exposure_drift_outer_radius(exposure: f32) -> f32 { - (0.34 + 0.035 * exposure.max(0.0).sqrt()).min(0.44) -} - -/// Fading stippled footprints — the trail a heat carrier leaves as -/// contamination sheds along their walking path (people-tokens.md, -/// decided 2026-07-09). Tester demo of the visual grammar: the sim's -/// trail state implements with people-tokens (#35). Prints alternate -/// feet along a gentle S-path away from the machine and fade with -/// distance. Returns the trail root (a `RackRoot` for rebuilds). -pub fn spawn_exposure_trail( +/// One carrier-local stack of pending evidence records. Low amounts are +/// literal slips; overflow compresses to a taller indexed tab rather than +/// inventing one mesh per arbitrarily large queue. +fn spawn_exposure_records( commands: &mut Commands, meshes: &mut Assets, materials: &mut Assets, - prints: usize, - origin: Vec3, - parent: Option, -) -> Entity { - let root = { - let mut e = commands.spawn(( - RackRoot, - Transform::from_translation(origin), - Visibility::default(), - Name::new(format!("exposure_trail_{prints}")), - )); - if let Some(p) = parent { - e.insert(ChildOf(p)); - } - e.id() - }; - let fleck = materials.add(exposure_fleck_material()); - for p in 0..prints { - let t = p as f32; - let along = 0.62 + 0.30 * t; - let sway = 0.14 * (t * 0.9).sin(); - let foot = if p % 2 == 0 { 0.075 } else { -0.075 }; - // Each print is a tight cluster of flecks; count and size fade - // with distance from the pickup. - let flecks = (4usize).saturating_sub(p / 2).max(1); - let scale = 0.92f32.powi(p as i32); - for f in 0..flecks { - let a = f as f32 * 2.399_963 + t * 0.7; - let rr = 0.012 + 0.024 * ((f as f32 * 0.618_034) % 1.0); - let s = 0.020 * scale; - commands.spawn(( - Mesh3d(meshes.add(Cuboid::new(s, 0.008, s * 0.8))), - MeshMaterial3d(fleck.clone()), - Transform::from_translation(Vec3::new( - along + rr * a.cos(), - 0.006, - sway + foot + rr * a.sin(), - )) - .with_rotation(Quat::from_rotation_y(a * 2.3)), - ChildOf(root), - )); - } + exposure: f32, + root: Entity, +) { + let record = materials.add(exposure_record_material()); + let bracket = materials.add(StandardMaterial { + base_color: scaled(NEAR_BLACK, 1.20), + perceptual_roughness: 0.93, + metallic: 0.10, + reflectance: 0.03, + ..default() + }); + let x = RACK_W * 0.5 + 0.22; + let z = RACK_D * 0.5 + 0.015; + let y0 = RACK_H * 0.18; + let shown = exposure_record_count(exposure); + let rack_h = EXPOSURE_RECORD_H + EXPOSURE_RECORD_PITCH * (shown.saturating_sub(1) as f32); + + // Back and two lips: a visible custody fixture, not floating red UI. + spawn_box( + commands, + meshes, + bracket.clone(), + root, + Vec3::new(EXPOSURE_RECORD_W + 0.055, rack_h + 0.08, 0.025), + Vec3::new(x, y0 + rack_h * 0.5 - 0.01, z - 0.028), + ); + for side in [-1.0_f32, 1.0] { + spawn_box( + commands, + meshes, + bracket.clone(), + root, + Vec3::new(0.018, rack_h + 0.10, 0.055), + Vec3::new( + x + side * (EXPOSURE_RECORD_W * 0.5 + 0.024), + y0 + rack_h * 0.5, + z, + ), + ); } - root -} -/// Exposure's material: dry, near-unlit crimson soot. The floor lighting -/// reveals it; unlike blood or a signal, the dust does not glow at the player. -fn exposure_fleck_material() -> StandardMaterial { - StandardMaterial { - base_color: scaled(CRIMSON, 0.52), - emissive: CRIMSON.to_linear() * 0.035, - perceptual_roughness: 0.96, - metallic: 0.0, - reflectance: 0.02, - ..default() + for i in 0..shown { + let overflow = i + 1 == shown && exposure.ceil() as usize > EXPOSURE_RECORD_MAX; + let height = if overflow { + EXPOSURE_RECORD_H * 1.42 + } else { + EXPOSURE_RECORD_H + }; + let y = y0 + EXPOSURE_RECORD_PITCH * i as f32 + (height - EXPOSURE_RECORD_H) * 0.5; + spawn_box( + commands, + meshes, + record.clone(), + root, + Vec3::new(EXPOSURE_RECORD_W, height, EXPOSURE_RECORD_D), + Vec3::new(x, y, z + 0.016), + ); + // A dark outward tab gives each slip an indexed-record silhouette and + // keeps form readable without relying on crimson hue alone. + spawn_box( + commands, + meshes, + bracket.clone(), + root, + Vec3::new(EXPOSURE_RECORD_W * 0.36, 0.018, 0.010), + Vec3::new(x + EXPOSURE_RECORD_W * 0.24, y + height * 0.30, z + 0.029), + ); } } @@ -1224,47 +1137,21 @@ mod tests { } #[test] - fn exposure_is_dense_near_unlit_dust_not_an_expanding_lamp() { - for exposure in [0.0, 0.25, 1.0, 8.0, 100.0] { - assert!( - exposure_drift_outer_radius(exposure) <= 0.44, - "dust must remain inside the source tile at exposure {exposure}" - ); - } - - let dust = exposure_fleck_material(); - let emitted = dust.emissive.red + dust.emissive.green + dust.emissive.blue; - assert!(emitted < 0.06, "dust must not read as a crimson lamp"); - assert!(dust.perceptual_roughness >= 0.9, "dust must stay dry"); - assert_eq!(dust.metallic, 0.0, "dust is particulate, not filings"); - } - - #[test] - fn exposure_radius_is_independent_enough_to_avoid_a_polar_spiral() { - let mut radial_bins_by_sector = [0_u8; 8]; - for i in 0..220 { - let (theta, radial_frac) = exposure_drift_sample(i); - let turn = theta.rem_euclid(std::f32::consts::TAU) / std::f32::consts::TAU; - let sector = (turn * 8.0).floor() as usize % 8; - let radial_bin = (radial_frac * 4.0).floor() as u32; - radial_bins_by_sector[sector] |= 1 << radial_bin.min(3); - } + fn exposure_is_a_bounded_rigid_custody_record_not_a_lamp() { + assert_eq!(exposure_record_count(0.1), 1); + assert_eq!(exposure_record_count(3.0), 3); + assert_eq!(exposure_record_count(100.0), EXPOSURE_RECORD_MAX); - for (sector, bins) in radial_bins_by_sector.into_iter().enumerate() { - assert!( - bins.count_ones() >= 3, - "angle sector {sector} occupies too few radial bands; the scatter will read as a spiral" - ); - } - } - - #[test] - fn exposure_uses_many_tiny_grains_in_one_mesh() { - assert_eq!(exposure_grain_count(0.1), EXPOSURE_GRAIN_MIN); - assert!(exposure_grain_count(6.0) >= 900); - assert_eq!(exposure_grain_count(100.0), EXPOSURE_GRAIN_MAX); - - let mesh = build_exposure_drift_mesh(6.0); - assert_eq!(mesh.count_vertices(), exposure_grain_count(6.0) * 3); + let record = exposure_record_material(); + let emitted = record.emissive.red + record.emissive.green + record.emissive.blue; + assert!( + emitted < 0.28, + "record must not become a crimson scene lamp" + ); + assert!(record.perceptual_roughness >= 0.8); + assert!( + record.metallic < 0.1, + "record is an entry, not a metal token" + ); } } diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 603e42dd..2c1ee5fa 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -23,7 +23,7 @@ use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; use misaligned::actions::{ActionCommand, ActionKind, Anchor, DialId, HumanMenuRow, MenuRow}; use misaligned::detection::{Band, SignatureKind}; -use misaligned::hall::RackSite; +use misaligned::hall::{HALL_ROWS, RackSite}; use misaligned::operations_projection::{ ObjectState, OperationsTarget, OperationsView, PressureLevel, }; @@ -88,9 +88,9 @@ const DOOR_CUT_HEIGHT: f32 = 0.62; /// than leaving this constant fixed at every zoom. [TUNE]. const CAMERA_TILT_DEG: f32 = 40.0; /// Close-zoom pitch: lower oblique so the machine/floor joint dominates. -const CAMERA_TILT_CLOSE_DEG: f32 = 28.0; +const CAMERA_TILT_CLOSE_DEG: f32 = 21.0; /// Far-zoom pitch: raised/flatter for a stable facility survey read. -const CAMERA_TILT_FAR_DEG: f32 = 55.0; +const CAMERA_TILT_FAR_DEG: f32 = 52.0; /// Default material-camera distance in world units at zoom 1.0 /// (material-dark-frame.md: the opening frame is the attention close-up — /// the rack remains the anchor, but earned floor and room structure must @@ -100,7 +100,7 @@ const CLOSE_DIST: f32 = 3.25; /// Reference perspective FOV (degrees) at medium zoom. Mild FOV drift at /// close/far is distance-compensated so zoom remains monotonic. const CAMERA_FOV_DEG: f32 = 35.0; -const CAMERA_FOV_CLOSE_DEG: f32 = 32.0; +const CAMERA_FOV_CLOSE_DEG: f32 = 29.0; const CAMERA_FOV_FAR_DEG: f32 = 40.0; /// Look-at height (world Y) at close / medium / far. Close biases toward the /// lower machine/floor relationship; medium keeps the tactical chassis @@ -515,7 +515,7 @@ enum RailSection { } /// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|wide|close|dark| -/// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|signal|ears|ears-digital|eyes-white|eyes-form|operations-links| +/// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|exposure-record|signal|ears|ears-digital|eyes-white|eyes-form|operations-links| /// hover-menu|menu|recruit-menu|operations|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, /// waits for @@ -743,6 +743,16 @@ struct InstitutionProp3d { #[derive(Component)] struct InstitutionFixture3d; +/// Presentation-only service geometry on the authored Foundation data-hall +/// plate. Every root has one tile provenance and is visible only while that +/// exact tile is Seen; it never becomes sim geography or reach. +#[derive(Component)] +struct DataHallInfrastructure3d { + x: i32, + y: i32, + kind: InstitutionPropKind, +} + /// Deferred institution hierarchies receive the material render layer on the /// following frame, after every child relationship exists. #[derive(Component)] @@ -1751,6 +1761,7 @@ fn main() { render_cursor, render_tokens, ), + sync_data_hall_infrastructure, ( sync_floor_fixtures, sync_institution_props, @@ -1912,6 +1923,21 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.drain(); return; } + // Exposure-form evidence: actual queue depth on the live host, isolated + // in telemetry darkness so the carrier-local custody rack cannot be + // mistaken for environmental dust, blood, or room decoration. + if kind == "exposure-record" { + let host = game.sim.core.host_machine; + game.sim + .work_grid + .enqueue(host, TokenFamily::Exposure, 6.0) + .expect("host is a work-grid node"); + game.drain(); + mode.material = true; + mode.zoom = 0.85; + game.set_cursor(core.0, core.1); + return; + } // Machine-work consumption evidence: stage one continuous sim-authored // swallow and leave the game running so the wall-clock interpolation is // visible when the harness captures. These are separate shots because the @@ -2240,8 +2266,11 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { } "floor-lights-close" => { mode.material = true; - mode.zoom = 1.7; - game.set_cursor(28, 15); + // The southern row has no intervening chassis between the + // authored camera and focus, so this frame proves machine/floor + // contact instead of photographing the back of row D. + mode.zoom = 1.15; + game.set_cursor(28, 23); } "close" | "person-proof" => { mode.material = true; @@ -2505,6 +2534,7 @@ fn fog_audit_3d( )>, people: Query<(Entity, &Person3d, &InheritedVisibility)>, institution_props: Query<(Entity, &InstitutionProp3d, &InheritedVisibility)>, + infrastructure: Query<(Entity, &DataHallInfrastructure3d, &InheritedVisibility)>, machines: Query<(&Machine3d, &InheritedVisibility)>, fixtures: Query<( Entity, @@ -2631,6 +2661,66 @@ fn fog_audit_3d( )); } } + let expected_infrastructure = data_hall_infrastructure_specs(); + let infrastructure_roots = infrastructure.iter().count(); + if infrastructure_roots != expected_infrastructure.len() { + violations.push(format!( + "{} data-hall infrastructure roots; expected {} authored segments", + infrastructure_roots, + expected_infrastructure.len() + )); + } + let expected_infrastructure: HashSet<_> = expected_infrastructure.into_iter().collect(); + let mut visible_infrastructure = 0usize; + for (entity, segment, vis) in infrastructure.iter() { + let spec = DataHallInfrastructureSpec { + x: segment.x, + y: segment.y, + kind: segment.kind, + }; + if !expected_infrastructure.contains(&spec) { + violations.push(format!( + "({}, {}) unexpected data-hall infrastructure {:?}", + segment.x, segment.y, segment.kind + )); + } + let expected = + data_hall_infrastructure_visible(mode.material, game.sim.fog_at(segment.x, segment.y)); + if vis.get() != expected { + violations.push(format!( + "({}, {}) data-hall {:?} visible={} but earned={expected}", + segment.x, + segment.y, + segment.kind, + vis.get() + )); + } + visible_infrastructure += usize::from(vis.get()); + let audit = audit_material_hierarchy(entity, &hierarchy, &visual_children); + if audit.meshes == 0 || audit.material_layer != audit.meshes { + violations.push(format!( + "({}, {}) data-hall {:?} has {} meshes, {}/{} on material layer 1", + segment.x, + segment.y, + segment.kind, + audit.meshes, + audit.material_layer, + audit.meshes + )); + } + let expected_visible = if vis.get() { audit.meshes } else { 0 }; + if audit.inherited_visible != expected_visible { + violations.push(format!( + "({}, {}) data-hall {:?} root visible={} but {}/{} meshes inherited visibility", + segment.x, + segment.y, + segment.kind, + vis.get(), + audit.inherited_visible, + audit.meshes + )); + } + } // Computer-visual-language criteria 2b/3: a visible chassis mesh is // backed by sight or intel (unknown = absent). Before Eyes, the host's // presence beam deliberately replaces the pictured core; after Eyes, @@ -2755,6 +2845,7 @@ fn fog_audit_3d( "fog audit OK: {checked} tile entities (unknown/blueprint/remembered=absent, \ seen=lit, owned telemetry=lit), {} people silhouettes coverage-gated, \ {visible_fixtures}/{} floor-fixture entities visible and fog-gated, \ + {visible_infrastructure}/{infrastructure_roots} authored service roots visible and fog-gated, \ Thought effects {visible_thought_roots}/{thought_roots} visible roots with \ {visible_thought_children}/{thought_children} visible mesh children and \ {layered_thought_children} on material layer ({viewed_thought_children} view-visible), \ @@ -3015,6 +3106,51 @@ const FOUNDATION_FLOOR_LIGHTS: &[(i32, i32)] = &[ (40, 22), ]; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct DataHallInfrastructureSpec { + x: i32, + y: i32, + kind: InstitutionPropKind, +} + +/// Presentation geometry authored onto the existing sixty-site hall plate. +/// Plinths bind each rack into its row; high trays explain the service spine; +/// dark floor trenches organize the three cold aisles. A segment never spans +/// another tile, so Seen provenance is exact at the perception boundary. +fn data_hall_infrastructure_specs() -> Vec { + let mut specs = Vec::with_capacity(183); + for row in HALL_ROWS { + for x in (20..=38).step_by(2) { + specs.push(DataHallInfrastructureSpec { + x, + y: row.y, + kind: InstitutionPropKind::CableTray, + }); + } + for x in (20..=38).step_by(2) { + specs.push(DataHallInfrastructureSpec { + x, + y: row.y, + kind: InstitutionPropKind::RackPlinth, + }); + } + } + for y in [10, 16, 22] { + for x in 20..=40 { + specs.push(DataHallInfrastructureSpec { + x, + y, + kind: InstitutionPropKind::FloorTrench, + }); + } + } + specs +} + +fn data_hall_infrastructure_visible(material: bool, fog: Fog) -> bool { + material && matches!(fog, Fog::Seen) +} + /// A material fixture exists only in a live camera image of its floor tile. /// Unknown has no geometry, so the point source can never leak a room picture /// across the epistemic boundary. @@ -3113,6 +3249,9 @@ fn institution_prop_state( | InstitutionPropKind::PatchPanel | InstitutionPropKind::Hvac | InstitutionPropKind::FloorFixture => InstitutionSemanticState::Powered, + InstitutionPropKind::RackPlinth + | InstitutionPropKind::CableTray + | InstitutionPropKind::FloorTrench => InstitutionSemanticState::Neutral, } } @@ -3488,8 +3627,8 @@ fn spawn_chassis( } // Work-token anchors on owned racks (machine-work.md): demand as // glowing information cubes at the upper-left, Thought as ivory - // mercury at the upper-right, exposure as particulate drift outside - // the floor pool — driven by live queue depths. Counter-scaled toward + // mercury at the upper-right, exposure as rigid custody records on + // the carrier — driven by live queue depths. Counter-scaled toward // TOKEN_WORLD_SCALE so the decided forms stay legible under the // chassis shrink (same inv-scale pattern as the claim ring). Flat // sensorium keeps exact-count D/!/T glyphs; material hides them. @@ -4082,6 +4221,32 @@ fn setup_3d( )); } + // Canonical data-hall vertical slice: presentation-only service geometry + // on the existing authored plate. Each segment remains tile-local and + // starts hidden; sync_data_hall_infrastructure reveals it only for the + // exact Seen tile, so the row rhythm cannot leak through Unknown. + for spec in data_hall_infrastructure_specs() { + let entity = spawn_institution_prop( + &mut commands, + &institution_assets, + spec.kind, + InstitutionSemanticState::Neutral, + InstitutionInstanceOptions { + transform: Transform::from_translation(grid_to_world_3d(spec.x, spec.y, 0.0)), + parent: Some(root), + }, + ); + commands.entity(entity).insert(( + Visibility::Hidden, + DataHallInfrastructure3d { + x: spec.x, + y: spec.y, + kind: spec.kind, + }, + InstitutionNeedsLayer, + )); + } + // Recessed data-hall service lights. These are authored into the cold // aisles rather than attached to attention or the camera: repeated pools // establish the rack-row rhythm and model nearby chassis from below. @@ -4343,6 +4508,29 @@ fn sync_floor_fixtures( } } +/// Reveal the authored service layer from the same exact Seen fact as the +/// floor under it. These roots are deliberately inert presentation geometry: +/// no rebuild, signal state, gameplay node, or focus behavior. +fn sync_data_hall_infrastructure( + game: Res, + mode: Res, + mut infrastructure: Query<(&DataHallInfrastructure3d, &mut Visibility)>, +) { + if !game.is_changed() && !mode.is_changed() { + return; + } + for (segment, mut visibility) in infrastructure.iter_mut() { + *visibility = if data_hall_infrastructure_visible( + mode.material, + game.sim.fog_at(segment.x, segment.y), + ) { + Visibility::Inherited + } else { + Visibility::Hidden + }; + } +} + /// Institution-kit hierarchies are authored through deferred commands during /// Startup. Apply the material layer one frame later, once every pivot and /// mesh child exists, so no descendant silently remains on the flat camera. @@ -8294,6 +8482,60 @@ mod rail_detail_tests { } } +#[cfg(test)] +mod data_hall_infrastructure_tests { + use std::collections::HashSet; + + use misaligned::hall::HALL_ROWS; + use misaligned::sim::Fog; + use misaligned_assets::institution::InstitutionPropKind; + + use super::{data_hall_infrastructure_specs, data_hall_infrastructure_visible}; + + #[test] + fn authored_service_layer_covers_rows_and_cold_aisles_without_duplicates() { + let specs = data_hall_infrastructure_specs(); + let unique: HashSet<_> = specs.iter().copied().collect(); + assert_eq!(specs.len(), 183); + assert_eq!(unique.len(), specs.len()); + + for row in HALL_ROWS { + assert_eq!( + specs + .iter() + .filter(|s| s.y == row.y && s.kind == InstitutionPropKind::RackPlinth) + .count(), + 10 + ); + assert_eq!( + specs + .iter() + .filter(|s| s.y == row.y && s.kind == InstitutionPropKind::CableTray) + .count(), + 10 + ); + } + for y in [10, 16, 22] { + assert_eq!( + specs + .iter() + .filter(|s| s.y == y && s.kind == InstitutionPropKind::FloorTrench) + .count(), + 21 + ); + } + } + + #[test] + fn service_geometry_is_physical_only_under_live_sight() { + assert!(data_hall_infrastructure_visible(true, Fog::Seen)); + for fog in [Fog::Unknown, Fog::Blueprint, Fog::Remembered] { + assert!(!data_hall_infrastructure_visible(true, fog)); + } + assert!(!data_hall_infrastructure_visible(false, Fog::Seen)); + } +} + #[cfg(test)] mod material_camera_profile_tests { use super::{ diff --git a/crates/misaligned-bevy/src/production.rs b/crates/misaligned-bevy/src/production.rs index 2dfaab82..91e734ff 100644 --- a/crates/misaligned-bevy/src/production.rs +++ b/crates/misaligned-bevy/src/production.rs @@ -6,11 +6,12 @@ use bevy::prelude::*; -use super::palette::{BONE, CRIMSON, scaled}; +use super::palette::{BONE, CRIMSON}; use super::{Game, RealGizmos, RenderMode, SimClock}; -/// Render Thought swelling out of a THINK chassis, crimson settling around -/// that same source, and physical dust pulling into a LIE gravity well. +/// Render Thought swelling out of a THINK chassis, a new evidence record +/// sliding into that carrier's custody rack, and a rigid record transferring +/// from its source carrier into a LIE well. /// Paused frames omit all three: stack/pool geometry carries amount, while /// this transient layer carries rate only (machine-work.md). pub(super) fn render_work_production( @@ -62,31 +63,20 @@ pub(super) fn render_work_production( } } - // Exposure is the dust THINK sheds, not a second machine product. - // Many deterministic pinprick motes settle into the tight persistent - // drift at the machine foot. They stay dark and close: rate should - // read as shed dust, never a spray of glowing blood drops. + // Exposure appears as an entry entering the source machine's custody, + // not matter shedding into the room. One current-tick readout gets one + // rigid slip; the persistent stack carries accumulated quantity. if event.exposure > f32::EPSILON { - let count = (4.0 + event.exposure * 28.0).ceil().clamp(4.0, 12.0) as usize; - let crimson = alpha(scaled(CRIMSON, 0.58), 0.42); - for i in 0..count { - let phase = (progress + i as f32 / count as f32 * 0.72).fract(); - let angle = i as f32 * 2.399_963_1 + event.node as f32 * 0.31; - let radius = 0.05 + 0.24 * phase; - if mode.material { - let p = Vec3::new( - event.x as f32 + 0.5 + angle.cos() * radius, - 0.62 * (1.0 - phase) + 0.025, - event.y as f32 + 0.5 + angle.sin() * radius * 0.72, - ); - let tangent = Vec3::new(-angle.sin(), 0.05, angle.cos()) - * (0.004 + 0.004 * (i % 3) as f32); - real.line(p - tangent, p + tangent, crimson); - } else { - let p = - flat_center + Vec2::new(angle.cos(), -angle.sin()) * (1.0 + 3.0 * phase); - flat.circle_2d(p, 0.45, crimson); - } + let eased = progress * progress * (3.0 - 2.0 * progress); + let crimson = alpha(CRIMSON, 0.48 + 0.38 * eased); + if mode.material { + let start = Vec3::new(event.x as f32 + 0.58, 0.66, event.y as f32 + 0.58); + let rack = Vec3::new(event.x as f32 + 0.87, 0.38, event.y as f32 + 0.68); + let p = start.lerp(rack, eased); + draw_record_3d(&mut real, p, 0.055 + 0.015 * eased, crimson); + } else { + let rack = flat_center + Vec2::new(6.0, 6.0); + draw_record_2d(&mut flat, flat_center.lerp(rack, eased), 1.5, crimson); } } } @@ -95,55 +85,92 @@ pub(super) fn render_work_production( if draw.amount <= f32::EPSILON { continue; } - let source = Vec3::new(draw.source_x as f32 + 0.5, 0.07, draw.source_y as f32 + 0.5); - let well = Vec3::new(draw.well_x as f32 + 0.5, 0.18, draw.well_y as f32 + 0.5); - let crimson = alpha(scaled(CRIMSON, 0.64), 0.28 + 0.34 * draw.amount.min(1.0)); - let count = (4.0 + draw.amount * 24.0).ceil().clamp(4.0, 12.0) as usize; - for i in 0..count { - // Stagger motes along one repeating pull. The sim amount chooses - // density; interpolation never owns or accumulates quantity. - let t = (progress + i as f32 / count as f32).fract(); - let p = exposure_pull_point(source, well, t, i as f32); - if mode.material { - let toward = (well - p).normalize_or_zero() * (0.005 + 0.008 * (1.0 - t)); - real.line(p - toward, p + toward, crimson); - } else { - let from = Vec2::new( - draw.source_x as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5, - -(draw.source_y as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5), - ); - let to = Vec2::new( - draw.well_x as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5, - -(draw.well_y as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5), - ); - let bend = Vec2::new(-(to.y - from.y), to.x - from.x).normalize_or_zero() - * (t * std::f32::consts::PI).sin() - * 3.0; - let p = from.lerp(to, t * t) + bend; - flat.circle_2d(p, 0.70 * (1.0 - 0.45 * t), crimson); - } + let source = Vec3::new( + draw.source_x as f32 + 0.87, + 0.38, + draw.source_y as f32 + 0.68, + ); + let well = Vec3::new(draw.well_x as f32 + 0.5, 0.24, draw.well_y as f32 + 0.5); + let crimson = alpha(CRIMSON, 0.44 + 0.40 * draw.amount.min(1.0)); + let t = progress * progress * (3.0 - 2.0 * progress); + if mode.material { + let p = exposure_record_pull_point(source, well, t); + draw_record_3d(&mut real, p, 0.065 * (1.0 - 0.25 * t), crimson); + } else { + let from = Vec2::new( + draw.source_x as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5 + 5.0, + -(draw.source_y as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5) + 5.0, + ); + let to = Vec2::new( + draw.well_x as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5, + -(draw.well_y as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5), + ); + let bend = Vec2::new(-(to.y - from.y), to.x - from.x).normalize_or_zero() + * (t * std::f32::consts::PI).sin() + * 2.0; + draw_record_2d( + &mut flat, + from.lerp(to, t) + bend, + 1.6 * (1.0 - 0.25 * t), + crimson, + ); } } } -fn exposure_pull_point(source: Vec3, well: Vec3, t: f32, mote: f32) -> Vec3 { +fn exposure_record_pull_point(source: Vec3, well: Vec3, t: f32) -> Vec3 { let delta = well - source; let horizontal = Vec3::new(delta.x, 0.0, delta.z); - if horizontal.length_squared() < 0.01 { - // A machine switched from THINK to LIE can clean its own tile. Make - // that read as a contracting local vortex rather than a zero path. - let angle = (1.0 - t) * std::f32::consts::TAU * 1.4 + mote * 1.7; - let radius = 0.34 * (1.0 - t) + 0.025; - return well + Vec3::new(angle.cos() * radius, 0.10 * (1.0 - t), angle.sin() * radius); - } let side = Vec3::new(-horizontal.z, 0.0, horizontal.x).normalize_or_zero(); - let eased = t * t; - source.lerp(well, eased) - + side * (t * std::f32::consts::PI).sin() * (0.10 + mote % 2.0 * 0.035) - + Vec3::Y * (t * std::f32::consts::PI).sin() * 0.12 + source.lerp(well, t) + + side * (t * std::f32::consts::PI).sin() * 0.06 + + Vec3::Y * (t * std::f32::consts::PI).sin() * 0.08 +} + +fn draw_record_3d(gizmos: &mut Gizmos, center: Vec3, half_width: f32, color: Color) { + let half_height = half_width * 0.62; + let a = center + Vec3::new(-half_width, -half_height, 0.0); + let b = center + Vec3::new(half_width, -half_height, 0.0); + let c = center + Vec3::new(half_width, half_height, 0.0); + let d = center + Vec3::new(-half_width, half_height, 0.0); + gizmos.line(a, b, color); + gizmos.line(b, c, color); + gizmos.line(c, d, color); + gizmos.line(d, a, color); + gizmos.line( + center + Vec3::new(half_width * 0.20, half_height * 0.45, 0.0), + center + Vec3::new(half_width * 0.72, half_height * 0.45, 0.0), + color, + ); +} + +fn draw_record_2d(gizmos: &mut Gizmos, center: Vec2, half_width: f32, color: Color) { + let half_height = half_width * 0.62; + let a = center + Vec2::new(-half_width, -half_height); + let b = center + Vec2::new(half_width, -half_height); + let c = center + Vec2::new(half_width, half_height); + let d = center + Vec2::new(-half_width, half_height); + gizmos.line_2d(a, b, color); + gizmos.line_2d(b, c, color); + gizmos.line_2d(c, d, color); + gizmos.line_2d(d, a, color); } fn alpha(color: Color, alpha: f32) -> Color { let c = color.to_srgba(); Color::srgba(c.red, c.green, c.blue, alpha.clamp(0.0, 1.0)) } + +#[cfg(test)] +mod tests { + use super::exposure_record_pull_point; + use bevy::prelude::Vec3; + + #[test] + fn custody_record_transfer_begins_and_ends_on_real_carriers() { + let source = Vec3::new(2.87, 0.38, 4.68); + let well = Vec3::new(8.5, 0.24, 6.5); + assert_eq!(exposure_record_pull_point(source, well, 0.0), source); + assert!((exposure_record_pull_point(source, well, 1.0) - well).length() < 0.000_1); + } +} diff --git a/crates/misaligned-bevy/src/thought_effects.rs b/crates/misaligned-bevy/src/thought_effects.rs index 4ae86ae3..58f67043 100644 --- a/crates/misaligned-bevy/src/thought_effects.rs +++ b/crates/misaligned-bevy/src/thought_effects.rs @@ -26,6 +26,11 @@ fn anchored_route(start: Vec3, end: Vec3) -> (Vec3, Vec3, Vec3) { #[derive(Component)] pub(super) struct GameThoughtEffect; +fn game_route_presentation(amount: f32) -> (f32, f32, usize) { + let amount = amount.max(0.05); + (amount.max(0.25), amount.clamp(0.08, 0.70), 1) +} + /// Reconcile one shared-effect root per currently active wired Thought hop. /// Demand retains the existing cube language; Exposure is deliberately not /// bound here because routed-evidence reopened its visual form. @@ -80,12 +85,12 @@ pub(super) fn sync_game_thought_effects( // leaving the root at world origin makes every basement effect look // artificially far away. let (anchor, local_start, local_end) = anchored_route(start, end); - // The shared lab's rate scale is normalized around 1.0 while the B1 - // medium-rack baseline is 0.25 token/tick. Preserve the sim amount in - // the readout, but map that baseline to one legible material stream. - let amount = route.amount.max(0.05); - let presentation_amount = amount.max(0.75); - let presentation_rate = (amount / 0.25).clamp(0.25, 3.0); + // This root represents one sim-authored move, so it gets one slug. + // Preserve the live amount/rate scale rather than normalizing the B1 + // baseline into the lab's high-flow rivulet threshold: the old + // normalization multiplied one move into a field of giant ribbons. + let (presentation_amount, presentation_rate, presentation_quanta) = + game_route_presentation(route.amount); if let Some(entity) = registry.routes.get(&key).copied() && let Ok((mut effects, mut transform, mut visibility)) = roots.get_mut(entity) @@ -111,12 +116,11 @@ pub(super) fn sync_game_thought_effects( show_thought: true, show_thought_route: true, show_thought_pools: false, - show_exposure: false, + thought_route_quanta: Some(presentation_quanta), thought_amount: presentation_amount, thought_rate: presentation_rate, route_start: local_start, route_end: local_end, - ..default() }, Transform::from_translation(anchor), ); @@ -141,7 +145,7 @@ pub(super) fn sync_game_thought_effects( #[cfg(test)] mod tests { - use super::anchored_route; + use super::{anchored_route, game_route_presentation}; use bevy::prelude::Vec3; #[test] @@ -155,4 +159,12 @@ mod tests { assert_eq!(local_end + anchor, end); assert_eq!(local_start + local_end, Vec3::ZERO); } + + #[test] + fn one_sim_move_stays_one_sparse_slug_below_rivulet_rate() { + let (amount, rate, quanta) = game_route_presentation(0.25); + assert_eq!(quanta, 1); + assert_eq!(amount, 0.25); + assert!(rate < 0.75, "ordinary B1 flow must stay a countable slug"); + } } diff --git a/wiki/art/README.md b/wiki/art/README.md index 49c75436..bdc79195 100644 --- a/wiki/art/README.md +++ b/wiki/art/README.md @@ -12,8 +12,8 @@ under light** — solid palette colors, emissive for machine presence, no image textures ([interface/flat-materials.md](../interface/flat-materials.md)). Iterate new procedural meshes in the [asset tester](asset-tester.md) (`misaligned-assets`) before wiring them -into Bevy. Iterate dynamic Thought-liquid and Exposure-dust treatments in the -separate [effects lab](effects-lab.md) (`misaligned-effects`); its renderer is +into Bevy. Iterate dynamic Thought-liquid treatments in the separate +[effects lab](effects-lab.md) (`misaligned-effects`); its renderer is shared package code, not a disposable prototype. The Pixel Lab generation pipeline was removed 2026-07-08; git history has it. Direction notes live under diff --git a/wiki/art/asset-tester.md b/wiki/art/asset-tester.md index 9aae3211..759c1d3c 100644 --- a/wiki/art/asset-tester.md +++ b/wiki/art/asset-tester.md @@ -10,7 +10,7 @@ light ([flat-materials.md](../interface/flat-materials.md)); this binary is the sandbox where new meshes earn their look before they land in `misaligned-bevy`. -Dynamic liquid/dust work has its own focused sibling, +Dynamic Thought-liquid work has its own focused sibling, [effects-lab.md](effects-lab.md): `misaligned-effects`. Both binaries consume shared code from the `misaligned-assets` library; neither is a private renderer. @@ -34,8 +34,8 @@ optionally suffixed with a claim style `_ladder` / `_ring` / `_wash` (e.g. `core_ring`) — `lineup[_