← back to Dw War Room
initial scaffold (gitify-all 2026-05-06)
8d94312193fa4c7169a37a7fc2ae0f5501e54eb9 · 2026-05-06 10:25:17 -0700 · Steve Abrams
Files touched
A .gitignoreA CLAUDE.mdA docs/plans/2026-03-02-figma-threejs-photorealism-design.mdA docs/plans/2026-03-02-figma-threejs-photorealism-plan.mdA ecosystem.config.cjsA index.htmlA package-lock.jsonA package.jsonA serve.cjsA showroom.htmlA src/data/ApiClient.tsA src/data/WebSocketClient.tsA src/effects/Particles.tsA src/effects/PostProcessing.tsA src/main.tsA src/panels/HudOverlay.tsA src/scene/AgentNodes.tsA src/scene/CommandTable.tsA src/scene/DataWalls.tsA src/scene/Environment.tsA src/scene/Lighting.tsA src/scene/WarRoom.tsA src/showroom/FigmaTransformer.tsA src/showroom/ProceduralMaps.tsA src/showroom/SearchOverlay.tsA src/showroom/Showroom.tsA src/showroom/ShowroomEnvironment.tsA src/showroom/ShowroomLayout.tsA src/showroom/ShowroomMaterials.tsA src/showroom/ShowroomPostProcess.tsA src/showroom/TextureCrop.tsA src/showroom/index.tsA src/showroom/types.tsA src/types.tsA tsconfig.jsonA vite.config.ts
Diff
commit 8d94312193fa4c7169a37a7fc2ae0f5501e54eb9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 10:25:17 2026 -0700
initial scaffold (gitify-all 2026-05-06)
---
.gitignore | 12 +
CLAUDE.md | 164 ++
...2026-03-02-figma-threejs-photorealism-design.md | 167 ++
.../2026-03-02-figma-threejs-photorealism-plan.md | 1623 ++++++++++++++++++++
ecosystem.config.cjs | 13 +
index.html | 30 +
package-lock.json | 1186 ++++++++++++++
package.json | 19 +
serve.cjs | 185 +++
showroom.html | 17 +
src/data/ApiClient.ts | 78 +
src/data/WebSocketClient.ts | 75 +
src/effects/Particles.ts | 308 ++++
src/effects/PostProcessing.ts | 193 +++
src/main.ts | 237 +++
src/panels/HudOverlay.ts | 897 +++++++++++
src/scene/AgentNodes.ts | 256 +++
src/scene/CommandTable.ts | 270 ++++
src/scene/DataWalls.ts | 387 +++++
src/scene/Environment.ts | 183 +++
src/scene/Lighting.ts | 60 +
src/scene/WarRoom.ts | 198 +++
src/showroom/FigmaTransformer.ts | 128 ++
src/showroom/ProceduralMaps.ts | 188 +++
src/showroom/SearchOverlay.ts | 732 +++++++++
src/showroom/Showroom.ts | 206 +++
src/showroom/ShowroomEnvironment.ts | 93 ++
src/showroom/ShowroomLayout.ts | 343 +++++
src/showroom/ShowroomMaterials.ts | 104 ++
src/showroom/ShowroomPostProcess.ts | 181 +++
src/showroom/TextureCrop.ts | 159 ++
src/showroom/index.ts | 50 +
src/showroom/types.ts | 50 +
src/types.ts | 84 +
tsconfig.json | 18 +
vite.config.ts | 107 ++
36 files changed, 9001 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d80acea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+dist/
+.env
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+.DS_Store
+build/
+.next/
+*.bak
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a3c4c61
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,164 @@
+# 3D War Room — Room Architecture & Rendering Specs
+
+## Build System
+- Source modules in `/public/js/modules/` (15 MAIN + 1 STANDALONE)
+- Build: `node build-warroom.js` → concatenates into single IIFE `warroom.js` (~24,810 lines)
+- Validate: `node validate-warroom.js [--verbose] [--json]` — 20 checks across build/navigation/culling/scene/css/performance
+- Module order: wr-core → wr-audio → wr-ui → wr-scene → wr-animate → wr-effects → wr-effects2 → wr-effects3 → wr-effects4 → wr-office → wr-rooms → wr-doors → wr-meeting → wr-nav → wr-lincoln → (standalone) wr-wallpicker
+- HTML loads ONLY `/js/warroom.js?v=251` — modular files are NOT loaded by browser
+- **After ANY code change**: edit module → `node build-warroom.js` → `node validate-warroom.js` → bump cache version in index.html
+- Global state exposed via `window._*` pattern (e.g., `window._camera`, `window._controls`)
+- Variable names MUST be room-prefixed to avoid IIFE scope collisions (e.g., `djVinyl` not `vinyl`)
+
+---
+
+## Boardroom (Main Conference Room)
+- **Dimensions**: 24w x 18d x 4.2h (ROOM_W=24, ROOM_D=18, WALL_H=4.2)
+- **Table**: center (0, 0.95, 0), 10w x 3.2d x 0.95h
+- **Podium**: (-8.5, 0, 0), facing table (rotation Math.PI/2)
+- **Back wall**: (0, 2.1, -9), 24w x 4.2h
+- **Front wall**: (0, 2.1, 9), 24w x 4.2h — has 5 transparisteel windows
+- **Left wall**: (-12, 2.1, 0), 18d x 4.2h
+- **Right wall**: (12, 2.1, 0), 18d x 4.2h
+- **Seating**: 50 positions (16 at table, 2 head/foot, 20 back rows, 12 side walls)
+- **Executives only**: EXEC_IDS = Set(['ceo', 'cfo', 'coo', 'cto', 'vp-ops'])
+
+## Garcia Room
+- **Center**: (0, 0, -42), width 14, depth 10, height 4.2
+- **Front wall**: z = -37 (guitar at x=3.5, SYF at x=-3.5, volume slider at x=5.5)
+- **Back wall**: z = -47 (neon GARCIA sign, NOW PLAYING display, SYF banner)
+- **Left wall**: x = -7 (Puppy TV — full wall screen)
+- **Right wall**: x = +7 (Met Museum art)
+- **Turntable**: (3.5, 1.0, -44.5) with GD/JGB/G&G collection buttons below
+- **Eames chair**: (0, 0, -41)
+- **Camera start**: (0, 1.6, -46) looking at (0, 1.8, -40) — back of room facing forward
+- **Key globals**: `window._garciaHitbox`, `window._garciaGuitar`, `window._garciaVolumeSlider`, `window._garciaSYF`, `window._garciaJukebox`, `window._garciaNowPlayingCanvas`
+
+## Showroom / Gallery Wings
+- **Showroom**: cam (22, 2, -20), center (22, 0, -22)
+- **Gallery 2**: cam (42, 2, -20), center (42, 0, -22)
+- **Gallery 3**: cam (62, 2, -20), center (62, 0, -22)
+- **Lazy loaded**: Products built on first navigation visit
+- **Culling**: `_setShowroomVisibility(roomIdx)` hides distant gallery boards
+
+## MET Gallery
+- **Center**: (0, 0, -22), width 14, depth 8, height 4.2
+- **Location**: Between breakout rooms corridor and Garcia
+- **Features**: 100 contemporary paintings (procedural canvas textures), museum bench, black grasscloth walls
+- **Key globals**: `window._metPaintings`, `window._metGalleryGroup`
+
+## Claudette's Lab
+- **Center**: (0, 0, -56), width 16, depth 14, height 4.2
+- **Location**: South of Garcia via 3.6m corridor through back wall
+- **Features**: 3D printer (wireframe IcosahedronGeometry), holographic globe with orbital rings, neural network visualization (4 layers of spheres + cylinder connections), Matrix code rain (canvas), server rack with LED indicators, floating thought bubbles, workstation desk with terminal, energy orb ceiling fixture
+- **Floor**: Circuit board (MeshStandardMaterial + procedural CanvasTexture)
+- **Animations**: All registered in `window._claudetteAnimations[]`, called from wr-animate.js every 2 frames
+- **Lighting**: 4 PointLights (cyan, purple, green, warm) + 4 tech sconces
+
+---
+
+## Room Navigation (ROOM_NAV)
+
+| Room | Camera Position | Look Target |
+|------|----------------|-------------|
+| podium | (-8.5, 3, 5) | (-8.5, 1.2, 0) |
+| boardroom | (0, 3.2, 10) | (0, 1.2, 0) |
+| lounge | (-8, 3, -13) | (-8, 1, -18) |
+| cafe | (8, 3, -13) | (8, 1, -18) |
+| dancehall | (-8, 3, -23) | (-8, 1, -28) |
+| thinktank | (8, 3, -23) | (8, 1, -28) |
+| garcia | (0, 2.5, -46) | (0, 1.8, -40) |
+| showroom | (22, 2, -20) | (22, 1.5, -28) |
+| metgallery | (0, 2.5, -30) | (0, 1.5, -22) |
+| claudettelab | (0, 2.5, -48) | (0, 1.5, -56) |
+
+## Camera Presets (Keys 1-8, 0 to reset)
+1=Wide, 2=Executive, 3=Bird's Eye, 4=Ground, 5=Front Row, 6=Head of Table, 7=Balcony, 8=Over Shoulder
+
+---
+
+## Avatar System
+- 50 humanoid robot avatars, HIDDEN by default (zero draw calls)
+- Only execs materialize for meetings (5 agents)
+- FSM states: seated-idle, seated-speaking, standing-up, walking, moonwalking, sitting-down, chatting, on-break, at-lunch, dancing
+- Dance party: `startWarRoomDanceParty()` materializes ALL agents
+- Agent count display: `document.getElementById('agent-count')`
+
+## Meeting System
+- Trigger: `triggerMeeting('adhoc'|'standup'|'operations'|'eod')`
+- Halt: `haltMeeting()`
+- Backend: boardroom-agent at port 9815 (proxied via `/api/proxy/meeting/trigger/:type`)
+- Ad-hoc: visual assembly only, user-driven
+- Std/Ops/EOD: backend-driven with TTS speaking turns
+
+## Performance Budget
+- Target: 20+ FPS
+- Default draw calls: ~67 (no agents, lazy showroom)
+- With 5 execs: ~267 draw calls (+200)
+- All 50 agents: ~2067 draw calls
+- Auto-rotate pauses on user interaction (8s timeout)
+
+---
+
+## Figma-Driven Design System Rules
+
+### Design Token Architecture
+All UI styling uses CSS custom properties defined in `/public/css/style.css` `:root` section.
+IMPORTANT: Never hardcode colors, fonts, spacing, or shadows in inline styles. Always use `var(--ds-*)` tokens.
+
+| Category | Token Pattern | Example |
+|----------|--------------|---------|
+| Typography | `--ds-font`, `--ds-text-{xs..3xl}`, `--ds-weight-{normal..bold}` | `font-family:var(--ds-font)` |
+| Colors | `--ds-gray-{50..900}`, `--ds-{success,warning,error,info}` | `color:var(--ds-gray-700)` |
+| Surfaces | `--ds-glass`, `--ds-glass-strong`, `--ds-glass-subtle` | `background:var(--ds-glass)` |
+| Blur | `--ds-blur`, `--ds-blur-sm`, `--ds-blur-lg` | `backdrop-filter:var(--ds-blur)` |
+| Borders | `--ds-border`, `--ds-border-strong`, `--ds-border-interactive` | `border:1px solid var(--ds-border)` |
+| Radius | `--ds-radius-{xs..full}` | `border-radius:var(--ds-radius-md)` |
+| Shadows | `--ds-shadow-{xs..xl}` | `box-shadow:var(--ds-shadow-md)` |
+| Spacing | `--ds-space-{1..12}` (4px base) | `padding:var(--ds-space-4)` |
+| Transitions | `--ds-ease`, `--ds-duration-{fast,normal,slow}` | `transition:all var(--ds-duration-fast) var(--ds-ease)` |
+
+### Glass Morphism Pattern (all overlays, modals, toolbars)
+```css
+background: var(--ds-glass-strong);
+backdrop-filter: var(--ds-blur);
+border: 1px solid var(--ds-border);
+border-radius: var(--ds-radius-lg);
+box-shadow: var(--ds-shadow-lg);
+```
+
+### Component Styling Rules
+- IMPORTANT: System font stack: `-apple-system, BlinkMacSystemFont, Inter, sans-serif` — never use Georgia or serif
+- IMPORTANT: Primary action buttons use `--ds-gray-900` bg + white text, secondary use `--ds-gray-50` bg + `--ds-border-strong`
+- IMPORTANT: Muted/secondary text uses `--ds-gray-500`, not brown tones
+- Hover states: buttons lighten to `--ds-gray-700` (primary) or `--ds-gray-100` (secondary)
+- Active/selected items: `--ds-gray-100` background
+- Toast success/warning/error use `--ds-success`/`--ds-warning`/`--ds-error` for accent colors on white glass bg
+
+### Canvas2D Text (3D labels, plaques, signs)
+Font string: `-apple-system, BlinkMacSystemFont, Inter, sans-serif` (NO quotes around Inter — breaks JS string escaping)
+Light backgrounds: `#f5f0eb` (warm gallery white)
+Dark text: `#1a1a1a` (primary), `#666` (secondary), `#888` (tertiary), `#999` (muted)
+
+### PBR Material Standards
+- Wing panels: `MeshStandardMaterial({ roughness: 0.35, metalness: 0.0, envMapIntensity: 0.5 })`
+- Frames: `MeshStandardMaterial({ roughness: 0.3, metalness: 0.4 })` — brushed aluminum look
+- Spine: `MeshStandardMaterial({ roughness: 0.2, metalness: 0.6 })` — chrome-like
+- Environment map: Procedural PMREMGenerator from canvas gradient (no external files)
+- Tone mapping: ACES Filmic, exposure 1.6 in showroom rooms, 1.2 elsewhere
+
+### Showroom Texture Pipeline
+- Canvas slices: 512x768 (2:3 aspect, center-cropped)
+- Fallback texture: light `#f0ebe4` with system font product name
+- Texture queue: 4 concurrent loaders, generation-stamped for page-change safety
+- Cache: Map keyed by product handle/SKU
+
+### CSS Vignette
+`#canvas-container::after` applies a radial gradient vignette for photographic depth (zero GPU cost)
+
+### Forbidden Patterns
+- NEVER use `Georgia, serif` font anywhere
+- NEVER use gold `#d4a574` for UI text or backgrounds (reserved only for 3D material highlight: `wingsFrameHoverMat`)
+- NEVER use dark brown `#1a0f08` backgrounds
+- NEVER hardcode hex colors in inline styles — use CSS variable tokens
+- NEVER use `rgba(42,26,10,*)` — this is the old dark theme
diff --git a/docs/plans/2026-03-02-figma-threejs-photorealism-design.md b/docs/plans/2026-03-02-figma-threejs-photorealism-design.md
new file mode 100644
index 0000000..3707409
--- /dev/null
+++ b/docs/plans/2026-03-02-figma-threejs-photorealism-design.md
@@ -0,0 +1,167 @@
+# Figma + Three.js Photorealistic Showroom Pipeline
+
+**Date:** 2026-03-02
+**Status:** Approved
+**Target Systems:** War Room (port 4060) + Boardroom-3D Showroom (port 7681)
+
+---
+
+## Vision
+
+Transform the existing 3D showroom into a photorealistic architectural trade showroom with:
+- Endless vendor wings (50 wings, lazy-loaded)
+- Exposed brick corridor with track lighting
+- PBR materials with normal/roughness maps
+- Full post-processing pipeline (SSAO, DOF, bloom, color grading)
+- Figma MCP-driven layout pipeline
+
+## Architecture
+
+### Pipeline Flow
+```
+Figma Design → MCP get_design_context → Claude Transform → TypeScript Modules → Vite Build → Live 3D
+```
+
+### Approach: MCP-Native
+- Uses existing Figma MCP tools in Claude Code
+- Zero custom infrastructure
+- Real-time design iteration
+
+---
+
+## Showroom Layout: Endless Wings Corridor
+
+### Structure
+```
+Entry → Central Corridor → 50 Vendor Wings (extending in +X)
+ → Side Walls: wallpaper samples + book displays
+ → Overhead: industrial track lighting
+```
+
+### Key Elements
+
+1. **Central Corridor** — Long exposed-brick hallway, polished concrete floor, industrial ceiling with exposed ductwork
+2. **50 Vendor Wings** — Perpendicular from corridor. Each = 1 vendor. Clicking opens to reveal 20-50 product panels. Lazy-loaded on first visit.
+3. **Side Walls** — Between wings: large-format wallpaper samples (4ft x 8ft mounted panels) + glass-front sample book shelves
+4. **Track Lighting** — Continuous rail along corridor ceiling + dedicated spots per wing bay entrance
+5. **Wing Interior** — Each vendor wing: 3 walls of product panels, sample table, vendor branding, warm accent lighting
+6. **Navigation** — WASD walking + click-to-teleport. Minimap shows corridor + wing positions.
+
+### Performance: Endless Illusion
+- Render only 3 wings at a time (current + adjacent)
+- Fog/atmosphere fades distant wings
+- Frustum culling hides everything beyond visible range
+- Lazy-load product textures only when wing approached
+
+---
+
+## Photorealism Rendering Pipeline
+
+### Material System
+
+| Surface | Color | Metalness | Roughness | Normal Map | Notes |
+|---------|-------|-----------|-----------|------------|-------|
+| Polished concrete floor | #8a8580 | 0.15 | 0.55 | Procedural micro-texture | Contact shadows |
+| Brick walls | #8b4513 | 0.0 | 0.85 | Procedural brick pattern | Mortar darkening via SSAO |
+| Steel ceiling/ducts | #2a2a2a | 0.6 | 0.3 | Metal panel grid | Exposed duct geometry |
+| Wing frames | #c0c0c0 | 0.7 | 0.25 | Brushed aluminum | Sharp reflections |
+| Product panels | Texture | 0.0 | 0.4 | Fabric/paper subtle | Matte wallcovering |
+| Book shelves | #8b6914 | 0.0 | 0.65 | Wood grain | Glass fronts (transmission 0.9) |
+| Track lights | #ffffff | 0.9 | 0.1 | Chrome | Emissive + SpotLight |
+
+### Post-Processing Stack
+```
+RenderPass → SSAOPass → UnrealBloomPass → BokehPass → ColorGradingPass → FilmGrainPass → Output
+```
+
+1. **SSAO** — radius 0.5, bias 0.025, intensity 1.2
+2. **Bloom** — strength 0.25, radius 0.5, threshold 0.65
+3. **Depth of Field** — focus on look target, aperture 0.02
+4. **Color Grading** — warm architectural tones, contrast boost
+5. **Film Grain** — opacity 0.008, temporal
+
+### Environment Lighting
+- HDR environment map (Polyhaven CC0 gallery/studio HDRI)
+- RGBELoader → PMREMGenerator for IBL
+- ACES Filmic tone mapping, exposure 1.4 (corridor) / 1.6 (wing interior)
+- Shadows: PCF soft, 2048x2048 from main directional
+- Track lights: SpotLight with cone + penumbra
+- Volumetric light cones (additive-blend geometry + dust particles)
+
+---
+
+## MCP Integration Flow
+
+### Step 1: Figma Design Creation
+- Create showroom floor plan in Figma
+- Define vendor wings, walls, lighting positions
+- Set material swatches as image fills
+- Use Figma variables for design tokens
+
+### Step 2: MCP Extraction
+```typescript
+// Extract design context
+get_design_context(nodeId, fileKey) → structured layout data
+
+// Extract design tokens
+get_variable_defs(nodeId, fileKey) → color/spacing tokens
+```
+
+### Step 3: Claude Transform
+- Map Figma frames → Three.js scene graph (positions, dimensions)
+- Map image fills → TextureLoader URLs
+- Map text nodes → canvas-rendered labels
+- Map component instances → reusable objects
+
+### Step 4: TypeScript Module Generation
+```
+src/showroom/
+├── ShowroomLayout.ts — corridor, wings, walls geometry
+├── ShowroomMaterials.ts — PBR material definitions + normal maps
+├── ShowroomLighting.ts — track lights, spots, ambient, HDRI
+├── ShowroomTextures.ts — texture pipeline (lazy-load, fallback)
+├── ShowroomNavigation.ts — WASD, teleport, wing selection
+├── ShowroomCulling.ts — visibility management, LOD
+└── ShowroomPostProcess.ts — SSAO + DOF + bloom + grading
+```
+
+### Step 5: Integration
+- War Room (4060): new showroom room accessible via navigation
+- Boardroom-3D (7681): upgraded gallery rendering with shared materials
+
+---
+
+## Performance Budget
+
+| Metric | Target |
+|--------|--------|
+| FPS | 30+ at 1080p |
+| Draw calls (base) | < 100 |
+| Draw calls (wing open) | < 300 |
+| Texture memory | < 256MB |
+| Initial load | < 3 seconds |
+| Wing load (lazy) | < 1 second |
+
+---
+
+## Implementation Phases
+
+### Phase 1: Figma Design + MCP Pipeline
+- Create showroom layout in Figma
+- Build MCP extraction → transform pipeline
+- Generate base TypeScript modules
+
+### Phase 2: Photorealism Rendering
+- Implement SSAO, HDR env map, normal maps
+- Add DOF, color grading, volumetric lights
+- Upgrade materials to full PBR with detail maps
+
+### Phase 3: Endless Wings System
+- Build lazy-loaded wing architecture
+- Implement culling and LOD
+- Connect to product database for real textures
+
+### Phase 4: Integration
+- Merge into War Room (4060)
+- Upgrade Boardroom-3D (7681)
+- Performance optimization pass
diff --git a/docs/plans/2026-03-02-figma-threejs-photorealism-plan.md b/docs/plans/2026-03-02-figma-threejs-photorealism-plan.md
new file mode 100644
index 0000000..9dda415
--- /dev/null
+++ b/docs/plans/2026-03-02-figma-threejs-photorealism-plan.md
@@ -0,0 +1,1623 @@
+# Figma + Three.js Photorealistic Showroom — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Build a Figma MCP-driven pipeline that generates photorealistic endless-wings showroom scenes for both the War Room (port 4060, TypeScript/Vite) and Boardroom-3D (port 7681, vanilla JS), featuring architectural trade showroom aesthetics with exposed brick, track lighting, and PBR materials.
+
+**Architecture:** Figma design → MCP `get_design_context` extraction → Claude transform → TypeScript/JS modules. The showroom uses an endless corridor with 50 vendor wings (lazy-loaded), exposed brick walls with sample book displays, polished concrete floors, and industrial track lighting. Rendering upgraded with SSAO, HDR environment maps, procedural normal maps, depth of field, and color grading.
+
+**Tech Stack:** Three.js r170 (war room) / r128 CDN (boardroom-3d), Vite 6, TypeScript 5.7, Figma MCP tools, procedural canvas normal/roughness maps, EffectComposer post-processing pipeline.
+
+---
+
+## Phase 1: Figma Showroom Design + MCP Pipeline
+
+### Task 1: Create Figma Showroom Floor Plan
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/figma-layout.json` (extracted layout data)
+
+**Step 1: Create the Figma design using generate_figma_design**
+
+Use the Figma MCP `generate_figma_design` tool to create a new Figma file with the showroom floor plan. The design should include:
+- Main corridor frame (40ft x 20ft, exposed brick fill)
+- 6 vendor wing bay frames extending perpendicular (each 12ft x 10ft)
+- Central display table rectangle
+- Track lighting rail lines along ceiling
+- Product display wall rectangles (4ft x 8ft each, wallpaper texture fills)
+- Glass entry frame
+- Color tokens: brick (#8b4513), concrete (#8a8580), aluminum (#c0c0c0), warm wood (#8b6914)
+
+**Step 2: Extract design context via MCP**
+
+```typescript
+// Use get_design_context to extract the showroom layout
+const designContext = await mcp.get_design_context({
+ nodeId: '<root-frame-id>',
+ fileKey: '<file-key>'
+});
+```
+
+Save extracted JSON to `figma-layout.json`.
+
+**Step 3: Extract design tokens via MCP**
+
+```typescript
+const tokens = await mcp.get_variable_defs({
+ nodeId: '<root-frame-id>',
+ fileKey: '<file-key>'
+});
+```
+
+**Step 4: Commit**
+
+```bash
+git add src/showroom/figma-layout.json
+git commit -m "feat(showroom): extract Figma showroom layout via MCP"
+```
+
+---
+
+### Task 2: Build Figma-to-SceneGraph Transformer
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/FigmaTransformer.ts`
+
+**Step 1: Write the transformer module**
+
+```typescript
+// FigmaTransformer.ts — Converts Figma design context into Three.js scene graph config
+import type { FigmaNode, SceneConfig, WallConfig, WingConfig, LightConfig } from './types';
+
+export interface SceneConfig {
+ corridor: {
+ width: number; // world units (1 unit = 1 foot)
+ depth: number;
+ height: number;
+ wallMaterial: 'brick' | 'concrete' | 'charcoal';
+ floorMaterial: 'polished-concrete' | 'wood' | 'marble';
+ };
+ wings: WingConfig[];
+ walls: WallConfig[];
+ lights: LightConfig[];
+ furniture: FurnitureConfig[];
+ entry: { position: [number, number, number]; signText: string };
+}
+
+export interface WingConfig {
+ index: number;
+ vendor: string;
+ position: [number, number, number];
+ rotation: number;
+ panelCount: number;
+ accentColor: string; // hex from Figma fill
+}
+
+export interface WallConfig {
+ position: [number, number, number];
+ dimensions: [number, number]; // width, height
+ material: string;
+ hasBookshelf: boolean;
+ hasSamplePanels: boolean;
+ sampleCount: number;
+}
+
+export interface LightConfig {
+ type: 'spot' | 'track' | 'ambient' | 'point';
+ position: [number, number, number];
+ target?: [number, number, number];
+ color: string;
+ intensity: number;
+ angle?: number; // SpotLight cone angle
+ penumbra?: number;
+}
+
+export interface FurnitureConfig {
+ type: 'display-table' | 'stool' | 'bookshelf' | 'sample-rack';
+ position: [number, number, number];
+ dimensions: [number, number, number];
+}
+
+/**
+ * Transform Figma design JSON into Three.js scene configuration.
+ * Maps Figma frames → positions, rectangles → geometry, fills → materials.
+ */
+export function transformFigmaToScene(figmaData: any): SceneConfig {
+ const root = figmaData.document || figmaData;
+ const config: SceneConfig = {
+ corridor: { width: 40, depth: 20, height: 12, wallMaterial: 'brick', floorMaterial: 'polished-concrete' },
+ wings: [],
+ walls: [],
+ lights: [],
+ furniture: [],
+ entry: { position: [0, 0, 10], signText: 'DESIGNER WALLCOVERINGS' },
+ };
+
+ // Traverse Figma node tree
+ function traverse(node: any, depth = 0) {
+ if (!node) return;
+
+ // Map FRAME nodes to wings or rooms based on name
+ if (node.type === 'FRAME' && node.name?.toLowerCase().includes('wing')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.wings.push({
+ index: config.wings.length,
+ vendor: node.name.replace(/wing[-_\s]*/i, '').trim(),
+ position: [bbox.x / 10 || config.wings.length * 4, 0, bbox.y / 10 || 0],
+ rotation: 0,
+ panelCount: (node.children || []).filter((c: any) => c.type === 'RECTANGLE').length || 10,
+ accentColor: extractFillColor(node) || '#c0c0c0',
+ });
+ }
+
+ // Map RECTANGLE nodes with IMAGE fills to wall sample panels
+ if (node.type === 'RECTANGLE' && node.fills?.some((f: any) => f.type === 'IMAGE')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.walls.push({
+ position: [bbox.x / 10 || 0, bbox.height / 20 || 2, bbox.y / 10 || 0],
+ dimensions: [bbox.width / 10 || 4, bbox.height / 10 || 8],
+ material: 'brick',
+ hasBookshelf: false,
+ hasSamplePanels: true,
+ sampleCount: 1,
+ });
+ }
+
+ // Map circles/ellipses to spotlights
+ if (node.type === 'ELLIPSE' && node.name?.toLowerCase().includes('light')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.lights.push({
+ type: 'spot',
+ position: [bbox.x / 10 || 0, 10, bbox.y / 10 || 0],
+ target: [bbox.x / 10 || 0, 0, bbox.y / 10 || 0],
+ color: extractFillColor(node) || '#ffffff',
+ intensity: 1.5,
+ angle: Math.PI / 6,
+ penumbra: 0.5,
+ });
+ }
+
+ if (node.children) node.children.forEach((c: any) => traverse(c, depth + 1));
+ }
+
+ traverse(root);
+
+ // Add default track lighting if none extracted
+ if (config.lights.length === 0) {
+ for (let i = 0; i < 8; i++) {
+ config.lights.push({
+ type: 'spot',
+ position: [i * 5 - 17.5, 11, 0],
+ target: [i * 5 - 17.5, 0, 0],
+ color: '#f5e6d0',
+ intensity: 1.2,
+ angle: Math.PI / 5,
+ penumbra: 0.4,
+ });
+ }
+ }
+
+ return config;
+}
+
+function extractFillColor(node: any): string | null {
+ const fill = (node.fills || []).find((f: any) => f.type === 'SOLID' && f.visible !== false);
+ if (!fill?.color) return null;
+ const r = Math.round(fill.color.r * 255);
+ const g = Math.round(fill.color.g * 255);
+ const b = Math.round(fill.color.b * 255);
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/showroom/FigmaTransformer.ts
+git commit -m "feat(showroom): Figma-to-SceneGraph transformer"
+```
+
+---
+
+## Phase 2: Photorealistic Material System
+
+### Task 3: Procedural Normal Map Generator
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/ProceduralMaps.ts`
+
+**Step 1: Write procedural normal/roughness map generators**
+
+```typescript
+// ProceduralMaps.ts — Canvas-based procedural normal and roughness maps
+// Zero external file dependencies — all generated at runtime
+import * as THREE from 'three';
+
+/**
+ * Generate a brick wall normal map using canvas.
+ * Creates realistic mortar lines and brick surface variation.
+ */
+export function generateBrickNormalMap(width = 1024, height = 1024): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d')!;
+
+ // Base neutral normal (pointing straight out: RGB 128,128,255)
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ const brickW = width / 8;
+ const brickH = height / 16;
+ const mortarW = 3;
+
+ for (let row = 0; row < 16; row++) {
+ const offset = (row % 2) * (brickW / 2); // stagger rows
+ for (let col = -1; col < 9; col++) {
+ const x = col * brickW + offset;
+ const y = row * brickH;
+
+ // Mortar lines (recessed = normal pointing inward)
+ // Horizontal mortar
+ ctx.fillStyle = 'rgb(128, 100, 255)'; // slight downward normal
+ ctx.fillRect(0, y, width, mortarW);
+
+ // Vertical mortar
+ ctx.fillStyle = 'rgb(100, 128, 255)'; // slight leftward normal
+ ctx.fillRect(x, y, mortarW, brickH);
+
+ // Brick surface noise (subtle variation)
+ for (let i = 0; i < 20; i++) {
+ const nx = x + Math.random() * brickW;
+ const ny = y + mortarW + Math.random() * (brickH - mortarW * 2);
+ const r = 128 + (Math.random() - 0.5) * 12;
+ const g = 128 + (Math.random() - 0.5) * 12;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(nx, ny, 3 + Math.random() * 6, 2 + Math.random() * 4);
+ }
+ }
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.repeat.set(2, 2);
+ return tex;
+}
+
+/**
+ * Generate a polished concrete normal map.
+ * Subtle micro-texture with occasional aggregate patches.
+ */
+export function generateConcreteNormalMap(width = 1024, height = 1024): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d')!;
+
+ // Base neutral normal
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Micro-texture noise — polished concrete has subtle surface variation
+ for (let i = 0; i < 5000; i++) {
+ const x = Math.random() * width;
+ const y = Math.random() * height;
+ const r = 128 + (Math.random() - 0.5) * 6;
+ const g = 128 + (Math.random() - 0.5) * 6;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(x, y, 1 + Math.random() * 3, 1 + Math.random() * 3);
+ }
+
+ // Aggregate patches (larger bumps)
+ for (let i = 0; i < 80; i++) {
+ const x = Math.random() * width;
+ const y = Math.random() * height;
+ const radius = 4 + Math.random() * 12;
+ const r = 128 + (Math.random() - 0.5) * 18;
+ const g = 128 + (Math.random() - 0.5) * 18;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fill();
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.repeat.set(3, 3);
+ return tex;
+}
+
+/**
+ * Generate a brushed aluminum normal map.
+ * Directional brushing lines for metallic surfaces.
+ */
+export function generateBrushedAluminumNormalMap(width = 512, height = 512): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d')!;
+
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Horizontal brush strokes
+ for (let y = 0; y < height; y++) {
+ const r = 128 + (Math.random() - 0.5) * 4;
+ const g = 128 + Math.sin(y * 0.3) * 3 + (Math.random() - 0.5) * 2;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(0, y, width, 1);
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ return tex;
+}
+
+/**
+ * Generate a wood grain normal map for shelving.
+ */
+export function generateWoodNormalMap(width = 512, height = 512): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d')!;
+
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Wood grain lines (vertical, slightly wavy)
+ for (let x = 0; x < width; x += 4 + Math.random() * 8) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ for (let y = 0; y < height; y += 10) {
+ ctx.lineTo(x + Math.sin(y * 0.02 + x * 0.1) * 3, y);
+ }
+ const r = 128 + (Math.random() - 0.5) * 10;
+ ctx.strokeStyle = `rgb(${r}, 128, 255)`;
+ ctx.lineWidth = 1 + Math.random() * 2;
+ ctx.stroke();
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ return tex;
+}
+
+/**
+ * Generate a fabric/paper subtle normal map for wallcovering product panels.
+ * Very subtle — just enough to catch light at grazing angles.
+ */
+export function generateFabricNormalMap(width = 256, height = 256): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d')!;
+
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Woven texture: alternating horizontal and vertical micro-lines
+ for (let y = 0; y < height; y += 2) {
+ for (let x = 0; x < width; x += 2) {
+ const horizontal = (y / 2) % 2 === 0;
+ const r = 128 + (horizontal ? 2 : -2) + (Math.random() - 0.5) * 3;
+ const g = 128 + (horizontal ? -2 : 2) + (Math.random() - 0.5) * 3;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(x, y, 2, 2);
+ }
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.repeat.set(4, 4);
+ return tex;
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/showroom/ProceduralMaps.ts
+git commit -m "feat(showroom): procedural normal map generators (brick, concrete, aluminum, wood, fabric)"
+```
+
+---
+
+### Task 4: PBR Material Factory
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/ShowroomMaterials.ts`
+
+**Step 1: Write the material factory**
+
+```typescript
+// ShowroomMaterials.ts — PBR material definitions for photorealistic showroom
+import * as THREE from 'three';
+import {
+ generateBrickNormalMap,
+ generateConcreteNormalMap,
+ generateBrushedAluminumNormalMap,
+ generateWoodNormalMap,
+ generateFabricNormalMap,
+} from './ProceduralMaps';
+
+export interface MaterialLibrary {
+ brick: THREE.MeshStandardMaterial;
+ concrete: THREE.MeshStandardMaterial;
+ aluminum: THREE.MeshStandardMaterial;
+ wood: THREE.MeshStandardMaterial;
+ fabric: THREE.MeshStandardMaterial;
+ steel: THREE.MeshStandardMaterial;
+ glass: THREE.MeshStandardMaterial;
+ trackLight: THREE.MeshStandardMaterial;
+}
+
+/**
+ * Create the full PBR material library for the showroom.
+ * Each material has proper normal maps for photorealistic surface detail.
+ */
+export function createMaterialLibrary(): MaterialLibrary {
+ return {
+ brick: new THREE.MeshStandardMaterial({
+ color: 0x8b4513,
+ metalness: 0.0,
+ roughness: 0.85,
+ normalMap: generateBrickNormalMap(),
+ normalScale: new THREE.Vector2(1.2, 1.2),
+ }),
+
+ concrete: new THREE.MeshStandardMaterial({
+ color: 0x8a8580,
+ metalness: 0.15,
+ roughness: 0.55,
+ normalMap: generateConcreteNormalMap(),
+ normalScale: new THREE.Vector2(0.5, 0.5),
+ }),
+
+ aluminum: new THREE.MeshStandardMaterial({
+ color: 0xc0c0c0,
+ metalness: 0.7,
+ roughness: 0.25,
+ normalMap: generateBrushedAluminumNormalMap(),
+ normalScale: new THREE.Vector2(0.3, 0.3),
+ envMapIntensity: 1.5,
+ }),
+
+ wood: new THREE.MeshStandardMaterial({
+ color: 0x8b6914,
+ metalness: 0.0,
+ roughness: 0.65,
+ normalMap: generateWoodNormalMap(),
+ normalScale: new THREE.Vector2(0.8, 0.8),
+ }),
+
+ fabric: new THREE.MeshStandardMaterial({
+ color: 0xf0ebe4,
+ metalness: 0.0,
+ roughness: 0.4,
+ normalMap: generateFabricNormalMap(),
+ normalScale: new THREE.Vector2(0.2, 0.2),
+ envMapIntensity: 0.3,
+ }),
+
+ steel: new THREE.MeshStandardMaterial({
+ color: 0x2a2a2a,
+ metalness: 0.6,
+ roughness: 0.3,
+ envMapIntensity: 1.0,
+ }),
+
+ glass: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ metalness: 0.0,
+ roughness: 0.05,
+ transparent: true,
+ opacity: 0.15,
+ envMapIntensity: 2.0,
+ }),
+
+ trackLight: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ metalness: 0.9,
+ roughness: 0.1,
+ emissive: new THREE.Color(0xf5e6d0),
+ emissiveIntensity: 0.8,
+ }),
+ };
+}
+
+/**
+ * Dispose all materials and their textures.
+ */
+export function disposeMaterialLibrary(lib: MaterialLibrary): void {
+ Object.values(lib).forEach(mat => {
+ if (mat.normalMap) mat.normalMap.dispose();
+ mat.dispose();
+ });
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/showroom/ShowroomMaterials.ts
+git commit -m "feat(showroom): PBR material factory with normal maps"
+```
+
+---
+
+## Phase 3: HDR Environment + Post-Processing Upgrade
+
+### Task 5: HDR Environment Map Loader
+
+**Files:**
+- Modify: `/root/Projects/dw-war-room/src/scene/WarRoom.ts:95-123`
+- Create: `/root/Projects/dw-war-room/src/showroom/ShowroomEnvironment.ts`
+
+**Step 1: Create showroom-specific environment module**
+
+```typescript
+// ShowroomEnvironment.ts — HDR environment map for photorealistic IBL
+import * as THREE from 'three';
+
+/**
+ * Generate a high-quality procedural environment map for gallery/showroom IBL.
+ * Creates a warm gallery interior environment rather than the cold command-center gradient.
+ *
+ * This procedural approach avoids needing an external HDRI file download.
+ * It simulates a well-lit interior with warm ceiling lights and neutral walls.
+ */
+export function createShowroomEnvMap(renderer: THREE.WebGLRenderer): THREE.Texture {
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ pmrem.compileEquirectangularShader();
+
+ const envScene = new THREE.Scene();
+
+ // Sky dome — warm neutral ceiling (gallery white)
+ const skyGeo = new THREE.SphereGeometry(50, 32, 16);
+ const skyMat = new THREE.MeshBasicMaterial({
+ color: 0x1a1815, // warm dark interior
+ side: THREE.BackSide,
+ });
+ envScene.add(new THREE.Mesh(skyGeo, skyMat));
+
+ // Ceiling highlight — warm white overhead panels (gallery lighting)
+ const ceilGeo = new THREE.SphereGeometry(40, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.2);
+ const ceilMat = new THREE.MeshBasicMaterial({
+ color: 0xf5e6d0, // warm white (3200K-ish)
+ side: THREE.BackSide,
+ });
+ const ceil = new THREE.Mesh(ceilGeo, ceilMat);
+ ceil.rotation.x = Math.PI;
+ envScene.add(ceil);
+
+ // Floor reflection — dark polished concrete
+ const floorGeo = new THREE.SphereGeometry(40, 16, 8, 0, Math.PI * 2, Math.PI * 0.75, Math.PI * 0.25);
+ const floorMat = new THREE.MeshBasicMaterial({
+ color: 0x0d0c0a,
+ side: THREE.BackSide,
+ });
+ envScene.add(new THREE.Mesh(floorGeo, floorMat));
+
+ // Wall bands — warm brick tones from sides
+ for (let i = 0; i < 4; i++) {
+ const wallGeo = new THREE.PlaneGeometry(30, 15);
+ const wallMat = new THREE.MeshBasicMaterial({
+ color: 0x3a2820, // dark warm brick
+ side: THREE.DoubleSide,
+ });
+ const wall = new THREE.Mesh(wallGeo, wallMat);
+ const angle = (i / 4) * Math.PI * 2;
+ wall.position.set(Math.cos(angle) * 35, 5, Math.sin(angle) * 35);
+ wall.lookAt(0, 5, 0);
+ envScene.add(wall);
+ }
+
+ // Track light spots — bright warm points at ceiling level
+ for (let i = 0; i < 6; i++) {
+ const spotGeo = new THREE.SphereGeometry(2, 8, 8);
+ const spotMat = new THREE.MeshBasicMaterial({ color: 0xffe8c0 });
+ const spot = new THREE.Mesh(spotGeo, spotMat);
+ const angle = (i / 6) * Math.PI * 2;
+ spot.position.set(Math.cos(angle) * 15, 18, Math.sin(angle) * 15);
+ envScene.add(spot);
+ }
+
+ const envRenderTarget = pmrem.fromScene(envScene as any, 0.02);
+ const texture = envRenderTarget.texture;
+
+ // Cleanup
+ pmrem.dispose();
+ skyMat.dispose();
+ ceilMat.dispose();
+ floorMat.dispose();
+
+ return texture;
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/showroom/ShowroomEnvironment.ts
+git commit -m "feat(showroom): warm gallery HDR environment map for IBL"
+```
+
+---
+
+### Task 6: SSAO + Depth of Field + Color Grading
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/ShowroomPostProcess.ts`
+
+**Step 1: Write the enhanced post-processing pipeline**
+
+```typescript
+// ShowroomPostProcess.ts — Photorealistic post-processing for the showroom
+// Adds SSAO, DOF, and color grading on top of existing bloom/grain pipeline
+import * as THREE from 'three';
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
+import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
+import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
+import { BokehPass } from 'three/examples/jsm/postprocessing/BokehPass.js';
+
+// ── Color Grading Shader ─────────────────────────────────────────────────────
+// Warm architectural tones: slight orange lift in shadows, cool highlight compression
+const ColorGradingShader = {
+ name: 'ColorGradingShader',
+ uniforms: {
+ tDiffuse: { value: null as THREE.Texture | null },
+ uSaturation: { value: 1.1 }, // slight boost
+ uContrast: { value: 1.08 }, // subtle contrast
+ uBrightness: { value: 0.02 }, // tiny lift
+ uWarmth: { value: 0.04 }, // warm shift
+ },
+ vertexShader: /* glsl */ `
+ varying vec2 vUv;
+ void main() {
+ vUv = uv;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `,
+ fragmentShader: /* glsl */ `
+ uniform sampler2D tDiffuse;
+ uniform float uSaturation;
+ uniform float uContrast;
+ uniform float uBrightness;
+ uniform float uWarmth;
+ varying vec2 vUv;
+
+ void main() {
+ vec4 color = texture2D(tDiffuse, vUv);
+
+ // Saturation adjustment
+ float luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
+ color.rgb = mix(vec3(luminance), color.rgb, uSaturation);
+
+ // Contrast (pivot at mid-gray)
+ color.rgb = (color.rgb - 0.5) * uContrast + 0.5;
+
+ // Brightness lift
+ color.rgb += uBrightness;
+
+ // Warm tint — add slight orange to shadows, cool highlights slightly
+ float shadowMask = 1.0 - smoothstep(0.0, 0.5, luminance);
+ color.r += uWarmth * shadowMask;
+ color.g += uWarmth * 0.5 * shadowMask;
+
+ // Vignette — darkens corners for photographic depth
+ vec2 vUvCenter = vUv - 0.5;
+ float vignette = 1.0 - dot(vUvCenter, vUvCenter) * 0.5;
+ color.rgb *= vignette;
+
+ gl_FragColor = color;
+ }
+ `,
+};
+
+// ── Film Grain (reused from war room) ────────────────────────────────────────
+const FilmGrainShader = {
+ name: 'FilmGrainShader',
+ uniforms: {
+ tDiffuse: { value: null as THREE.Texture | null },
+ uTime: { value: 0.0 },
+ uOpacity: { value: 0.006 },
+ },
+ vertexShader: /* glsl */ `
+ varying vec2 vUv;
+ void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
+ `,
+ fragmentShader: /* glsl */ `
+ uniform sampler2D tDiffuse;
+ uniform float uTime;
+ uniform float uOpacity;
+ varying vec2 vUv;
+ float hash(vec2 p) { p = fract(p * vec2(234.34, 435.345)); p += dot(p, p + 34.23); return fract(p.x * p.y); }
+ void main() {
+ vec4 color = texture2D(tDiffuse, vUv);
+ float grain = hash(vUv + fract(uTime * 0.1));
+ color.rgb += (grain - 0.5) * 2.0 * uOpacity;
+ gl_FragColor = color;
+ }
+ `,
+};
+
+export class ShowroomPostProcess {
+ private composer: EffectComposer;
+ private ssaoPass: SSAOPass;
+ private bloomPass: UnrealBloomPass;
+ private bokehPass: BokehPass;
+ private colorGradingPass: ShaderPass;
+ private filmGrainPass: ShaderPass;
+ private clock: THREE.Clock;
+
+ constructor(
+ renderer: THREE.WebGLRenderer,
+ scene: THREE.Scene,
+ camera: THREE.PerspectiveCamera
+ ) {
+ this.clock = new THREE.Clock();
+ const size = renderer.getSize(new THREE.Vector2());
+
+ this.composer = new EffectComposer(renderer);
+
+ // Pass 1: Render
+ this.composer.addPass(new RenderPass(scene, camera));
+
+ // Pass 2: SSAO — ambient occlusion for depth
+ this.ssaoPass = new SSAOPass(scene, camera, size.x, size.y);
+ this.ssaoPass.kernelRadius = 0.5;
+ this.ssaoPass.minDistance = 0.001;
+ this.ssaoPass.maxDistance = 0.1;
+ (this.ssaoPass as any).output = SSAOPass.OUTPUT.Default;
+ this.composer.addPass(this.ssaoPass);
+
+ // Pass 3: Bloom — subtle architectural glow
+ this.bloomPass = new UnrealBloomPass(
+ new THREE.Vector2(size.x, size.y),
+ 0.2, // strength (slightly less than war room — showroom is brighter)
+ 0.4, // radius
+ 0.7 // threshold
+ );
+ this.composer.addPass(this.bloomPass);
+
+ // Pass 4: Depth of Field — cinematic focus
+ this.bokehPass = new BokehPass(scene, camera, {
+ focus: 15.0, // focus distance (adjustable)
+ aperture: 0.002, // subtle DOF
+ maxblur: 0.005, // max blur radius
+ });
+ this.composer.addPass(this.bokehPass);
+
+ // Pass 5: Color Grading — warm architectural tones
+ this.colorGradingPass = new ShaderPass(ColorGradingShader);
+ this.composer.addPass(this.colorGradingPass);
+
+ // Pass 6: Film Grain — organic texture
+ this.filmGrainPass = new ShaderPass(FilmGrainShader);
+ this.composer.addPass(this.filmGrainPass);
+ }
+
+ render(): void {
+ this.filmGrainPass.uniforms['uTime'].value = this.clock.getElapsedTime();
+ this.composer.render();
+ }
+
+ setFocusDistance(distance: number): void {
+ (this.bokehPass as any).uniforms['focus'].value = distance;
+ }
+
+ resize(width: number, height: number): void {
+ this.composer.setSize(width, height);
+ this.bloomPass.resolution.set(width, height);
+ this.ssaoPass.setSize(width, height);
+ }
+
+ getComposer(): EffectComposer {
+ return this.composer;
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/showroom/ShowroomPostProcess.ts
+git commit -m "feat(showroom): SSAO + DOF + color grading post-processing pipeline"
+```
+
+---
+
+## Phase 4: Showroom Scene Builder
+
+### Task 7: Showroom Layout Builder (Corridor + Wings)
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/ShowroomLayout.ts`
+
+**Step 1: Write the main showroom scene builder**
+
+This module creates the architectural corridor with endless wings, using the material library from Task 4 and scene config from Task 2.
+
+```typescript
+// ShowroomLayout.ts — Builds the photorealistic showroom corridor and wing bays
+import * as THREE from 'three';
+import type { SceneConfig, WingConfig, WallConfig, LightConfig } from './FigmaTransformer';
+import type { MaterialLibrary } from './ShowroomMaterials';
+
+export class ShowroomLayout {
+ private scene: THREE.Scene;
+ private materials: MaterialLibrary;
+ private corridorGroup: THREE.Group;
+ private wingGroups: THREE.Group[] = [];
+ private spotLights: THREE.SpotLight[] = [];
+
+ constructor(scene: THREE.Scene, materials: MaterialLibrary) {
+ this.scene = scene;
+ this.materials = materials;
+ this.corridorGroup = new THREE.Group();
+ this.corridorGroup.name = 'showroom-corridor';
+ scene.add(this.corridorGroup);
+ }
+
+ /**
+ * Build the showroom from a SceneConfig (output of FigmaTransformer).
+ */
+ buildFromConfig(config: SceneConfig): void {
+ this.buildCorridor(config.corridor);
+ this.buildWings(config.wings);
+ this.buildWallDisplays(config.walls);
+ this.buildLighting(config.lights);
+ this.buildFurniture(config.furniture);
+ this.buildEntry(config.entry);
+ }
+
+ private buildCorridor(corridor: SceneConfig['corridor']): void {
+ const { width, depth, height } = corridor;
+ const halfW = width / 2;
+ const halfD = depth / 2;
+
+ // Floor — polished concrete with normal map
+ const floorGeo = new THREE.PlaneGeometry(width, depth, 1, 1);
+ const floor = new THREE.Mesh(floorGeo, this.materials.concrete);
+ floor.rotation.x = -Math.PI / 2;
+ floor.receiveShadow = true;
+ this.corridorGroup.add(floor);
+
+ // Left wall — exposed brick
+ const leftWallGeo = new THREE.PlaneGeometry(depth, height);
+ const leftWall = new THREE.Mesh(leftWallGeo, this.materials.brick);
+ leftWall.position.set(-halfW, height / 2, 0);
+ leftWall.rotation.y = Math.PI / 2;
+ leftWall.receiveShadow = true;
+ this.corridorGroup.add(leftWall);
+
+ // Right wall — exposed brick
+ const rightWall = new THREE.Mesh(leftWallGeo, this.materials.brick);
+ rightWall.position.set(halfW, height / 2, 0);
+ rightWall.rotation.y = -Math.PI / 2;
+ rightWall.receiveShadow = true;
+ this.corridorGroup.add(rightWall);
+
+ // Back wall — dark charcoal
+ const backWallGeo = new THREE.PlaneGeometry(width, height);
+ const backWallMat = this.materials.steel.clone();
+ backWallMat.color.setHex(0x1a1a1a);
+ const backWall = new THREE.Mesh(backWallGeo, backWallMat);
+ backWall.position.set(0, height / 2, -halfD);
+ backWall.receiveShadow = true;
+ this.corridorGroup.add(backWall);
+
+ // Ceiling — steel with exposed duct geometry
+ const ceilGeo = new THREE.PlaneGeometry(width, depth);
+ const ceiling = new THREE.Mesh(ceilGeo, this.materials.steel);
+ ceiling.rotation.x = Math.PI / 2;
+ ceiling.position.y = height;
+ this.corridorGroup.add(ceiling);
+
+ // Exposed ducts — cylindrical pipes along ceiling
+ const ductMat = this.materials.steel.clone();
+ ductMat.metalness = 0.5;
+ for (let i = 0; i < 3; i++) {
+ const ductGeo = new THREE.CylinderGeometry(0.15, 0.15, depth, 8);
+ const duct = new THREE.Mesh(ductGeo, ductMat);
+ duct.rotation.x = Math.PI / 2;
+ duct.position.set(-halfW / 2 + i * (halfW / 2), height - 0.3, 0);
+ this.corridorGroup.add(duct);
+ }
+
+ // Baseboards — warm wood trim
+ const baseGeo = new THREE.BoxGeometry(depth, 0.15, 0.04);
+ for (const xPos of [-halfW + 0.02, halfW - 0.02]) {
+ const base = new THREE.Mesh(baseGeo, this.materials.wood);
+ base.position.set(xPos, 0.075, 0);
+ base.rotation.y = Math.PI / 2;
+ this.corridorGroup.add(base);
+ }
+ }
+
+ private buildWings(wings: WingConfig[]): void {
+ wings.forEach((wingConfig, i) => {
+ const group = new THREE.Group();
+ group.name = `wing-${i}-${wingConfig.vendor}`;
+ group.userData = { vendor: wingConfig.vendor, index: i, loaded: false };
+
+ // Wing entrance frame — brushed aluminum
+ const frameGeo = new THREE.BoxGeometry(0.08, 3.5, 0.08);
+ const leftFrame = new THREE.Mesh(frameGeo, this.materials.aluminum);
+ leftFrame.position.set(-1.5, 1.75, 0);
+ group.add(leftFrame);
+
+ const rightFrame = new THREE.Mesh(frameGeo, this.materials.aluminum);
+ rightFrame.position.set(1.5, 1.75, 0);
+ group.add(rightFrame);
+
+ // Header bar
+ const headerGeo = new THREE.BoxGeometry(3.08, 0.08, 0.08);
+ const header = new THREE.Mesh(headerGeo, this.materials.aluminum);
+ header.position.set(0, 3.5, 0);
+ group.add(header);
+
+ // Vendor label (canvas texture)
+ const labelCanvas = document.createElement('canvas');
+ labelCanvas.width = 512;
+ labelCanvas.height = 64;
+ const ctx = labelCanvas.getContext('2d')!;
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, 0, 512, 64);
+ ctx.fillStyle = '#f0ebe4';
+ ctx.font = 'bold 28px Inter, -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(wingConfig.vendor.toUpperCase(), 256, 42);
+ const labelTex = new THREE.CanvasTexture(labelCanvas);
+ const labelGeo = new THREE.PlaneGeometry(2.5, 0.3);
+ const labelMat = new THREE.MeshBasicMaterial({ map: labelTex });
+ const label = new THREE.Mesh(labelGeo, labelMat);
+ label.position.set(0, 3.7, 0.01);
+ group.add(label);
+
+ // Position the wing in the corridor
+ group.position.set(
+ wingConfig.position[0],
+ wingConfig.position[1],
+ wingConfig.position[2]
+ );
+ group.rotation.y = wingConfig.rotation;
+
+ this.wingGroups.push(group);
+ this.corridorGroup.add(group);
+ });
+ }
+
+ private buildWallDisplays(walls: WallConfig[]): void {
+ walls.forEach(wall => {
+ if (wall.hasSamplePanels) {
+ // Large mounted sample panel
+ const panelGeo = new THREE.PlaneGeometry(wall.dimensions[0], wall.dimensions[1]);
+ const panel = new THREE.Mesh(panelGeo, this.materials.fabric);
+ panel.position.set(...wall.position);
+ this.corridorGroup.add(panel);
+
+ // Aluminum frame around panel
+ const frameThickness = 0.03;
+ const frameDepth = 0.04;
+ const frameMat = this.materials.aluminum;
+
+ // Top frame
+ const topGeo = new THREE.BoxGeometry(wall.dimensions[0] + frameThickness * 2, frameThickness, frameDepth);
+ const top = new THREE.Mesh(topGeo, frameMat);
+ top.position.set(wall.position[0], wall.position[1] + wall.dimensions[1] / 2, wall.position[2] + 0.02);
+ this.corridorGroup.add(top);
+
+ // Bottom frame
+ const bottom = top.clone();
+ bottom.position.y = wall.position[1] - wall.dimensions[1] / 2;
+ this.corridorGroup.add(bottom);
+ }
+
+ if (wall.hasBookshelf) {
+ // Glass-front bookshelf
+ const shelfGeo = new THREE.BoxGeometry(wall.dimensions[0], wall.dimensions[1], 0.3);
+ const shelf = new THREE.Mesh(shelfGeo, this.materials.wood);
+ shelf.position.set(...wall.position);
+ this.corridorGroup.add(shelf);
+
+ // Glass front
+ const glassGeo = new THREE.PlaneGeometry(wall.dimensions[0] - 0.05, wall.dimensions[1] - 0.05);
+ const glass = new THREE.Mesh(glassGeo, this.materials.glass);
+ glass.position.set(wall.position[0], wall.position[1], wall.position[2] + 0.16);
+ this.corridorGroup.add(glass);
+ }
+ });
+ }
+
+ private buildLighting(lights: LightConfig[]): void {
+ lights.forEach(lightConfig => {
+ if (lightConfig.type === 'spot') {
+ const spot = new THREE.SpotLight(
+ new THREE.Color(lightConfig.color),
+ lightConfig.intensity,
+ 30, // distance
+ lightConfig.angle || Math.PI / 6,
+ lightConfig.penumbra || 0.4,
+ 1 // decay
+ );
+ spot.position.set(...lightConfig.position);
+ if (lightConfig.target) {
+ spot.target.position.set(...lightConfig.target);
+ this.scene.add(spot.target);
+ }
+ spot.castShadow = true;
+ spot.shadow.mapSize.set(1024, 1024);
+ this.spotLights.push(spot);
+ this.corridorGroup.add(spot);
+
+ // Track light housing (small chrome cylinder)
+ const housingGeo = new THREE.CylinderGeometry(0.08, 0.12, 0.15, 8);
+ const housing = new THREE.Mesh(housingGeo, this.materials.trackLight);
+ housing.position.set(...lightConfig.position);
+ this.corridorGroup.add(housing);
+
+ // Volumetric light cone (subtle)
+ const coneH = lightConfig.position[1] - (lightConfig.target?.[1] || 0);
+ const coneR = Math.tan(lightConfig.angle || Math.PI / 6) * coneH;
+ const coneGeo = new THREE.ConeGeometry(coneR, coneH, 16, 1, true);
+ const coneMat = new THREE.MeshBasicMaterial({
+ color: lightConfig.color,
+ transparent: true,
+ opacity: 0.015,
+ blending: THREE.AdditiveBlending,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ });
+ const cone = new THREE.Mesh(coneGeo, coneMat);
+ cone.position.set(
+ lightConfig.position[0],
+ lightConfig.position[1] - coneH / 2,
+ lightConfig.position[2]
+ );
+ this.corridorGroup.add(cone);
+ }
+ });
+
+ // Track rails (continuous aluminum rail along ceiling)
+ const railGeo = new THREE.BoxGeometry(40, 0.03, 0.05);
+ const rail = new THREE.Mesh(railGeo, this.materials.aluminum);
+ rail.position.set(0, 11.5, 0);
+ this.corridorGroup.add(rail);
+ }
+
+ private buildFurniture(furniture: SceneConfig['furniture']): void {
+ furniture.forEach(item => {
+ if (item.type === 'display-table') {
+ const tableGeo = new THREE.BoxGeometry(...item.dimensions);
+ const table = new THREE.Mesh(tableGeo, this.materials.wood);
+ table.position.set(...item.position);
+ table.castShadow = true;
+ this.corridorGroup.add(table);
+ }
+ });
+ }
+
+ private buildEntry(entry: SceneConfig['entry']): void {
+ // Entry signage
+ const signCanvas = document.createElement('canvas');
+ signCanvas.width = 1024;
+ signCanvas.height = 128;
+ const ctx = signCanvas.getContext('2d')!;
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, 0, 1024, 128);
+ ctx.fillStyle = '#f0ebe4';
+ ctx.font = 'bold 36px Inter, -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(entry.signText, 512, 70);
+ ctx.font = '18px Inter, -apple-system, sans-serif';
+ ctx.fillStyle = '#888888';
+ ctx.fillText('ARCHITECTURAL TRADE SHOWROOM', 512, 105);
+
+ const signTex = new THREE.CanvasTexture(signCanvas);
+ const signGeo = new THREE.PlaneGeometry(6, 0.75);
+ const signMat = new THREE.MeshStandardMaterial({
+ map: signTex,
+ emissive: new THREE.Color(0xffffff),
+ emissiveMap: signTex,
+ emissiveIntensity: 0.3,
+ });
+ const sign = new THREE.Mesh(signGeo, signMat);
+ sign.position.set(entry.position[0], 10, entry.position[2]);
+ this.corridorGroup.add(sign);
+ }
+
+ /**
+ * Get wing groups for culling/visibility management.
+ */
+ getWings(): THREE.Group[] {
+ return this.wingGroups;
+ }
+
+ /**
+ * Set visibility of wings by distance from camera for performance.
+ */
+ updateVisibility(cameraPosition: THREE.Vector3, maxDistance = 30): void {
+ this.wingGroups.forEach(wing => {
+ const dist = wing.position.distanceTo(cameraPosition);
+ wing.visible = dist < maxDistance;
+ });
+ }
+
+ dispose(): void {
+ this.corridorGroup.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ child.geometry.dispose();
+ }
+ });
+ this.scene.remove(this.corridorGroup);
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/showroom/ShowroomLayout.ts
+git commit -m "feat(showroom): corridor + wings scene builder with PBR materials"
+```
+
+---
+
+### Task 8: Showroom Scene Compositor
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/Showroom.ts`
+
+**Step 1: Write the main Showroom class that ties everything together**
+
+```typescript
+// Showroom.ts — Main photorealistic showroom compositor
+// Ties together: FigmaTransformer + Materials + Environment + Layout + PostProcess
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { createMaterialLibrary, disposeMaterialLibrary } from './ShowroomMaterials';
+import { createShowroomEnvMap } from './ShowroomEnvironment';
+import { ShowroomLayout } from './ShowroomLayout';
+import { ShowroomPostProcess } from './ShowroomPostProcess';
+import { transformFigmaToScene, SceneConfig } from './FigmaTransformer';
+import type { MaterialLibrary } from './ShowroomMaterials';
+
+export class Showroom {
+ scene: THREE.Scene;
+ camera: THREE.PerspectiveCamera;
+ renderer: THREE.WebGLRenderer;
+ controls: OrbitControls;
+
+ private clock: THREE.Clock;
+ private materials: MaterialLibrary;
+ private layout: ShowroomLayout;
+ private postProcess: ShowroomPostProcess;
+ private dustParticles: THREE.Points;
+
+ constructor(container: HTMLElement) {
+ // Scene
+ this.scene = new THREE.Scene();
+ this.scene.background = new THREE.Color(0x0d0c0a);
+ this.scene.fog = new THREE.Fog(0x0d0c0a, 20, 60);
+
+ // Camera — human eye level
+ this.camera = new THREE.PerspectiveCamera(
+ 55, container.clientWidth / container.clientHeight, 0.1, 100
+ );
+ this.camera.position.set(0, 1.7, 8); // standing at entry
+ this.camera.lookAt(0, 1.5, 0);
+
+ // Renderer — photorealistic pipeline
+ this.renderer = new THREE.WebGLRenderer({ antialias: true });
+ this.renderer.setSize(container.clientWidth, container.clientHeight);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ this.renderer.toneMappingExposure = 1.4;
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
+ this.renderer.shadowMap.enabled = true;
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ container.appendChild(this.renderer.domElement);
+
+ // Controls — walking speed
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.08;
+ this.controls.maxPolarAngle = Math.PI / 2;
+ this.controls.minDistance = 1;
+ this.controls.maxDistance = 50;
+
+ // Clock
+ this.clock = new THREE.Clock();
+
+ // Materials
+ this.materials = createMaterialLibrary();
+
+ // Environment map (warm gallery IBL)
+ this.renderer.environment = createShowroomEnvMap(this.renderer) as THREE.Texture;
+
+ // Ambient + key lighting
+ this.addBaseLighting();
+
+ // Layout builder
+ this.layout = new ShowroomLayout(this.scene, this.materials);
+
+ // Atmospheric dust particles
+ this.dustParticles = this.createDustMotes();
+ this.scene.add(this.dustParticles);
+
+ // Post-processing
+ this.postProcess = new ShowroomPostProcess(this.renderer, this.scene, this.camera);
+
+ // Resize
+ window.addEventListener('resize', () => this.onResize(container));
+ }
+
+ /**
+ * Load showroom from Figma design data.
+ */
+ loadFromFigma(figmaData: any): void {
+ const config = transformFigmaToScene(figmaData);
+ this.layout.buildFromConfig(config);
+ }
+
+ /**
+ * Load showroom from a pre-built SceneConfig.
+ */
+ loadFromConfig(config: SceneConfig): void {
+ this.layout.buildFromConfig(config);
+ }
+
+ private addBaseLighting(): void {
+ // Ambient — warm neutral
+ const ambient = new THREE.AmbientLight(0x2a2520, 0.4);
+ this.scene.add(ambient);
+
+ // Hemisphere — sky/ground color contrast
+ const hemi = new THREE.HemisphereLight(0xf5e6d0, 0x1a1510, 0.3);
+ this.scene.add(hemi);
+
+ // Main key light — warm directional
+ const key = new THREE.DirectionalLight(0xf5e6d0, 0.8);
+ key.position.set(5, 15, 10);
+ key.castShadow = true;
+ key.shadow.mapSize.set(2048, 2048);
+ key.shadow.camera.near = 0.5;
+ key.shadow.camera.far = 50;
+ key.shadow.camera.left = -25;
+ key.shadow.camera.right = 25;
+ key.shadow.camera.top = 15;
+ key.shadow.camera.bottom = -15;
+ key.shadow.bias = -0.0003;
+ this.scene.add(key);
+ }
+
+ private createDustMotes(): THREE.Points {
+ const count = 200;
+ const positions = new Float32Array(count * 3);
+ for (let i = 0; i < count * 3; i += 3) {
+ positions[i] = (Math.random() - 0.5) * 40; // x
+ positions[i + 1] = Math.random() * 12; // y
+ positions[i + 2] = (Math.random() - 0.5) * 20; // z
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ const mat = new THREE.PointsMaterial({
+ color: 0xf5e6d0,
+ size: 0.02,
+ transparent: true,
+ opacity: 0.3,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ return new THREE.Points(geo, mat);
+ }
+
+ animate(): void {
+ requestAnimationFrame(() => this.animate());
+ const dt = Math.min(this.clock.getDelta(), 0.1);
+ const elapsed = this.clock.getElapsedTime();
+
+ this.controls.update();
+
+ // Animate dust motes
+ const dustPos = this.dustParticles.geometry.attributes.position;
+ for (let i = 0; i < dustPos.count; i++) {
+ const y = dustPos.getY(i) + Math.sin(elapsed * 0.3 + i) * 0.001;
+ dustPos.setY(i, y > 12 ? 0 : y);
+ }
+ dustPos.needsUpdate = true;
+
+ // Update wing visibility based on camera distance
+ this.layout.updateVisibility(this.camera.position);
+
+ // Update DOF focus to orbit target distance
+ const focusDist = this.camera.position.distanceTo(this.controls.target);
+ this.postProcess.setFocusDistance(focusDist);
+
+ // Render through post-processing pipeline
+ this.postProcess.render();
+ }
+
+ private onResize(container: HTMLElement): void {
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(w, h);
+ this.postProcess.resize(w, h);
+ }
+
+ dispose(): void {
+ this.layout.dispose();
+ disposeMaterialLibrary(this.materials);
+ this.dustParticles.geometry.dispose();
+ (this.dustParticles.material as THREE.PointsMaterial).dispose();
+ this.renderer.dispose();
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/showroom/Showroom.ts
+git commit -m "feat(showroom): main Showroom compositor with dust particles and DOF"
+```
+
+---
+
+### Task 9: Showroom Entry Point + HTML Page
+
+**Files:**
+- Create: `/root/Projects/dw-war-room/src/showroom/index.ts`
+- Create: `/root/Projects/dw-war-room/showroom.html`
+
+**Step 1: Write entry point**
+
+```typescript
+// src/showroom/index.ts — Showroom standalone entry point
+import { Showroom } from './Showroom';
+import type { SceneConfig } from './FigmaTransformer';
+
+// Default showroom config (used when no Figma data available)
+const DEFAULT_CONFIG: SceneConfig = {
+ corridor: { width: 40, depth: 20, height: 12, wallMaterial: 'brick', floorMaterial: 'polished-concrete' },
+ wings: Array.from({ length: 6 }, (_, i) => ({
+ index: i,
+ vendor: ['Thibaut', 'Schumacher', 'Arte', 'Scalamandre', 'Elitis', 'Phillip Jeffries'][i],
+ position: [-12 + i * 5, 0, -8] as [number, number, number],
+ rotation: 0,
+ panelCount: 12,
+ accentColor: ['#1a4b8c', '#c41e3a', '#d4a574', '#2d4a3e', '#e67e22', '#8b4513'][i],
+ })),
+ walls: [
+ { position: [-18, 4, 0], dimensions: [4, 8], material: 'brick', hasBookshelf: true, hasSamplePanels: false, sampleCount: 0 },
+ { position: [18, 4, 0], dimensions: [4, 8], material: 'brick', hasBookshelf: false, hasSamplePanels: true, sampleCount: 3 },
+ ],
+ lights: Array.from({ length: 8 }, (_, i) => ({
+ type: 'spot' as const,
+ position: [-14 + i * 4, 11, 0] as [number, number, number],
+ target: [-14 + i * 4, 0, 0] as [number, number, number],
+ color: '#f5e6d0',
+ intensity: 1.2,
+ angle: Math.PI / 5,
+ penumbra: 0.4,
+ })),
+ furniture: [
+ { type: 'display-table' as const, position: [0, 0.45, 0] as [number, number, number], dimensions: [3, 0.9, 1.5] as [number, number, number] },
+ ],
+ entry: { position: [0, 0, 10], signText: 'DESIGNER WALLCOVERINGS' },
+};
+
+// Boot
+const container = document.getElementById('showroom-container');
+if (container) {
+ const showroom = new Showroom(container);
+ showroom.loadFromConfig(DEFAULT_CONFIG);
+ showroom.animate();
+
+ // Expose for MCP/debug
+ (window as any).__showroom = showroom;
+}
+```
+
+**Step 2: Write HTML page**
+
+```html
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>DW Showroom — Photorealistic 3D</title>
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body { overflow: hidden; background: #0d0c0a; font-family: Inter, -apple-system, sans-serif; }
+ #showroom-container { width: 100vw; height: 100vh; }
+ </style>
+</head>
+<body>
+ <div id="showroom-container"></div>
+ <script type="module" src="/src/showroom/index.ts"></script>
+</body>
+</html>
+```
+
+**Step 3: Add Vite route for showroom page**
+
+Modify `vite.config.ts` to add the showroom entry:
+
+```typescript
+import { defineConfig } from 'vite';
+import { resolve } from 'path';
+
+export default defineConfig({
+ server: { port: 4060, host: '0.0.0.0' },
+ preview: { port: 4060, host: '0.0.0.0' },
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ showroom: resolve(__dirname, 'showroom.html'),
+ },
+ },
+ },
+});
+```
+
+**Step 4: Commit**
+
+```bash
+git add src/showroom/index.ts showroom.html vite.config.ts
+git commit -m "feat(showroom): standalone showroom page with default 6-vendor config"
+```
+
+---
+
+## Phase 5: Boardroom-3D Integration (Port 7681)
+
+### Task 10: Backport Photorealistic Materials to wr-wings.js
+
+**Files:**
+- Modify: `/root/DW-Agents/boardroom-3d/public/js/modules/wr-wings.js`
+- Create: `/root/DW-Agents/boardroom-3d/public/js/modules/wr-pbr-materials.js`
+
+**Step 1: Create PBR materials module for the vanilla JS system**
+
+Write a new module `wr-pbr-materials.js` that provides the same procedural normal maps and material factory, adapted for the older Three.js CDN version (r128). This module follows the existing `window.WR` namespace pattern.
+
+Key adaptations:
+- Use `THREE.MeshStandardMaterial` (available in r128+)
+- Canvas normal maps generated identically to the TypeScript version
+- Exposed via `window.DWS.materials` namespace
+
+**Step 2: Integrate into wr-wings.js wing panel materials**
+
+Replace the existing `MeshStandardMaterial({ roughness: 0.35, metalness: 0.0 })` wing panel material with the new PBR material that includes normal maps.
+
+**Step 3: Build and test**
+
+```bash
+cd /root/DW-Agents/boardroom-3d
+node build-warroom.js
+node validate-warroom.js
+```
+
+**Step 4: Commit**
+
+```bash
+git add public/js/modules/wr-pbr-materials.js public/js/modules/wr-wings.js
+git commit -m "feat(boardroom-3d): PBR materials with procedural normal maps for showroom wings"
+```
+
+---
+
+### Task 11: Backport Post-Processing to wr-effects.js
+
+**Files:**
+- Modify: `/root/DW-Agents/boardroom-3d/public/js/modules/wr-effects.js`
+
+**Step 1: Add SSAO and color grading to the existing effects pipeline**
+
+The boardroom-3d system currently has bloom disabled by default (performance). Add optional SSAO and color grading that can be toggled:
+
+```javascript
+// In wr-effects.js — add after existing toggleBloom function
+window.toggleSSAO = function() { /* ... */ };
+window.toggleColorGrading = function() { /* ... */ };
+window.setQualityPreset = function(level) {
+ // 'performance' = no post-processing
+ // 'balanced' = bloom only
+ // 'quality' = bloom + color grading
+ // 'ultra' = bloom + SSAO + color grading + grain
+};
+```
+
+**Step 2: Build and validate**
+
+```bash
+node build-warroom.js && node validate-warroom.js
+```
+
+**Step 3: Commit**
+
+```bash
+git commit -am "feat(boardroom-3d): quality presets with SSAO and color grading"
+```
+
+---
+
+## Phase 6: Testing + Polish
+
+### Task 12: Visual Verification
+
+**Step 1: Start dev server and verify war room showroom**
+
+```bash
+cd /root/Projects/dw-war-room && npm run dev
+```
+
+Navigate to `http://45.61.58.125:4060/showroom.html` and verify:
+- Brick walls have visible mortar normal map detail
+- Polished concrete floor reflects environment
+- Track lights cast visible spot cones
+- SSAO darkens wall-floor junctions
+- DOF blurs background when looking at near wing
+- Color grading gives warm architectural tone
+- 30+ FPS maintained
+
+**Step 2: Verify boardroom-3d showroom**
+
+```bash
+cd /root/DW-Agents/boardroom-3d && pm2 restart boardroom-3d
+```
+
+Navigate to showroom wings at `http://45.61.58.125:7681/` and verify PBR materials apply.
+
+**Step 3: Performance audit**
+
+Check draw calls, FPS, texture memory via browser dev tools:
+- Target: <300 draw calls, 30+ FPS, <256MB textures
+
+---
+
+### Task 13: Final Commit + Documentation
+
+**Step 1: Update CLAUDE.md with showroom architecture**
+
+Add showroom module documentation to `/root/Projects/dw-war-room/CLAUDE.md`.
+
+**Step 2: Final commit**
+
+```bash
+cd /root/Projects/dw-war-room
+git add -A
+git commit -m "feat(showroom): complete Figma + Three.js photorealistic showroom pipeline"
+```
+
+**Step 3: Push to GitHub**
+
+```bash
+git push origin main
+```
+
+---
+
+## Summary
+
+| Phase | Tasks | Key Deliverables |
+|-------|-------|-----------------|
+| 1 | Tasks 1-2 | Figma design + MCP extraction pipeline |
+| 2 | Tasks 3-4 | Procedural normal maps + PBR material factory |
+| 3 | Tasks 5-6 | HDR environment + SSAO/DOF/color grading |
+| 4 | Tasks 7-9 | Showroom layout builder + entry point |
+| 5 | Tasks 10-11 | Boardroom-3D backport |
+| 6 | Tasks 12-13 | Testing + documentation |
+
+**Total new files:** ~8 TypeScript modules + 1 HTML page + 1 JS module
+**Estimated draw calls:** ~150 base corridor, ~250 with 3 wings visible
+**Target FPS:** 30+ at 1080p on desktop GPU
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..a00bbf0
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,13 @@
+module.exports = {
+ apps: [
+ {
+ name: 'dw-war-room',
+ script: './serve.cjs',
+ cwd: '/root/Projects/dw-war-room',
+ env: {
+ NODE_ENV: 'production',
+ },
+ max_memory_restart: '100M',
+ }
+ ]
+};
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..76e6953
--- /dev/null
+++ b/index.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>DW War Room</title>
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
+ <style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ html, body {
+ width: 100%; height: 100%; overflow: hidden;
+ font-family: 'Inter', -apple-system, system-ui, sans-serif;
+ background: #050510;
+ color: #e8eaf0;
+ }
+ #war-room {
+ width: 100vw; height: 100vh;
+ position: relative;
+ }
+ canvas { display: block; }
+ ::-webkit-scrollbar { width: 4px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb { background: #252540; border-radius: 2px; }
+ </style>
+</head>
+<body>
+ <div id="war-room"></div>
+ <script type="module" src="/src/main.ts"></script>
+</body>
+</html>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ea92931
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1186 @@
+{
+ "name": "dw-war-room",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dw-war-room",
+ "version": "1.0.0",
+ "dependencies": {
+ "three": "^0.170.0"
+ },
+ "devDependencies": {
+ "@types/three": "^0.170.0",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
+ "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
+ "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
+ "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
+ "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
+ "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
+ "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
+ "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
+ "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
+ "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
+ "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
+ "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
+ "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
+ "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
+ "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
+ "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
+ "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
+ "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
+ "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
+ "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
+ "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
+ "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
+ "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": "*",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~0.18.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@webgpu/types": {
+ "version": "0.1.69",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
+ "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
+ "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.0",
+ "@rollup/rollup-android-arm64": "4.60.0",
+ "@rollup/rollup-darwin-arm64": "4.60.0",
+ "@rollup/rollup-darwin-x64": "4.60.0",
+ "@rollup/rollup-freebsd-arm64": "4.60.0",
+ "@rollup/rollup-freebsd-x64": "4.60.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.0",
+ "@rollup/rollup-linux-arm64-musl": "4.60.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.0",
+ "@rollup/rollup-linux-loong64-musl": "4.60.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-musl": "4.60.0",
+ "@rollup/rollup-openbsd-x64": "4.60.0",
+ "@rollup/rollup-openharmony-arm64": "4.60.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.0",
+ "@rollup/rollup-win32-x64-gnu": "4.60.0",
+ "@rollup/rollup-win32-x64-msvc": "4.60.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4675458
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "dw-war-room",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "three": "^0.170.0"
+ },
+ "devDependencies": {
+ "@types/three": "^0.170.0",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3"
+ }
+}
diff --git a/serve.cjs b/serve.cjs
new file mode 100644
index 0000000..ca3bbcc
--- /dev/null
+++ b/serve.cjs
@@ -0,0 +1,185 @@
+// Simple static server with Basic Auth for War Room
+// Includes Shopify search proxy endpoint for showroom live search
+// Uses Shopify GraphQL Admin API for fuzzy product search
+const http = require('http');
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+const url = require('url');
+
+const PORT = 4060;
+const USER = 'admin';
+const PASS = 'DWSecure2024!';
+const DIST = path.join(__dirname, 'dist');
+
+// Shopify API config (Admin API — server-side only)
+const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN = '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';
+const SHOPIFY_API_VERSION = '2024-01';
+
+const MIME = {
+ '.html': 'text/html',
+ '.js': 'application/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.svg': 'image/svg+xml',
+ '.woff2': 'font/woff2',
+ '.woff': 'font/woff',
+};
+
+/**
+ * Proxy Shopify product search via GraphQL — keeps API token server-side.
+ * Uses GraphQL for proper fuzzy search (REST title= requires exact match).
+ * GET /api/shopify/search?q=QUERY&limit=10
+ * Returns: { products: [{ id, title, vendor, product_type, handle, images, variants, tags }] }
+ */
+function handleShopifySearch(query, limit, res) {
+ if (!query || query.length < 2) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
+ }
+
+ limit = Math.min(parseInt(limit, 10) || 10, 20);
+
+ // GraphQL query — searches across title, vendor, tags, etc.
+ const gqlQuery = JSON.stringify({
+ query: `{
+ products(first: ${limit}, query: "${query.replace(/"/g, '\\"')}") {
+ edges {
+ node {
+ id
+ title
+ vendor
+ productType
+ handle
+ tags
+ featuredImage {
+ url
+ altText
+ }
+ images(first: 1) {
+ edges {
+ node {
+ url
+ altText
+ }
+ }
+ }
+ variants(first: 1) {
+ edges {
+ node {
+ price
+ sku
+ }
+ }
+ }
+ }
+ }
+ }
+ }`
+ });
+
+ const options = {
+ hostname: SHOPIFY_STORE,
+ path: `/admin/api/${SHOPIFY_API_VERSION}/graphql.json`,
+ method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': SHOPIFY_TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(gqlQuery),
+ },
+ };
+
+ const req2 = https.request(options, (shopifyRes) => {
+ let data = '';
+ shopifyRes.on('data', (chunk) => { data += chunk; });
+ shopifyRes.on('end', () => {
+ try {
+ const gqlResponse = JSON.parse(data);
+ const edges = gqlResponse?.data?.products?.edges || [];
+
+ // Transform GraphQL response to REST-like format for frontend compatibility
+ const products = edges.map(({ node }) => ({
+ id: parseInt(node.id.replace('gid://shopify/Product/', ''), 10),
+ title: node.title,
+ vendor: node.vendor,
+ product_type: node.productType,
+ handle: node.handle,
+ tags: node.tags ? node.tags.join(', ') : '',
+ images: node.images?.edges?.length
+ ? node.images.edges.map(e => ({ src: e.node.url }))
+ : node.featuredImage
+ ? [{ src: node.featuredImage.url }]
+ : [],
+ variants: node.variants?.edges?.length
+ ? node.variants.edges.map(e => ({ price: e.node.price, sku: e.node.sku || '' }))
+ : [{ price: '0.00', sku: '' }],
+ }));
+
+ res.writeHead(200, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'max-age=30',
+ });
+ res.end(JSON.stringify({ products }));
+ } catch (parseErr) {
+ console.error('[Shopify Search Proxy] Parse error:', parseErr.message);
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Failed to parse Shopify response', products: [] }));
+ }
+ });
+ });
+
+ req2.on('error', (err) => {
+ console.error('[Shopify Search Proxy] Error:', err.message);
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Shopify API request failed', products: [] }));
+ });
+
+ req2.write(gqlQuery);
+ req2.end();
+}
+
+const server = http.createServer((req, res) => {
+ // Basic Auth check
+ const auth = req.headers.authorization;
+ if (!auth || !auth.startsWith('Basic ')) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW War Room"' });
+ return res.end('Unauthorized');
+ }
+ const [u, p] = Buffer.from(auth.slice(6), 'base64').toString().split(':');
+ if (u !== USER || p !== PASS) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW War Room"' });
+ return res.end('Unauthorized');
+ }
+
+ // Parse URL
+ const parsedUrl = url.parse(req.url, true);
+ const pathname = parsedUrl.pathname;
+
+ // API routes — handle before static file serving
+ if (pathname === '/api/shopify/search' && req.method === 'GET') {
+ return handleShopifySearch(parsedUrl.query.q, parsedUrl.query.limit, res);
+ }
+
+ // Serve static files
+ let filePath = path.join(DIST, pathname === '/' ? 'index.html' : pathname);
+ if (!fs.existsSync(filePath)) filePath = path.join(DIST, 'index.html');
+
+ const ext = path.extname(filePath);
+ const mime = MIME[ext] || 'application/octet-stream';
+
+ try {
+ const content = fs.readFileSync(filePath);
+ res.writeHead(200, { 'Content-Type': mime });
+ res.end(content);
+ } catch {
+ res.writeHead(404);
+ res.end('Not found');
+ }
+});
+
+server.listen(PORT, '0.0.0.0', () => {
+ console.log(`[War Room] Serving on port ${PORT} with auth + Shopify search proxy (GraphQL)`);
+});
diff --git a/showroom.html b/showroom.html
new file mode 100644
index 0000000..2fcc9b1
--- /dev/null
+++ b/showroom.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>DW Showroom — Photorealistic 3D</title>
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body { overflow: hidden; background: #0d0c0a; font-family: Inter, -apple-system, sans-serif; }
+ #showroom-container { width: 100vw; height: 100vh; }
+ </style>
+</head>
+<body>
+ <div id="showroom-container"></div>
+ <script type="module" src="/src/showroom/index.ts"></script>
+</body>
+</html>
diff --git a/src/data/ApiClient.ts b/src/data/ApiClient.ts
new file mode 100644
index 0000000..73e9776
--- /dev/null
+++ b/src/data/ApiClient.ts
@@ -0,0 +1,78 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — API Client
+// Fetches from both Governance (4020) and Boardroom (4040)
+// ═══════════════════════════════════════════════
+
+import type { DecisionStats, EscalationAlert, GovernanceStatus, MeetingState } from '../types';
+
+const GOV_URL = import.meta.env.VITE_GOVERNANCE_URL || 'http://45.61.58.125:4020';
+const BR_URL = import.meta.env.VITE_BOARDROOM_URL || 'http://45.61.58.125:4040';
+const AUTH = import.meta.env.VITE_AUTH || btoa('admin:DWSecure2024!');
+
+const headers: Record<string, string> = {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Basic ${AUTH}`,
+};
+
+async function fetchJson<T>(url: string): Promise<T> {
+ const res = await fetch(url, { headers });
+ if (!res.ok) throw new Error(`${res.status}`);
+ return res.json();
+}
+
+// Governance API (port 4020)
+export async function getGovernanceStatus(): Promise<GovernanceStatus> {
+ return fetchJson<GovernanceStatus>(`${GOV_URL}/api/governance/status`);
+}
+
+export async function getDecisionStats(): Promise<DecisionStats> {
+ return fetchJson<DecisionStats>(`${GOV_URL}/api/decisions/stats`);
+}
+
+export async function getActiveEscalations(): Promise<EscalationAlert[]> {
+ return fetchJson<EscalationAlert[]>(`${GOV_URL}/api/escalations/active`);
+}
+
+export async function getPendingDecisions(): Promise<any[]> {
+ return fetchJson<any[]>(`${GOV_URL}/api/decisions?status=pending`);
+}
+
+// Boardroom API (port 4040)
+export async function getBoardroomStatus(): Promise<any> {
+ return fetchJson<any>(`${BR_URL}/api/status`);
+}
+
+export async function getActiveMeeting(): Promise<MeetingState> {
+ const data = await fetchJson<any>(`${BR_URL}/api/meetings/active`);
+ return {
+ active: data.active,
+ id: data.meeting?.id,
+ type: data.meeting?.meeting_type,
+ phase: data.meeting?.phase,
+ status: data.meeting?.status,
+ messages: data.messages || [],
+ };
+}
+
+export async function getAgents(): Promise<any> {
+ return fetchJson<any>(`${BR_URL}/api/agents`);
+}
+
+export async function getTeams(): Promise<any> {
+ return fetchJson<any>(`${BR_URL}/api/agents/teams`);
+}
+
+// System health from boardroom proxy
+export async function getSystemHealth(): Promise<any> {
+ return fetchJson<any>(`${BR_URL}/api/system-health`);
+}
+
+// Activity feed from boardroom proxy
+export async function getFeed(): Promise<any[]> {
+ return fetchJson<any[]>(`${BR_URL}/api/feed`);
+}
+
+// Agent schedules / activity
+export async function getAgentActivity(): Promise<any[]> {
+ return fetchJson<any[]>(`${BR_URL}/api/agent-activity`);
+}
diff --git a/src/data/WebSocketClient.ts b/src/data/WebSocketClient.ts
new file mode 100644
index 0000000..1d0ecbe
--- /dev/null
+++ b/src/data/WebSocketClient.ts
@@ -0,0 +1,75 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Dual WebSocket Client
+// Connects to both Governance and Boardroom WS servers
+// ═══════════════════════════════════════════════
+
+import type { WSEvent } from '../types';
+
+type EventHandler = (event: WSEvent, source: 'governance' | 'boardroom') => void;
+
+export class DualWebSocketClient {
+ private govWs: WebSocket | null = null;
+ private brWs: WebSocket | null = null;
+ private handlers: EventHandler[] = [];
+ private reconnectTimers: { gov?: number; br?: number } = {};
+
+ constructor(
+ private govUrl: string = import.meta.env.VITE_GOVERNANCE_WS || 'ws://45.61.58.125:4020/ws',
+ private brUrl: string = import.meta.env.VITE_BOARDROOM_WS || 'ws://45.61.58.125:4040/ws'
+ ) {}
+
+ onEvent(handler: EventHandler): void {
+ this.handlers.push(handler);
+ }
+
+ connect(): void {
+ this.connectGov();
+ this.connectBr();
+ }
+
+ disconnect(): void {
+ if (this.govWs) this.govWs.close();
+ if (this.brWs) this.brWs.close();
+ if (this.reconnectTimers.gov) clearTimeout(this.reconnectTimers.gov);
+ if (this.reconnectTimers.br) clearTimeout(this.reconnectTimers.br);
+ }
+
+ private connectGov(): void {
+ try {
+ this.govWs = new WebSocket(this.govUrl);
+ this.govWs.onmessage = (evt) => this.dispatch(evt.data, 'governance');
+ this.govWs.onclose = () => {
+ this.reconnectTimers.gov = window.setTimeout(() => this.connectGov(), 5000);
+ };
+ this.govWs.onerror = () => this.govWs?.close();
+ } catch {}
+ }
+
+ private connectBr(): void {
+ try {
+ this.brWs = new WebSocket(this.brUrl);
+ this.brWs.onmessage = (evt) => this.dispatch(evt.data, 'boardroom');
+ this.brWs.onclose = () => {
+ this.reconnectTimers.br = window.setTimeout(() => this.connectBr(), 5000);
+ };
+ this.brWs.onerror = () => this.brWs?.close();
+ } catch {}
+ }
+
+ private dispatch(data: string, source: 'governance' | 'boardroom'): void {
+ try {
+ const event: WSEvent = JSON.parse(data);
+ for (const handler of this.handlers) {
+ handler(event, source);
+ }
+ } catch {}
+ }
+
+ get govConnected(): boolean {
+ return this.govWs?.readyState === WebSocket.OPEN;
+ }
+
+ get brConnected(): boolean {
+ return this.brWs?.readyState === WebSocket.OPEN;
+ }
+}
diff --git a/src/effects/Particles.ts b/src/effects/Particles.ts
new file mode 100644
index 0000000..cf44bfb
--- /dev/null
+++ b/src/effects/Particles.ts
@@ -0,0 +1,308 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Ambient Particle System
+// Data flow visualization: dust motes, data
+// streams, and connection pulse dots
+// ═══════════════════════════════════════════════
+
+import * as THREE from 'three';
+
+// ─── Constants ────────────────────────────────
+const DUST_COUNT = 300;
+const STREAM_COUNT = 150;
+const PULSE_COUNT = 50;
+
+// ─── Internal state per particle group ────────
+
+interface DustMote {
+ baseX: number;
+ baseY: number;
+ baseZ: number;
+ phaseX: number;
+ phaseY: number;
+ phaseZ: number;
+ speed: number;
+ amplitude: number;
+}
+
+interface StreamParticle {
+ x: number;
+ z: number;
+ y: number;
+ speed: number;
+ spiralRadius: number;
+ spiralPhase: number;
+ spiralSpeed: number;
+}
+
+interface PulseDot {
+ startX: number;
+ startZ: number;
+ endX: number;
+ endZ: number;
+ progress: number; // 0 → 1 → 0 (ping-pong toward center and back)
+ speed: number;
+ direction: number; // +1 = toward center, -1 = away from center
+}
+
+// ─── ParticleSystem ───────────────────────────
+
+export class ParticleSystem {
+ private scene: THREE.Scene;
+
+ // Group 1 — Ambient dust motes
+ private dustPoints: THREE.Points;
+ private dustPositions: Float32Array;
+ private dustMeta: DustMote[] = [];
+
+ // Group 2 — Data stream particles
+ private streamPoints: THREE.Points;
+ private streamPositions: Float32Array;
+ private streamMeta: StreamParticle[] = [];
+
+ // Group 3 — Connection pulse dots
+ private pulsePoints: THREE.Points;
+ private pulsePositions: Float32Array;
+ private pulseMeta: PulseDot[] = [];
+
+ constructor(scene: THREE.Scene) {
+ this.scene = scene;
+
+ this.dustPoints = this.buildDustMotes();
+ this.streamPoints = this.buildDataStreams();
+ this.pulsePoints = this.buildPulseDots();
+
+ this.scene.add(this.dustPoints);
+ this.scene.add(this.streamPoints);
+ this.scene.add(this.pulsePoints);
+ }
+
+ // ── Group 1: Ambient dust motes ──────────────
+
+ private buildDustMotes(): THREE.Points {
+ const positions = new Float32Array(DUST_COUNT * 3);
+
+ for (let i = 0; i < DUST_COUNT; i++) {
+ const x = (Math.random() - 0.5) * 40; // -20 to 20
+ const y = Math.random() * 15; // 0 to 15
+ const z = (Math.random() - 0.5) * 40; // -20 to 20
+
+ positions[i * 3] = x;
+ positions[i * 3 + 1] = y;
+ positions[i * 3 + 2] = z;
+
+ this.dustMeta.push({
+ baseX: x,
+ baseY: y,
+ baseZ: z,
+ phaseX: Math.random() * Math.PI * 2,
+ phaseY: Math.random() * Math.PI * 2,
+ phaseZ: Math.random() * Math.PI * 2,
+ speed: 0.0003 + Math.random() * 0.0004,
+ amplitude: 0.15 + Math.random() * 0.25,
+ });
+ }
+
+ this.dustPositions = positions;
+
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+
+ const mat = new THREE.PointsMaterial({
+ color: 0x8b5cf6,
+ size: 0.03,
+ sizeAttenuation: true,
+ transparent: true,
+ opacity: 0.3,
+ depthWrite: false,
+ });
+
+ return new THREE.Points(geo, mat);
+ }
+
+ // ── Group 2: Data stream particles ───────────
+
+ private buildDataStreams(): THREE.Points {
+ const positions = new Float32Array(STREAM_COUNT * 3);
+
+ for (let i = 0; i < STREAM_COUNT; i++) {
+ // Random angle and radius within a circle of radius 6 around origin
+ const angle = Math.random() * Math.PI * 2;
+ const radius = Math.random() * 6;
+ const x = Math.cos(angle) * radius;
+ const z = Math.sin(angle) * radius;
+ const y = 0.5 + Math.random() * 7.5; // spread from 0.5 to 8
+
+ positions[i * 3] = x;
+ positions[i * 3 + 1] = y;
+ positions[i * 3 + 2] = z;
+
+ this.streamMeta.push({
+ x,
+ z,
+ y,
+ speed: 0.003 + Math.random() * 0.008,
+ spiralRadius: 0.1 + Math.random() * 0.4,
+ spiralPhase: Math.random() * Math.PI * 2,
+ spiralSpeed: 0.5 + Math.random() * 1.0,
+ });
+ }
+
+ this.streamPositions = positions;
+
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+
+ const mat = new THREE.PointsMaterial({
+ color: 0x93c5fd,
+ size: 0.05,
+ sizeAttenuation: true,
+ transparent: true,
+ opacity: 0.7,
+ depthWrite: false,
+ });
+
+ return new THREE.Points(geo, mat);
+ }
+
+ // ── Group 3: Connection pulse dots ───────────
+
+ private buildPulseDots(): THREE.Points {
+ const positions = new Float32Array(PULSE_COUNT * 3);
+
+ for (let i = 0; i < PULSE_COUNT; i++) {
+ const { startX, startZ, endX, endZ } = this.randomPulsePath();
+ const progress = Math.random(); // stagger initial positions
+
+ positions[i * 3] = THREE.MathUtils.lerp(startX, endX, progress);
+ positions[i * 3 + 1] = 3.5;
+ positions[i * 3 + 2] = THREE.MathUtils.lerp(startZ, endZ, progress);
+
+ this.pulseMeta.push({
+ startX,
+ startZ,
+ endX,
+ endZ,
+ progress,
+ speed: 0.004 + Math.random() * 0.006,
+ direction: Math.random() > 0.5 ? 1 : -1,
+ });
+ }
+
+ this.pulsePositions = positions;
+
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+
+ const mat = new THREE.PointsMaterial({
+ color: 0x22c55e,
+ size: 0.06,
+ sizeAttenuation: true,
+ transparent: true,
+ opacity: 0.4,
+ depthWrite: false,
+ });
+
+ return new THREE.Points(geo, mat);
+ }
+
+ // Pick an edge start and a center-region end for a pulse path.
+ // "Edge" = between radius 8 and 14 on the horizontal plane.
+ private randomPulsePath(): { startX: number; startZ: number; endX: number; endZ: number } {
+ const edgeAngle = Math.random() * Math.PI * 2;
+ const edgeRadius = 8 + Math.random() * 6; // 8–14
+
+ const startX = Math.cos(edgeAngle) * edgeRadius;
+ const startZ = Math.sin(edgeAngle) * edgeRadius;
+
+ // End near the center table (within radius 3)
+ const centerAngle = Math.random() * Math.PI * 2;
+ const centerRadius = Math.random() * 3;
+ const endX = Math.cos(centerAngle) * centerRadius;
+ const endZ = Math.sin(centerAngle) * centerRadius;
+
+ return { startX, startZ, endX, endZ };
+ }
+
+ // ── animate ──────────────────────────────────
+ //
+ // elapsed: milliseconds from THREE.Clock.getElapsedTime() * 1000
+ // (matches the convention used in WarRoom.ts)
+
+ animate(elapsed: number): void {
+ const t = elapsed * 0.001; // convert to seconds for trig functions
+ this.animateDust(t);
+ this.animateStreams(t);
+ this.animatePulse();
+ }
+
+ private animateDust(t: number): void {
+ for (let i = 0; i < DUST_COUNT; i++) {
+ const m = this.dustMeta[i];
+ const base = i * 3;
+
+ this.dustPositions[base] = m.baseX + Math.cos(t * m.speed * 1000 + m.phaseX) * m.amplitude;
+ this.dustPositions[base + 1] = m.baseY + Math.sin(t * m.speed * 700 + m.phaseY) * m.amplitude * 0.5;
+ this.dustPositions[base + 2] = m.baseZ + Math.cos(t * m.speed * 800 + m.phaseZ) * m.amplitude;
+ }
+
+ (this.dustPoints.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
+ }
+
+ private animateStreams(t: number): void {
+ for (let i = 0; i < STREAM_COUNT; i++) {
+ const m = this.streamMeta[i];
+ const base = i * 3;
+
+ // Rise upward
+ m.y += m.speed;
+
+ // Respawn at bottom when reaching ceiling
+ if (m.y >= 8) {
+ const angle = Math.random() * Math.PI * 2;
+ const radius = Math.random() * 6;
+ m.x = Math.cos(angle) * radius;
+ m.z = Math.sin(angle) * radius;
+ m.y = 0.5;
+ m.spiralPhase = Math.random() * Math.PI * 2;
+ }
+
+ // Spiral offset around the base x/z position
+ const spiralAngle = t * m.spiralSpeed + m.spiralPhase;
+ this.streamPositions[base] = m.x + Math.cos(spiralAngle) * m.spiralRadius;
+ this.streamPositions[base + 1] = m.y;
+ this.streamPositions[base + 2] = m.z + Math.sin(spiralAngle) * m.spiralRadius;
+ }
+
+ (this.streamPoints.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
+ }
+
+ private animatePulse(): void {
+ for (let i = 0; i < PULSE_COUNT; i++) {
+ const m = this.pulseMeta[i];
+ const base = i * 3;
+
+ m.progress += m.speed * m.direction;
+
+ // Bounce: reverse direction at each end, then pick a new path
+ if (m.progress >= 1) {
+ m.progress = 1;
+ m.direction = -1;
+ } else if (m.progress <= 0) {
+ m.progress = 0;
+ m.direction = 1;
+ // Assign a fresh path so pulses don't all follow the same lines
+ const fresh = this.randomPulsePath();
+ m.startX = fresh.startX;
+ m.startZ = fresh.startZ;
+ m.endX = fresh.endX;
+ m.endZ = fresh.endZ;
+ }
+
+ this.pulsePositions[base] = THREE.MathUtils.lerp(m.startX, m.endX, m.progress);
+ this.pulsePositions[base + 1] = 3.5;
+ this.pulsePositions[base + 2] = THREE.MathUtils.lerp(m.startZ, m.endZ, m.progress);
+ }
+
+ (this.pulsePoints.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
+ }
+}
diff --git a/src/effects/PostProcessing.ts b/src/effects/PostProcessing.ts
new file mode 100644
index 0000000..4533ed9
--- /dev/null
+++ b/src/effects/PostProcessing.ts
@@ -0,0 +1,193 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Post-Processing Effects Pipeline
+// Sci-fi command center visual upgrade:
+// - Unreal Bloom (subtle glow on emissive elements)
+// - Scanlines overlay (barely perceptible CRT feel)
+// - Film grain (organic noise, not a rave)
+// ═══════════════════════════════════════════════
+
+import * as THREE from 'three';
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
+import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
+
+// ─── Scanlines Shader ────────────────────────────────────────────────────────
+// Horizontal lines with 0.04 opacity — enough to hint at a CRT monitor without
+// actually making anyone squint.
+
+const ScanlinesShader = {
+ name: 'ScanlinesShader',
+
+ uniforms: {
+ tDiffuse: { value: null as THREE.Texture | null },
+ uResolution: { value: new THREE.Vector2(1, 1) },
+ uOpacity: { value: 0.0 },
+ uLineSpacing: { value: 3.0 }, // pixels between scanlines
+ },
+
+ vertexShader: /* glsl */ `
+ varying vec2 vUv;
+ void main() {
+ vUv = uv;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `,
+
+ fragmentShader: /* glsl */ `
+ uniform sampler2D tDiffuse;
+ uniform vec2 uResolution;
+ uniform float uOpacity;
+ uniform float uLineSpacing;
+
+ varying vec2 vUv;
+
+ void main() {
+ vec4 color = texture2D(tDiffuse, vUv);
+
+ // Compute screen-space Y pixel coordinate
+ float screenY = vUv.y * uResolution.y;
+
+ // Every uLineSpacing pixels draw a dark scanline band
+ float line = mod(floor(screenY), uLineSpacing);
+ float scanline = step(uLineSpacing - 1.0, line); // 1.0 on the dark stripe, 0.0 elsewhere
+
+ // Darken by uOpacity on scanline rows, pass through elsewhere
+ color.rgb *= 1.0 - (scanline * uOpacity);
+
+ gl_FragColor = color;
+ }
+ `,
+};
+
+// ─── Film Grain Shader ────────────────────────────────────────────────────────
+// Temporal noise seeded by a time uniform so grains shift every frame.
+// Opacity 0.03 — you'd notice its absence more than its presence.
+
+const FilmGrainShader = {
+ name: 'FilmGrainShader',
+
+ uniforms: {
+ tDiffuse: { value: null as THREE.Texture | null },
+ uTime: { value: 0.0 },
+ uOpacity: { value: 0.008 },
+ },
+
+ vertexShader: /* glsl */ `
+ varying vec2 vUv;
+ void main() {
+ vUv = uv;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `,
+
+ fragmentShader: /* glsl */ `
+ uniform sampler2D tDiffuse;
+ uniform float uTime;
+ uniform float uOpacity;
+
+ varying vec2 vUv;
+
+ // Fast pseudo-random hash
+ float hash(vec2 p) {
+ p = fract(p * vec2(234.34, 435.345));
+ p += dot(p, p + 34.23);
+ return fract(p.x * p.y);
+ }
+
+ void main() {
+ vec4 color = texture2D(tDiffuse, vUv);
+
+ // Seed changes per frame so grain is temporal, not static burn-in
+ float grain = hash(vUv + fract(uTime * 0.1));
+
+ // Remap from [0,1] → [-1,1] then attenuate heavily
+ float noise = (grain - 0.5) * 2.0 * uOpacity;
+
+ color.rgb += noise;
+
+ gl_FragColor = color;
+ }
+ `,
+};
+
+// ─── PostProcessingPipeline ───────────────────────────────────────────────────
+
+export class PostProcessingPipeline {
+ private composer: EffectComposer;
+ private bloomPass: UnrealBloomPass;
+ private scanlinesPass: ShaderPass;
+ private filmGrainPass: ShaderPass;
+
+ private clock: THREE.Clock;
+
+ /**
+ * Usage:
+ * this.postProcessing = new PostProcessingPipeline(renderer, scene, camera);
+ * // In animate():
+ * this.postProcessing.render(this.scene, this.camera);
+ * // In onResize():
+ * this.postProcessing.resize(w, h);
+ */
+ constructor(
+ renderer: THREE.WebGLRenderer,
+ scene: THREE.Scene,
+ camera: THREE.Camera
+ ) {
+ this.clock = new THREE.Clock();
+
+ const { width, height } = renderer.getSize(new THREE.Vector2());
+
+ // ── Composer ───────────────────────────────────────────────────────────
+ this.composer = new EffectComposer(renderer);
+
+ // ── Pass 1: Standard render ────────────────────────────────────────────
+ const renderPass = new RenderPass(scene, camera);
+ this.composer.addPass(renderPass);
+
+ // ── Pass 2: Bloom ──────────────────────────────────────────────────────
+ // strength 0.25 — subtle professional glow, not arcade neon
+ // radius 0.5 — tighter spread for a cleaner look
+ // threshold 0.65 — catches more emissive surfaces without blowing out
+ this.bloomPass = new UnrealBloomPass(
+ new THREE.Vector2(width, height),
+ 0.25, // strength — reduced from 0.4 for architectural subtlety
+ 0.5, // radius
+ 0.65 // threshold — lowered from 0.85 for controlled professional bloom
+ );
+ this.composer.addPass(this.bloomPass);
+
+ // ── Pass 3: Scanlines ──────────────────────────────────────────────────
+ this.scanlinesPass = new ShaderPass(ScanlinesShader);
+ this.scanlinesPass.uniforms['uResolution'].value.set(width, height);
+ this.composer.addPass(this.scanlinesPass);
+
+ // ── Pass 4: Film grain ─────────────────────────────────────────────────
+ this.filmGrainPass = new ShaderPass(FilmGrainShader);
+ this.composer.addPass(this.filmGrainPass);
+ }
+
+ /**
+ * Call this instead of renderer.render(scene, camera) in your animation loop.
+ * The grain shader receives the elapsed time so each frame has unique noise.
+ */
+ render(_scene: THREE.Scene, _camera: THREE.Camera): void {
+ // Advance grain time uniform so noise shifts every frame
+ this.filmGrainPass.uniforms['uTime'].value = this.clock.getElapsedTime();
+ this.composer.render();
+ }
+
+ /**
+ * Call from onResize() to keep the render target and bloom resolution in sync.
+ */
+ resize(width: number, height: number): void {
+ this.composer.setSize(width, height);
+ this.bloomPass.resolution.set(width, height);
+ this.scanlinesPass.uniforms['uResolution'].value.set(width, height);
+ }
+
+ /** Expose composer for debugging / extending in the future */
+ getComposer(): EffectComposer {
+ return this.composer;
+ }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..55ef01e
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,237 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Main Entry (Enhanced)
+// Three.js command center with full data wiring
+// System health, governance, feed, agent click
+// ═══════════════════════════════════════════════
+
+import { WarRoom } from './scene/WarRoom';
+import { HudOverlay } from './panels/HudOverlay';
+import { DualWebSocketClient } from './data/WebSocketClient';
+import * as Api from './data/ApiClient';
+
+async function main() {
+ const container = document.getElementById('war-room')!;
+
+ // Initialize 3D scene
+ const warRoom = new WarRoom(container);
+
+ // Initialize HUD overlay
+ const hud = new HudOverlay(container);
+
+ // Initialize WebSocket connections
+ const ws = new DualWebSocketClient();
+
+ // Agent click → show tooltip
+ warRoom.onAgentClick = (agent, screenX, screenY) => {
+ hud.showAgentTooltip(agent, screenX, screenY);
+ };
+
+ // Load initial data (all in parallel)
+ try {
+ const [agentData, decisionStats, escalations, meetingState, governanceStatus] = await Promise.all([
+ Api.getAgents(),
+ Api.getDecisionStats(),
+ Api.getActiveEscalations(),
+ Api.getActiveMeeting(),
+ Api.getGovernanceStatus().catch(() => null),
+ ]);
+
+ // Build agent hierarchy
+ warRoom.agentNodes.buildHierarchy(agentData.agents, agentData.executives);
+ const allAgents = warRoom.agentNodes.getAllAgents();
+ hud.updateAgentCount(allAgents.length, 0);
+
+ // Update HUD panels
+ hud.updateDecisions(decisionStats);
+ hud.updateEscalations(escalations);
+ hud.updateMeeting(meetingState);
+ if (governanceStatus) hud.updateGovernance(governanceStatus);
+
+ // Wire data to 3D DataWalls
+ warRoom.dataWalls.updatePanelData('decisions', {
+ Pending: String(decisionStats.pending || 0),
+ Approved: String(decisionStats.approved || 0),
+ Rejected: String(decisionStats.rejected || 0),
+ Deferred: String(decisionStats.deferred || 0),
+ });
+
+ warRoom.dataWalls.updatePanelData('escalations', {
+ Active: String(escalations.length),
+ 'Avg Age': escalations.length > 0
+ ? `${(escalations.reduce((s, e) => s + e.hours_elapsed, 0) / escalations.length).toFixed(1)}h`
+ : '0h',
+ Critical: String(escalations.filter(e => e.hours_elapsed > 24).length),
+ Resolved: '\u2014',
+ });
+
+ warRoom.dataWalls.updatePanelData('meeting', {
+ Status: meetingState.active ? 'ACTIVE' : 'IDLE',
+ Phase: meetingState.phase || '\u2014',
+ Agents: String(allAgents.length),
+ Duration: '\u2014',
+ });
+
+ // Set escalation pulse
+ warRoom.setEscalationPulse(escalations.length > 0);
+ } catch (err) {
+ console.error('[WarRoom] Initial data load failed:', err);
+ }
+
+ // Load system health (separate try-catch — may not be available)
+ try {
+ const health = await Api.getSystemHealth();
+ if (health) {
+ hud.updateSystemHealth(health);
+ warRoom.dataWalls.updatePanelData('status', {
+ CPU: health.cpu || '\u2014',
+ Memory: health.memory || '\u2014',
+ Services: String(health.services || 0),
+ Uptime: health.uptime || '\u2014',
+ });
+ }
+ } catch {
+ console.warn('[WarRoom] System health not available');
+ }
+
+ // Load activity feed (separate try-catch — may not be available)
+ try {
+ const feed = await Api.getFeed();
+ if (feed && feed.length > 0) {
+ hud.loadFeed(feed);
+ }
+ } catch {
+ console.warn('[WarRoom] Feed not available');
+ }
+
+ // WebSocket event handling
+ ws.onEvent((event) => {
+ switch (event.type) {
+ case 'meeting_message': {
+ const msg = event.payload;
+ hud.addFeedMessage(msg.agent_name, msg.message);
+ warRoom.agentNodes.setSpeaking(msg.agent_id, true);
+ setTimeout(() => warRoom.agentNodes.setSpeaking(msg.agent_id, false), 3000);
+ // Update speaking count
+ const agents = warRoom.agentNodes.getAllAgents();
+ hud.updateAgentCount(agents.length, agents.filter(a => a.speaking).length);
+ break;
+ }
+
+ case 'meeting_started':
+ case 'meeting_completed':
+ case 'meeting_halted':
+ Api.getActiveMeeting().then(state => {
+ hud.updateMeeting(state);
+ warRoom.dataWalls.updatePanelData('meeting', {
+ Status: state.active ? 'ACTIVE' : 'IDLE',
+ Phase: state.phase || '\u2014',
+ });
+ }).catch(() => {});
+ if (event.type === 'meeting_completed' || event.type === 'meeting_halted') {
+ warRoom.agentNodes.clearAllSpeaking();
+ }
+ break;
+
+ case 'meeting_phase':
+ hud.updateMeeting({
+ active: true,
+ phase: event.payload.phase,
+ type: event.payload.meetingType,
+ messages: [],
+ });
+ warRoom.dataWalls.updatePanelData('meeting', {
+ Status: 'ACTIVE',
+ Phase: event.payload.phase,
+ });
+ break;
+
+ case 'new_decision':
+ case 'decision_resolved':
+ Api.getDecisionStats().then(stats => {
+ hud.updateDecisions(stats);
+ warRoom.dataWalls.updatePanelData('decisions', {
+ Pending: String(stats.pending || 0),
+ Approved: String(stats.approved || 0),
+ Rejected: String(stats.rejected || 0),
+ Deferred: String(stats.deferred || 0),
+ });
+ }).catch(() => {});
+ break;
+
+ case 'escalation':
+ Api.getActiveEscalations().then(esc => {
+ hud.updateEscalations(esc);
+ warRoom.setEscalationPulse(esc.length > 0);
+ warRoom.dataWalls.updatePanelData('escalations', {
+ Active: String(esc.length),
+ 'Avg Age': esc.length > 0
+ ? `${(esc.reduce((s, e) => s + e.hours_elapsed, 0) / esc.length).toFixed(1)}h`
+ : '0h',
+ Critical: String(esc.filter(e => e.hours_elapsed > 24).length),
+ });
+ }).catch(() => {});
+ break;
+ }
+ });
+
+ ws.connect();
+
+ // Polling fallback — refresh every 30s
+ setInterval(async () => {
+ try {
+ const [stats, escalations, meeting, governance] = await Promise.all([
+ Api.getDecisionStats(),
+ Api.getActiveEscalations(),
+ Api.getActiveMeeting(),
+ Api.getGovernanceStatus().catch(() => null),
+ ]);
+ hud.updateDecisions(stats);
+ hud.updateEscalations(escalations);
+ hud.updateMeeting(meeting);
+ hud.updateConnection(ws.govConnected, ws.brConnected);
+ warRoom.setEscalationPulse(escalations.length > 0);
+
+ if (governance) hud.updateGovernance(governance);
+
+ // Update 3D DataWalls
+ warRoom.dataWalls.updatePanelData('decisions', {
+ Pending: String(stats.pending || 0),
+ Approved: String(stats.approved || 0),
+ Rejected: String(stats.rejected || 0),
+ Deferred: String(stats.deferred || 0),
+ });
+ } catch {}
+ }, 30000);
+
+ // System health polling — every 60s
+ setInterval(async () => {
+ try {
+ const health = await Api.getSystemHealth();
+ if (health) {
+ hud.updateSystemHealth(health);
+ warRoom.dataWalls.updatePanelData('status', {
+ CPU: health.cpu || '\u2014',
+ Memory: health.memory || '\u2014',
+ Services: String(health.services || 0),
+ Uptime: health.uptime || '\u2014',
+ });
+ }
+ } catch {}
+ }, 60000);
+
+ // Clock update
+ setInterval(() => hud.updateClock(), 1000);
+ hud.updateClock();
+
+ // Camera view buttons
+ document.getElementById('btn-overview')?.addEventListener('click', () => warRoom.viewOverview());
+ document.getElementById('btn-meeting')?.addEventListener('click', () => warRoom.viewMeetingFocus());
+ document.getElementById('btn-agents')?.addEventListener('click', () => warRoom.viewAgentFocus());
+
+ // Start animation loop
+ warRoom.animate();
+
+ console.log('[WarRoom] Initialized — enhanced with system health, governance, agent tooltips');
+}
+
+main().catch(err => console.error('[WarRoom] Fatal:', err));
diff --git a/src/panels/HudOverlay.ts b/src/panels/HudOverlay.ts
new file mode 100644
index 0000000..189e276
--- /dev/null
+++ b/src/panels/HudOverlay.ts
@@ -0,0 +1,897 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — HUD Overlay (Figma Design System)
+// Professional glassmorphism command center overlay
+// Design tokens: --ds-* CSS custom properties
+// Stock Trader UI Kit inspired dark dashboard
+// ═══════════════════════════════════════════════
+
+import type { AgentNode, DecisionStats, GovernanceStatus, MeetingState, EscalationAlert, SystemHealth, FeedItem } from '../types';
+
+const PHASE_LABELS: Record<string, string> = {
+ caucus: 'Caucus', call_to_order: 'Call to Order', old_business: 'Old Business',
+ current_business: 'Current Business', new_business: 'New Business',
+ breakout: 'Breakout', minutes: 'Minutes', adjournment: 'Adjournment',
+};
+
+// Design token references for dynamic JS styling
+// Uses CSS custom property syntax so values resolve at render time
+const DS = {
+ success: 'var(--ds-success)',
+ warning: 'var(--ds-warning)',
+ error: 'var(--ds-error)',
+ info: 'var(--ds-info)',
+ violet: 'var(--ds-violet)',
+ cyan: 'var(--ds-cyan)',
+ gray100: 'var(--ds-gray-100)',
+ gray400: 'var(--ds-gray-400)',
+ gray500: 'var(--ds-gray-500)',
+};
+
+export class HudOverlay {
+ private container: HTMLElement;
+ private tooltipTimeout: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(parent: HTMLElement) {
+ this.container = document.createElement('div');
+ this.container.id = 'war-room-hud';
+ this.buildDom();
+ parent.appendChild(this.container);
+ this.applyStyles();
+ }
+
+ /* ───────────────────────────────────────────
+ * DOM Construction — uses safe DOM methods
+ * ─────────────────────────────────────────── */
+ private buildDom(): void {
+ const root = this.container;
+
+ // ── Header ──
+ const header = this.el('div', 'hud-header');
+ const headerLeft = this.el('div', 'hud-header-left');
+ headerLeft.appendChild(this.el('span', 'hud-logo-pulse'));
+ const title = this.el('span', 'hud-title');
+ title.textContent = 'DW WAR ROOM';
+ headerLeft.appendChild(title);
+ header.appendChild(headerLeft);
+
+ const statusGroup = this.el('div', 'hud-status-group');
+ const statusDot = this.el('span', 'hud-status-dot');
+ statusDot.id = 'hud-status-dot';
+ statusGroup.appendChild(statusDot);
+ const statusText = this.el('span', 'hud-status');
+ statusText.id = 'hud-connection';
+ statusText.textContent = 'CONNECTING...';
+ statusGroup.appendChild(statusText);
+ const govMode = this.el('span', 'hud-governance-mode');
+ govMode.id = 'hud-gov-mode';
+ statusGroup.appendChild(govMode);
+ header.appendChild(statusGroup);
+ root.appendChild(header);
+
+ // ── Meeting Panel ──
+ root.appendChild(this.buildPanel(
+ 'hud-meeting', 'hud-top-left accent-violet', 'MEETING STATUS',
+ 'hud-meeting-content', 'No active meeting'
+ ));
+
+ // ── Decisions Panel ──
+ root.appendChild(this.buildPanel(
+ 'hud-decisions', 'hud-top-right accent-cyan', 'DECISIONS',
+ 'hud-decisions-content', 'Loading...'
+ ));
+
+ // ── Live Feed Panel ──
+ const feedPanel = this.buildPanel(
+ 'hud-feed', 'hud-bottom-left accent-amber', 'LIVE FEED',
+ 'hud-feed-content', ''
+ );
+ // Add feed count badge to title
+ const feedTitle = feedPanel.querySelector('.hud-panel-title');
+ if (feedTitle) {
+ const badge = this.el('span', 'hud-feed-badge');
+ badge.id = 'hud-feed-count';
+ feedTitle.appendChild(badge);
+ }
+ // Add scroll class to body
+ const feedBody = feedPanel.querySelector('.hud-panel-body');
+ if (feedBody) feedBody.classList.add('hud-feed-scroll');
+ root.appendChild(feedPanel);
+
+ // ── Escalations Panel ──
+ root.appendChild(this.buildPanel(
+ 'hud-escalations', 'hud-bottom-right accent-red', 'ESCALATIONS',
+ 'hud-escalations-content', 'None active'
+ ));
+
+ // ── System Health Panel ──
+ const healthPanel = this.buildPanel(
+ 'hud-health', 'hud-mid-right accent-emerald', 'SYSTEM HEALTH',
+ 'hud-health-content', ''
+ );
+ const healthBody = healthPanel.querySelector('.hud-panel-body')!;
+ ['CPU', 'MEMORY', 'SERVICES', 'UPTIME'].forEach(label => {
+ const row = this.el('div', 'hud-metric');
+ const lbl = this.el('span', 'hud-metric-label');
+ lbl.textContent = label;
+ row.appendChild(lbl);
+ const val = this.el('span', 'hud-metric-value');
+ val.id = `hud-${label.toLowerCase()}`;
+ val.textContent = '\u2014';
+ row.appendChild(val);
+ healthBody.appendChild(row);
+ });
+ root.appendChild(healthPanel);
+
+ // ── Governance Panel ──
+ const govPanel = this.buildPanel(
+ 'hud-governance', 'hud-mid-left accent-blue', 'GOVERNANCE',
+ 'hud-governance-content', ''
+ );
+ const govBody = govPanel.querySelector('.hud-panel-body')!;
+ const govMetrics = [
+ { label: 'MODE', id: 'hud-gov-mode-val' },
+ { label: 'PENDING', id: 'hud-gov-pending' },
+ { label: 'DELEGATIONS', id: 'hud-gov-delegations' },
+ { label: 'ESCALATIONS', id: 'hud-gov-escalations' },
+ ];
+ govMetrics.forEach(m => {
+ const row = this.el('div', 'hud-metric');
+ const lbl = this.el('span', 'hud-metric-label');
+ lbl.textContent = m.label;
+ row.appendChild(lbl);
+ const val = this.el('span', 'hud-metric-value');
+ val.id = m.id;
+ val.textContent = '\u2014';
+ row.appendChild(val);
+ govBody.appendChild(row);
+ });
+ root.appendChild(govPanel);
+
+ // ── Agent Tooltip ──
+ const tooltip = this.el('div', 'hud-agent-tooltip');
+ tooltip.id = 'hud-agent-tooltip';
+ tooltip.style.display = 'none';
+ ['tooltip-header', 'tooltip-role', 'tooltip-dept', 'tooltip-exec', 'tooltip-status'].forEach(cls => {
+ const d = this.el('div', cls);
+ d.id = cls;
+ tooltip.appendChild(d);
+ });
+ root.appendChild(tooltip);
+
+ // ── Bottom Bar ──
+ const bottomBar = this.el('div', 'hud-bottom-bar');
+ const bottomNav = this.el('div', 'hud-bottom-nav');
+
+ const btnData = [
+ { id: 'btn-overview', label: 'Overview', icon: '<rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/>' },
+ { id: 'btn-meeting', label: 'Meeting', icon: '<circle cx="8" cy="5" r="3"/><path d="M2 15c0-3.3 2.7-6 6-6s6 2.7 6 6"/>' },
+ { id: 'btn-agents', label: 'Agents', icon: '<circle cx="5" cy="5" r="2.5"/><circle cx="11" cy="5" r="2.5"/><path d="M1 14c0-2.2 1.8-4 4-4s4 1.8 4 4"/><path d="M7 14c0-2.2 1.8-4 4-4s4 1.8 4 4"/>' },
+ ];
+ btnData.forEach(b => {
+ const btn = document.createElement('button');
+ btn.className = 'hud-view-btn';
+ btn.id = b.id;
+ // Create SVG icon safely via namespace
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(ns, 'svg');
+ svg.setAttribute('width', '13');
+ svg.setAttribute('height', '13');
+ svg.setAttribute('viewBox', '0 0 16 16');
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'currentColor');
+ svg.setAttribute('stroke-width', '1.5');
+ // Use a temporary container to parse SVG children safely
+ const tmp = document.createElementNS(ns, 'svg');
+ tmp.innerHTML = b.icon;
+ while (tmp.firstChild) svg.appendChild(tmp.firstChild);
+ btn.appendChild(svg);
+ btn.appendChild(document.createTextNode(b.label));
+ bottomNav.appendChild(btn);
+ });
+ bottomBar.appendChild(bottomNav);
+
+ const bottomInfo = this.el('div', 'hud-bottom-info');
+ const agentCount = this.el('span', 'hud-agent-count');
+ agentCount.id = 'hud-agent-count';
+ bottomInfo.appendChild(agentCount);
+ bottomInfo.appendChild(this.el('span', 'hud-bottom-divider'));
+ const clock = this.el('span', 'hud-clock');
+ clock.id = 'hud-clock';
+ bottomInfo.appendChild(clock);
+ bottomBar.appendChild(bottomInfo);
+ root.appendChild(bottomBar);
+ }
+
+ /** Helper: create element with class(es) */
+ private el(tag: string, className: string): HTMLElement {
+ const e = document.createElement(tag);
+ e.className = className;
+ return e;
+ }
+
+ /** Helper: build a standard glass panel */
+ private buildPanel(id: string, classes: string, title: string, bodyId: string, bodyText: string): HTMLElement {
+ const panel = this.el('div', `hud-panel ${classes}`);
+ panel.id = id;
+ const titleEl = this.el('div', 'hud-panel-title');
+ titleEl.textContent = title;
+ panel.appendChild(titleEl);
+ const body = this.el('div', 'hud-panel-body');
+ body.id = bodyId;
+ if (bodyText) body.textContent = bodyText;
+ panel.appendChild(body);
+ return panel;
+ }
+
+ /* ───────────────────────────────────────────
+ * Data Update Methods
+ * ─────────────────────────────────────────── */
+
+ updateConnection(govConnected: boolean, brConnected: boolean): void {
+ const el = document.getElementById('hud-connection');
+ const dot = document.getElementById('hud-status-dot');
+ if (!el) return;
+ if (govConnected && brConnected) {
+ el.textContent = 'ALL SYSTEMS ONLINE';
+ el.style.color = DS.success;
+ if (dot) { dot.className = 'hud-status-dot dot-success'; }
+ } else if (govConnected || brConnected) {
+ el.textContent = 'PARTIAL CONNECTION';
+ el.style.color = DS.warning;
+ if (dot) { dot.className = 'hud-status-dot dot-warning'; }
+ } else {
+ el.textContent = 'DISCONNECTED';
+ el.style.color = DS.error;
+ if (dot) { dot.className = 'hud-status-dot dot-error'; }
+ }
+ }
+
+ updateMeeting(state: MeetingState): void {
+ const el = document.getElementById('hud-meeting-content');
+ if (!el) return;
+ if (!state.active) {
+ el.textContent = 'No active meeting';
+ el.style.color = DS.gray500;
+ return;
+ }
+ const phaseLabel = PHASE_LABELS[state.phase || ''] || state.phase || '\u2014';
+ el.textContent = '';
+
+ const typeDiv = document.createElement('div');
+ typeDiv.style.cssText = `color:${DS.violet};font-weight:700;font-size:13px`;
+ typeDiv.textContent = (state.type || '').toUpperCase();
+ el.appendChild(typeDiv);
+
+ const phaseDiv = document.createElement('div');
+ phaseDiv.style.marginTop = '4px';
+ phaseDiv.textContent = 'Phase: ';
+ const phaseSpan = document.createElement('span');
+ phaseSpan.style.color = DS.cyan;
+ phaseSpan.textContent = phaseLabel;
+ phaseDiv.appendChild(phaseSpan);
+ el.appendChild(phaseDiv);
+
+ const statusDiv = document.createElement('div');
+ statusDiv.textContent = 'Status: ';
+ const statusSpan = document.createElement('span');
+ statusSpan.style.color = state.status === 'active' ? DS.success : DS.warning;
+ statusSpan.textContent = (state.status || 'active').toUpperCase();
+ statusDiv.appendChild(statusSpan);
+ el.appendChild(statusDiv);
+
+ const msgDiv = document.createElement('div');
+ msgDiv.style.cssText = `font-size:9px;color:${DS.gray500};margin-top:4px`;
+ msgDiv.textContent = `${state.messages.length} message${state.messages.length !== 1 ? 's' : ''}`;
+ el.appendChild(msgDiv);
+ }
+
+ updateDecisions(stats: DecisionStats): void {
+ const el = document.getElementById('hud-decisions-content');
+ if (!el) return;
+ const p = stats.pending || 0;
+ const a = stats.approved || 0;
+ const r = stats.rejected || 0;
+ const d = stats.deferred || 0;
+ const t = stats.total || (p + a + r + d);
+
+ el.textContent = '';
+
+ const grid = document.createElement('div');
+ grid.className = 'decision-grid';
+
+ const statData = [
+ { val: p, color: DS.warning, label: 'Pending' },
+ { val: a, color: DS.success, label: 'Approved' },
+ { val: r, color: DS.error, label: 'Rejected' },
+ { val: d, color: DS.gray500, label: 'Deferred' },
+ ];
+
+ statData.forEach(s => {
+ const cell = document.createElement('div');
+ cell.className = 'decision-stat';
+ const count = document.createElement('span');
+ count.className = 'decision-count';
+ count.style.color = s.color;
+ count.textContent = String(s.val);
+ const label = document.createElement('span');
+ label.className = 'decision-label';
+ label.textContent = s.label;
+ cell.appendChild(count);
+ cell.appendChild(label);
+ grid.appendChild(cell);
+ });
+ el.appendChild(grid);
+
+ const totalDiv = document.createElement('div');
+ totalDiv.className = 'decision-total';
+ totalDiv.textContent = 'Total Decisions: ';
+ const totalSpan = document.createElement('span');
+ totalSpan.style.cssText = `color:${DS.cyan};font-weight:700`;
+ totalSpan.textContent = String(t);
+ totalDiv.appendChild(totalSpan);
+ el.appendChild(totalDiv);
+ }
+
+ updateEscalations(alerts: EscalationAlert[]): void {
+ const el = document.getElementById('hud-escalations-content');
+ if (!el) return;
+ el.textContent = '';
+
+ if (alerts.length === 0) {
+ const span = document.createElement('span');
+ span.style.color = DS.success;
+ span.textContent = 'None active';
+ el.appendChild(span);
+ return;
+ }
+
+ const header = document.createElement('div');
+ header.className = 'escalation-header';
+ header.textContent = `${alerts.length} ACTIVE`;
+ el.appendChild(header);
+
+ alerts.slice(0, 4).forEach(a => {
+ const row = document.createElement('div');
+ row.className = 'escalation-row';
+ const hours = document.createElement('span');
+ hours.className = 'escalation-hours';
+ hours.textContent = `${a.hours_elapsed.toFixed(1)}h`;
+ row.appendChild(hours);
+ row.appendChild(document.createTextNode(' \u2014 '));
+ const type = document.createElement('span');
+ type.style.color = DS.gray100;
+ type.textContent = a.escalation_type;
+ row.appendChild(type);
+ if (a.action_taken) {
+ const action = document.createElement('span');
+ action.className = 'escalation-action';
+ action.textContent = `[${a.action_taken}]`;
+ row.appendChild(action);
+ }
+ el.appendChild(row);
+ });
+ }
+
+ updateSystemHealth(health: SystemHealth): void {
+ const cpu = document.getElementById('hud-cpu');
+ const mem = document.getElementById('hud-memory');
+ const svc = document.getElementById('hud-services');
+ const up = document.getElementById('hud-uptime');
+ if (cpu) {
+ cpu.textContent = health.cpu;
+ cpu.style.color = parseFloat(health.cpu) > 80 ? DS.error : parseFloat(health.cpu) > 50 ? DS.warning : DS.success;
+ }
+ if (mem) {
+ mem.textContent = health.memory;
+ mem.style.color = parseFloat(health.memory) > 80 ? DS.error : parseFloat(health.memory) > 50 ? DS.warning : DS.success;
+ }
+ if (svc) {
+ svc.textContent = String(health.services);
+ svc.style.color = DS.info;
+ }
+ if (up) {
+ up.textContent = health.uptime;
+ up.style.color = DS.gray400;
+ }
+ }
+
+ updateGovernance(status: GovernanceStatus): void {
+ const mode = document.getElementById('hud-gov-mode-val');
+ const pending = document.getElementById('hud-gov-pending');
+ const delegations = document.getElementById('hud-gov-delegations');
+ const escalations = document.getElementById('hud-gov-escalations');
+ const headerMode = document.getElementById('hud-gov-mode');
+
+ const gm = status.governance_mode || 'unknown';
+ if (mode) {
+ mode.textContent = gm.toUpperCase();
+ mode.style.color = gm === 'autonomous' ? DS.success : DS.warning;
+ }
+ if (pending) {
+ const pd = status.pending_decisions ?? 0;
+ pending.textContent = String(pd);
+ pending.style.color = pd > 5 ? DS.error : pd > 0 ? DS.warning : DS.success;
+ }
+ if (delegations) {
+ delegations.textContent = String(status.active_delegations ?? 0);
+ delegations.style.color = DS.info;
+ }
+ if (escalations) {
+ const ae = status.active_escalations ?? 0;
+ escalations.textContent = String(ae);
+ escalations.style.color = ae > 0 ? DS.error : DS.success;
+ }
+ if (headerMode) {
+ headerMode.textContent = gm.toUpperCase();
+ headerMode.style.color = gm === 'autonomous' ? DS.success : DS.warning;
+ }
+ }
+
+ updateAgentCount(total: number, speaking: number): void {
+ const el = document.getElementById('hud-agent-count');
+ if (!el) return;
+ el.textContent = '';
+ const count = document.createElement('span');
+ count.style.color = DS.cyan;
+ count.textContent = String(total);
+ el.appendChild(count);
+ el.appendChild(document.createTextNode(' agents'));
+ if (speaking > 0) {
+ const sp = document.createElement('span');
+ sp.style.color = DS.success;
+ sp.textContent = ` (${speaking} speaking)`;
+ el.appendChild(sp);
+ }
+ }
+
+ addFeedMessage(agentName: string, message: string, type?: string): void {
+ const el = document.getElementById('hud-feed-content');
+ if (!el) return;
+
+ const div = document.createElement('div');
+ div.className = 'feed-item';
+ const typeColor = type === 'error' ? DS.error : type === 'warning' ? DS.warning : DS.violet;
+ const time = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Los_Angeles' });
+
+ const timeSpan = document.createElement('span');
+ timeSpan.className = 'feed-time';
+ timeSpan.textContent = time;
+ div.appendChild(timeSpan);
+
+ const nameSpan = document.createElement('span');
+ nameSpan.style.cssText = `color:${typeColor};font-weight:600`;
+ nameSpan.textContent = agentName;
+ div.appendChild(nameSpan);
+
+ const msgSpan = document.createElement('span');
+ msgSpan.style.color = DS.gray400;
+ msgSpan.textContent = `: ${message.slice(0, 100)}`;
+ div.appendChild(msgSpan);
+
+ el.appendChild(div);
+ el.scrollTop = el.scrollHeight;
+
+ // Update count badge
+ const badge = document.getElementById('hud-feed-count');
+ if (badge) badge.textContent = String(el.children.length);
+
+ // Keep max 30 messages
+ while (el.children.length > 30) {
+ el.removeChild(el.firstChild!);
+ }
+ }
+
+ // Bulk load feed items from API
+ loadFeed(items: FeedItem[]): void {
+ const el = document.getElementById('hud-feed-content');
+ if (!el) return;
+ if (el.children.length > 0) return;
+ items.slice(-15).forEach(item => {
+ this.addFeedMessage(item.agent_name, item.action, item.type);
+ });
+ }
+
+ // Show agent detail tooltip at screen position
+ showAgentTooltip(agent: AgentNode, screenX: number, screenY: number): void {
+ const tooltip = document.getElementById('hud-agent-tooltip');
+ if (!tooltip) return;
+
+ if (this.tooltipTimeout) clearTimeout(this.tooltipTimeout);
+
+ const header = document.getElementById('tooltip-header');
+ const role = document.getElementById('tooltip-role');
+ const dept = document.getElementById('tooltip-dept');
+ const exec = document.getElementById('tooltip-exec');
+ const status = document.getElementById('tooltip-status');
+
+ if (header) {
+ header.textContent = agent.name;
+ header.style.color = agent.color;
+ }
+ if (role) role.textContent = agent.role;
+ if (dept) dept.textContent = `Dept: ${agent.dept}`;
+ if (exec) exec.textContent = `Reports to: ${agent.exec.toUpperCase()}`;
+ if (status) {
+ status.textContent = agent.speaking ? 'SPEAKING' : 'IDLE';
+ status.style.color = agent.speaking ? DS.success : DS.gray500;
+ }
+
+ const maxX = window.innerWidth - 200;
+ const maxY = window.innerHeight - 140;
+ tooltip.style.left = `${Math.min(screenX + 15, maxX)}px`;
+ tooltip.style.top = `${Math.min(screenY - 20, maxY)}px`;
+ tooltip.style.display = 'block';
+
+ this.tooltipTimeout = setTimeout(() => {
+ tooltip.style.display = 'none';
+ }, 5000);
+ }
+
+ hideAgentTooltip(): void {
+ const tooltip = document.getElementById('hud-agent-tooltip');
+ if (tooltip) tooltip.style.display = 'none';
+ if (this.tooltipTimeout) clearTimeout(this.tooltipTimeout);
+ }
+
+ updateClock(): void {
+ const el = document.getElementById('hud-clock');
+ if (!el) return;
+ const now = new Date();
+ const pt = new Date(now.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }));
+ el.textContent = pt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' PT';
+ }
+
+ /* ───────────────────────────────────────────
+ * Design System Stylesheet
+ * ─────────────────────────────────────────── */
+ private applyStyles(): void {
+ const style = document.createElement('style');
+ style.textContent = `
+ /* ═══ Design System Tokens ═══ */
+ :root {
+ /* Typography */
+ --ds-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+
+ /* Gray palette (Slate scale) */
+ --ds-gray-50: #f8fafc;
+ --ds-gray-100: #f1f5f9;
+ --ds-gray-200: #e2e8f0;
+ --ds-gray-300: #cbd5e1;
+ --ds-gray-400: #94a3b8;
+ --ds-gray-500: #64748b;
+ --ds-gray-600: #475569;
+ --ds-gray-700: #334155;
+ --ds-gray-800: #1e293b;
+ --ds-gray-900: #0f172a;
+
+ /* Accent colors */
+ --ds-violet: #8b5cf6;
+ --ds-indigo: #6366f1;
+ --ds-cyan: #06b6d4;
+ --ds-blue: #3b82f6;
+ --ds-emerald: #10b981;
+ --ds-amber: #f59e0b;
+
+ /* Semantic colors */
+ --ds-success: #10b981;
+ --ds-warning: #f59e0b;
+ --ds-error: #ef4444;
+ --ds-info: #3b82f6;
+
+ /* Glass surfaces */
+ --ds-glass: rgba(10, 14, 28, 0.72);
+ --ds-glass-strong: rgba(8, 11, 22, 0.88);
+ --ds-glass-subtle: rgba(15, 20, 38, 0.5);
+
+ /* Blur */
+ --ds-blur-sm: blur(8px);
+ --ds-blur: blur(16px);
+ --ds-blur-lg: blur(24px);
+
+ /* Borders */
+ --ds-border: rgba(148, 163, 184, 0.06);
+ --ds-border-strong: rgba(148, 163, 184, 0.12);
+ --ds-border-interactive: rgba(99, 102, 241, 0.25);
+
+ /* Radius */
+ --ds-radius-sm: 6px;
+ --ds-radius-md: 10px;
+ --ds-radius-lg: 14px;
+ --ds-radius-full: 9999px;
+
+ /* Shadows */
+ --ds-shadow-sm: 0 2px 8px rgba(0,0,0,0.3);
+ --ds-shadow-md: 0 4px 16px rgba(0,0,0,0.4);
+ --ds-shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
+ --ds-shadow-glow-violet: 0 0 20px rgba(139,92,246,0.15);
+ --ds-shadow-glow-cyan: 0 0 20px rgba(6,182,212,0.12);
+
+ /* Transitions */
+ --ds-ease: cubic-bezier(0.4, 0, 0.2, 1);
+ --ds-duration-fast: 150ms;
+ --ds-duration-normal: 250ms;
+ }
+
+ /* ═══ HUD Container ═══ */
+ #war-room-hud {
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+ pointer-events: none;
+ font-family: var(--ds-font);
+ color: var(--ds-gray-100);
+ z-index: 10;
+ }
+
+ /* ═══ Header Bar ═══ */
+ .hud-header {
+ position: absolute; top: 0; left: 0; right: 0;
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 14px 24px;
+ background: linear-gradient(180deg, rgba(5,5,16,0.95) 0%, rgba(5,5,16,0.6) 70%, transparent 100%);
+ }
+ .hud-header-left {
+ display: flex; align-items: center; gap: 10px;
+ }
+ .hud-logo-pulse {
+ width: 8px; height: 8px; border-radius: var(--ds-radius-full);
+ background: var(--ds-violet);
+ box-shadow: 0 0 8px rgba(139,92,246,0.6), 0 0 16px rgba(139,92,246,0.3);
+ animation: logoPulse 2.5s ease-in-out infinite;
+ }
+ @keyframes logoPulse {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 8px rgba(139,92,246,0.6), 0 0 16px rgba(139,92,246,0.3); }
+ 50% { opacity: 0.7; box-shadow: 0 0 4px rgba(139,92,246,0.3), 0 0 8px rgba(139,92,246,0.15); }
+ }
+ .hud-title {
+ font-size: 13px; font-weight: 800; letter-spacing: 2.5px;
+ background: linear-gradient(135deg, var(--ds-violet), var(--ds-cyan));
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
+ background-clip: text;
+ }
+ .hud-status-group {
+ display: flex; gap: 10px; align-items: center;
+ }
+ .hud-status-dot {
+ width: 6px; height: 6px; border-radius: var(--ds-radius-full);
+ background: var(--ds-warning);
+ animation: dotPulse 1.5s ease-in-out infinite;
+ }
+ .hud-status-dot.dot-success { background: var(--ds-success); animation: none; }
+ .hud-status-dot.dot-warning { background: var(--ds-warning); }
+ .hud-status-dot.dot-error { background: var(--ds-error); }
+ @keyframes dotPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+ }
+ .hud-status {
+ font-size: 10px; font-weight: 700; letter-spacing: 0.5px;
+ color: var(--ds-warning);
+ }
+ .hud-governance-mode {
+ font-size: 9px; font-weight: 600;
+ padding: 2px 8px; border-radius: var(--ds-radius-full);
+ background: rgba(139,92,246,0.1);
+ border: 1px solid rgba(139,92,246,0.2);
+ }
+
+ /* ═══ Glass Panels ═══ */
+ .hud-panel {
+ position: absolute;
+ padding: 16px 20px;
+ background: var(--ds-glass-strong);
+ backdrop-filter: var(--ds-blur);
+ -webkit-backdrop-filter: var(--ds-blur);
+ border: 1px solid var(--ds-border-strong);
+ border-radius: var(--ds-radius-lg);
+ min-width: 240px;
+ font-size: 13px;
+ box-shadow: var(--ds-shadow-lg), inset 0 1px 0 rgba(255,255,255,0.03);
+ transition: border-color var(--ds-duration-normal) var(--ds-ease),
+ box-shadow var(--ds-duration-normal) var(--ds-ease);
+ overflow: hidden;
+ }
+ /* Colored accent bar (left edge) via pseudo-element */
+ .hud-panel::before {
+ content: '';
+ position: absolute; left: 0; top: 0; bottom: 0;
+ width: 3px; border-radius: 3px 0 0 3px;
+ background: var(--ds-violet);
+ opacity: 0.8;
+ }
+ .hud-panel.accent-violet::before { background: var(--ds-violet); }
+ .hud-panel.accent-cyan::before { background: var(--ds-cyan); }
+ .hud-panel.accent-amber::before { background: var(--ds-amber); }
+ .hud-panel.accent-red::before { background: var(--ds-error); }
+ .hud-panel.accent-emerald::before { background: var(--ds-emerald); }
+ .hud-panel.accent-blue::before { background: var(--ds-blue); }
+
+ .hud-panel-title {
+ font-size: 11px; font-weight: 700;
+ color: var(--ds-gray-400);
+ letter-spacing: 1.5px;
+ margin-bottom: 12px;
+ display: flex; align-items: center; gap: 6px;
+ }
+ .hud-panel-body {
+ color: var(--ds-gray-300);
+ }
+
+ /* ═══ Panel Positions ═══ */
+ .hud-top-left { top: 56px; left: 16px; }
+ .hud-top-right { top: 56px; right: 16px; }
+ .hud-bottom-left { bottom: 64px; left: 16px; max-width: 320px; }
+ .hud-bottom-right { bottom: 64px; right: 16px; }
+ .hud-mid-right { top: 210px; right: 16px; }
+ .hud-mid-left { top: 210px; left: 16px; }
+
+ /* ═══ Feed ═══ */
+ .hud-feed-scroll { max-height: 220px; overflow-y: auto; pointer-events: all; }
+ .hud-feed-scroll::-webkit-scrollbar { width: 3px; }
+ .hud-feed-scroll::-webkit-scrollbar-track { background: transparent; }
+ .hud-feed-scroll::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.15); border-radius: 2px; }
+ .hud-feed-badge {
+ font-size: 10px; font-weight: 600;
+ padding: 2px 8px; border-radius: var(--ds-radius-full);
+ background: rgba(139,92,246,0.15);
+ color: var(--ds-violet);
+ margin-left: 4px;
+ }
+ .feed-item {
+ padding: 5px 0;
+ border-bottom: 1px solid var(--ds-border);
+ font-size: 12px; line-height: 1.5;
+ }
+ .feed-item:last-child { border-bottom: none; }
+ .feed-time {
+ color: var(--ds-gray-500);
+ font-size: 10px; margin-right: 6px;
+ font-variant-numeric: tabular-nums;
+ }
+
+ /* ═══ Metrics ═══ */
+ .hud-metric {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 4px 0;
+ border-bottom: 1px solid var(--ds-border);
+ }
+ .hud-metric:last-child { border-bottom: none; }
+ .hud-metric-label {
+ font-size: 11px; color: var(--ds-gray-400);
+ letter-spacing: 0.5px; font-weight: 500;
+ }
+ .hud-metric-value {
+ font-size: 18px; font-weight: 700;
+ color: var(--ds-gray-100);
+ font-variant-numeric: tabular-nums;
+ }
+
+ /* ═══ Decisions Grid ═══ */
+ .decision-grid {
+ display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
+ }
+ .decision-stat {
+ display: flex; flex-direction: column; align-items: center;
+ padding: 6px 4px;
+ background: rgba(255,255,255,0.02);
+ border-radius: var(--ds-radius-sm);
+ border: 1px solid var(--ds-border);
+ transition: background var(--ds-duration-fast) var(--ds-ease);
+ }
+ .decision-count {
+ font-size: 26px; font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.2;
+ }
+ .decision-label {
+ font-size: 11px; color: var(--ds-gray-400);
+ letter-spacing: 0.5px; margin-top: 2px;
+ }
+ .decision-total {
+ margin-top: 8px; font-size: 12px;
+ color: var(--ds-gray-400);
+ border-top: 1px solid var(--ds-border-strong);
+ padding-top: 6px;
+ }
+
+ /* ═══ Escalations ═══ */
+ .escalation-header {
+ font-size: 12px; color: var(--ds-error);
+ font-weight: 700; margin-bottom: 8px;
+ display: flex; align-items: center; gap: 5px;
+ }
+ .escalation-header::before {
+ content: '';
+ width: 6px; height: 6px; border-radius: var(--ds-radius-full);
+ background: var(--ds-error);
+ animation: dotPulse 1s ease-in-out infinite;
+ }
+ .escalation-row {
+ padding: 5px 0;
+ border-bottom: 1px solid var(--ds-border);
+ font-size: 12px;
+ }
+ .escalation-row:last-child { border-bottom: none; }
+ .escalation-hours {
+ color: var(--ds-error); font-weight: 600;
+ }
+ .escalation-action {
+ color: var(--ds-success);
+ font-size: 9px; margin-left: 4px;
+ }
+
+ /* ═══ Agent Tooltip ═══ */
+ .hud-agent-tooltip {
+ position: absolute;
+ padding: 14px 18px;
+ background: var(--ds-glass-strong);
+ backdrop-filter: var(--ds-blur-lg);
+ -webkit-backdrop-filter: var(--ds-blur-lg);
+ border: 1px solid var(--ds-border-interactive);
+ border-radius: var(--ds-radius-lg);
+ min-width: 190px;
+ pointer-events: none; z-index: 20;
+ box-shadow: var(--ds-shadow-lg), var(--ds-shadow-glow-violet);
+ }
+ .tooltip-header { font-size: 14px; font-weight: 700; margin-bottom: 6px; }
+ .tooltip-role { font-size: 11px; color: var(--ds-gray-400); margin-bottom: 3px; }
+ .tooltip-dept { font-size: 10px; color: var(--ds-gray-500); }
+ .tooltip-exec { font-size: 10px; color: var(--ds-gray-500); }
+ .tooltip-status { font-size: 10px; font-weight: 600; margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--ds-border); }
+
+ /* ═══ Bottom Bar ═══ */
+ .hud-bottom-bar {
+ position: absolute; bottom: 0; left: 0; right: 0;
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 10px 24px;
+ background: linear-gradient(0deg, rgba(5,5,16,0.95) 0%, rgba(5,5,16,0.6) 70%, transparent 100%);
+ }
+ .hud-bottom-nav {
+ display: flex; gap: 6px;
+ }
+ .hud-view-btn {
+ pointer-events: all;
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px 14px; border-radius: var(--ds-radius-md);
+ border: 1px solid var(--ds-border-strong);
+ background: var(--ds-glass);
+ backdrop-filter: var(--ds-blur-sm);
+ -webkit-backdrop-filter: var(--ds-blur-sm);
+ color: var(--ds-gray-300); font-size: 12px; font-weight: 600;
+ cursor: pointer; font-family: inherit;
+ transition: all var(--ds-duration-fast) var(--ds-ease);
+ letter-spacing: 0.3px;
+ }
+ .hud-view-btn svg {
+ width: 13px; height: 13px; opacity: 0.7;
+ transition: opacity var(--ds-duration-fast) var(--ds-ease);
+ }
+ .hud-view-btn:hover {
+ border-color: var(--ds-border-interactive);
+ color: var(--ds-gray-100);
+ background: rgba(99,102,241,0.08);
+ box-shadow: var(--ds-shadow-sm), 0 0 12px rgba(99,102,241,0.1);
+ }
+ .hud-view-btn:hover svg { opacity: 1; }
+ .hud-view-btn:active {
+ transform: scale(0.97);
+ }
+
+ .hud-bottom-info {
+ display: flex; align-items: center; gap: 12px;
+ }
+ .hud-agent-count {
+ font-size: 12px; color: var(--ds-gray-400);
+ }
+ .hud-bottom-divider {
+ width: 1px; height: 12px;
+ background: var(--ds-border-strong);
+ }
+ .hud-clock {
+ font-size: 13px; color: var(--ds-gray-400);
+ font-variant-numeric: tabular-nums;
+ font-weight: 500;
+ letter-spacing: 0.3px;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+}
diff --git a/src/scene/AgentNodes.ts b/src/scene/AgentNodes.ts
new file mode 100644
index 0000000..d9b5813
--- /dev/null
+++ b/src/scene/AgentNodes.ts
@@ -0,0 +1,256 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Agent Node Visualization
+// 47 agent spheres in org hierarchy + 5 exec nodes
+// Text label sprites + raycasting support
+// ═══════════════════════════════════════════════
+
+import * as THREE from 'three';
+import type { AgentNode } from '../types';
+
+// Exec colors matching production config
+const EXEC_COLORS: Record<string, number> = {
+ ceo: 0x5B21B6,
+ cfo: 0x065F46,
+ coo: 0xCD7F32,
+ cto: 0x9B111E,
+ 'vp-ops': 0x6D28D9,
+};
+
+// Department → exec mapping
+const DEPT_EXEC: Record<string, string> = {
+ 'Operations': 'coo',
+ 'Commerce': 'cfo',
+ 'Finance': 'cfo',
+ 'Legal': 'ceo',
+ 'Marketing': 'vp-ops',
+ 'Catalogs': 'vp-ops',
+ 'Catalogs-Heritage': 'vp-ops',
+ 'Catalogs-Modern': 'vp-ops',
+ 'Catalogs-National': 'vp-ops',
+ 'Pipeline': 'vp-ops',
+ 'Data': 'cto',
+ 'Imagery': 'vp-ops',
+};
+
+// ─── Text sprite helper ────────────────────────────────────────────────────
+function createTextSprite(text: string, colorHex: string, fontSize: number = 28): THREE.Sprite {
+ const canvas = document.createElement('canvas');
+ canvas.width = 256;
+ canvas.height = 64;
+ const ctx = canvas.getContext('2d')!;
+
+ // Transparent background
+ ctx.clearRect(0, 0, 256, 64);
+
+ // Text with glow
+ ctx.font = `bold ${fontSize}px Inter, -apple-system, monospace`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ // Shadow/glow
+ ctx.shadowColor = '#000';
+ ctx.shadowBlur = 4;
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 1;
+
+ ctx.fillStyle = colorHex;
+ ctx.fillText(text, 128, 32);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.minFilter = THREE.LinearFilter;
+ const mat = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthTest: false,
+ });
+ const sprite = new THREE.Sprite(mat);
+ sprite.scale.set(2, 0.5, 1);
+ return sprite;
+}
+
+export class AgentNodeManager {
+ private nodes: Map<string, { mesh: THREE.Mesh; glow: THREE.Mesh; label: THREE.Sprite; data: AgentNode }> = new Map();
+ private group: THREE.Group;
+ private connections: THREE.Line[] = [];
+
+ constructor(private scene: THREE.Scene) {
+ this.group = new THREE.Group();
+ this.group.name = 'agent-nodes';
+ scene.add(this.group);
+ }
+
+ buildHierarchy(agents: any[], executives: any[]): void {
+ // Clear existing
+ this.group.clear();
+ this.nodes.clear();
+ this.connections.forEach(c => this.scene.remove(c));
+ this.connections = [];
+
+ // Place executives in a ring at top
+ const execRadius = 3;
+ executives.forEach((exec: any, i: number) => {
+ const angle = (i / executives.length) * Math.PI * 2 - Math.PI / 2;
+ const x = Math.cos(angle) * execRadius;
+ const z = Math.sin(angle) * execRadius;
+ this.createNode(exec.id, exec.name, exec.role, 'Executive', exec.color, x, 5, z, 0.4);
+ });
+
+ // Place agents in rings below their exec
+ const execAgents: Record<string, any[]> = {};
+ agents.forEach((agent: any) => {
+ const exec = DEPT_EXEC[agent.dept] || 'vp-ops';
+ if (!execAgents[exec]) execAgents[exec] = [];
+ execAgents[exec].push(agent);
+ });
+
+ Object.entries(execAgents).forEach(([execId, agentList]) => {
+ const execNode = this.nodes.get(execId);
+ if (!execNode) return;
+
+ const baseX = execNode.data.position.x;
+ const baseZ = execNode.data.position.z;
+ const ringRadius = Math.min(2 + agentList.length * 0.15, 5);
+
+ agentList.forEach((agent: any, i: number) => {
+ const angle = (i / agentList.length) * Math.PI * 2;
+ const x = baseX + Math.cos(angle) * ringRadius;
+ const z = baseZ + Math.sin(angle) * ringRadius;
+ const y = 2;
+
+ this.createNode(agent.id, agent.name, agent.role, agent.dept, agent.color, x, y, z, 0.2);
+
+ // Connection line to exec
+ this.drawConnection(execNode.mesh.position, new THREE.Vector3(x, y, z), execNode.data.color);
+ });
+ });
+ }
+
+ private createNode(
+ id: string, name: string, role: string, dept: string,
+ colorHex: string, x: number, y: number, z: number, size: number
+ ): void {
+ const color = new THREE.Color(colorHex);
+
+ // Main sphere — MeshStandardMaterial for PBR lighting
+ const geo = new THREE.SphereGeometry(size, 32, 32);
+ const mat = new THREE.MeshStandardMaterial({
+ color,
+ emissive: color,
+ emissiveIntensity: 0.25,
+ metalness: 0.6,
+ roughness: 0.3,
+ transparent: true,
+ opacity: 0.92,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(x, y, z);
+ mesh.name = `node-${id}`;
+ // Store agent ID in userData for raycasting lookup
+ mesh.userData.agentId = id;
+ this.group.add(mesh);
+
+ // Glow shell (invisible by default — lights up when speaking)
+ const glowGeo = new THREE.SphereGeometry(size * 1.6, 16, 16);
+ const glowMat = new THREE.MeshBasicMaterial({
+ color,
+ transparent: true,
+ opacity: 0.0,
+ });
+ const glow = new THREE.Mesh(glowGeo, glowMat);
+ glow.position.set(x, y, z);
+ glow.name = `glow-${id}`;
+ this.group.add(glow);
+
+ // Text label above node
+ const isExec = dept === 'Executive';
+ const displayName = isExec ? name.toUpperCase() : name.split(' ').pop() || name;
+ const label = createTextSprite(displayName, isExec ? '#e8eaf0' : '#9ca3af', isExec ? 30 : 22);
+ label.position.set(x, y + size + 0.5, z);
+ label.scale.set(isExec ? 2.4 : 1.6, isExec ? 0.6 : 0.4, 1);
+ this.group.add(label);
+
+ const data: AgentNode = {
+ id, name, role, dept, color: colorHex,
+ exec: DEPT_EXEC[dept] || 'vp-ops',
+ speaking: false,
+ position: { x, y, z },
+ };
+ this.nodes.set(id, { mesh, glow, label, data });
+ }
+
+ private drawConnection(from: THREE.Vector3, to: THREE.Vector3, colorHex: string): void {
+ const points = [from.clone(), to.clone()];
+ const geo = new THREE.BufferGeometry().setFromPoints(points);
+ const mat = new THREE.LineBasicMaterial({
+ color: new THREE.Color(colorHex),
+ transparent: true,
+ opacity: 0.15,
+ });
+ const line = new THREE.Line(geo, mat);
+ this.scene.add(line);
+ this.connections.push(line);
+ }
+
+ setSpeaking(agentId: string, speaking: boolean): void {
+ const node = this.nodes.get(agentId);
+ if (!node) return;
+
+ node.data.speaking = speaking;
+ const glowMat = node.glow.material as THREE.MeshBasicMaterial;
+ glowMat.opacity = speaking ? 0.3 : 0.0;
+
+ const meshMat = node.mesh.material as THREE.MeshStandardMaterial;
+ meshMat.emissiveIntensity = speaking ? 0.9 : 0.25;
+ }
+
+ clearAllSpeaking(): void {
+ this.nodes.forEach((node) => {
+ this.setSpeaking(node.data.id, false);
+ });
+ }
+
+ // Get all agent sphere meshes for raycasting
+ getMeshes(): THREE.Mesh[] {
+ const meshes: THREE.Mesh[] = [];
+ this.nodes.forEach(node => meshes.push(node.mesh));
+ return meshes;
+ }
+
+ // Lookup agent data from a raycasted mesh
+ getNodeByMesh(mesh: THREE.Object3D): AgentNode | null {
+ const agentId = mesh.userData?.agentId;
+ if (!agentId) return null;
+ const node = this.nodes.get(agentId);
+ return node ? node.data : null;
+ }
+
+ // Get agent data by ID
+ getAgent(id: string): AgentNode | null {
+ const node = this.nodes.get(id);
+ return node ? node.data : null;
+ }
+
+ // Get all agent data
+ getAllAgents(): AgentNode[] {
+ const agents: AgentNode[] = [];
+ this.nodes.forEach(n => agents.push(n.data));
+ return agents;
+ }
+
+ animate(time: number): void {
+ // Subtle floating animation for all nodes
+ this.nodes.forEach((node) => {
+ const offset = parseInt(node.data.id, 36) * 0.5;
+ node.mesh.position.y = node.data.position.y + Math.sin(time * 0.001 + offset) * 0.05;
+ node.glow.position.y = node.mesh.position.y;
+ // Keep label above the sphere
+ node.label.position.y = node.mesh.position.y + 0.5;
+
+ // Pulse glow for speaking agents
+ if (node.data.speaking) {
+ const glowMat = node.glow.material as THREE.MeshBasicMaterial;
+ glowMat.opacity = 0.15 + Math.sin(time * 0.005) * 0.15;
+ }
+ });
+ }
+}
diff --git a/src/scene/CommandTable.ts b/src/scene/CommandTable.ts
new file mode 100644
index 0000000..425e48a
--- /dev/null
+++ b/src/scene/CommandTable.ts
@@ -0,0 +1,270 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Command Table
+// Central holographic table in the war room
+// ═══════════════════════════════════════════════
+
+import * as THREE from 'three';
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export interface CommandTableHandle {
+ animate(elapsed: number): void;
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+/** Build a flat torus lying horizontal (rotated -90° on X). */
+function horizontalTorus(
+ radius: number,
+ tube: number,
+ radialSeg: number,
+ tubularSeg: number,
+ color: number,
+ opacity: number,
+ additive = false,
+): THREE.Mesh {
+ const geo = new THREE.TorusGeometry(radius, tube, radialSeg, tubularSeg);
+ const mat = new THREE.MeshBasicMaterial({
+ color,
+ transparent: true,
+ opacity,
+ blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
+ depthWrite: false,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.rotation.x = -Math.PI / 2;
+ return mesh;
+}
+
+// ─── Main factory ────────────────────────────────────────────────────────────
+
+export function createCommandTable(scene: THREE.Scene): CommandTableHandle {
+ const group = new THREE.Group();
+ group.name = 'command-table';
+
+ // ── 1. Table base — dark cylinder ────────────────────────────────────────
+ const baseGeo = new THREE.CylinderGeometry(6, 7, 0.3, 64);
+ const baseMat = new THREE.MeshPhongMaterial({
+ color: 0x0a0a1e,
+ emissive: 0x1a1a3e,
+ emissiveIntensity: 0.4,
+ transparent: true,
+ opacity: 0.92,
+ });
+ const base = new THREE.Mesh(baseGeo, baseMat);
+ base.position.y = 0.15;
+ group.add(base);
+
+ // ── 2. Animated outer ring (purple) ──────────────────────────────────────
+ const outerRing = horizontalTorus(6.2, 0.08, 8, 96, 0x8b5cf6, 0.75, true);
+ outerRing.position.y = 0.35;
+ group.add(outerRing);
+
+ // ── 3. Secondary outer glow ring (same radius, wider tube, very faint) ───
+ const outerGlow = horizontalTorus(6.2, 0.22, 8, 64, 0x8b5cf6, 0.12, true);
+ outerGlow.position.y = 0.35;
+ group.add(outerGlow);
+
+ // ── 4. Inner ring (blue) ─────────────────────────────────────────────────
+ const innerRing = horizontalTorus(4, 0.05, 8, 64, 0x3b82f6, 0.5, true);
+ innerRing.position.y = 0.36;
+ group.add(innerRing);
+
+ // ── 5. Mid ring (blue, thin) ─────────────────────────────────────────────
+ const midRing = horizontalTorus(5.1, 0.035, 8, 64, 0x3b82f6, 0.3, true);
+ midRing.position.y = 0.36;
+ group.add(midRing);
+
+ // ── 6. Edge glow ring — bright emissive at table edge ────────────────────
+ const edgeGlowGeo = new THREE.TorusGeometry(6.85, 0.06, 8, 96);
+ const edgeGlowMat = new THREE.MeshStandardMaterial({
+ color: 0x8b5cf6,
+ emissive: 0x8b5cf6,
+ emissiveIntensity: 2.5,
+ transparent: true,
+ opacity: 0.55,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ const edgeGlow = new THREE.Mesh(edgeGlowGeo, edgeGlowMat);
+ edgeGlow.rotation.x = -Math.PI / 2;
+ edgeGlow.position.y = 0.33;
+ group.add(edgeGlow);
+
+ // ── 7. Animated grid ─────────────────────────────────────────────────────
+ const gridHelper = new THREE.GridHelper(12, 20, 0x3b3366, 0x1a1a3e);
+ gridHelper.position.y = 0.32;
+ // GridHelper material is transparent by default — boost it slightly
+ const gridMats = Array.isArray(gridHelper.material)
+ ? gridHelper.material
+ : [gridHelper.material];
+ gridMats.forEach((m) => {
+ (m as THREE.LineBasicMaterial).transparent = true;
+ (m as THREE.LineBasicMaterial).opacity = 0.55;
+ (m as THREE.LineBasicMaterial).blending = THREE.AdditiveBlending;
+ });
+ group.add(gridHelper);
+
+ // ── 8. Holographic projection cone ───────────────────────────────────────
+ // Open-ended cone pointing upward, wide base at table level, tip at top
+ const coneGeo = new THREE.ConeGeometry(5.5, 9, 64, 1, true);
+ const coneMat = new THREE.MeshBasicMaterial({
+ color: 0x8b5cf6,
+ transparent: true,
+ opacity: 0.06,
+ side: THREE.DoubleSide,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ const cone = new THREE.Mesh(coneGeo, coneMat);
+ // Base of cone sits at table surface (y=0.35), tip points up to y≈9.35
+ cone.position.y = 0.35 + 9 / 2; // y = 4.85
+ group.add(cone);
+
+ // Second cone pass — slightly smaller, more opaque for depth
+ const coneInnerGeo = new THREE.ConeGeometry(3.2, 7, 48, 1, true);
+ const coneInnerMat = new THREE.MeshBasicMaterial({
+ color: 0x3b82f6,
+ transparent: true,
+ opacity: 0.045,
+ side: THREE.DoubleSide,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ const coneInner = new THREE.Mesh(coneInnerGeo, coneInnerMat);
+ coneInner.position.y = 0.35 + 7 / 2; // y = 3.85
+ group.add(coneInner);
+
+ // ── 9. Pulsing energy core — sphere at y=2 ───────────────────────────────
+ const coreGeo = new THREE.SphereGeometry(0.22, 32, 32);
+ const coreMat = new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ emissive: 0x8b5cf6,
+ emissiveIntensity: 3.0,
+ transparent: true,
+ opacity: 0.9,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ const core = new THREE.Mesh(coreGeo, coreMat);
+ core.position.y = 2;
+ group.add(core);
+
+ // Core halo — larger diffuse sphere around the core
+ const haloGeo = new THREE.SphereGeometry(0.55, 24, 24);
+ const haloMat = new THREE.MeshBasicMaterial({
+ color: 0x8b5cf6,
+ transparent: true,
+ opacity: 0.12,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ const halo = new THREE.Mesh(haloGeo, haloMat);
+ halo.position.y = 2;
+ group.add(halo);
+
+ // ── 10. Data particle column ─────────────────────────────────────────────
+ const PARTICLE_COUNT = 200;
+ const particlePositions = new Float32Array(PARTICLE_COUNT * 3);
+ // Random speeds per particle (stored separately; not in geometry)
+ const particleSpeeds = new Float32Array(PARTICLE_COUNT);
+ const particleAngles = new Float32Array(PARTICLE_COUNT);
+ const particleRadii = new Float32Array(PARTICLE_COUNT);
+
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const radius = Math.random() * 4.5 + 0.5; // 0.5 – 5.0 from center
+ const startY = Math.random() * 8; // scattered initial heights
+
+ particleAngles[i] = angle;
+ particleRadii[i] = radius;
+ particleSpeeds[i] = 0.4 + Math.random() * 1.2; // units/s
+
+ particlePositions[i * 3] = Math.cos(angle) * radius;
+ particlePositions[i * 3 + 1] = startY;
+ particlePositions[i * 3 + 2] = Math.sin(angle) * radius;
+ }
+
+ const particleGeo = new THREE.BufferGeometry();
+ particleGeo.setAttribute(
+ 'position',
+ new THREE.BufferAttribute(particlePositions, 3),
+ );
+
+ const particleMat = new THREE.PointsMaterial({
+ color: 0x8b5cf6,
+ size: 0.05,
+ transparent: true,
+ opacity: 0.7,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ sizeAttenuation: true,
+ });
+
+ const particles = new THREE.Points(particleGeo, particleMat);
+ group.add(particles);
+
+ // ── Finalize group ────────────────────────────────────────────────────────
+ scene.add(group);
+
+ // ─── Animate closure ────────────────────────────────────────────────────
+ // elapsed is in milliseconds (matching the convention used in WarRoom.ts)
+ let lastElapsed = 0;
+
+ return {
+ animate(elapsed: number): void {
+ const t = elapsed / 1000; // seconds
+ const dt = (elapsed - lastElapsed) / 1000;
+ lastElapsed = elapsed;
+
+ // — Outer ring: slow clockwise rotation on Y axis —
+ outerRing.rotation.z += 0.004;
+ outerGlow.rotation.z -= 0.002; // counter-rotate for layered feel
+
+ // — Inner/mid rings: gentle counter-rotation —
+ innerRing.rotation.z -= 0.006;
+ midRing.rotation.z += 0.003;
+
+ // — Animated grid: very slow rotation —
+ gridHelper.rotation.y += 0.001;
+
+ // — Edge glow: pulse brightness via emissiveIntensity —
+ const edgePulse = 1.8 + Math.sin(t * 1.8) * 0.7;
+ (edgeGlowMat as THREE.MeshStandardMaterial).emissiveIntensity = edgePulse;
+
+ // — Energy core: scale + emissive pulse —
+ const corePulse = 0.85 + Math.sin(t * 2.4) * 0.25;
+ core.scale.setScalar(corePulse);
+ (coreMat as THREE.MeshStandardMaterial).emissiveIntensity =
+ 2.5 + Math.sin(t * 2.4) * 1.5;
+ halo.scale.setScalar(0.9 + Math.sin(t * 2.4 + 0.5) * 0.35);
+ (haloMat as THREE.MeshBasicMaterial).opacity =
+ 0.08 + Math.sin(t * 2.4) * 0.06;
+
+ // — Holographic cones: very slow counter-rotations on Y —
+ cone.rotation.y += 0.0015;
+ coneInner.rotation.y -= 0.002;
+
+ // — Data particles: rise upward, respawn at bottom —
+ const posAttr = particleGeo.attributes['position'] as THREE.BufferAttribute;
+ const posArray = posAttr.array as Float32Array;
+ const COLUMN_TOP = 8.5;
+
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ posArray[i * 3 + 1] += particleSpeeds[i] * dt;
+
+ if (posArray[i * 3 + 1] > COLUMN_TOP) {
+ // Respawn at table surface with fresh random position
+ const newAngle = Math.random() * Math.PI * 2;
+ const newRadius = Math.random() * 4.5 + 0.5;
+ particleAngles[i] = newAngle;
+ particleRadii[i] = newRadius;
+ posArray[i * 3] = Math.cos(newAngle) * newRadius;
+ posArray[i * 3 + 1] = 0.35 + Math.random() * 0.5; // just above table
+ posArray[i * 3 + 2] = Math.sin(newAngle) * newRadius;
+ }
+ }
+ posAttr.needsUpdate = true;
+ },
+ };
+}
diff --git a/src/scene/DataWalls.ts b/src/scene/DataWalls.ts
new file mode 100644
index 0000000..f5aa6bd
--- /dev/null
+++ b/src/scene/DataWalls.ts
@@ -0,0 +1,387 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Data Wall Panels
+// Floating panels showing live metrics
+// Canvas-textured with scanline sweep + corner brackets
+// ═══════════════════════════════════════════════
+//
+// Usage:
+// const walls = new DataWalls(scene);
+// walls.animate(elapsedMs);
+// walls.pulsePanel('escalations', 0.3);
+// walls.updatePanelData('decisions', { Pending: '7', Approved: '42', Rejected: '3' });
+
+import * as THREE from 'three';
+
+// ─── Canvas texture dimensions ───────────────────────────────────────────────
+const CANVAS_W = 768;
+const CANVAS_H = 512;
+
+// ─── Scanline sweep period (ms) ──────────────────────────────────────────────
+const SWEEP_PERIOD_MS = 4000;
+
+// ─── Default metric values per panel ────────────────────────────────────────
+const DEFAULT_DATA: Record<string, Record<string, string>> = {
+ decisions: { Pending: '—', Approved: '—', Rejected: '—', Deferred: '—' },
+ escalations: { Active: '—', 'Avg Age': '—h', Critical: '—', Resolved: '—' },
+ meeting: { Status: 'IDLE', Phase: '—', Agents: '—', Duration: '—' },
+ status: { 'CPU': '—%', 'Memory': '—%', 'Services': '—', 'Uptime': '—' },
+};
+
+// ─── Panel definition ────────────────────────────────────────────────────────
+interface PanelDef {
+ name: string;
+ x: number;
+ y: number;
+ z: number;
+ width: number;
+ height: number;
+ accentColor: number;
+ accentHex: string; // CSS hex for canvas rendering
+ labelCaps: string; // Header text shown on the panel
+}
+
+const PANEL_DEFS: PanelDef[] = [
+ { name: 'decisions', x: -12, y: 6, z: 0, width: 5, height: 4, accentColor: 0x8b5cf6, accentHex: '#8b5cf6', labelCaps: 'DECISIONS' },
+ { name: 'escalations', x: 12, y: 6, z: 0, width: 5, height: 4, accentColor: 0xef4444, accentHex: '#ef4444', labelCaps: 'ESCALATIONS' },
+ { name: 'meeting', x: -10, y: 2, z: 8, width: 6, height: 3, accentColor: 0x3b82f6, accentHex: '#3b82f6', labelCaps: 'MEETING' },
+ { name: 'status', x: 10, y: 2, z: 8, width: 6, height: 3, accentColor: 0x22c55e, accentHex: '#22c55e', labelCaps: 'SYS STATUS' },
+];
+
+// ─── Per-panel runtime state ─────────────────────────────────────────────────
+interface PanelState {
+ def: PanelDef;
+ mesh: THREE.Mesh;
+ canvas: HTMLCanvasElement;
+ ctx: CanvasRenderingContext2D;
+ texture: THREE.CanvasTexture;
+ sweepY: number; // current scanline y position in canvas pixels
+ data: Record<string, string>;
+ baseY: number; // world-space Y at creation (for hover animation)
+}
+
+// ─── Corner bracket geometry helpers ────────────────────────────────────────
+// Returns 8 points (4 corners × 2 lines each) for L-shaped corner brackets.
+// All coordinates are in local panel space; the caller positions the LineSegments.
+function buildCornerBracketPoints(w: number, h: number, arm: number): Float32Array {
+ const hw = w / 2;
+ const hh = h / 2;
+ // Each corner: 2 line segments = 4 points.
+ // Order: (x1,y1,z), (x2,y2,z) per segment.
+ return new Float32Array([
+ // Top-left corner — horizontal arm
+ -hw, hh, 0, -hw + arm, hh, 0,
+ // Top-left corner — vertical arm
+ -hw, hh, 0, -hw, hh - arm, 0,
+
+ // Top-right corner — horizontal arm
+ hw, hh, 0, hw - arm, hh, 0,
+ // Top-right corner — vertical arm
+ hw, hh, 0, hw, hh - arm, 0,
+
+ // Bottom-left corner — horizontal arm
+ -hw, -hh, 0, -hw + arm, -hh, 0,
+ // Bottom-left corner — vertical arm
+ -hw, -hh, 0, -hw, -hh + arm, 0,
+
+ // Bottom-right corner — horizontal arm
+ hw, -hh, 0, hw - arm, -hh, 0,
+ // Bottom-right corner — vertical arm
+ hw, -hh, 0, hw, -hh + arm, 0,
+ ]);
+}
+
+// ─── Canvas draw helpers ────────────────────────────────────────────────────
+
+function drawPanelCanvas(
+ ctx: CanvasRenderingContext2D,
+ def: PanelDef,
+ data: Record<string, string>,
+ sweepY: number
+): void {
+ const { accentHex, labelCaps } = def;
+ const W = CANVAS_W;
+ const H = CANVAS_H;
+
+ // Background
+ ctx.fillStyle = '#0a0a1e';
+ ctx.fillRect(0, 0, W, H);
+
+ // Header bar (top strip, 52px tall)
+ const headerH = 52;
+ ctx.fillStyle = accentHex;
+ ctx.globalAlpha = 0.18;
+ ctx.fillRect(0, 0, W, headerH);
+ ctx.globalAlpha = 1.0;
+
+ // Header text
+ ctx.font = 'bold 22px Inter, monospace';
+ ctx.fillStyle = accentHex;
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'left';
+ ctx.fillText(labelCaps, 18, headerH / 2);
+
+ // Live indicator dot
+ const dotX = W - 14;
+ const dotY = headerH / 2;
+ ctx.beginPath();
+ ctx.arc(dotX, dotY, 4, 0, Math.PI * 2);
+ ctx.fillStyle = accentHex;
+ ctx.globalAlpha = 0.9;
+ ctx.fill();
+ ctx.globalAlpha = 1.0;
+
+ // Divider line below header
+ ctx.strokeStyle = accentHex;
+ ctx.globalAlpha = 0.35;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(0, headerH);
+ ctx.lineTo(W, headerH);
+ ctx.stroke();
+ ctx.globalAlpha = 1.0;
+
+ // Metric rows
+ const entries = Object.entries(data);
+ const rowH = Math.floor((H - headerH - 24) / Math.max(entries.length, 1));
+ const colLabelX = 18;
+ const colValueX = W - 18;
+ const startY = headerH + 14;
+
+ entries.forEach(([key, value], i) => {
+ const rowY = startY + i * rowH + rowH / 2;
+
+ // Key label — larger, brighter for legibility
+ ctx.font = '16px Inter, monospace';
+ ctx.fillStyle = '#b0b4c8';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(key.toUpperCase(), colLabelX, rowY);
+
+ // Value — accent-colored, bold, much larger
+ ctx.font = 'bold 28px Inter, monospace';
+ ctx.fillStyle = value === '—' ? '#3a3a5a' : accentHex;
+ ctx.textAlign = 'right';
+ ctx.fillText(value, colValueX, rowY);
+
+ // Subtle row divider
+ if (i < entries.length - 1) {
+ ctx.strokeStyle = '#1e1e3a';
+ ctx.globalAlpha = 0.8;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(colLabelX, rowY + rowH / 2);
+ ctx.lineTo(W - colLabelX, rowY + rowH / 2);
+ ctx.stroke();
+ ctx.globalAlpha = 1.0;
+ }
+
+ // Progress bar beneath value (proportional fill based on numeric parse)
+ const numVal = parseFloat(value);
+ if (!isNaN(numVal) && numVal > 0) {
+ const barMaxW = 140;
+ const barH = 6;
+ const barY = rowY + 16;
+ const fill = Math.min(numVal / 100, 1.0);
+ ctx.fillStyle = '#1a1a38';
+ ctx.beginPath();
+ ctx.roundRect(colValueX - barMaxW, barY, barMaxW, barH, 3);
+ ctx.fill();
+ ctx.fillStyle = accentHex;
+ ctx.globalAlpha = 0.8;
+ ctx.beginPath();
+ ctx.roundRect(colValueX - barMaxW, barY, barMaxW * fill, barH, 3);
+ ctx.fill();
+ ctx.globalAlpha = 1.0;
+ }
+ });
+
+ // Scanline sweep — bright horizontal band
+ if (sweepY >= 0 && sweepY <= H) {
+ const grad = ctx.createLinearGradient(0, sweepY - 6, 0, sweepY + 6);
+ grad.addColorStop(0, 'rgba(255,255,255,0)');
+ grad.addColorStop(0.4, `${accentHex}55`);
+ grad.addColorStop(0.5, `${accentHex}bb`);
+ grad.addColorStop(0.6, `${accentHex}55`);
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, sweepY - 6, W, 12);
+ }
+
+ // Subtle vignette border
+ const vignette = ctx.createRadialGradient(W / 2, H / 2, H * 0.25, W / 2, H / 2, H * 0.85);
+ vignette.addColorStop(0, 'rgba(0,0,0,0)');
+ vignette.addColorStop(1, 'rgba(0,0,0,0.45)');
+ ctx.fillStyle = vignette;
+ ctx.fillRect(0, 0, W, H);
+}
+
+// ─── DataWalls class ─────────────────────────────────────────────────────────
+
+export class DataWalls {
+ // Expose panels map for external pulsePanel usage (keyed by panel name)
+ private panels: Map<string, THREE.Mesh> = new Map();
+ private states: Map<string, PanelState> = new Map();
+ private group: THREE.Group;
+
+ constructor(private scene: THREE.Scene) {
+ this.group = new THREE.Group();
+ this.group.name = 'data-walls';
+ scene.add(this.group);
+ this.createPanels();
+ }
+
+ // ── Panel construction ──────────────────────────────────────────────────────
+ private createPanels(): void {
+ for (const def of PANEL_DEFS) {
+ this.createPanel(def);
+ }
+ }
+
+ private createPanel(def: PanelDef): void {
+ const { name, x, y, z, width, height, accentColor } = def;
+
+ // ── Canvas + texture ──
+ const canvas = document.createElement('canvas');
+ canvas.width = CANVAS_W;
+ canvas.height = CANVAS_H;
+ const ctx = canvas.getContext('2d')!;
+
+ const initialData = { ...DEFAULT_DATA[name] } || {};
+ drawPanelCanvas(ctx, def, initialData, -20); // -20 = scanline off-screen initially
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+
+ // ── Panel mesh (MeshBasicMaterial for sharp canvas text — no lighting wash) ──
+ const geo = new THREE.PlaneGeometry(width, height);
+ const mat = new THREE.MeshBasicMaterial({
+ map: texture,
+ transparent: true,
+ opacity: 0.92,
+ side: THREE.DoubleSide,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(x, y, z);
+ mesh.name = `panel-${name}`;
+
+ // Face toward scene center slightly
+ if (x !== 0) {
+ mesh.rotation.y = x > 0 ? -Math.PI * 0.15 : Math.PI * 0.15;
+ }
+
+ this.group.add(mesh);
+ this.panels.set(name, mesh);
+
+ // ── Header bar strip — thin BoxGeometry at top of panel ──
+ const barThickness = 0.04;
+ const barGeo = new THREE.BoxGeometry(width, 0.22, barThickness);
+ const barMat = new THREE.MeshStandardMaterial({
+ color: accentColor,
+ emissive: new THREE.Color(accentColor),
+ emissiveIntensity: 0.5,
+ roughness: 0.4,
+ metalness: 0.2,
+ });
+ const bar = new THREE.Mesh(barGeo, barMat);
+ bar.name = `bar-${name}`;
+ // Position at top edge of panel, slightly in front of it
+ bar.position.set(x, y + height / 2 - 0.11, z + 0.02);
+ bar.rotation.copy(mesh.rotation);
+ this.group.add(bar);
+
+ // ── Corner brackets (Line segments — no full frame) ──
+ const armLength = Math.min(width, height) * 0.18;
+ const bracketPositions = buildCornerBracketPoints(width, height, armLength);
+ const bracketGeo = new THREE.BufferGeometry();
+ bracketGeo.setAttribute('position', new THREE.BufferAttribute(bracketPositions, 3));
+ const bracketMat = new THREE.LineBasicMaterial({
+ color: accentColor,
+ transparent: true,
+ opacity: 0.75,
+ });
+ const brackets = new THREE.LineSegments(bracketGeo, bracketMat);
+ brackets.position.copy(mesh.position);
+ brackets.rotation.copy(mesh.rotation);
+ // Offset slightly in front so brackets are visible over panel face
+ brackets.position.z += 0.01;
+ if (x !== 0 && z === 0) {
+ // Recalculate the Z-forward offset in world space after rotation
+ // Simpler: just nudge along local Z via translateZ after rotation is applied
+ brackets.translateZ(0.02);
+ }
+ this.group.add(brackets);
+
+ // ── Store runtime state ──
+ const state: PanelState = {
+ def,
+ mesh,
+ canvas,
+ ctx,
+ texture,
+ sweepY: 0,
+ data: initialData,
+ baseY: y,
+ };
+ this.states.set(name, state);
+ }
+
+ // ── Public: update panel data and redraw ────────────────────────────────────
+ updatePanelData(name: string, data: Record<string, string>): void {
+ const state = this.states.get(name);
+ if (!state) return;
+
+ // Merge incoming data over existing data
+ state.data = { ...state.data, ...data };
+ this.redrawPanel(state);
+ }
+
+ // ── Public: pulse emissive (brightness flash from external events) ──────────
+ pulsePanel(name: string, intensity: number): void {
+ // With MeshBasicMaterial there's no emissive, so we modulate opacity instead
+ const panel = this.panels.get(name);
+ if (!panel) return;
+ const mat = panel.material as THREE.MeshBasicMaterial;
+ // Map intensity to opacity range [0.70, 1.0]
+ mat.opacity = 0.70 + Math.min(intensity, 1.0) * 0.30;
+ mat.needsUpdate = true;
+
+ // Also briefly brighten the header bar — find it by name convention
+ const bar = this.group.children.find(
+ c => c instanceof THREE.Mesh && c.name === `bar-${name}`
+ ) as THREE.Mesh | undefined;
+ if (bar) {
+ const bMat = bar.material as THREE.MeshStandardMaterial;
+ bMat.emissiveIntensity = 0.5 + intensity * 0.8;
+ }
+ }
+
+ // ── Public: main animate loop ───────────────────────────────────────────────
+ animate(time: number): void {
+ this.states.forEach((state) => {
+ // Subtle hover — different phase per panel so they move independently
+ const phase = this.hashName(state.def.name);
+ state.mesh.position.y = state.baseY + Math.sin(time * 0.0008 + phase) * 0.03;
+
+ // Advance scanline sweep
+ const progress = (time % SWEEP_PERIOD_MS) / SWEEP_PERIOD_MS;
+ state.sweepY = Math.floor(progress * CANVAS_H);
+
+ // Redraw canvas with updated sweep position
+ this.redrawPanel(state);
+ });
+ }
+
+ // ── Internal redraw ──────────────────────────────────────────────────────────
+ private redrawPanel(state: PanelState): void {
+ drawPanelCanvas(state.ctx, state.def, state.data, state.sweepY);
+ state.texture.needsUpdate = true;
+ }
+
+ // ── Utility ──────────────────────────────────────────────────────────────────
+ private hashName(name: string): number {
+ let h = 0;
+ for (let i = 0; i < name.length; i++) h += name.charCodeAt(i);
+ return h;
+ }
+}
diff --git a/src/scene/Environment.ts b/src/scene/Environment.ts
new file mode 100644
index 0000000..cd7f338
--- /dev/null
+++ b/src/scene/Environment.ts
@@ -0,0 +1,183 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Room Environment
+// Architectural shell: floor grid, pillars, ceiling, walls
+// ═══════════════════════════════════════════════
+
+import * as THREE from 'three';
+
+export function createEnvironment(scene: THREE.Scene): void {
+ addFloorWithGrid(scene);
+ addPillars(scene);
+ addCeiling(scene);
+ addWallSegments(scene);
+}
+
+// ─── Floor + Grid ───────────────────────────────
+function addFloorWithGrid(scene: THREE.Scene): void {
+ // Base floor plane — industrial concrete/polished stone (not pure metallic)
+ const floorGeo = new THREE.PlaneGeometry(60, 60);
+ const floorMat = new THREE.MeshStandardMaterial({
+ color: 0x0b0f14,
+ metalness: 0.2,
+ roughness: 0.65,
+ });
+ const floor = new THREE.Mesh(floorGeo, floorMat);
+ floor.rotation.x = -Math.PI / 2;
+ floor.position.y = -0.01;
+ floor.receiveShadow = true;
+ scene.add(floor);
+
+ // Grid overlay — subtle cool-grey lines, not glowing neon
+ const grid = new THREE.GridHelper(60, 40, 0x1a2530, 0x0d1520);
+ grid.position.y = 0.01;
+ scene.add(grid);
+}
+
+// ─── Octagonal Pillar Ring ───────────────────────
+function addPillars(scene: THREE.Scene): void {
+ const COUNT = 8;
+ const RADIUS = 22;
+ const PILLAR_HEIGHT = 10;
+
+ // Brushed aluminum pillar — high metalness, low roughness
+ const pillarMat = new THREE.MeshStandardMaterial({
+ color: 0x141c24,
+ emissive: new THREE.Color(0x0a1018),
+ emissiveIntensity: 0.1,
+ metalness: 0.8,
+ roughness: 0.2,
+ });
+
+ // Warm gold torus rings at base and top — corporate accent, not neon
+ const ringMat = new THREE.MeshStandardMaterial({
+ color: 0xb8860b,
+ emissive: new THREE.Color(0xc8900c),
+ emissiveIntensity: 0.6,
+ metalness: 0.7,
+ roughness: 0.3,
+ transparent: true,
+ opacity: 0.55,
+ });
+
+ for (let i = 0; i < COUNT; i++) {
+ const angle = (i / COUNT) * Math.PI * 2;
+ const x = Math.cos(angle) * RADIUS;
+ const z = Math.sin(angle) * RADIUS;
+
+ // Main pillar body — octagonal cross-section (8 radial segments)
+ const pillarGeo = new THREE.CylinderGeometry(0.3, 0.3, PILLAR_HEIGHT, 8);
+ const pillar = new THREE.Mesh(pillarGeo, pillarMat);
+ pillar.position.set(x, PILLAR_HEIGHT / 2, z);
+ scene.add(pillar);
+
+ // Base ring
+ const baseRingGeo = new THREE.TorusGeometry(0.55, 0.06, 8, 24);
+ const baseRing = new THREE.Mesh(baseRingGeo, ringMat);
+ baseRing.rotation.x = Math.PI / 2;
+ baseRing.position.set(x, 0.12, z);
+ scene.add(baseRing);
+
+ // Top ring
+ const topRingGeo = new THREE.TorusGeometry(0.55, 0.06, 8, 24);
+ const topRing = new THREE.Mesh(topRingGeo, ringMat);
+ topRing.rotation.x = Math.PI / 2;
+ topRing.position.set(x, PILLAR_HEIGHT - 0.12, z);
+ scene.add(topRing);
+ }
+}
+
+// ─── Ceiling + Recessed Light Slots ─────────────
+function addCeiling(scene: THREE.Scene): void {
+ const CEILING_Y = 10;
+
+ // Ceiling plane — matte dark panel (not metallic — ceilings are typically matte)
+ const ceilGeo = new THREE.PlaneGeometry(50, 50);
+ const ceilMat = new THREE.MeshStandardMaterial({
+ color: 0x090d12,
+ metalness: 0.0,
+ roughness: 0.85,
+ transparent: true,
+ opacity: 0.45,
+ side: THREE.BackSide, // render from below
+ });
+ const ceiling = new THREE.Mesh(ceilGeo, ceilMat);
+ ceiling.rotation.x = Math.PI / 2;
+ ceiling.position.y = CEILING_Y;
+ scene.add(ceiling);
+
+ // 4 recessed light slots — warm white architectural lighting, not neon blue
+ const slotMat = new THREE.MeshStandardMaterial({
+ color: 0xe8e4d8,
+ emissive: new THREE.Color(0xd4c090),
+ emissiveIntensity: 0.7,
+ metalness: 0.1,
+ roughness: 0.5,
+ transparent: true,
+ opacity: 0.75,
+ });
+
+ const slotPositions: [number, number][] = [
+ [-8, -8],
+ [8, -8],
+ [-8, 8],
+ [8, 8],
+ ];
+
+ slotPositions.forEach(([sx, sz]) => {
+ const slotGeo = new THREE.BoxGeometry(6, 0.05, 0.4);
+ const slot = new THREE.Mesh(slotGeo, slotMat);
+ slot.position.set(sx, CEILING_Y - 0.03, sz);
+ scene.add(slot);
+ });
+}
+
+// ─── Wall Segments ───────────────────────────────
+function addWallSegments(scene: THREE.Scene): void {
+ const WALL_HEIGHT = 10;
+ const WALL_LENGTH = 50;
+ const OFFSET = 25;
+
+ // Matte architectural walls — zero metalness, high roughness (concrete/gypsum)
+ const wallMat = new THREE.MeshStandardMaterial({
+ color: 0x0a0e14,
+ emissive: new THREE.Color(0x080c10),
+ emissiveIntensity: 0.05,
+ metalness: 0.0,
+ roughness: 0.75,
+ transparent: true,
+ opacity: 0.6,
+ side: THREE.DoubleSide,
+ });
+
+ // Wall configs: [x, z, rotationY]
+ const walls: [number, number, number][] = [
+ [0, -OFFSET, 0], // back (+Z face)
+ [0, OFFSET, Math.PI], // front (-Z face)
+ [-OFFSET, 0, Math.PI / 2], // left
+ [ OFFSET, 0, -Math.PI / 2], // right
+ ];
+
+ walls.forEach(([wx, wz, ry]) => {
+ const wallGeo = new THREE.PlaneGeometry(WALL_LENGTH, WALL_HEIGHT);
+ const wall = new THREE.Mesh(wallGeo, wallMat);
+ wall.position.set(wx, WALL_HEIGHT / 2, wz);
+ wall.rotation.y = ry;
+ scene.add(wall);
+
+ // Warm gold edge trim — architectural base accent, not neon purple
+ const trimMat = new THREE.MeshStandardMaterial({
+ color: 0xa07828,
+ emissive: new THREE.Color(0xb08030),
+ emissiveIntensity: 0.4,
+ metalness: 0.6,
+ roughness: 0.4,
+ transparent: true,
+ opacity: 0.45,
+ });
+ const trimGeo = new THREE.BoxGeometry(WALL_LENGTH, 0.06, 0.08);
+ const trim = new THREE.Mesh(trimGeo, trimMat);
+ trim.position.set(wx, 0.06, wz);
+ trim.rotation.y = ry;
+ scene.add(trim);
+ });
+}
diff --git a/src/scene/Lighting.ts b/src/scene/Lighting.ts
new file mode 100644
index 0000000..f061c68
--- /dev/null
+++ b/src/scene/Lighting.ts
@@ -0,0 +1,60 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Lighting Setup
+// Sci-fi ambient with purple accents
+// ═══════════════════════════════════════════════
+
+import * as THREE from 'three';
+
+export function createLighting(scene: THREE.Scene): void {
+ // Ambient — cool neutral base (lifted from dark purple to architectural neutral)
+ const ambient = new THREE.AmbientLight(0x223344, 0.6);
+ scene.add(ambient);
+
+ // Primary directional — cool white key light with shadows
+ const mainLight = new THREE.DirectionalLight(0xddeeff, 1.2);
+ mainLight.position.set(8, 25, 12);
+ mainLight.castShadow = true;
+ mainLight.shadow.mapSize.width = 2048;
+ mainLight.shadow.mapSize.height = 2048;
+ mainLight.shadow.camera.near = 0.5;
+ mainLight.shadow.camera.far = 80;
+ mainLight.shadow.camera.left = -30;
+ mainLight.shadow.camera.right = 30;
+ mainLight.shadow.camera.top = 30;
+ mainLight.shadow.camera.bottom = -30;
+ mainLight.shadow.bias = -0.0005;
+ scene.add(mainLight);
+
+ // Secondary fill — warm gold from front-right (corporate warmth)
+ const fillLight = new THREE.DirectionalLight(0xffd580, 0.35);
+ fillLight.position.set(15, 10, 15);
+ scene.add(fillLight);
+
+ // Cool white accent from left (clean industrial)
+ const leftAccent = new THREE.PointLight(0xc8d8f0, 0.5, 55);
+ leftAccent.position.set(-15, 8, 0);
+ scene.add(leftAccent);
+
+ // Warm gold accent from right
+ const rightAccent = new THREE.PointLight(0xffa040, 0.3, 55);
+ rightAccent.position.set(15, 8, 0);
+ scene.add(rightAccent);
+
+ // Red warning light (for escalations) — starts dim
+ const redLight = new THREE.PointLight(0xef4444, 0.0, 30);
+ redLight.position.set(0, 12, -10);
+ redLight.name = 'escalation-light';
+ scene.add(redLight);
+
+ // Subtle uplight from floor — bounced ambient warmth
+ const uplift = new THREE.PointLight(0x334455, 0.2, 20);
+ uplift.position.set(0, 0.5, 0);
+ scene.add(uplift);
+}
+
+export function pulseEscalationLight(scene: THREE.Scene, intensity: number): void {
+ const light = scene.getObjectByName('escalation-light') as THREE.PointLight;
+ if (light) {
+ light.intensity = intensity;
+ }
+}
diff --git a/src/scene/WarRoom.ts b/src/scene/WarRoom.ts
new file mode 100644
index 0000000..0386413
--- /dev/null
+++ b/src/scene/WarRoom.ts
@@ -0,0 +1,198 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Main Scene Composition
+// Assembles all scene components + raycasting
+// ═══════════════════════════════════════════════
+
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { createLighting, pulseEscalationLight } from './Lighting';
+import { createCommandTable, CommandTableHandle } from './CommandTable';
+import { AgentNodeManager } from './AgentNodes';
+import { DataWalls } from './DataWalls';
+import { PostProcessingPipeline } from '../effects/PostProcessing';
+import { ParticleSystem } from '../effects/Particles';
+import { createEnvironment } from './Environment';
+import type { AgentNode } from '../types';
+
+export class WarRoom {
+ scene: THREE.Scene;
+ camera: THREE.PerspectiveCamera;
+ renderer: THREE.WebGLRenderer;
+ controls: OrbitControls;
+ agentNodes: AgentNodeManager;
+ dataWalls: DataWalls;
+
+ private clock: THREE.Clock;
+ private postProcessing: PostProcessingPipeline;
+ private particles: ParticleSystem;
+ private commandTable: CommandTableHandle;
+ private raycaster: THREE.Raycaster;
+ private mouse: THREE.Vector2;
+
+ // Callback for agent click
+ onAgentClick: ((agent: AgentNode, screenX: number, screenY: number) => void) | null = null;
+
+ constructor(container: HTMLElement) {
+ // Scene
+ this.scene = new THREE.Scene();
+ this.scene.background = new THREE.Color(0x050510);
+ this.scene.fog = new THREE.Fog(0x050510, 25, 80);
+
+ // Camera
+ this.camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 100);
+ this.camera.position.set(0, 12, 18);
+ this.camera.lookAt(0, 2, 0);
+
+ // Renderer — physically-based rendering pipeline
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ this.renderer.setSize(container.clientWidth, container.clientHeight);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ this.renderer.toneMappingExposure = 1.2;
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
+ this.renderer.useLegacyLights = false;
+ this.renderer.shadowMap.enabled = true;
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ container.appendChild(this.renderer.domElement);
+
+ // Controls
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.05;
+ this.controls.minDistance = 5;
+ this.controls.maxDistance = 40;
+ this.controls.maxPolarAngle = Math.PI / 2.1;
+
+ // Clock
+ this.clock = new THREE.Clock();
+
+ // Raycasting
+ this.raycaster = new THREE.Raycaster();
+ this.mouse = new THREE.Vector2();
+
+ // Build scene
+ createLighting(this.scene);
+ this.commandTable = createCommandTable(this.scene);
+ this.agentNodes = new AgentNodeManager(this.scene);
+ this.dataWalls = new DataWalls(this.scene);
+ createEnvironment(this.scene);
+ this.addEnvMap();
+ this.addStarfield();
+
+ // Particle system
+ this.particles = new ParticleSystem(this.scene);
+
+ // Post-processing
+ this.postProcessing = new PostProcessingPipeline(this.renderer, this.scene, this.camera);
+
+ // Resize handling
+ window.addEventListener('resize', () => this.onResize(container));
+
+ // Click handler for agent raycasting
+ this.renderer.domElement.addEventListener('click', (e) => this.handleClick(e));
+ }
+
+ private addEnvMap(): void {
+ // Generate a neutral PMREM environment from a simple gradient scene
+ // Gives all MeshStandardMaterial surfaces ambient IBL reflections
+ const pmrem = new THREE.PMREMGenerator(this.renderer);
+ pmrem.compileEquirectangularShader();
+
+ // Build a tiny gradient sky scene to bake into the env map
+ const envScene = new THREE.Scene();
+ // Cool dark-blue top gradient, warm dark base — corporate command center tone
+ const skyGeo = new THREE.SphereGeometry(50, 16, 8);
+ const skyMat = new THREE.MeshBasicMaterial({
+ color: 0x0a1520,
+ side: THREE.BackSide,
+ });
+ envScene.add(new THREE.Mesh(skyGeo, skyMat));
+
+ // Add a soft white highlight for IBL top reflection
+ const capGeo = new THREE.SphereGeometry(40, 8, 4, 0, Math.PI * 2, 0, Math.PI * 0.25);
+ const capMat = new THREE.MeshBasicMaterial({ color: 0x4488aa, side: THREE.BackSide });
+ const cap = new THREE.Mesh(capGeo, capMat);
+ cap.rotation.x = Math.PI;
+ envScene.add(cap);
+
+ const envRenderTarget = pmrem.fromScene(envScene as any, 0.04);
+ this.renderer.environment = envRenderTarget.texture;
+ pmrem.dispose();
+ skyMat.dispose();
+ capMat.dispose();
+ }
+
+ private addStarfield(): void {
+ const starGeo = new THREE.BufferGeometry();
+ const positions = new Float32Array(1500);
+ for (let i = 0; i < 1500; i += 3) {
+ positions[i] = (Math.random() - 0.5) * 80;
+ positions[i + 1] = Math.random() * 30 + 10;
+ positions[i + 2] = (Math.random() - 0.5) * 80;
+ }
+ starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ // Neutral cool-white stars — no neon purple
+ const starMat = new THREE.PointsMaterial({ color: 0xc8ddf0, size: 0.05, transparent: true, opacity: 0.5 });
+ const stars = new THREE.Points(starGeo, starMat);
+ this.scene.add(stars);
+ }
+
+ private handleClick(event: MouseEvent): void {
+ const rect = this.renderer.domElement.getBoundingClientRect();
+ this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
+ this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
+
+ this.raycaster.setFromCamera(this.mouse, this.camera);
+ const meshes = this.agentNodes.getMeshes();
+ const intersects = this.raycaster.intersectObjects(meshes);
+
+ if (intersects.length > 0) {
+ const agent = this.agentNodes.getNodeByMesh(intersects[0].object);
+ if (agent && this.onAgentClick) {
+ this.onAgentClick(agent, event.clientX, event.clientY);
+ }
+ }
+ }
+
+ setEscalationPulse(active: boolean): void {
+ const intensity = active ? 0.8 : 0.0;
+ pulseEscalationLight(this.scene, intensity);
+ this.dataWalls.pulsePanel('escalations', active ? 0.2 : 0.05);
+ }
+
+ animate(): void {
+ requestAnimationFrame(() => this.animate());
+ const elapsed = this.clock.getElapsedTime() * 1000;
+ this.controls.update();
+ this.commandTable.animate(elapsed);
+ this.agentNodes.animate(elapsed);
+ this.dataWalls.animate(elapsed);
+ this.particles.animate(elapsed);
+ this.postProcessing.render(this.scene, this.camera);
+ }
+
+ private onResize(container: HTMLElement): void {
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(w, h);
+ this.postProcessing.resize(w, h);
+ }
+
+ // Preset camera views
+ viewOverview(): void {
+ this.camera.position.set(0, 12, 18);
+ this.camera.lookAt(0, 2, 0);
+ }
+
+ viewMeetingFocus(): void {
+ this.camera.position.set(0, 8, 10);
+ this.camera.lookAt(0, 3, 0);
+ }
+
+ viewAgentFocus(): void {
+ this.camera.position.set(5, 6, 8);
+ this.camera.lookAt(0, 3, 0);
+ }
+}
diff --git a/src/showroom/FigmaTransformer.ts b/src/showroom/FigmaTransformer.ts
new file mode 100644
index 0000000..174e684
--- /dev/null
+++ b/src/showroom/FigmaTransformer.ts
@@ -0,0 +1,128 @@
+// FigmaTransformer.ts — Converts Figma design context into Three.js scene graph config
+// Maps Figma frames → positions, rectangles → geometry, fills → materials
+import type { SceneConfig, WingConfig, WallConfig, LightConfig, FurnitureConfig } from './types';
+
+/**
+ * Transform Figma design JSON into Three.js scene configuration.
+ * Accepts output from Figma MCP `get_design_context` or raw Figma REST API.
+ */
+export function transformFigmaToScene(figmaData: any): SceneConfig {
+ const root = figmaData.document || figmaData;
+ const config: SceneConfig = {
+ corridor: { width: 40, depth: 20, height: 12, wallMaterial: 'brick', floorMaterial: 'polished-concrete' },
+ wings: [],
+ walls: [],
+ lights: [],
+ furniture: [],
+ entry: { position: [0, 0, 10], signText: 'DESIGNER WALLCOVERINGS' },
+ };
+
+ // Traverse Figma node tree
+ function traverse(node: any, depth = 0) {
+ if (!node) return;
+
+ // Map FRAME nodes to wings or rooms based on name
+ if (node.type === 'FRAME' && node.name?.toLowerCase().includes('wing')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.wings.push({
+ index: config.wings.length,
+ vendor: node.name.replace(/wing[-_\s]*/i, '').trim(),
+ position: [bbox.x / 10 || config.wings.length * 4, 0, bbox.y / 10 || 0],
+ rotation: 0,
+ panelCount: (node.children || []).filter((c: any) => c.type === 'RECTANGLE').length || 10,
+ accentColor: extractFillColor(node) || '#c0c0c0',
+ });
+ }
+
+ // Map RECTANGLE nodes with IMAGE fills to wall sample panels
+ if (node.type === 'RECTANGLE' && node.fills?.some((f: any) => f.type === 'IMAGE')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.walls.push({
+ position: [bbox.x / 10 || 0, bbox.height / 20 || 2, bbox.y / 10 || 0],
+ dimensions: [bbox.width / 10 || 4, bbox.height / 10 || 8],
+ material: 'brick',
+ hasBookshelf: false,
+ hasSamplePanels: true,
+ sampleCount: 1,
+ });
+ }
+
+ // Map circles/ellipses to spotlights
+ if (node.type === 'ELLIPSE' && node.name?.toLowerCase().includes('light')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.lights.push({
+ type: 'spot',
+ position: [bbox.x / 10 || 0, 10, bbox.y / 10 || 0],
+ target: [bbox.x / 10 || 0, 0, bbox.y / 10 || 0],
+ color: extractFillColor(node) || '#ffffff',
+ intensity: 1.5,
+ angle: Math.PI / 6,
+ penumbra: 0.5,
+ });
+ }
+
+ // Map FRAME named 'furniture' or 'table' to furniture
+ if (node.type === 'FRAME' && /table|desk|furniture/i.test(node.name || '')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.furniture.push({
+ type: 'display-table',
+ position: [bbox.x / 10 || 0, 0.45, bbox.y / 10 || 0],
+ dimensions: [bbox.width / 10 || 3, 0.9, (bbox.height || 150) / 10],
+ });
+ }
+
+ // Map FRAME named 'entry' or 'entrance' to entry
+ if (node.type === 'FRAME' && /entry|entrance|door/i.test(node.name || '')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.entry.position = [bbox.x / 10 || 0, 0, bbox.y / 10 || 10];
+ // Look for text child as sign text
+ const textChild = (node.children || []).find((c: any) => c.type === 'TEXT');
+ if (textChild?.characters) {
+ config.entry.signText = textChild.characters;
+ }
+ }
+
+ // Map RECTANGLE with SOLID fills named 'bookshelf' or 'shelf'
+ if (node.type === 'RECTANGLE' && /bookshelf|shelf|book/i.test(node.name || '')) {
+ const bbox = node.absoluteBoundingBox || {};
+ config.walls.push({
+ position: [bbox.x / 10 || 0, bbox.height / 20 || 2, bbox.y / 10 || 0],
+ dimensions: [bbox.width / 10 || 4, bbox.height / 10 || 6],
+ material: 'wood',
+ hasBookshelf: true,
+ hasSamplePanels: false,
+ sampleCount: 0,
+ });
+ }
+
+ if (node.children) node.children.forEach((c: any) => traverse(c, depth + 1));
+ }
+
+ traverse(root);
+
+ // Add default track lighting if none extracted
+ if (config.lights.length === 0) {
+ for (let i = 0; i < 8; i++) {
+ config.lights.push({
+ type: 'spot',
+ position: [i * 5 - 17.5, 11, 0],
+ target: [i * 5 - 17.5, 0, 0],
+ color: '#f5e6d0',
+ intensity: 1.2,
+ angle: Math.PI / 5,
+ penumbra: 0.4,
+ });
+ }
+ }
+
+ return config;
+}
+
+function extractFillColor(node: any): string | null {
+ const fill = (node.fills || []).find((f: any) => f.type === 'SOLID' && f.visible !== false);
+ if (!fill?.color) return null;
+ const r = Math.round(fill.color.r * 255);
+ const g = Math.round(fill.color.g * 255);
+ const b = Math.round(fill.color.b * 255);
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
+}
diff --git a/src/showroom/ProceduralMaps.ts b/src/showroom/ProceduralMaps.ts
new file mode 100644
index 0000000..ec475bc
--- /dev/null
+++ b/src/showroom/ProceduralMaps.ts
@@ -0,0 +1,188 @@
+// ProceduralMaps.ts — Canvas-based procedural normal and roughness maps
+// Zero external file dependencies — all generated at runtime
+import * as THREE from 'three';
+
+/**
+ * Generate a brick wall normal map using canvas.
+ * Creates realistic mortar lines and brick surface variation.
+ */
+export function generateBrickNormalMap(width = 1024, height = 1024): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Failed to create 2d canvas context');
+
+ // Base neutral normal (pointing straight out: RGB 128,128,255)
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ const brickW = width / 8;
+ const brickH = height / 16;
+ const mortarW = 3;
+
+ for (let row = 0; row < 16; row++) {
+ const offset = (row % 2) * (brickW / 2); // stagger rows
+ for (let col = -1; col < 9; col++) {
+ const x = col * brickW + offset;
+ const y = row * brickH;
+
+ // Mortar lines (recessed = normal pointing inward)
+ // Horizontal mortar
+ ctx.fillStyle = 'rgb(128, 100, 255)'; // slight downward normal
+ ctx.fillRect(0, y, width, mortarW);
+
+ // Vertical mortar
+ ctx.fillStyle = 'rgb(100, 128, 255)'; // slight leftward normal
+ ctx.fillRect(x, y, mortarW, brickH);
+
+ // Brick surface noise (subtle variation)
+ for (let i = 0; i < 20; i++) {
+ const nx = x + Math.random() * brickW;
+ const ny = y + mortarW + Math.random() * (brickH - mortarW * 2);
+ const r = 128 + (Math.random() - 0.5) * 12;
+ const g = 128 + (Math.random() - 0.5) * 12;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(nx, ny, 3 + Math.random() * 6, 2 + Math.random() * 4);
+ }
+ }
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.repeat.set(2, 2);
+ return tex;
+}
+
+/**
+ * Generate a polished concrete normal map.
+ * Subtle micro-texture with occasional aggregate patches.
+ */
+export function generateConcreteNormalMap(width = 1024, height = 1024): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Failed to create 2d canvas context');
+
+ // Base neutral normal
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Micro-texture noise — polished concrete has subtle surface variation
+ for (let i = 0; i < 5000; i++) {
+ const x = Math.random() * width;
+ const y = Math.random() * height;
+ const r = 128 + (Math.random() - 0.5) * 6;
+ const g = 128 + (Math.random() - 0.5) * 6;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(x, y, 1 + Math.random() * 3, 1 + Math.random() * 3);
+ }
+
+ // Aggregate patches (larger bumps)
+ for (let i = 0; i < 80; i++) {
+ const x = Math.random() * width;
+ const y = Math.random() * height;
+ const radius = 4 + Math.random() * 12;
+ const r = 128 + (Math.random() - 0.5) * 18;
+ const g = 128 + (Math.random() - 0.5) * 18;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fill();
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.repeat.set(3, 3);
+ return tex;
+}
+
+/**
+ * Generate a brushed aluminum normal map.
+ * Directional brushing lines for metallic surfaces.
+ */
+export function generateBrushedAluminumNormalMap(width = 512, height = 512): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Failed to create 2d canvas context');
+
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Horizontal brush strokes
+ for (let y = 0; y < height; y++) {
+ const r = 128 + (Math.random() - 0.5) * 4;
+ const g = 128 + Math.sin(y * 0.3) * 3 + (Math.random() - 0.5) * 2;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(0, y, width, 1);
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ return tex;
+}
+
+/**
+ * Generate a wood grain normal map for shelving.
+ */
+export function generateWoodNormalMap(width = 512, height = 512): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Failed to create 2d canvas context');
+
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Wood grain lines (vertical, slightly wavy)
+ for (let x = 0; x < width; x += 4 + Math.random() * 8) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ for (let y = 0; y < height; y += 10) {
+ ctx.lineTo(x + Math.sin(y * 0.02 + x * 0.1) * 3, y);
+ }
+ const r = 128 + (Math.random() - 0.5) * 10;
+ ctx.strokeStyle = `rgb(${r}, 128, 255)`;
+ ctx.lineWidth = 1 + Math.random() * 2;
+ ctx.stroke();
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ return tex;
+}
+
+/**
+ * Generate a fabric/paper subtle normal map for wallcovering product panels.
+ * Very subtle — just enough to catch light at grazing angles.
+ */
+export function generateFabricNormalMap(width = 256, height = 256): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Failed to create 2d canvas context');
+
+ ctx.fillStyle = 'rgb(128, 128, 255)';
+ ctx.fillRect(0, 0, width, height);
+
+ // Woven texture: alternating horizontal and vertical micro-lines
+ for (let y = 0; y < height; y += 2) {
+ for (let x = 0; x < width; x += 2) {
+ const horizontal = (y / 2) % 2 === 0;
+ const r = 128 + (horizontal ? 2 : -2) + (Math.random() - 0.5) * 3;
+ const g = 128 + (horizontal ? -2 : 2) + (Math.random() - 0.5) * 3;
+ ctx.fillStyle = `rgb(${r}, ${g}, 255)`;
+ ctx.fillRect(x, y, 2, 2);
+ }
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.repeat.set(4, 4);
+ return tex;
+}
diff --git a/src/showroom/SearchOverlay.ts b/src/showroom/SearchOverlay.ts
new file mode 100644
index 0000000..d5cda80
--- /dev/null
+++ b/src/showroom/SearchOverlay.ts
@@ -0,0 +1,732 @@
+// SearchOverlay.ts — Live Shopify product search overlay for the 3D showroom
+// Debounced search with glassmorphism dropdown matching the DW design system
+// Uses a server-side proxy at /api/shopify/search to keep credentials safe
+
+import * as THREE from 'three';
+import type { Showroom } from './Showroom';
+import { cropTexture } from './TextureCrop';
+
+interface ShopifyProduct {
+ id: number;
+ title: string;
+ vendor: string;
+ product_type: string;
+ handle: string;
+ images: { src: string }[];
+ variants: { price: string; sku: string }[];
+ tags: string;
+}
+
+interface SearchResult {
+ id: number;
+ title: string;
+ vendor: string;
+ productType: string;
+ handle: string;
+ imageUrl: string;
+ price: string;
+ sku: string;
+}
+
+export class SearchOverlay {
+ private container: HTMLElement;
+ private inputEl!: HTMLInputElement;
+ private resultsEl!: HTMLElement;
+ private wrapperEl!: HTMLElement;
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
+ private isOpen = false;
+ private results: SearchResult[] = [];
+ private selectedIndex = -1;
+ private showroom: Showroom | null = null;
+ private loadingEl!: HTMLElement;
+ private emptyEl!: HTMLElement;
+
+ constructor(parent: HTMLElement) {
+ this.container = parent;
+ this.buildDom();
+ this.applyStyles();
+ this.bindEvents();
+ }
+
+ /** Optionally wire to a Showroom instance for wall display */
+ setShowroom(showroom: Showroom): void {
+ this.showroom = showroom;
+ }
+
+ private buildDom(): void {
+ // Wrapper — top-center overlay
+ this.wrapperEl = document.createElement('div');
+ this.wrapperEl.id = 'search-overlay';
+ this.wrapperEl.className = 'search-overlay';
+
+ // Search input group
+ const inputGroup = document.createElement('div');
+ inputGroup.className = 'search-input-group';
+
+ // Search icon
+ const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ iconSvg.setAttribute('width', '16');
+ iconSvg.setAttribute('height', '16');
+ iconSvg.setAttribute('viewBox', '0 0 24 24');
+ iconSvg.setAttribute('fill', 'none');
+ iconSvg.setAttribute('stroke', 'currentColor');
+ iconSvg.setAttribute('stroke-width', '2');
+ iconSvg.setAttribute('class', 'search-icon');
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ circle.setAttribute('cx', '11');
+ circle.setAttribute('cy', '11');
+ circle.setAttribute('r', '8');
+ iconSvg.appendChild(circle);
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+ line.setAttribute('x1', '21');
+ line.setAttribute('y1', '21');
+ line.setAttribute('x2', '16.65');
+ line.setAttribute('y2', '16.65');
+ iconSvg.appendChild(line);
+ inputGroup.appendChild(iconSvg);
+
+ // Input
+ this.inputEl = document.createElement('input');
+ this.inputEl.type = 'text';
+ this.inputEl.className = 'search-input';
+ this.inputEl.placeholder = 'Search products...';
+ this.inputEl.autocomplete = 'off';
+ this.inputEl.spellcheck = false;
+ inputGroup.appendChild(this.inputEl);
+
+ // Keyboard shortcut hint
+ const shortcut = document.createElement('span');
+ shortcut.className = 'search-shortcut';
+ shortcut.textContent = '/';
+ inputGroup.appendChild(shortcut);
+
+ this.wrapperEl.appendChild(inputGroup);
+
+ // Results dropdown
+ this.resultsEl = document.createElement('div');
+ this.resultsEl.className = 'search-results';
+ this.resultsEl.style.display = 'none';
+
+ // Loading indicator
+ this.loadingEl = document.createElement('div');
+ this.loadingEl.className = 'search-loading';
+ this.loadingEl.textContent = 'Searching...';
+ this.loadingEl.style.display = 'none';
+ this.resultsEl.appendChild(this.loadingEl);
+
+ // Empty state
+ this.emptyEl = document.createElement('div');
+ this.emptyEl.className = 'search-empty';
+ this.emptyEl.textContent = 'No products found';
+ this.emptyEl.style.display = 'none';
+ this.resultsEl.appendChild(this.emptyEl);
+
+ this.wrapperEl.appendChild(this.resultsEl);
+ this.container.appendChild(this.wrapperEl);
+ }
+
+ private bindEvents(): void {
+ // Input events
+ this.inputEl.addEventListener('input', () => this.onInputChange());
+ this.inputEl.addEventListener('focus', () => this.onFocus());
+ this.inputEl.addEventListener('keydown', (e) => this.onKeyDown(e));
+
+ // Close on outside click
+ document.addEventListener('click', (e) => {
+ if (!this.wrapperEl.contains(e.target as Node)) {
+ this.closeResults();
+ }
+ });
+
+ // Keyboard shortcut: "/" to focus search
+ document.addEventListener('keydown', (e) => {
+ if (e.key === '/' && document.activeElement !== this.inputEl) {
+ e.preventDefault();
+ this.inputEl.focus();
+ }
+ if (e.key === 'Escape' && document.activeElement === this.inputEl) {
+ this.inputEl.blur();
+ this.closeResults();
+ }
+ });
+ }
+
+ private onInputChange(): void {
+ const query = this.inputEl.value.trim();
+
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
+
+ if (query.length < 2) {
+ this.closeResults();
+ return;
+ }
+
+ this.showLoading();
+
+ this.debounceTimer = setTimeout(() => {
+ this.searchShopify(query);
+ }, 300);
+ }
+
+ private onFocus(): void {
+ if (this.results.length > 0) {
+ this.openResults();
+ }
+ }
+
+ private onKeyDown(e: KeyboardEvent): void {
+ if (!this.isOpen) return;
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
+ this.highlightResult();
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
+ this.highlightResult();
+ break;
+ case 'Enter':
+ e.preventDefault();
+ if (this.selectedIndex >= 0 && this.selectedIndex < this.results.length) {
+ this.selectResult(this.results[this.selectedIndex]);
+ }
+ break;
+ case 'Escape':
+ this.closeResults();
+ break;
+ }
+ }
+
+ private async searchShopify(query: string): Promise<void> {
+ try {
+ const res = await fetch(`/api/shopify/search?q=${encodeURIComponent(query)}&limit=10`);
+ if (!res.ok) throw new Error(`${res.status}`);
+ const data = await res.json();
+
+ const products: ShopifyProduct[] = data.products || [];
+ this.results = products.map(p => ({
+ id: p.id,
+ title: p.title,
+ vendor: p.vendor,
+ productType: p.product_type,
+ handle: p.handle,
+ imageUrl: p.images?.[0]?.src || '',
+ price: p.variants?.[0]?.price || '0.00',
+ sku: p.variants?.[0]?.sku || '',
+ }));
+
+ this.renderResults();
+ } catch (err) {
+ console.error('[SearchOverlay] Search failed:', err);
+ this.results = [];
+ this.showEmpty('Search unavailable');
+ }
+ }
+
+ private renderResults(): void {
+ // Clear previous result items (keep loading/empty elements)
+ const items = this.resultsEl.querySelectorAll('.search-result-item');
+ items.forEach(item => item.remove());
+
+ this.loadingEl.style.display = 'none';
+
+ if (this.results.length === 0) {
+ this.showEmpty('No products found');
+ return;
+ }
+
+ this.emptyEl.style.display = 'none';
+ this.selectedIndex = -1;
+
+ this.results.forEach((result, index) => {
+ const item = document.createElement('div');
+ item.className = 'search-result-item';
+ item.dataset.index = String(index);
+
+ // Product image
+ const imgWrap = document.createElement('div');
+ imgWrap.className = 'search-result-image';
+ if (result.imageUrl) {
+ const img = document.createElement('img');
+ // Use Shopify CDN resize for small thumbnails
+ img.src = result.imageUrl.replace(/\.([^.]+)$/, '_80x80.$1');
+ img.alt = result.title;
+ img.loading = 'lazy';
+ img.onerror = () => {
+ img.style.display = 'none';
+ imgWrap.classList.add('no-image');
+ };
+ imgWrap.appendChild(img);
+ } else {
+ imgWrap.classList.add('no-image');
+ }
+ item.appendChild(imgWrap);
+
+ // Product details
+ const details = document.createElement('div');
+ details.className = 'search-result-details';
+
+ const titleEl = document.createElement('div');
+ titleEl.className = 'search-result-title';
+ titleEl.textContent = result.title;
+ details.appendChild(titleEl);
+
+ const metaEl = document.createElement('div');
+ metaEl.className = 'search-result-meta';
+
+ const vendorSpan = document.createElement('span');
+ vendorSpan.className = 'search-result-vendor';
+ vendorSpan.textContent = result.vendor;
+ metaEl.appendChild(vendorSpan);
+
+ if (result.productType) {
+ const dot = document.createElement('span');
+ dot.className = 'search-result-dot';
+ dot.textContent = ' \u00b7 ';
+ metaEl.appendChild(dot);
+ const typeSpan = document.createElement('span');
+ typeSpan.textContent = result.productType;
+ metaEl.appendChild(typeSpan);
+ }
+
+ details.appendChild(metaEl);
+ item.appendChild(details);
+
+ // Price
+ const priceEl = document.createElement('div');
+ priceEl.className = 'search-result-price';
+ const priceVal = parseFloat(result.price);
+ priceEl.textContent = priceVal > 0 ? `$${priceVal.toFixed(2)}` : '';
+ item.appendChild(priceEl);
+
+ // Events
+ item.addEventListener('click', () => this.selectResult(result));
+ item.addEventListener('mouseenter', () => {
+ this.selectedIndex = index;
+ this.highlightResult();
+ });
+
+ this.resultsEl.appendChild(item);
+ });
+
+ this.openResults();
+ }
+
+ private highlightResult(): void {
+ const items = this.resultsEl.querySelectorAll('.search-result-item');
+ items.forEach((item, i) => {
+ (item as HTMLElement).classList.toggle('highlighted', i === this.selectedIndex);
+ });
+
+ // Scroll highlighted item into view
+ if (this.selectedIndex >= 0) {
+ const highlighted = items[this.selectedIndex] as HTMLElement;
+ if (highlighted) {
+ highlighted.scrollIntoView({ block: 'nearest' });
+ }
+ }
+ }
+
+ private selectResult(result: SearchResult): void {
+ console.log('[SearchOverlay] Selected product:', result.title, result.handle);
+ this.closeResults();
+ this.inputEl.value = '';
+
+ // Try to apply texture to a wall in the showroom
+ if (result.imageUrl && this.showroom) {
+ this.applyToShowroomWall(result);
+ } else {
+ // Fallback: open product on Shopify store
+ const url = `https://designer-laboratory-sandbox.myshopify.com/products/${result.handle}`;
+ window.open(url, '_blank');
+ }
+ }
+
+ private applyToShowroomWall(result: SearchResult): void {
+ if (!this.showroom) return;
+
+ this.showToast(`Loading "${result.title}" onto wall...`);
+
+ // Use higher resolution source, then crop to 512x768 (no stretching)
+ const textureUrl = result.imageUrl.replace(/\.([^.]+)$/, '_600x600.$1');
+
+ cropTexture(textureUrl)
+ .then((texture) => {
+ const scene = this.showroom!.scene;
+ let applied = false;
+
+ scene.traverse((child) => {
+ if (applied) return;
+ if (child instanceof THREE.Mesh && child.name === 'search-preview-wall') {
+ const mat = child.material as THREE.MeshStandardMaterial;
+ if (mat.map) mat.map.dispose();
+ mat.map = texture;
+ mat.needsUpdate = true;
+ applied = true;
+ }
+ });
+
+ if (!applied) {
+ const camera = this.showroom!.camera;
+ const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
+ const wallPos = camera.position.clone().add(dir.multiplyScalar(5));
+ wallPos.y = 2.5;
+
+ // PlaneGeometry matches the 2:3 crop aspect (3 wide x 4.5 tall)
+ const geo = new THREE.PlaneGeometry(3, 4.5);
+ const mat = new THREE.MeshStandardMaterial({
+ map: texture,
+ roughness: 0.4,
+ metalness: 0.0,
+ envMapIntensity: 0.5,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.name = 'search-preview-wall';
+ mesh.position.copy(wallPos);
+ mesh.lookAt(camera.position);
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ scene.add(mesh);
+
+ // Frame matches the 2:3 plane
+ const frameGeo = new THREE.BoxGeometry(3.15, 4.65, 0.05);
+ const frameMat = new THREE.MeshStandardMaterial({
+ color: 0x333333,
+ roughness: 0.3,
+ metalness: 0.4,
+ });
+ const frame = new THREE.Mesh(frameGeo, frameMat);
+ frame.name = 'search-preview-frame';
+ frame.position.copy(wallPos);
+ frame.position.z -= 0.03;
+ frame.lookAt(camera.position);
+ scene.add(frame);
+
+ // Label below
+ const labelCanvas = document.createElement('canvas');
+ labelCanvas.width = 512;
+ labelCanvas.height = 64;
+ const ctx = labelCanvas.getContext('2d');
+ if (ctx) {
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, 0, 512, 64);
+ ctx.fillStyle = '#f0ebe4';
+ ctx.font = '24px Inter, -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(result.title.substring(0, 40), 256, 28);
+ ctx.font = '14px Inter, -apple-system, sans-serif';
+ ctx.fillStyle = '#888888';
+ ctx.fillText(result.vendor, 256, 52);
+ }
+ const labelTex = new THREE.CanvasTexture(labelCanvas);
+ const labelGeo = new THREE.PlaneGeometry(2.5, 0.3);
+ const labelMat = new THREE.MeshBasicMaterial({ map: labelTex });
+ const label = new THREE.Mesh(labelGeo, labelMat);
+ label.name = 'search-preview-label';
+ label.position.copy(wallPos);
+ label.position.y -= 2.55;
+ label.lookAt(camera.position);
+ scene.add(label);
+ }
+
+ this.showToast(`"${result.title}" displayed on wall`);
+ })
+ .catch((err) => {
+ console.error('[SearchOverlay] Failed to crop texture:', err);
+ const url = `https://designer-laboratory-sandbox.myshopify.com/products/${result.handle}`;
+ window.open(url, '_blank');
+ });
+ }
+
+ private showToast(message: string): void {
+ // Remove existing toast
+ const existing = document.getElementById('search-toast');
+ if (existing) existing.remove();
+
+ const toast = document.createElement('div');
+ toast.id = 'search-toast';
+ toast.className = 'search-toast';
+ toast.textContent = message;
+ this.container.appendChild(toast);
+
+ // Auto-remove after 3 seconds
+ setTimeout(() => {
+ toast.classList.add('fade-out');
+ setTimeout(() => toast.remove(), 300);
+ }, 3000);
+ }
+
+ private showLoading(): void {
+ this.resultsEl.style.display = 'block';
+ this.isOpen = true;
+ this.loadingEl.style.display = 'block';
+ this.emptyEl.style.display = 'none';
+ // Remove existing result items
+ const items = this.resultsEl.querySelectorAll('.search-result-item');
+ items.forEach(item => item.remove());
+ }
+
+ private showEmpty(message: string): void {
+ this.resultsEl.style.display = 'block';
+ this.isOpen = true;
+ this.loadingEl.style.display = 'none';
+ this.emptyEl.style.display = 'block';
+ this.emptyEl.textContent = message;
+ }
+
+ private openResults(): void {
+ this.resultsEl.style.display = 'block';
+ this.isOpen = true;
+ }
+
+ private closeResults(): void {
+ this.resultsEl.style.display = 'none';
+ this.isOpen = false;
+ this.selectedIndex = -1;
+ }
+
+ private applyStyles(): void {
+ const style = document.createElement('style');
+ style.textContent = `
+ /* ===== Search Overlay ===== */
+ .search-overlay {
+ position: fixed;
+ top: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 100;
+ width: 420px;
+ max-width: calc(100vw - 32px);
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ }
+
+ /* ===== Input Group ===== */
+ .search-input-group {
+ position: relative;
+ display: flex;
+ align-items: center;
+ background: rgba(8, 11, 22, 0.88);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 10px;
+ padding: 0 14px;
+ transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
+ box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ }
+ .search-input-group:focus-within {
+ border-color: rgba(99, 102, 241, 0.4);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4), 0 0 20px rgba(99, 102, 241, 0.1);
+ }
+
+ .search-icon {
+ color: #64748b;
+ flex-shrink: 0;
+ margin-right: 10px;
+ transition: color 150ms;
+ }
+ .search-input-group:focus-within .search-icon {
+ color: #8b5cf6;
+ }
+
+ .search-input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: #f1f5f9;
+ font-size: 14px;
+ font-family: inherit;
+ font-weight: 500;
+ padding: 12px 0;
+ letter-spacing: 0.2px;
+ }
+ .search-input::placeholder {
+ color: #64748b;
+ font-weight: 400;
+ }
+
+ .search-shortcut {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ color: #64748b;
+ font-size: 11px;
+ font-weight: 600;
+ font-family: monospace;
+ margin-left: 8px;
+ }
+ .search-input-group:focus-within .search-shortcut {
+ display: none;
+ }
+
+ /* ===== Results Dropdown ===== */
+ .search-results {
+ margin-top: 6px;
+ background: rgba(8, 11, 22, 0.95);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid rgba(148, 163, 184, 0.1);
+ border-radius: 12px;
+ max-height: 420px;
+ overflow-y: auto;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.02) inset;
+ }
+ .search-results::-webkit-scrollbar { width: 4px; }
+ .search-results::-webkit-scrollbar-track { background: transparent; }
+ .search-results::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.15); border-radius: 2px; }
+
+ /* ===== Result Item ===== */
+ .search-result-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ cursor: pointer;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.06);
+ transition: background 100ms;
+ }
+ .search-result-item:last-child {
+ border-bottom: none;
+ }
+ .search-result-item:hover,
+ .search-result-item.highlighted {
+ background: rgba(99, 102, 241, 0.06);
+ }
+
+ /* ===== Product Image ===== */
+ .search-result-image {
+ width: 48px;
+ height: 48px;
+ border-radius: 6px;
+ overflow: hidden;
+ flex-shrink: 0;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(148, 163, 184, 0.08);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .search-result-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ .search-result-image.no-image::after {
+ content: '';
+ width: 20px;
+ height: 20px;
+ background: rgba(148, 163, 184, 0.15);
+ border-radius: 3px;
+ }
+
+ /* ===== Product Details ===== */
+ .search-result-details {
+ flex: 1;
+ min-width: 0;
+ }
+ .search-result-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #f1f5f9;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.3;
+ }
+ .search-result-meta {
+ font-size: 11px;
+ color: #64748b;
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .search-result-vendor {
+ color: #8b5cf6;
+ font-weight: 500;
+ }
+ .search-result-dot {
+ color: #475569;
+ }
+
+ /* ===== Price ===== */
+ .search-result-price {
+ flex-shrink: 0;
+ font-size: 13px;
+ font-weight: 700;
+ color: #06b6d4;
+ font-variant-numeric: tabular-nums;
+ }
+
+ /* ===== Loading / Empty ===== */
+ .search-loading,
+ .search-empty {
+ padding: 20px 14px;
+ text-align: center;
+ color: #64748b;
+ font-size: 13px;
+ }
+ .search-loading::before {
+ content: '';
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(148, 163, 184, 0.15);
+ border-top-color: #8b5cf6;
+ border-radius: 50%;
+ animation: searchSpin 0.6s linear infinite;
+ margin-right: 8px;
+ vertical-align: middle;
+ }
+ @keyframes searchSpin {
+ to { transform: rotate(360deg); }
+ }
+
+ /* ===== Toast Notification ===== */
+ .search-toast {
+ position: fixed;
+ bottom: 80px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 200;
+ background: rgba(8, 11, 22, 0.92);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ border-radius: 8px;
+ padding: 10px 20px;
+ color: #f1f5f9;
+ font-size: 13px;
+ font-family: 'Inter', -apple-system, sans-serif;
+ font-weight: 500;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);
+ animation: toastIn 250ms cubic-bezier(0.4, 0, 0.2, 1);
+ }
+ .search-toast.fade-out {
+ opacity: 0;
+ transform: translateX(-50%) translateY(10px);
+ transition: opacity 300ms, transform 300ms;
+ }
+ @keyframes toastIn {
+ from { opacity: 0; transform: translateX(-50%) translateY(10px); }
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ dispose(): void {
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
+ this.wrapperEl.remove();
+ }
+}
diff --git a/src/showroom/Showroom.ts b/src/showroom/Showroom.ts
new file mode 100644
index 0000000..7764082
--- /dev/null
+++ b/src/showroom/Showroom.ts
@@ -0,0 +1,206 @@
+// Showroom.ts — Main photorealistic showroom compositor
+// Ties together: FigmaTransformer + Materials + Environment + Layout + PostProcess
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { createMaterialLibrary, disposeMaterialLibrary } from './ShowroomMaterials';
+import { createShowroomEnvMap } from './ShowroomEnvironment';
+import { ShowroomLayout } from './ShowroomLayout';
+import { ShowroomPostProcess } from './ShowroomPostProcess';
+import { transformFigmaToScene } from './FigmaTransformer';
+import type { SceneConfig } from './types';
+import type { MaterialLibrary } from './ShowroomMaterials';
+
+export class Showroom {
+ scene: THREE.Scene;
+ camera: THREE.PerspectiveCamera;
+ renderer: THREE.WebGLRenderer;
+ controls: OrbitControls;
+
+ private clock: THREE.Clock;
+ private _elapsed = 0;
+ private _running = false;
+ private _resizeHandler: (() => void) | null = null;
+ private materials: MaterialLibrary;
+ private layout: ShowroomLayout;
+ private postProcess: ShowroomPostProcess;
+ private dustParticles: THREE.Points;
+
+ constructor(container: HTMLElement) {
+ // Scene
+ this.scene = new THREE.Scene();
+ this.scene.background = new THREE.Color(0x0d0c0a);
+ this.scene.fog = new THREE.Fog(0x0d0c0a, 20, 60);
+
+ // Camera — human eye level
+ this.camera = new THREE.PerspectiveCamera(
+ 55, container.clientWidth / container.clientHeight, 0.1, 100
+ );
+ this.camera.position.set(0, 1.7, 8);
+ this.camera.lookAt(0, 1.5, 0);
+
+ // Renderer — photorealistic pipeline
+ this.renderer = new THREE.WebGLRenderer({ antialias: true });
+ this.renderer.setSize(container.clientWidth, container.clientHeight);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ this.renderer.toneMappingExposure = 1.4;
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
+ this.renderer.shadowMap.enabled = true;
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ container.appendChild(this.renderer.domElement);
+
+ // Controls — walking speed
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.08;
+ this.controls.maxPolarAngle = Math.PI / 2;
+ this.controls.minDistance = 1;
+ this.controls.maxDistance = 50;
+
+ // Clock
+ this.clock = new THREE.Clock();
+
+ // Materials
+ this.materials = createMaterialLibrary();
+
+ // Environment map (warm gallery IBL)
+ this.scene.environment = createShowroomEnvMap(this.renderer);
+
+ // Ambient + key lighting
+ this.addBaseLighting();
+
+ // Layout builder
+ this.layout = new ShowroomLayout(this.scene, this.materials);
+
+ // Atmospheric dust particles
+ this.dustParticles = this.createDustMotes();
+ this.scene.add(this.dustParticles);
+
+ // Post-processing
+ this.postProcess = new ShowroomPostProcess(this.renderer, this.scene, this.camera);
+
+ // Resize — store reference for cleanup
+ this._resizeHandler = () => this.onResize(container);
+ window.addEventListener('resize', this._resizeHandler);
+ }
+
+ /**
+ * Load showroom from Figma design data.
+ */
+ loadFromFigma(figmaData: any): void {
+ const config = transformFigmaToScene(figmaData);
+ this.layout.buildFromConfig(config);
+ }
+
+ /**
+ * Load showroom from a pre-built SceneConfig.
+ */
+ loadFromConfig(config: SceneConfig): void {
+ this.layout.buildFromConfig(config);
+ }
+
+ private addBaseLighting(): void {
+ // Ambient — warm neutral
+ const ambient = new THREE.AmbientLight(0x2a2520, 0.4);
+ this.scene.add(ambient);
+
+ // Hemisphere — sky/ground color contrast
+ const hemi = new THREE.HemisphereLight(0xf5e6d0, 0x1a1510, 0.3);
+ this.scene.add(hemi);
+
+ // Main key light — warm directional
+ const key = new THREE.DirectionalLight(0xf5e6d0, 0.8);
+ key.position.set(5, 15, 10);
+ key.castShadow = true;
+ key.shadow.mapSize.set(2048, 2048);
+ key.shadow.camera.near = 0.5;
+ key.shadow.camera.far = 50;
+ key.shadow.camera.left = -25;
+ key.shadow.camera.right = 25;
+ key.shadow.camera.top = 15;
+ key.shadow.camera.bottom = -15;
+ key.shadow.bias = -0.0003;
+ this.scene.add(key);
+ }
+
+ private createDustMotes(): THREE.Points {
+ const count = 200;
+ const positions = new Float32Array(count * 3);
+ for (let i = 0; i < count * 3; i += 3) {
+ positions[i] = (Math.random() - 0.5) * 40;
+ positions[i + 1] = Math.random() * 12;
+ positions[i + 2] = (Math.random() - 0.5) * 20;
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ const mat = new THREE.PointsMaterial({
+ color: 0xf5e6d0,
+ size: 0.02,
+ transparent: true,
+ opacity: 0.3,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ return new THREE.Points(geo, mat);
+ }
+
+ animate(): void {
+ if (!this._running) return;
+ requestAnimationFrame(() => this.animate());
+ // Use only getDelta() — getElapsedTime() internally calls getDelta() again,
+ // which double-advances the clock and yields a near-zero second delta
+ const dt = Math.min(this.clock.getDelta(), 0.1);
+ this._elapsed += dt;
+ const elapsed = this._elapsed;
+
+ this.controls.update();
+
+ // Animate dust motes
+ const dustPos = this.dustParticles.geometry.attributes.position;
+ for (let i = 0; i < dustPos.count; i++) {
+ const y = dustPos.getY(i) + Math.sin(elapsed * 0.3 + i) * 0.001;
+ dustPos.setY(i, y > 12 ? 0 : y);
+ }
+ dustPos.needsUpdate = true;
+
+ // Update wing visibility based on camera distance
+ this.layout.updateVisibility(this.camera.position);
+
+ // Update DOF focus to orbit target distance
+ const focusDist = this.camera.position.distanceTo(this.controls.target);
+ this.postProcess.setFocusDistance(focusDist);
+
+ // Render through post-processing pipeline
+ this.postProcess.render();
+ }
+
+ private onResize(container: HTMLElement): void {
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(w, h);
+ this.postProcess.resize(w, h);
+ }
+
+ start(): void {
+ this._running = true;
+ this.clock.start();
+ this.animate();
+ }
+
+ dispose(): void {
+ this._running = false;
+ if (this._resizeHandler) {
+ window.removeEventListener('resize', this._resizeHandler);
+ this._resizeHandler = null;
+ }
+ this.postProcess.dispose();
+ this.layout.dispose();
+ disposeMaterialLibrary(this.materials);
+ this.dustParticles.geometry.dispose();
+ (this.dustParticles.material as THREE.PointsMaterial).dispose();
+ this.controls.dispose();
+ this.renderer.dispose();
+ }
+}
diff --git a/src/showroom/ShowroomEnvironment.ts b/src/showroom/ShowroomEnvironment.ts
new file mode 100644
index 0000000..6b4925f
--- /dev/null
+++ b/src/showroom/ShowroomEnvironment.ts
@@ -0,0 +1,93 @@
+// ShowroomEnvironment.ts — HDR environment map for photorealistic IBL
+import * as THREE from 'three';
+
+/**
+ * Generate a high-quality procedural environment map for gallery/showroom IBL.
+ * Creates a warm gallery interior environment rather than the cold command-center gradient.
+ *
+ * This procedural approach avoids needing an external HDRI file download.
+ * It simulates a well-lit interior with warm ceiling lights and neutral walls.
+ */
+export function createShowroomEnvMap(renderer: THREE.WebGLRenderer): THREE.Texture {
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ pmrem.compileEquirectangularShader();
+
+ const envScene = new THREE.Scene();
+
+ // Sky dome — warm dark interior
+ const skyGeo = new THREE.SphereGeometry(50, 32, 16);
+ const skyMat = new THREE.MeshBasicMaterial({
+ color: 0x1a1815,
+ side: THREE.BackSide,
+ });
+ envScene.add(new THREE.Mesh(skyGeo, skyMat));
+
+ // Ceiling highlight — warm white overhead panels (gallery lighting ~3200K)
+ const ceilGeo = new THREE.SphereGeometry(40, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.2);
+ const ceilMat = new THREE.MeshBasicMaterial({
+ color: 0xf5e6d0,
+ side: THREE.BackSide,
+ });
+ const ceil = new THREE.Mesh(ceilGeo, ceilMat);
+ ceil.rotation.x = Math.PI;
+ envScene.add(ceil);
+
+ // Floor reflection — dark polished concrete
+ const floorGeo = new THREE.SphereGeometry(40, 16, 8, 0, Math.PI * 2, Math.PI * 0.75, Math.PI * 0.25);
+ const floorMat = new THREE.MeshBasicMaterial({
+ color: 0x0d0c0a,
+ side: THREE.BackSide,
+ });
+ envScene.add(new THREE.Mesh(floorGeo, floorMat));
+
+ // Wall bands — warm brick tones from 4 sides
+ const wallMaterials: THREE.MeshBasicMaterial[] = [];
+ for (let i = 0; i < 4; i++) {
+ const wallGeo = new THREE.PlaneGeometry(30, 15);
+ const wallMat = new THREE.MeshBasicMaterial({
+ color: 0x3a2820,
+ side: THREE.DoubleSide,
+ });
+ wallMaterials.push(wallMat);
+ const wall = new THREE.Mesh(wallGeo, wallMat);
+ const angle = (i / 4) * Math.PI * 2;
+ wall.position.set(Math.cos(angle) * 35, 5, Math.sin(angle) * 35);
+ wall.lookAt(0, 5, 0);
+ envScene.add(wall);
+ }
+
+ // Track light spots — bright warm points at ceiling level
+ const spotMaterials: THREE.MeshBasicMaterial[] = [];
+ for (let i = 0; i < 6; i++) {
+ const spotGeo = new THREE.SphereGeometry(2, 8, 8);
+ const spotMat = new THREE.MeshBasicMaterial({ color: 0xffe8c0 });
+ spotMaterials.push(spotMat);
+ const spot = new THREE.Mesh(spotGeo, spotMat);
+ const angle = (i / 6) * Math.PI * 2;
+ spot.position.set(Math.cos(angle) * 15, 18, Math.sin(angle) * 15);
+ envScene.add(spot);
+ }
+
+ const envRenderTarget = pmrem.fromScene(envScene as any, 0.02);
+ const texture = envRenderTarget.texture;
+
+ // Cleanup — dispose PMREM render target, generator, and all env scene materials/geometries
+ pmrem.dispose();
+ envRenderTarget.dispose();
+ skyGeo.dispose();
+ skyMat.dispose();
+ ceilGeo.dispose();
+ ceilMat.dispose();
+ floorGeo.dispose();
+ floorMat.dispose();
+ wallMaterials.forEach(m => m.dispose());
+ spotMaterials.forEach(m => m.dispose());
+ // Dispose wall and spot geometries via traversal
+ envScene.traverse(child => {
+ if (child instanceof THREE.Mesh && child.geometry) {
+ child.geometry.dispose();
+ }
+ });
+
+ return texture;
+}
diff --git a/src/showroom/ShowroomLayout.ts b/src/showroom/ShowroomLayout.ts
new file mode 100644
index 0000000..3160ff6
--- /dev/null
+++ b/src/showroom/ShowroomLayout.ts
@@ -0,0 +1,343 @@
+// ShowroomLayout.ts — Builds the photorealistic showroom corridor and wing bays
+import * as THREE from 'three';
+import type { SceneConfig, WingConfig, WallConfig, LightConfig } from './types';
+import type { MaterialLibrary } from './ShowroomMaterials';
+
+export class ShowroomLayout {
+ private scene: THREE.Scene;
+ private materials: MaterialLibrary;
+ private corridorGroup: THREE.Group;
+ private wingGroups: THREE.Group[] = [];
+ private spotLights: THREE.SpotLight[] = [];
+
+ constructor(scene: THREE.Scene, materials: MaterialLibrary) {
+ this.scene = scene;
+ this.materials = materials;
+ this.corridorGroup = new THREE.Group();
+ this.corridorGroup.name = 'showroom-corridor';
+ scene.add(this.corridorGroup);
+ }
+
+ /**
+ * Build the showroom from a SceneConfig (output of FigmaTransformer).
+ */
+ buildFromConfig(config: SceneConfig): void {
+ this.buildCorridor(config.corridor);
+ this.buildWings(config.wings);
+ this.buildWallDisplays(config.walls);
+ this.buildLighting(config.lights);
+ this.buildFurniture(config.furniture);
+ this.buildEntry(config.entry);
+ }
+
+ private buildCorridor(corridor: SceneConfig['corridor']): void {
+ const { width, depth, height } = corridor;
+ const halfW = width / 2;
+ const halfD = depth / 2;
+
+ // Floor — polished concrete with normal map
+ const floorGeo = new THREE.PlaneGeometry(width, depth, 1, 1);
+ const floor = new THREE.Mesh(floorGeo, this.materials.concrete);
+ floor.rotation.x = -Math.PI / 2;
+ floor.receiveShadow = true;
+ this.corridorGroup.add(floor);
+
+ // Left wall — exposed brick
+ const leftWallGeo = new THREE.PlaneGeometry(depth, height);
+ const leftWall = new THREE.Mesh(leftWallGeo, this.materials.brick);
+ leftWall.position.set(-halfW, height / 2, 0);
+ leftWall.rotation.y = Math.PI / 2;
+ leftWall.receiveShadow = true;
+ this.corridorGroup.add(leftWall);
+
+ // Right wall — exposed brick
+ const rightWall = new THREE.Mesh(leftWallGeo, this.materials.brick);
+ rightWall.position.set(halfW, height / 2, 0);
+ rightWall.rotation.y = -Math.PI / 2;
+ rightWall.receiveShadow = true;
+ this.corridorGroup.add(rightWall);
+
+ // Back wall — dark charcoal
+ const backWallGeo = new THREE.PlaneGeometry(width, height);
+ const backWallMat = this.materials.steel.clone();
+ backWallMat.color.setHex(0x1a1a1a);
+ const backWall = new THREE.Mesh(backWallGeo, backWallMat);
+ backWall.position.set(0, height / 2, -halfD);
+ backWall.receiveShadow = true;
+ this.corridorGroup.add(backWall);
+
+ // Ceiling — steel with exposed duct geometry
+ const ceilGeo = new THREE.PlaneGeometry(width, depth);
+ const ceiling = new THREE.Mesh(ceilGeo, this.materials.steel);
+ ceiling.rotation.x = Math.PI / 2;
+ ceiling.position.y = height;
+ this.corridorGroup.add(ceiling);
+
+ // Exposed ducts — cylindrical pipes along ceiling
+ const ductMat = this.materials.steel.clone();
+ ductMat.metalness = 0.5;
+ for (let i = 0; i < 3; i++) {
+ const ductGeo = new THREE.CylinderGeometry(0.15, 0.15, depth, 8);
+ const duct = new THREE.Mesh(ductGeo, ductMat);
+ duct.rotation.x = Math.PI / 2;
+ duct.position.set(-halfW / 2 + i * (halfW / 2), height - 0.3, 0);
+ this.corridorGroup.add(duct);
+ }
+
+ // Baseboards — warm wood trim
+ const baseGeo = new THREE.BoxGeometry(depth, 0.15, 0.04);
+ for (const xPos of [-halfW + 0.02, halfW - 0.02]) {
+ const base = new THREE.Mesh(baseGeo, this.materials.wood);
+ base.position.set(xPos, 0.075, 0);
+ base.rotation.y = Math.PI / 2;
+ this.corridorGroup.add(base);
+ }
+ }
+
+ private buildWings(wings: WingConfig[]): void {
+ wings.forEach((wingConfig, i) => {
+ const group = new THREE.Group();
+ group.name = `wing-${i}-${wingConfig.vendor}`;
+ group.userData = { vendor: wingConfig.vendor, index: i, loaded: false };
+
+ // Wing entrance frame — brushed aluminum
+ const frameGeo = new THREE.BoxGeometry(0.08, 3.5, 0.08);
+ const leftFrame = new THREE.Mesh(frameGeo, this.materials.aluminum);
+ leftFrame.position.set(-1.5, 1.75, 0);
+ group.add(leftFrame);
+
+ const rightFrame = new THREE.Mesh(frameGeo, this.materials.aluminum);
+ rightFrame.position.set(1.5, 1.75, 0);
+ group.add(rightFrame);
+
+ // Header bar
+ const headerGeo = new THREE.BoxGeometry(3.08, 0.08, 0.08);
+ const header = new THREE.Mesh(headerGeo, this.materials.aluminum);
+ header.position.set(0, 3.5, 0);
+ group.add(header);
+
+ // Vendor label (canvas texture)
+ const labelCanvas = document.createElement('canvas');
+ labelCanvas.width = 512;
+ labelCanvas.height = 64;
+ const ctx = labelCanvas.getContext('2d');
+ if (ctx) {
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, 0, 512, 64);
+ ctx.fillStyle = '#f0ebe4';
+ ctx.font = 'bold 28px Inter, -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(wingConfig.vendor.toUpperCase(), 256, 42);
+ }
+ const labelTex = new THREE.CanvasTexture(labelCanvas);
+ const labelGeo = new THREE.PlaneGeometry(2.5, 0.3);
+ const labelMat = new THREE.MeshBasicMaterial({ map: labelTex });
+ const label = new THREE.Mesh(labelGeo, labelMat);
+ label.position.set(0, 3.7, 0.01);
+ group.add(label);
+
+ // Position the wing in the corridor
+ group.position.set(
+ wingConfig.position[0],
+ wingConfig.position[1],
+ wingConfig.position[2]
+ );
+ group.rotation.y = wingConfig.rotation;
+
+ this.wingGroups.push(group);
+ this.corridorGroup.add(group);
+ });
+ }
+
+ private buildWallDisplays(walls: WallConfig[]): void {
+ walls.forEach(wall => {
+ if (wall.hasSamplePanels) {
+ // Large mounted sample panel
+ const panelGeo = new THREE.PlaneGeometry(wall.dimensions[0], wall.dimensions[1]);
+ const panel = new THREE.Mesh(panelGeo, this.materials.fabric);
+ panel.position.set(wall.position[0], wall.position[1], wall.position[2]);
+ this.corridorGroup.add(panel);
+
+ // Aluminum frame around panel
+ const frameThickness = 0.03;
+ const frameDepth = 0.04;
+ const frameMat = this.materials.aluminum;
+
+ // Top frame
+ const topGeo = new THREE.BoxGeometry(wall.dimensions[0] + frameThickness * 2, frameThickness, frameDepth);
+ const top = new THREE.Mesh(topGeo, frameMat);
+ top.position.set(wall.position[0], wall.position[1] + wall.dimensions[1] / 2, wall.position[2] + 0.02);
+ this.corridorGroup.add(top);
+
+ // Bottom frame
+ const bottom = top.clone();
+ bottom.position.y = wall.position[1] - wall.dimensions[1] / 2;
+ this.corridorGroup.add(bottom);
+ }
+
+ if (wall.hasBookshelf) {
+ // Glass-front bookshelf
+ const shelfGeo = new THREE.BoxGeometry(wall.dimensions[0], wall.dimensions[1], 0.3);
+ const shelf = new THREE.Mesh(shelfGeo, this.materials.wood);
+ shelf.position.set(wall.position[0], wall.position[1], wall.position[2]);
+ this.corridorGroup.add(shelf);
+
+ // Glass front
+ const glassGeo = new THREE.PlaneGeometry(wall.dimensions[0] - 0.05, wall.dimensions[1] - 0.05);
+ const glass = new THREE.Mesh(glassGeo, this.materials.glass);
+ glass.position.set(wall.position[0], wall.position[1], wall.position[2] + 0.16);
+ this.corridorGroup.add(glass);
+ }
+ });
+ }
+
+ private buildLighting(lights: LightConfig[]): void {
+ lights.forEach(lightConfig => {
+ if (lightConfig.type === 'spot') {
+ const spot = new THREE.SpotLight(
+ new THREE.Color(lightConfig.color),
+ lightConfig.intensity,
+ 30,
+ lightConfig.angle || Math.PI / 6,
+ lightConfig.penumbra || 0.4,
+ 1
+ );
+ spot.position.set(lightConfig.position[0], lightConfig.position[1], lightConfig.position[2]);
+ if (lightConfig.target) {
+ spot.target.position.set(lightConfig.target[0], lightConfig.target[1], lightConfig.target[2]);
+ this.scene.add(spot.target);
+ }
+ spot.castShadow = true;
+ spot.shadow.mapSize.set(1024, 1024);
+ this.spotLights.push(spot);
+ this.corridorGroup.add(spot);
+
+ // Track light housing (small chrome cylinder)
+ const housingGeo = new THREE.CylinderGeometry(0.08, 0.12, 0.15, 8);
+ const housing = new THREE.Mesh(housingGeo, this.materials.trackLight);
+ housing.position.set(lightConfig.position[0], lightConfig.position[1], lightConfig.position[2]);
+ this.corridorGroup.add(housing);
+
+ // Volumetric light cone (subtle)
+ const coneH = lightConfig.position[1] - (lightConfig.target?.[1] || 0);
+ const coneR = Math.tan(lightConfig.angle || Math.PI / 6) * coneH;
+ const coneGeo = new THREE.ConeGeometry(coneR, coneH, 16, 1, true);
+ const coneMat = new THREE.MeshBasicMaterial({
+ color: lightConfig.color,
+ transparent: true,
+ opacity: 0.015,
+ blending: THREE.AdditiveBlending,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ });
+ const cone = new THREE.Mesh(coneGeo, coneMat);
+ cone.position.set(
+ lightConfig.position[0],
+ lightConfig.position[1] - coneH / 2,
+ lightConfig.position[2]
+ );
+ this.corridorGroup.add(cone);
+ }
+ });
+
+ // Track rails (continuous aluminum rail along ceiling)
+ const railGeo = new THREE.BoxGeometry(40, 0.03, 0.05);
+ const rail = new THREE.Mesh(railGeo, this.materials.aluminum);
+ rail.position.set(0, 11.5, 0);
+ this.corridorGroup.add(rail);
+ }
+
+ private buildFurniture(furniture: SceneConfig['furniture']): void {
+ furniture.forEach(item => {
+ if (item.type === 'display-table') {
+ const tableGeo = new THREE.BoxGeometry(item.dimensions[0], item.dimensions[1], item.dimensions[2]);
+ const table = new THREE.Mesh(tableGeo, this.materials.wood);
+ table.position.set(item.position[0], item.position[1], item.position[2]);
+ table.castShadow = true;
+ this.corridorGroup.add(table);
+ }
+ });
+ }
+
+ private buildEntry(entry: SceneConfig['entry']): void {
+ // Entry signage
+ const signCanvas = document.createElement('canvas');
+ signCanvas.width = 1024;
+ signCanvas.height = 128;
+ const ctx = signCanvas.getContext('2d');
+ if (ctx) {
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, 0, 1024, 128);
+ ctx.fillStyle = '#f0ebe4';
+ ctx.font = 'bold 36px Inter, -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(entry.signText, 512, 70);
+ ctx.font = '18px Inter, -apple-system, sans-serif';
+ ctx.fillStyle = '#888888';
+ ctx.fillText('ARCHITECTURAL TRADE SHOWROOM', 512, 105);
+ }
+
+ const signTex = new THREE.CanvasTexture(signCanvas);
+ const signGeo = new THREE.PlaneGeometry(6, 0.75);
+ const signMat = new THREE.MeshStandardMaterial({
+ map: signTex,
+ emissive: new THREE.Color(0xffffff),
+ emissiveMap: signTex,
+ emissiveIntensity: 0.3,
+ });
+ const sign = new THREE.Mesh(signGeo, signMat);
+ sign.position.set(entry.position[0], 10, entry.position[2]);
+ this.corridorGroup.add(sign);
+ }
+
+ /**
+ * Get wing groups for culling/visibility management.
+ */
+ getWings(): THREE.Group[] {
+ return this.wingGroups;
+ }
+
+ /**
+ * Set visibility of wings by distance from camera for performance.
+ */
+ updateVisibility(cameraPosition: THREE.Vector3, maxDistance = 30): void {
+ this.wingGroups.forEach(wing => {
+ const dist = wing.position.distanceTo(cameraPosition);
+ wing.visible = dist < maxDistance;
+ });
+ }
+
+ dispose(): void {
+ // Dispose spotlight targets from scene root
+ this.spotLights.forEach(spot => {
+ if (spot.target.parent) {
+ spot.target.parent.remove(spot.target);
+ }
+ spot.dispose();
+ });
+
+ this.corridorGroup.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ child.geometry.dispose();
+ // Dispose material (handles cloned materials and canvas textures)
+ const mat = child.material;
+ if (Array.isArray(mat)) {
+ mat.forEach(m => {
+ if (m.map) m.map.dispose();
+ if (m.emissiveMap) m.emissiveMap.dispose();
+ if (m.normalMap) m.normalMap.dispose();
+ m.dispose();
+ });
+ } else {
+ if (mat.map) mat.map.dispose();
+ if ((mat as any).emissiveMap) (mat as any).emissiveMap.dispose();
+ if ((mat as any).normalMap) (mat as any).normalMap.dispose();
+ mat.dispose();
+ }
+ }
+ });
+ this.scene.remove(this.corridorGroup);
+ this.wingGroups.length = 0;
+ this.spotLights.length = 0;
+ }
+}
diff --git a/src/showroom/ShowroomMaterials.ts b/src/showroom/ShowroomMaterials.ts
new file mode 100644
index 0000000..80af5ca
--- /dev/null
+++ b/src/showroom/ShowroomMaterials.ts
@@ -0,0 +1,104 @@
+// ShowroomMaterials.ts — PBR material definitions for photorealistic showroom
+import * as THREE from 'three';
+import {
+ generateBrickNormalMap,
+ generateConcreteNormalMap,
+ generateBrushedAluminumNormalMap,
+ generateWoodNormalMap,
+ generateFabricNormalMap,
+} from './ProceduralMaps';
+
+export interface MaterialLibrary {
+ brick: THREE.MeshStandardMaterial;
+ concrete: THREE.MeshStandardMaterial;
+ aluminum: THREE.MeshStandardMaterial;
+ wood: THREE.MeshStandardMaterial;
+ fabric: THREE.MeshStandardMaterial;
+ steel: THREE.MeshStandardMaterial;
+ glass: THREE.MeshStandardMaterial;
+ trackLight: THREE.MeshStandardMaterial;
+}
+
+/**
+ * Create the full PBR material library for the showroom.
+ * Each material has proper normal maps for photorealistic surface detail.
+ */
+export function createMaterialLibrary(): MaterialLibrary {
+ return {
+ brick: new THREE.MeshStandardMaterial({
+ color: 0x8b4513,
+ metalness: 0.0,
+ roughness: 0.85,
+ normalMap: generateBrickNormalMap(),
+ normalScale: new THREE.Vector2(1.2, 1.2),
+ }),
+
+ concrete: new THREE.MeshStandardMaterial({
+ color: 0x8a8580,
+ metalness: 0.15,
+ roughness: 0.55,
+ normalMap: generateConcreteNormalMap(),
+ normalScale: new THREE.Vector2(0.5, 0.5),
+ }),
+
+ aluminum: new THREE.MeshStandardMaterial({
+ color: 0xc0c0c0,
+ metalness: 0.7,
+ roughness: 0.25,
+ normalMap: generateBrushedAluminumNormalMap(),
+ normalScale: new THREE.Vector2(0.3, 0.3),
+ envMapIntensity: 1.5,
+ }),
+
+ wood: new THREE.MeshStandardMaterial({
+ color: 0x8b6914,
+ metalness: 0.0,
+ roughness: 0.65,
+ normalMap: generateWoodNormalMap(),
+ normalScale: new THREE.Vector2(0.8, 0.8),
+ }),
+
+ fabric: new THREE.MeshStandardMaterial({
+ color: 0xf0ebe4,
+ metalness: 0.0,
+ roughness: 0.4,
+ normalMap: generateFabricNormalMap(),
+ normalScale: new THREE.Vector2(0.2, 0.2),
+ envMapIntensity: 0.3,
+ }),
+
+ steel: new THREE.MeshStandardMaterial({
+ color: 0x2a2a2a,
+ metalness: 0.6,
+ roughness: 0.3,
+ envMapIntensity: 1.0,
+ }),
+
+ glass: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ metalness: 0.0,
+ roughness: 0.05,
+ transparent: true,
+ opacity: 0.15,
+ envMapIntensity: 2.0,
+ }),
+
+ trackLight: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ metalness: 0.9,
+ roughness: 0.1,
+ emissive: new THREE.Color(0xf5e6d0),
+ emissiveIntensity: 0.8,
+ }),
+ };
+}
+
+/**
+ * Dispose all materials and their textures.
+ */
+export function disposeMaterialLibrary(lib: MaterialLibrary): void {
+ Object.values(lib).forEach(mat => {
+ if (mat.normalMap) mat.normalMap.dispose();
+ mat.dispose();
+ });
+}
diff --git a/src/showroom/ShowroomPostProcess.ts b/src/showroom/ShowroomPostProcess.ts
new file mode 100644
index 0000000..baa28dc
--- /dev/null
+++ b/src/showroom/ShowroomPostProcess.ts
@@ -0,0 +1,181 @@
+// ShowroomPostProcess.ts — Photorealistic post-processing for the showroom
+// Adds SSAO, DOF, and color grading on top of existing bloom/grain pipeline
+import * as THREE from 'three';
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
+import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
+import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
+import { BokehPass } from 'three/examples/jsm/postprocessing/BokehPass.js';
+
+// ── Color Grading Shader ─────────────────────────────────────────────────────
+// Warm architectural tones: slight orange lift in shadows, cool highlight compression
+const ColorGradingShader = {
+ name: 'ColorGradingShader',
+ uniforms: {
+ tDiffuse: { value: null as THREE.Texture | null },
+ uSaturation: { value: 1.1 },
+ uContrast: { value: 1.08 },
+ uBrightness: { value: 0.02 },
+ uWarmth: { value: 0.04 },
+ },
+ vertexShader: /* glsl */ `
+ varying vec2 vUv;
+ void main() {
+ vUv = uv;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `,
+ fragmentShader: /* glsl */ `
+ uniform sampler2D tDiffuse;
+ uniform float uSaturation;
+ uniform float uContrast;
+ uniform float uBrightness;
+ uniform float uWarmth;
+ varying vec2 vUv;
+
+ void main() {
+ vec4 color = texture2D(tDiffuse, vUv);
+
+ // Saturation adjustment
+ float luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
+ color.rgb = mix(vec3(luminance), color.rgb, uSaturation);
+
+ // Contrast (pivot at mid-gray)
+ color.rgb = (color.rgb - 0.5) * uContrast + 0.5;
+
+ // Brightness lift
+ color.rgb += uBrightness;
+
+ // Warm tint — add slight orange to shadows, cool highlights slightly
+ float shadowMask = 1.0 - smoothstep(0.0, 0.5, luminance);
+ color.r += uWarmth * shadowMask;
+ color.g += uWarmth * 0.5 * shadowMask;
+
+ // Vignette — darkens corners for photographic depth
+ vec2 vUvCenter = vUv - 0.5;
+ float vignette = 1.0 - dot(vUvCenter, vUvCenter) * 0.5;
+ color.rgb *= vignette;
+
+ gl_FragColor = color;
+ }
+ `,
+};
+
+// ── Film Grain (reused from war room) ────────────────────────────────────────
+const FilmGrainShader = {
+ name: 'FilmGrainShader',
+ uniforms: {
+ tDiffuse: { value: null as THREE.Texture | null },
+ uTime: { value: 0.0 },
+ uOpacity: { value: 0.006 },
+ },
+ vertexShader: /* glsl */ `
+ varying vec2 vUv;
+ void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
+ `,
+ fragmentShader: /* glsl */ `
+ uniform sampler2D tDiffuse;
+ uniform float uTime;
+ uniform float uOpacity;
+ varying vec2 vUv;
+ float hash(vec2 p) { p = fract(p * vec2(234.34, 435.345)); p += dot(p, p + 34.23); return fract(p.x * p.y); }
+ void main() {
+ vec4 color = texture2D(tDiffuse, vUv);
+ float grain = hash(vUv + fract(uTime * 0.1));
+ color.rgb += (grain - 0.5) * 2.0 * uOpacity;
+ gl_FragColor = color;
+ }
+ `,
+};
+
+export class ShowroomPostProcess {
+ private composer: EffectComposer;
+ private ssaoPass: SSAOPass;
+ private bloomPass: UnrealBloomPass;
+ private bokehPass: BokehPass;
+ private colorGradingPass: ShaderPass;
+ private filmGrainPass: ShaderPass;
+ private clock: THREE.Clock;
+
+ constructor(
+ renderer: THREE.WebGLRenderer,
+ scene: THREE.Scene,
+ camera: THREE.PerspectiveCamera
+ ) {
+ this.clock = new THREE.Clock();
+ const size = renderer.getSize(new THREE.Vector2());
+
+ this.composer = new EffectComposer(renderer);
+
+ // Pass 1: Render
+ this.composer.addPass(new RenderPass(scene, camera));
+
+ // Pass 2: SSAO — ambient occlusion for depth
+ this.ssaoPass = new SSAOPass(scene, camera, size.x, size.y);
+ this.ssaoPass.kernelRadius = 0.5;
+ this.ssaoPass.minDistance = 0.001;
+ this.ssaoPass.maxDistance = 0.1;
+ (this.ssaoPass as any).output = SSAOPass.OUTPUT.Default;
+ this.composer.addPass(this.ssaoPass);
+
+ // Pass 3: Bloom — subtle architectural glow
+ this.bloomPass = new UnrealBloomPass(
+ new THREE.Vector2(size.x, size.y),
+ 0.2, // strength (slightly less than war room — showroom is brighter)
+ 0.4, // radius
+ 0.7 // threshold
+ );
+ this.composer.addPass(this.bloomPass);
+
+ // Pass 4: Depth of Field — cinematic focus
+ this.bokehPass = new BokehPass(scene, camera, {
+ focus: 15.0,
+ aperture: 0.002,
+ maxblur: 0.005,
+ });
+ this.composer.addPass(this.bokehPass);
+
+ // Pass 5: Color Grading — warm architectural tones
+ this.colorGradingPass = new ShaderPass(ColorGradingShader);
+ this.composer.addPass(this.colorGradingPass);
+
+ // Pass 6: Film Grain — organic texture
+ this.filmGrainPass = new ShaderPass(FilmGrainShader);
+ this.composer.addPass(this.filmGrainPass);
+ }
+
+ render(): void {
+ this.filmGrainPass.uniforms['uTime'].value = this.clock.getElapsedTime();
+ this.composer.render();
+ }
+
+ setFocusDistance(distance: number): void {
+ // BokehPass stores uniforms on materialBokeh.uniforms
+ const uniforms = (this.bokehPass as any).materialBokeh?.uniforms
+ ?? (this.bokehPass as any).uniforms;
+ if (uniforms?.focus) {
+ uniforms.focus.value = distance;
+ }
+ }
+
+ resize(width: number, height: number): void {
+ this.composer.setSize(width, height);
+ this.bloomPass.resolution.set(width, height);
+ this.ssaoPass.setSize(width, height);
+ }
+
+ getComposer(): EffectComposer {
+ return this.composer;
+ }
+
+ dispose(): void {
+ this.composer.renderTarget1.dispose();
+ this.composer.renderTarget2.dispose();
+ this.composer.passes.forEach(pass => {
+ if ('dispose' in pass && typeof pass.dispose === 'function') {
+ pass.dispose();
+ }
+ });
+ }
+}
diff --git a/src/showroom/TextureCrop.ts b/src/showroom/TextureCrop.ts
new file mode 100644
index 0000000..bccc742
--- /dev/null
+++ b/src/showroom/TextureCrop.ts
@@ -0,0 +1,159 @@
+// TextureCrop.ts — Center-crop images to 512x768 with white-border trimming
+// Loads image → trims white borders → center-crops to 2:3 aspect → returns CanvasTexture
+import * as THREE from 'three';
+
+const CANVAS_W = 512;
+const CANVAS_H = 768;
+const WHITE_THRESHOLD = 240; // RGB channels above this = "white"
+const SCAN_DEPTH = 80; // Max pixels inward to scan for border
+
+/**
+ * Detect how many pixels of white border exist on each edge.
+ * Scans rows/columns from outside in, stops when a non-white row/col is found.
+ */
+function detectWhiteBorders(
+ data: Uint8ClampedArray,
+ w: number,
+ h: number
+): { top: number; bottom: number; left: number; right: number } {
+ const isWhitePixel = (x: number, y: number): boolean => {
+ const i = (y * w + x) * 4;
+ return data[i] > WHITE_THRESHOLD
+ && data[i + 1] > WHITE_THRESHOLD
+ && data[i + 2] > WHITE_THRESHOLD;
+ };
+
+ // Check if an entire row is white (sample every 4th pixel for speed)
+ const isWhiteRow = (y: number): boolean => {
+ for (let x = 0; x < w; x += 4) {
+ if (!isWhitePixel(x, y)) return false;
+ }
+ return true;
+ };
+
+ // Check if an entire column is white
+ const isWhiteCol = (x: number): boolean => {
+ for (let y = 0; y < h; y += 4) {
+ if (!isWhitePixel(x, y)) return false;
+ }
+ return true;
+ };
+
+ let top = 0;
+ while (top < Math.min(SCAN_DEPTH, h / 3) && isWhiteRow(top)) top++;
+
+ let bottom = 0;
+ while (bottom < Math.min(SCAN_DEPTH, h / 3) && isWhiteRow(h - 1 - bottom)) bottom++;
+
+ let left = 0;
+ while (left < Math.min(SCAN_DEPTH, w / 3) && isWhiteCol(left)) left++;
+
+ let right = 0;
+ while (right < Math.min(SCAN_DEPTH, w / 3) && isWhiteCol(w - 1 - right)) right++;
+
+ return { top, bottom, left, right };
+}
+
+/**
+ * Load an image URL, trim white borders, center-crop to 512x768 canvas.
+ * Returns a Promise<THREE.CanvasTexture>.
+ */
+export function cropTexture(imageUrl: string): Promise<THREE.CanvasTexture> {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+
+ img.onload = () => {
+ // Step 1: Draw full image to a scratch canvas for pixel analysis
+ const scratch = document.createElement('canvas');
+ scratch.width = img.naturalWidth;
+ scratch.height = img.naturalHeight;
+ const sCtx = scratch.getContext('2d')!;
+ sCtx.drawImage(img, 0, 0);
+
+ const imgData = sCtx.getImageData(0, 0, scratch.width, scratch.height);
+ const borders = detectWhiteBorders(imgData.data, scratch.width, scratch.height);
+
+ // Step 2: Compute content rect (after trimming white borders)
+ const contentX = borders.left;
+ const contentY = borders.top;
+ const contentW = scratch.width - borders.left - borders.right;
+ const contentH = scratch.height - borders.top - borders.bottom;
+
+ if (contentW <= 0 || contentH <= 0) {
+ // Entire image is white — use full image as fallback
+ const fallback = createFallbackTexture(imageUrl);
+ resolve(fallback);
+ return;
+ }
+
+ // Step 3: Center-crop the content rect to 2:3 aspect ratio
+ const targetAspect = CANVAS_W / CANVAS_H; // 0.667
+ const contentAspect = contentW / contentH;
+
+ let srcX: number, srcY: number, srcW: number, srcH: number;
+
+ if (contentAspect > targetAspect) {
+ // Content is wider than target — crop sides
+ srcH = contentH;
+ srcW = Math.round(contentH * targetAspect);
+ srcX = contentX + Math.round((contentW - srcW) / 2);
+ srcY = contentY;
+ } else {
+ // Content is taller than target — crop top/bottom
+ srcW = contentW;
+ srcH = Math.round(contentW / targetAspect);
+ srcX = contentX;
+ srcY = contentY + Math.round((contentH - srcH) / 2);
+ }
+
+ // Step 4: Draw cropped region onto final 512x768 canvas
+ const canvas = document.createElement('canvas');
+ canvas.width = CANVAS_W;
+ canvas.height = CANVAS_H;
+ const ctx = canvas.getContext('2d')!;
+
+ // Fill with warm gallery bg in case of rounding gaps
+ ctx.fillStyle = '#f0ebe4';
+ ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
+
+ ctx.drawImage(
+ img,
+ srcX, srcY, srcW, srcH, // source rect (cropped)
+ 0, 0, CANVAS_W, CANVAS_H // dest rect (fill canvas)
+ );
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.generateMipmaps = true;
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+
+ resolve(texture);
+ };
+
+ img.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
+ img.src = imageUrl;
+ });
+}
+
+/**
+ * Fallback: warm canvas with a subtle message (for all-white or broken images).
+ */
+function createFallbackTexture(label: string): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = CANVAS_W;
+ canvas.height = CANVAS_H;
+ const ctx = canvas.getContext('2d')!;
+
+ ctx.fillStyle = '#f0ebe4';
+ ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
+ ctx.fillStyle = '#888888';
+ ctx.font = '18px Inter, -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('Image unavailable', CANVAS_W / 2, CANVAS_H / 2);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ return texture;
+}
diff --git a/src/showroom/index.ts b/src/showroom/index.ts
new file mode 100644
index 0000000..1a8e0e0
--- /dev/null
+++ b/src/showroom/index.ts
@@ -0,0 +1,50 @@
+// src/showroom/index.ts — Showroom standalone entry point
+import { Showroom } from './Showroom';
+import { SearchOverlay } from './SearchOverlay';
+import type { SceneConfig } from './types';
+
+// Default showroom config (used when no Figma data available)
+const DEFAULT_CONFIG: SceneConfig = {
+ corridor: { width: 40, depth: 20, height: 12, wallMaterial: 'brick', floorMaterial: 'polished-concrete' },
+ wings: Array.from({ length: 6 }, (_, i) => ({
+ index: i,
+ vendor: ['Thibaut', 'Schumacher', 'Arte', 'Scalamandre', 'Elitis', 'Phillip Jeffries'][i],
+ position: [-12 + i * 5, 0, -8] as [number, number, number],
+ rotation: 0,
+ panelCount: 12,
+ accentColor: ['#1a4b8c', '#c41e3a', '#d4a574', '#2d4a3e', '#e67e22', '#8b4513'][i],
+ })),
+ walls: [
+ { position: [-18, 4, 0], dimensions: [4, 8], material: 'brick', hasBookshelf: true, hasSamplePanels: false, sampleCount: 0 },
+ { position: [18, 4, 0], dimensions: [4, 8], material: 'brick', hasBookshelf: false, hasSamplePanels: true, sampleCount: 3 },
+ ],
+ lights: Array.from({ length: 8 }, (_, i) => ({
+ type: 'spot' as const,
+ position: [-14 + i * 4, 11, 0] as [number, number, number],
+ target: [-14 + i * 4, 0, 0] as [number, number, number],
+ color: '#f5e6d0',
+ intensity: 1.2,
+ angle: Math.PI / 5,
+ penumbra: 0.4,
+ })),
+ furniture: [
+ { type: 'display-table' as const, position: [0, 0.45, 0] as [number, number, number], dimensions: [3, 0.9, 1.5] as [number, number, number] },
+ ],
+ entry: { position: [0, 0, 10], signText: 'DESIGNER WALLCOVERINGS' },
+};
+
+// Boot
+const container = document.getElementById('showroom-container');
+if (container) {
+ const showroom = new Showroom(container);
+ showroom.loadFromConfig(DEFAULT_CONFIG);
+ showroom.start();
+
+ // Live search overlay — searches DW Shopify products
+ const search = new SearchOverlay(container);
+ search.setShowroom(showroom);
+
+ // Expose for MCP/debug
+ (window as any).__showroom = showroom;
+ (window as any).__search = search;
+}
diff --git a/src/showroom/types.ts b/src/showroom/types.ts
new file mode 100644
index 0000000..ac7bab2
--- /dev/null
+++ b/src/showroom/types.ts
@@ -0,0 +1,50 @@
+// types.ts — Shared type definitions for the photorealistic showroom pipeline
+
+export interface SceneConfig {
+ corridor: {
+ width: number; // world units (1 unit = 1 foot)
+ depth: number;
+ height: number;
+ wallMaterial: 'brick' | 'concrete' | 'charcoal';
+ floorMaterial: 'polished-concrete' | 'wood' | 'marble';
+ };
+ wings: WingConfig[];
+ walls: WallConfig[];
+ lights: LightConfig[];
+ furniture: FurnitureConfig[];
+ entry: { position: [number, number, number]; signText: string };
+}
+
+export interface WingConfig {
+ index: number;
+ vendor: string;
+ position: [number, number, number];
+ rotation: number;
+ panelCount: number;
+ accentColor: string; // hex from Figma fill
+}
+
+export interface WallConfig {
+ position: [number, number, number];
+ dimensions: [number, number]; // width, height
+ material: string;
+ hasBookshelf: boolean;
+ hasSamplePanels: boolean;
+ sampleCount: number;
+}
+
+export interface LightConfig {
+ type: 'spot' | 'track' | 'ambient' | 'point';
+ position: [number, number, number];
+ target?: [number, number, number];
+ color: string;
+ intensity: number;
+ angle?: number; // SpotLight cone angle
+ penumbra?: number;
+}
+
+export interface FurnitureConfig {
+ type: 'display-table' | 'stool' | 'bookshelf' | 'sample-rack';
+ position: [number, number, number];
+ dimensions: [number, number, number];
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..4a443e8
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,84 @@
+// ═══════════════════════════════════════════════
+// DW War Room 3D — Types
+// ═══════════════════════════════════════════════
+
+export interface AgentNode {
+ id: string;
+ name: string;
+ role: string;
+ dept: string;
+ color: string;
+ exec: string;
+ port?: number;
+ speaking: boolean;
+ position: { x: number; y: number; z: number };
+}
+
+export interface Executive {
+ id: string;
+ name: string;
+ role: string;
+ color: string;
+ port: number;
+}
+
+export interface MeetingState {
+ active: boolean;
+ id?: string;
+ type?: string;
+ phase?: string;
+ status?: string;
+ messages: MeetingMessage[];
+}
+
+export interface MeetingMessage {
+ id: number;
+ agent_id: string;
+ agent_name: string;
+ message: string;
+ message_type: string;
+ phase?: string;
+}
+
+export interface DecisionStats {
+ total: number;
+ pending: number;
+ approved: number;
+ rejected: number;
+ deferred: number;
+}
+
+export interface EscalationAlert {
+ id: number;
+ decision_id: string | null;
+ escalation_type: string;
+ hours_elapsed: number;
+ action_taken: string | null;
+}
+
+export interface GovernanceStatus {
+ governance_mode: string;
+ pending_decisions: number;
+ active_delegations: number;
+ active_escalations: number;
+}
+
+export interface WSEvent {
+ type: string;
+ payload: any;
+ timestamp: string;
+}
+
+export interface SystemHealth {
+ cpu: string;
+ memory: string;
+ services: number;
+ uptime: string;
+}
+
+export interface FeedItem {
+ agent_name: string;
+ action: string;
+ timestamp: string;
+ type?: string;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c7d9591
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false
+ },
+ "include": ["src"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..9765235
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,107 @@
+import { defineConfig } from 'vite';
+import { resolve } from 'path';
+import https from 'https';
+
+// Shopify GraphQL search proxy for Vite dev server
+function shopifySearchPlugin() {
+ const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+ const SHOPIFY_TOKEN = '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';
+ const API_VERSION = '2024-01';
+
+ return {
+ name: 'shopify-search-proxy',
+ configureServer(server: any) {
+ server.middlewares.use('/api/shopify/search', (req: any, res: any) => {
+ const url = new URL(req.url || '/', `http://${req.headers.host}`);
+ const query = url.searchParams.get('q') || '';
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '10', 10), 20);
+
+ if (query.length < 2) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'Query too short' }));
+ }
+
+ const gqlQuery = JSON.stringify({
+ query: `{
+ products(first: ${limit}, query: "${query.replace(/"/g, '\\"')}") {
+ edges { node {
+ id title vendor productType handle tags
+ images(first: 1) { edges { node { url } } }
+ variants(first: 1) { edges { node { price sku } } }
+ }}
+ }
+ }`
+ });
+
+ const options = {
+ hostname: SHOPIFY_STORE,
+ path: `/admin/api/${API_VERSION}/graphql.json`,
+ method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': SHOPIFY_TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(gqlQuery),
+ },
+ };
+
+ const proxyReq = https.request(options, (shopifyRes) => {
+ let data = '';
+ shopifyRes.on('data', (chunk: string) => { data += chunk; });
+ shopifyRes.on('end', () => {
+ try {
+ const gql = JSON.parse(data);
+ const edges = gql?.data?.products?.edges || [];
+ const products = edges.map(({ node }: any) => ({
+ id: parseInt(node.id.replace('gid://shopify/Product/', ''), 10),
+ title: node.title,
+ vendor: node.vendor,
+ product_type: node.productType,
+ handle: node.handle,
+ tags: node.tags ? node.tags.join(', ') : '',
+ images: node.images?.edges?.length
+ ? node.images.edges.map((e: any) => ({ src: e.node.url }))
+ : [],
+ variants: node.variants?.edges?.length
+ ? node.variants.edges.map((e: any) => ({ price: e.node.price, sku: e.node.sku || '' }))
+ : [{ price: '0.00', sku: '' }],
+ }));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ products }));
+ } catch {
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Parse error', products: [] }));
+ }
+ });
+ });
+
+ proxyReq.on('error', () => {
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Proxy error', products: [] }));
+ });
+
+ proxyReq.write(gqlQuery);
+ proxyReq.end();
+ });
+ },
+ };
+}
+
+export default defineConfig({
+ plugins: [shopifySearchPlugin()],
+ server: {
+ port: 4060,
+ host: '0.0.0.0',
+ },
+ preview: {
+ port: 4060,
+ host: '0.0.0.0',
+ },
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ showroom: resolve(__dirname, 'showroom.html'),
+ },
+ },
+ },
+});
(oldest)
·
back to Dw War Room
·
gitignore: add backup file patterns (*.bak-*, *.pre-*, *.ori 8109610 →