privateboard 0.1.37 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/boot.js +1415 -91
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +1415 -91
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +1271 -81
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/adjourn-overlay.css +2 -2
  13. package/public/agent-overlay.css +27 -15
  14. package/public/agent-overlay.js +3 -1
  15. package/public/agent-profile.css +331 -41
  16. package/public/agent-profile.js +499 -75
  17. package/public/app-updater.css +1 -1
  18. package/public/app.js +2090 -547
  19. package/public/avatar-3d-snap.js +205 -0
  20. package/public/avatar-3d.js +792 -0
  21. package/public/avatar-customizer.html +274 -0
  22. package/public/avatar3d-editor.css +240 -0
  23. package/public/avatar3d-editor.js +481 -0
  24. package/public/avatars/3d/chair.png +0 -0
  25. package/public/avatars/3d/first-principles.png +0 -0
  26. package/public/avatars/3d/historian.png +0 -0
  27. package/public/avatars/3d/long-horizon.png +0 -0
  28. package/public/avatars/3d/phenomenologist.png +0 -0
  29. package/public/avatars/3d/socrates.png +0 -0
  30. package/public/avatars/3d/user-empathy.png +0 -0
  31. package/public/avatars/3d/value-investor.png +0 -0
  32. package/public/core-avatars.js +86 -0
  33. package/public/home-3d-loader.js +15 -4
  34. package/public/home-3d-mock.js +18 -7
  35. package/public/home.html +80 -18
  36. package/public/i18n.js +279 -4
  37. package/public/icons/avatar_1779855104027.glb +0 -0
  38. package/public/icons/logo.png +0 -0
  39. package/public/icons/new-style.glb +0 -0
  40. package/public/icons/new-style2.glb +0 -0
  41. package/public/icons/new-style3.glb +0 -0
  42. package/public/icons/new-style4.glb +0 -0
  43. package/public/icons/new-style5.glb +0 -0
  44. package/public/icons/office.glb +0 -0
  45. package/public/icons/stuff.glb +0 -0
  46. package/public/index.html +203 -182
  47. package/public/mention-picker.js +1 -1
  48. package/public/new-agent.css +7 -7
  49. package/public/new-agent.js +46 -20
  50. package/public/office-viewer.html +340 -0
  51. package/public/onboarding.css +5 -5
  52. package/public/quote-cta.css +5 -4
  53. package/public/quote-cta.js +50 -5
  54. package/public/room-settings.css +24 -9
  55. package/public/stuff-viewer.html +330 -0
  56. package/public/thread.css +1211 -0
  57. package/public/user-settings.css +16 -19
  58. package/public/user-settings.js +86 -78
  59. package/public/vendor/BufferGeometryUtils.js +1434 -0
  60. package/public/vendor/DRACOLoader.js +739 -0
  61. package/public/vendor/GLTFLoader.js +4860 -0
  62. package/public/vendor/RoomEnvironment.js +185 -0
  63. package/public/vendor/SkeletonUtils.js +496 -0
  64. package/public/vendor/draco/draco_decoder.js +34 -0
  65. package/public/vendor/draco/draco_decoder.wasm +0 -0
  66. package/public/vendor/draco/draco_encoder.js +33 -0
  67. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  68. package/public/vendor/meshopt_decoder.module.js +196 -0
  69. package/public/voice-3d-banner.js +12 -0
  70. package/public/voice-3d.js +1407 -432
  71. package/public/voice-clone.css +875 -0
  72. package/public/voice-clone.js +1351 -0
  73. package/public/voice-replay.css +3 -3
  74. package/public/voice-replay.js +21 -0
  75. package/public/avatar-skill.js +0 -629
  76. package/public/icons/folded-sidebar.png +0 -0
@@ -28,9 +28,10 @@
28
28
 
29
29
  Toggle
30
30
  ──────
31
- Gated by `localStorage["boardroom.stage3d"]` ("on" | "off",
32
- default "on"). When off the legacy 2D SVG renders instead · same
33
- code path that shipped before this module existed.
31
+ Retired (2026-05). The voice room is 3D-only · `app.js`
32
+ `renderRoundTable` always calls into VS3D when WebGL is available.
33
+ The old `localStorage["boardroom.stage3d"]` key is no longer
34
+ consulted; any cached value is inert.
34
35
 
35
36
  Why a separate file
36
37
  ───────────────────
@@ -41,6 +42,8 @@
41
42
 
42
43
  import * as THREE from "/vendor/three.module.min.js";
43
44
  import { OrbitControls } from "/vendor/OrbitControls.js";
45
+ import { RoomEnvironment } from "/vendor/RoomEnvironment.js";
46
+ import { loadAvatar3D, buildAvatar3D, isAvatar3DReady, deriveDefaultAvatarConfig, AVATAR_MODELS } from "/avatar-3d.js";
44
47
 
45
48
  (function () {
46
49
  /* ── State held across mount lifecycle ─────────────────────── */
@@ -98,8 +101,6 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
98
101
  const CHAIR_WIDTH = 0.8;
99
102
  const CHAIR_DEPTH = 0.8;
100
103
  const CHAIR_SEAT_H = 0.45;
101
- const CHAIR_BACK_H = 1.15;
102
- const CHAIR_BACK_T = 0.15;
103
104
 
104
105
  /** Director sprite · the rasterised SVG canvas is 1:1 (256×256),
105
106
  * so the sprite MUST also be 1:1 — otherwise nearest-neighbour
@@ -158,6 +159,39 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
158
159
  critique: { wall: 0x6B3F2F, trim: 0x2A1612, rail: 0x4A2A20 },
159
160
  };
160
161
 
162
+ /** Per-tone table finish · the shared table materials are retinted
163
+ * (refreshTable) to echo each room without re-modelling the table.
164
+ * `body` = mid slab, `top` = upper rim, `shade` = floor shadow strip.
165
+ * constructive flips the top to a translucent GLASS slab over a steel
166
+ * body (the one room that reads as a different material, not just a
167
+ * different wood). Tones track the room's walls / floor so the table
168
+ * belongs to the room rather than floating as a neutral default. */
169
+ const TABLE_PALETTE_BY_TONE = {
170
+ brainstorm: { body: 0x3E6B4A, top: 0x4F8460, shade: 0x264A32, material: "plastic" }, // deep forest-green plastic
171
+ debate: { body: 0x6E4F33, top: 0x9E7A4E, shade: 0x3F2C1B, material: "wood" }, // deep chamber oak · matches the debate wall planks
172
+ research: { body: 0x9A7C50, top: 0xC2A06A, shade: 0x5A4530, material: "wood" }, // light library oak
173
+ constructive: { body: 0x4A525C, top: 0x9FB4C2, shade: 0x2E333A, material: "glass" }, // steel frame + glass top
174
+ critique: { body: 0x4A2A20, top: 0x6B3F2F, shade: 0x2A1612, material: "wood" }, // dark mahogany
175
+ };
176
+
177
+ /** Per-tone velvet for the sheen armchair (buildSheenChair) · every
178
+ * room uses the upholstered chair, dressed in its own fabric so the
179
+ * seating belongs to the room. `body` = cushions/back, `lit` = the
180
+ * lighter sheen-catch panel, `shade` = arms/shell, `leg` = splayed
181
+ * legs, `specular` = the Phong sheen highlight.
182
+ * brainstorm · warm ochre on walnut (forest-green/cream room)
183
+ * constructive · cool slate-blue, charcoal metal legs (steel/glass)
184
+ * debate · oxblood club velvet on dark walnut (deep oak chamber)
185
+ * research · bottle-green reading velvet on oak (warm library)
186
+ * critique · graphite velvet on brass legs (dark mahogany + brass) */
187
+ const SHEEN_PALETTE_BY_TONE = {
188
+ brainstorm: { body: 0xBE8A3C, lit: 0xD8A856, shade: 0x9C6F2E, leg: 0x4A3526, specular: 0x4A3A1E },
189
+ constructive: { body: 0x55677A, lit: 0x86A0B6, shade: 0x3C4A59, leg: 0x33373D, specular: 0x2E3942 },
190
+ debate: { body: 0x8A4038, lit: 0xA85A4E, shade: 0x5E2A24, leg: 0x3A2418, specular: 0x3A201A },
191
+ research: { body: 0x3D5A45, lit: 0x577E63, shade: 0x274033, leg: 0x6B4A2C, specular: 0x223A2C },
192
+ critique: { body: 0x3C3C42, lit: 0x5C5C64, shade: 0x28282E, leg: 0x8A6A2E, specular: 0x3A3A40 },
193
+ };
194
+
161
195
  /** Wood palette for the table · matches `.rt-table-*` CSS tokens
162
196
  * (rim / mid / hi / shadow). The 2D table is a flat SVG; the 3D
163
197
  * version uses these for top-face, side-face, and edge-highlight
@@ -181,6 +215,41 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
181
215
  let avatarGroup = null;
182
216
  let floorMesh = null;
183
217
  let tableGroup = null;
218
+ // 3D-avatar integration · the rigged GLB figures replace the sprite
219
+ // billboards on the chairs. Models load async; until ready,
220
+ // buildDirectorFigure falls back to the sprite. `_lastPositions` /
221
+ // `_lastMode` are stashed each update() so the preload completion can
222
+ // re-fire a full rebuild and swap sprites → 3D in one pass.
223
+ let avatar3dReady = false;
224
+ let avatar3dPreloadStarted = false;
225
+ let _lastPositions = null;
226
+ let _lastMode = null;
227
+ const AVATAR_FIG_HEIGHT = 1.55; // feet at y=0; head clears the chair back
228
+ const AVATAR_SEAT_LIFT = 0.4; // raise 3D figures so the body clears the seat cushion
229
+ const MOUTH_OPEN = 0.062; // mouth-overlay max height (fully open) while talking
230
+ const MOUTH_MIN = 0.014; // mouth-overlay min height (closed-mouth line) while talking
231
+ /** Table materials · created fresh in buildTable() each mount and
232
+ * retinted per tone by refreshTable(). Module-scope so refreshTable
233
+ * can reach them; nulled on unmount (rebuilt next mount). */
234
+ let tableBodyMat = null;
235
+ let tableTopMat = null;
236
+ let tableShadeMat = null;
237
+ /** Grayscale surface-grain texture for the table · multiplies the
238
+ * per-tone material colour (white base = colour unchanged, darker
239
+ * streaks = subtle grain) so one neutral texture works for every
240
+ * wood / plastic / steel tint. Built lazily in refreshTable, applied
241
+ * as `.map` to the opaque body / top (skipped on translucent glass /
242
+ * acrylic tops so see-through surfaces stay clean). */
243
+ let tableGrainTex = null;
244
+ /** Per-room 3D furniture · real box meshes against the back wall (NOT
245
+ * painted into the wall texture) so each room has a signature piece
246
+ * with real depth at a scale that matches the table — brainstorm a
247
+ * chest + sofa, debate a lectern, research a bookcase, constructive a
248
+ * steel credenza, critique a mahogany credenza + globe. Rebuilt by
249
+ * refreshFurniture() only when the tone changes; `roomFurnitureMode`
250
+ * tracks what's currently built so an unchanged tone is a no-op. */
251
+ let roomFurniture = null;
252
+ let roomFurnitureMode = null;
184
253
  /** Wall materials · shared across all wall / trim / rail meshes
185
254
  * so a single colour swap (in refreshWallColors) repaints the
186
255
  * whole room when the tone changes. Lazily created in
@@ -202,6 +271,18 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
202
271
  let brainstormWallTexture = null;
203
272
  let constructiveWallTexture = null;
204
273
  let critiqueWallTexture = null;
274
+ // debate → warm-oak forum/chamber paneling with tall daylight
275
+ // windows (mullions + sills), a chair rail, and frame-and-panel
276
+ // wainscot painted in. Same lazy module-singleton lifecycle as the
277
+ // other three; the texture supplies its own rail/baseboard so the
278
+ // debate branch hides `wallTrimGroup` like the other textured tones.
279
+ let debateWallTexture = null;
280
+ // research → light-oak library · built-in bookcases (rows of varied
281
+ // book spines with gilt titles), oak shelf boards + case uprights, a
282
+ // crown, and a cabinet plinth. Scholarly, warm-neutral. Same lazy
283
+ // module-singleton lifecycle; supplies its own trim so the research
284
+ // branch hides `wallTrimGroup`.
285
+ let researchWallTexture = null;
205
286
  /** Procedural plant-baseboard texture · dense foliage band that
206
287
  * hugs the wall-floor seam in the constructive room (replaces a
207
288
  * cold sterile cove with a "boardroom is lived-in" green band).
@@ -294,6 +375,33 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
294
375
  /** Resting camera position the entry animation lerps TO · captured
295
376
  * in mount() after the resting camera is positioned. */
296
377
  let cameraRestPos = null;
378
+
379
+ /** ── Speaker-change camera pulse ──────────────────────────────
380
+ * Every time `activeSpeakerId` flips while the new speaker is
381
+ * actively `speaking`, the camera does one quick cinematic move:
382
+ * dollies + lifts toward the new speaker's seat, peaks at ~45%
383
+ * of the duration, then eases back to the resting position. Reads
384
+ * as a "cut to the next director" scene swap.
385
+ *
386
+ * · Disabled when the entry animation is still running (the entry
387
+ * sequence owns the camera fully for its first 700-1100ms).
388
+ * · OrbitControls is muted for the pulse window so the user's
389
+ * manual orbit doesn't fight the lerp; restored on completion.
390
+ * · Skipped when the new speaker is the user (`isUser`) — the
391
+ * user has no seat figure worth focusing on. */
392
+ const CAMERA_PULSE_DURATION_MS = 1200;
393
+ const CAMERA_PULSE_FORWARD = 5.0; // world units shifted toward the seat
394
+ const CAMERA_PULSE_LIFT = 1.4; // world units the camera rises mid-pulse
395
+ let cameraPulseActive = false;
396
+ let cameraPulseStart = 0;
397
+ let cameraPulseDirX = 0;
398
+ let cameraPulseDirZ = 0;
399
+ /** Tracks the speaker id we last triggered a pulse for so that
400
+ * repeated `update()` calls inside the same turn don't re-fire
401
+ * the animation every frame. Initialised to `undefined` so the
402
+ * FIRST recognised speaker (post-mount entry) DOES skip the pulse
403
+ * — entry animation already provides the cinematic arrival. */
404
+ let lastPulseSpeakerId = undefined;
297
405
  /** Camera params resolved at mount() time · default to the legacy
298
406
  * app values (18 unit distance, 30° elevation, lookAt y = 0.5)
299
407
  * unless `mount(host, { camera: { ... } })` overrides them. The
@@ -521,13 +629,30 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
521
629
  // ── three.js core ──
522
630
  renderer = new THREE.WebGLRenderer({
523
631
  canvas: canvasEl,
524
- antialias: false, // pixel-art register · sharper without AA
632
+ // Smooth rigged-GLB avatars sit on the chairs now · antialias on
633
+ // softens the voxel chairs/table slightly but the character edges
634
+ // benefit far more than the props lose.
635
+ antialias: true,
525
636
  alpha: true,
526
637
  });
527
638
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
528
639
  renderer.setClearColor(0x000000, 0);
640
+ // ACES tone mapping · the avatar materials (MeshStandard + env map)
641
+ // are authored for it. Exposure slightly above the customizer's 0.7
642
+ // to partly compensate for ACES darkening the existing voxel props.
643
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
644
+ renderer.toneMappingExposure = 0.85;
529
645
 
530
646
  scene = new THREE.Scene();
647
+ // Image-based lighting · a PMREM-filtered RoomEnvironment gives the
648
+ // avatars' skin/hair their gloss. Lambert/Phong props ignore env maps,
649
+ // so the env contribution lands almost entirely on the avatars — kept
650
+ // modest so it lifts them without washing out.
651
+ {
652
+ const pmrem = new THREE.PMREMGenerator(renderer);
653
+ scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
654
+ scene.environmentIntensity = 0.25;
655
+ }
531
656
 
532
657
  // Camera · matches the 2D view's orientation. The 2D layout is
533
658
  // a top-down rectangle with the long table edge running screen-
@@ -587,7 +712,7 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
587
712
  // legible without losing the "indoor lamp" mood.
588
713
  const ambient = new THREE.AmbientLight(0xffffff, 0.55);
589
714
  scene.add(ambient);
590
- const key = new THREE.DirectionalLight(0xffe9c8, 0.85);
715
+ const key = new THREE.DirectionalLight(0xffe9c8, 0.92);
591
716
  key.position.set(4, 8, 5);
592
717
  scene.add(key);
593
718
  const fill = new THREE.DirectionalLight(0x9ec0ff, 0.25);
@@ -624,6 +749,9 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
624
749
  scene.add(buildBushyPlant(STAGE_HALF_X * 0.78, STAGE_HALF_Z * -0.75));
625
750
  scene.add(buildSnakePlant(STAGE_HALF_X * -0.65, STAGE_HALF_Z * 0.60));
626
751
 
752
+ // Per-room 3D furniture is built lazily by refreshFurniture() on the
753
+ // first update() (once the tone is known) and rebuilt on tone change.
754
+
627
755
  // Resize handling · the stage is inside a flex chat-col, so its
628
756
  // pixel dimensions change as the user resizes the window or
629
757
  // collapses/expands the sidebar. ResizeObserver fires whenever
@@ -728,6 +856,19 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
728
856
  // RAF loop · animations + render each frame.
729
857
  startRaf();
730
858
 
859
+ // Preload the avatar GLB templates (best-effort, non-blocking). Until
860
+ // they resolve, seats render the sprite fallback; once ready we re-fire
861
+ // the last rebuild so the directors swap to their 3D figures.
862
+ if (!avatar3dPreloadStarted) {
863
+ avatar3dPreloadStarted = true;
864
+ Promise.all(AVATAR_MODELS.map((m) => loadAvatar3D(m.id)))
865
+ .then(() => {
866
+ avatar3dReady = true;
867
+ if (avatarGroup && _lastPositions) rebuildSeats(_lastPositions, _lastMode);
868
+ })
869
+ .catch((e) => { console.warn("[voice-3d] avatar3d preload failed; keeping sprites", e); });
870
+ }
871
+
731
872
  return true;
732
873
  }
733
874
 
@@ -852,8 +993,20 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
852
993
  camera = null;
853
994
  chairGroup = null;
854
995
  avatarGroup = null;
996
+ // Reset the per-mount preload trigger + stashed render args (the GLB
997
+ // templates themselves stay cached in avatar-3d.js across mounts). On
998
+ // remount the preload block re-runs and re-fires a rebuild once ready.
999
+ avatar3dPreloadStarted = false;
1000
+ _lastPositions = null;
1001
+ _lastMode = null;
855
1002
  floorMesh = null;
856
1003
  tableGroup = null;
1004
+ tableBodyMat = null;
1005
+ tableTopMat = null;
1006
+ tableShadeMat = null;
1007
+ if (tableGrainTex) { try { tableGrainTex.dispose(); } catch (_) {} tableGrainTex = null; }
1008
+ roomFurniture = null;
1009
+ roomFurnitureMode = null;
857
1010
  // Wall / trim / rail materials are shared module-scope
858
1011
  // singletons (lazy-created in buildBoardroomWalls). We KEEP
859
1012
  // them alive across unmount cycles so a room swap doesn't
@@ -926,9 +1079,26 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
926
1079
  const mode = (state && state.mode) || "constructive";
927
1080
  rebuildFloor(mode);
928
1081
  refreshWallColors(mode);
929
- rebuildSeats(state && state.positions ? state.positions : []);
1082
+ refreshTable(mode);
1083
+ refreshFurniture(mode);
1084
+ _lastPositions = state && state.positions ? state.positions : [];
1085
+ _lastMode = mode;
1086
+ rebuildSeats(_lastPositions, mode);
1087
+ const prevSpeakerIdSnapshot = activeSpeakerId;
930
1088
  activeSpeakerId = (state && state.speakerId) || null;
931
1089
  activeSpeakerState = (state && state.speakerState) || null;
1090
+ // Trigger the "scene-cut to new director" camera pulse when the
1091
+ // speaker actually changed AND the new turn is in `speaking`
1092
+ // (not just `thinking` — thinking phase is transient and a
1093
+ // pulse there would feel like camera jitter when the bubble
1094
+ // flips text without an audible audio swap).
1095
+ if (
1096
+ activeSpeakerId
1097
+ && activeSpeakerId !== prevSpeakerIdSnapshot
1098
+ && activeSpeakerState === "speaking"
1099
+ ) {
1100
+ maybeTriggerSpeakerCameraPulse(activeSpeakerId);
1101
+ }
932
1102
  activeUserWait = !!(state && state.userWait);
933
1103
  activeUserBubble = (state && state.userBubble && typeof state.userBubble === "object"
934
1104
  && typeof state.userBubble.text === "string" && state.userBubble.text.trim())
@@ -968,12 +1138,33 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
968
1138
  if (!wallMat || !trimMat || !railMat) return;
969
1139
  const palette = WALL_PALETTE_BY_TONE[mode] || WALL_PALETTE_BY_TONE.constructive;
970
1140
 
1141
+ // Default · walls are lit by the room lights only. The brainstorm
1142
+ // nature-vista opts into self-illumination below (emissive map) so
1143
+ // its daylight reads bright in the dim room; reset emissive here so
1144
+ // every OTHER tone stays normally lit.
1145
+ if (wallMat.emissiveMap || (wallMat.emissive && wallMat.emissive.getHex() !== 0x000000)) {
1146
+ wallMat.emissive.setHex(0x000000);
1147
+ wallMat.emissiveMap = null;
1148
+ wallMat.needsUpdate = true;
1149
+ }
1150
+
971
1151
  if (mode === "brainstorm") {
972
1152
  if (!brainstormWallTexture) brainstormWallTexture = buildBrainstormWallTexture();
973
1153
  if (wallMat.map !== brainstormWallTexture) {
974
1154
  wallMat.map = brainstormWallTexture;
975
1155
  wallMat.needsUpdate = true;
976
1156
  }
1157
+ // Gentle self-illumination · the same texture as an emissive map
1158
+ // lifts the cozy interior so it reads in the dim room without
1159
+ // flattening the baked furniture shading. Kept VERY LOW (0.10) —
1160
+ // the cream field is large and even 0.22 still self-glowed enough
1161
+ // to read as glaring on the voice-room stage. The room lights
1162
+ // (ambient + key) carry the base wall brightness; this is just a
1163
+ // faint lift on top so the daylight window doesn't go flat.
1164
+ wallMat.emissive.setHex(0xFFFFFF);
1165
+ wallMat.emissiveMap = brainstormWallTexture;
1166
+ wallMat.emissiveIntensity = 0.10;
1167
+ wallMat.needsUpdate = true;
977
1168
  // White color so the texture renders un-tinted under Lambert
978
1169
  // shading. Lambert multiplies color × map per pixel.
979
1170
  wallMat.color.setHex(0xFFFFFF);
@@ -986,14 +1177,19 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
986
1177
  if (mode === "constructive") {
987
1178
  if (!constructiveWallTexture) constructiveWallTexture = buildConstructiveWallTexture();
988
1179
  if (wallMat.map !== constructiveWallTexture) {
1180
+ // Tile the elevation twice across the wide wall so its features
1181
+ // read at ~half the table width, not a single oversized print.
1182
+ constructiveWallTexture.wrapS = THREE.RepeatWrapping;
1183
+ constructiveWallTexture.repeat.x = 2;
989
1184
  wallMat.map = constructiveWallTexture;
990
1185
  wallMat.needsUpdate = true;
991
1186
  }
992
1187
  wallMat.color.setHex(0xFFFFFF);
993
- // Hide the original baseboard / chair-rail · the plant band
994
- // takes over the bottom of the wall here.
1188
+ // Glass curtain wall paints its own steel/concrete spandrel base,
1189
+ // so hide both the flat-paint trim band AND the foliage band (a
1190
+ // planter at the foot of a glass partition would clash).
995
1191
  if (wallTrimGroup) wallTrimGroup.visible = false;
996
- if (plantBaseboardGroup) plantBaseboardGroup.visible = true;
1192
+ if (plantBaseboardGroup) plantBaseboardGroup.visible = false;
997
1193
  if (woodBaseboardGroup) woodBaseboardGroup.visible = false;
998
1194
  return;
999
1195
  }
@@ -1001,13 +1197,58 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1001
1197
  if (mode === "critique") {
1002
1198
  if (!critiqueWallTexture) critiqueWallTexture = buildCritiqueWallTexture();
1003
1199
  if (wallMat.map !== critiqueWallTexture) {
1200
+ critiqueWallTexture.wrapS = THREE.RepeatWrapping;
1201
+ critiqueWallTexture.repeat.x = 2;
1004
1202
  wallMat.map = critiqueWallTexture;
1005
1203
  wallMat.needsUpdate = true;
1006
1204
  }
1007
1205
  wallMat.color.setHex(0xFFFFFF);
1206
+ // Mahogany panelling paints its own chair-rail + plinth, so hide
1207
+ // every trim band (the old sandstone wall needed the wood
1208
+ // baseboard; the panelled wall does not).
1008
1209
  if (wallTrimGroup) wallTrimGroup.visible = false;
1009
1210
  if (plantBaseboardGroup) plantBaseboardGroup.visible = false;
1010
- if (woodBaseboardGroup) woodBaseboardGroup.visible = true;
1211
+ if (woodBaseboardGroup) woodBaseboardGroup.visible = false;
1212
+ return;
1213
+ }
1214
+
1215
+ if (mode === "debate") {
1216
+ if (!debateWallTexture) debateWallTexture = buildDebateWallTexture();
1217
+ if (wallMat.map !== debateWallTexture) {
1218
+ debateWallTexture.wrapS = THREE.RepeatWrapping;
1219
+ debateWallTexture.repeat.x = 2;
1220
+ wallMat.map = debateWallTexture;
1221
+ wallMat.needsUpdate = true;
1222
+ }
1223
+ wallMat.color.setHex(0xFFFFFF);
1224
+ // The texture paints its own chair-rail + wainscot + baseboard,
1225
+ // so hide the flat-paint trim band (a horizontal rail strip would
1226
+ // otherwise slice across the windows / wainscot panels).
1227
+ if (wallTrimGroup) wallTrimGroup.visible = false;
1228
+ if (plantBaseboardGroup) plantBaseboardGroup.visible = false;
1229
+ if (woodBaseboardGroup) woodBaseboardGroup.visible = false;
1230
+ return;
1231
+ }
1232
+
1233
+ if (mode === "research") {
1234
+ if (!researchWallTexture) researchWallTexture = buildResearchWallTexture();
1235
+ if (wallMat.map !== researchWallTexture) {
1236
+ // No horizontal tiling for the bookcase · repeat.x=2 squeezed the
1237
+ // spines into thin slivers (~0.12–0.29 world units wide) that read
1238
+ // as too-narrow next to the 0.8-wide directors. At repeat.x=1 the
1239
+ // 3 bays span the full 26-unit back wall and book widths land in a
1240
+ // believable range against the seated figures.
1241
+ researchWallTexture.wrapS = THREE.ClampToEdgeWrapping;
1242
+ researchWallTexture.repeat.x = 1;
1243
+ wallMat.map = researchWallTexture;
1244
+ wallMat.needsUpdate = true;
1245
+ }
1246
+ wallMat.color.setHex(0xFFFFFF);
1247
+ // Bookcases + crown + plinth are painted into the texture; hide
1248
+ // the flat-paint trim band so it doesn't cut across the shelves.
1249
+ if (wallTrimGroup) wallTrimGroup.visible = false;
1250
+ if (plantBaseboardGroup) plantBaseboardGroup.visible = false;
1251
+ if (woodBaseboardGroup) woodBaseboardGroup.visible = false;
1011
1252
  return;
1012
1253
  }
1013
1254
 
@@ -1023,12 +1264,14 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1023
1264
  if (woodBaseboardGroup) woodBaseboardGroup.visible = false;
1024
1265
  }
1025
1266
 
1026
- /** Procedural pixel-art brick wall painted into a canvas, returned
1027
- * as a NearestFilter CanvasTexture so the chunky aesthetic of the
1028
- * rest of the scene carries through. Matches `public/icons/wall.png`
1029
- * running-bond red bricks with deep mortar lines, irregular
1030
- * grey stone bands top + bottom, and green moss clusters scattered
1031
- * along the mortar / stone seams. */
1267
+ /** Procedural pixel-art COZY MODERN INTERIOR · a warm low-poly room
1268
+ * modelled on the three.js skinning-IK example's furnished scene:
1269
+ * cream walls, a daylight window, a wooden chest of drawers, a sage
1270
+ * sofa with framed art above it, and a leafy floor plant. The same
1271
+ * texture wraps all three walls so the directors sit in a furnished
1272
+ * lounge. Palette locked with the user · sage #8CA07E sofa, cream
1273
+ * #E4DAC8 walls, oak #B08A5A wood. refreshWallColors() gives the wall
1274
+ * a gentle emissive lift so the room reads bright. */
1032
1275
  function buildBrainstormWallTexture() {
1033
1276
  const W = 1024, H = 512;
1034
1277
  const canvas = document.createElement("canvas");
@@ -1037,76 +1280,44 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1037
1280
  const ctx = canvas.getContext("2d");
1038
1281
  ctx.imageSmoothingEnabled = false;
1039
1282
 
1040
- // Mortar base · dark warm grey fills the whole canvas so any
1041
- // gaps between bricks / stones read as mortar lines instead of
1042
- // background bleed-through.
1043
- ctx.fillStyle = "#3A302B";
1044
- ctx.fillRect(0, 0, W, H);
1045
-
1046
- // Stone bands · irregular grey block strips along the top and
1047
- // bottom edges, mirroring the reference's framing detail.
1048
- const stoneBandH = 38;
1049
- drawStoneBand(ctx, W, 0, stoneBandH, mulberry32(101));
1050
- drawStoneBand(ctx, W, H - stoneBandH, stoneBandH, mulberry32(211));
1051
-
1052
- // Brick courses · running-bond pattern between the two stone
1053
- // bands. Each brick gets a slight per-block colour wobble + a
1054
- // top-edge highlight + bottom-edge shadow to suggest stacked
1055
- // clay courses.
1056
- const brickW = 46;
1057
- const brickH = 16;
1058
- const mortar = 3;
1059
- const brickPalette = [
1060
- "#A03A2A", "#B24535", "#92322A", "#BC5040",
1061
- "#8B3025", "#A8413A", "#9F3B2E", "#B54838",
1062
- ];
1063
- const brickHighlight = "#D26E5C";
1064
- const brickShadow = "#5E2017";
1065
- const rand = mulberry32(7);
1066
-
1067
- const courseTop = stoneBandH;
1068
- const courseBottom = H - stoneBandH;
1069
- let row = 0;
1070
- for (let y = courseTop; y < courseBottom; y += brickH) {
1071
- const stagger = (row % 2 === 0) ? 0 : -Math.floor(brickW / 2);
1072
- for (let i = -1; i < Math.ceil(W / brickW) + 2; i++) {
1073
- const bx = i * brickW + stagger;
1074
- const color = brickPalette[Math.floor(rand() * brickPalette.length)];
1075
- const x0 = bx + Math.floor(mortar / 2);
1076
- const y0 = y + Math.floor(mortar / 2);
1077
- const w = brickW - mortar;
1078
- const h = brickH - mortar;
1079
- ctx.fillStyle = color;
1080
- ctx.fillRect(x0, y0, w, h);
1081
- ctx.fillStyle = brickHighlight;
1082
- ctx.fillRect(x0, y0, w, 1);
1083
- ctx.fillStyle = brickShadow;
1084
- ctx.fillRect(x0, y0 + h - 1, w, 1);
1085
- }
1086
- row += 1;
1087
- }
1088
-
1089
- // Moss patches · scatter green clusters along brick seams + at
1090
- // the stone-band boundaries. Drawn after bricks so they overlap
1091
- // and read as growth on the wall, not under it.
1092
- const mossPalette = ["#5E7A3A", "#6E8E48", "#82A656", "#476830"];
1093
- const mossRand = mulberry32(53);
1094
- for (let n = 0; n < 56; n++) {
1095
- const mx = Math.floor(mossRand() * W);
1096
- // Bias moss toward the stone-band edges + scattered through
1097
- // the brick field. ~1/3 hugging the top band, ~1/6 hugging
1098
- // the bottom, the rest spread through the brick field.
1099
- const zone = mossRand();
1100
- let my;
1101
- if (zone < 0.33) {
1102
- my = stoneBandH - 6 + Math.floor(mossRand() * 18);
1103
- } else if (zone < 0.50) {
1104
- my = H - stoneBandH - 8 + Math.floor(mossRand() * 14);
1105
- } else {
1106
- my = stoneBandH + Math.floor(mossRand() * (H - stoneBandH * 2));
1107
- }
1108
- drawMossBlob(ctx, mx, my, mossPalette, mossRand);
1109
- }
1283
+ const P = {
1284
+ // Softer, deeper cream than the original #E4DAC8 the brighter
1285
+ // cream read as glaring on the voice-room stage once the wall
1286
+ // self-illuminates. This is a muted, gentler warm tone.
1287
+ WALL: "#CEC2AB", WALL_HI: "#DACFBB", WALL_SH: "#BEB298",
1288
+ BASE: "#C9BB9E", BASE_DK: "#AE9F82",
1289
+ WOOD: "#B08A5A", WOOD_HI: "#C8A472", WOOD_DK: "#8A6A3E",
1290
+ };
1291
+ const FLOOR = 498; // wall meets floor / furniture baseline
1292
+
1293
+ // Wall · warm cream, lifted near the ceiling, gently shaded toward
1294
+ // the floor so the room has soft ambient depth.
1295
+ ctx.fillStyle = P.WALL; ctx.fillRect(0, 0, W, H);
1296
+ const topLight = ctx.createLinearGradient(0, 0, 0, 130);
1297
+ topLight.addColorStop(0, "rgba(255,250,240,0.18)");
1298
+ topLight.addColorStop(1, "rgba(255,250,240,0)");
1299
+ ctx.fillStyle = topLight; ctx.fillRect(0, 0, W, 130);
1300
+ const floorShade = ctx.createLinearGradient(0, H * 0.45, 0, FLOOR);
1301
+ floorShade.addColorStop(0, "rgba(150,135,105,0)");
1302
+ floorShade.addColorStop(1, "rgba(150,135,105,0.16)");
1303
+ ctx.fillStyle = floorShade; ctx.fillRect(0, Math.round(H * 0.45), W, FLOOR - Math.round(H * 0.45));
1304
+
1305
+ // Baseboard · behind the furniture, where wall meets floor.
1306
+ ctx.fillStyle = P.BASE; ctx.fillRect(0, FLOOR, W, H - FLOOR);
1307
+ ctx.fillStyle = P.BASE_DK; ctx.fillRect(0, FLOOR, W, 2);
1308
+
1309
+ // Wall backdrop · two glass windows (wood frame + clean glass, no
1310
+ // painted view) + framed art on the cream wall. The chest / sofa /
1311
+ // plant are NOT painted here — they're real 3D furniture meshes in
1312
+ // the room (buildRoomFurniture) so they have actual depth and a scale
1313
+ // that matches the table, instead of a flat wallpaper print.
1314
+ // Windows + art sit in the wall's LOWER band · the camera frames the
1315
+ // table and only the lower strip of the back wall is in view at init,
1316
+ // so anything up near y=60 would be cropped off-screen. Dropping them
1317
+ // to ~y=248 puts a window in the opening shot.
1318
+ drawWindowGlass(ctx, 96, 248, 232, 190, P);
1319
+ drawWallArt(ctx, 430, 285, 164, 116, P);
1320
+ drawWindowGlass(ctx, 696, 248, 232, 190, P);
1110
1321
 
1111
1322
  const tex = new THREE.CanvasTexture(canvas);
1112
1323
  tex.magFilter = THREE.NearestFilter;
@@ -1116,46 +1327,63 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1116
1327
  return tex;
1117
1328
  }
1118
1329
 
1119
- /** Irregular grey stone band · packs variable-width stone blocks
1120
- * along a horizontal strip with thin mortar gaps. Each stone
1121
- * picks a grey tone + gets a 1px top highlight / bottom shadow
1122
- * so the band reads as cobbled masonry instead of one flat slab. */
1123
- function drawStoneBand(ctx, W, yStart, height, rand) {
1124
- const palette = ["#5C5650", "#6E6862", "#4A4540", "#7A746E", "#635B54"];
1125
- const highlight = "#8A847C";
1126
- const shadow = "#2E2823";
1127
- let x = 0;
1128
- while (x < W) {
1129
- const w = 18 + Math.floor(rand() * 42);
1130
- const color = palette[Math.floor(rand() * palette.length)];
1131
- ctx.fillStyle = color;
1132
- ctx.fillRect(x, yStart + 1, w, height - 2);
1133
- ctx.fillStyle = highlight;
1134
- ctx.fillRect(x, yStart + 1, w, 1);
1135
- ctx.fillStyle = shadow;
1136
- ctx.fillRect(x, yStart + height - 2, w, 1);
1137
- x += w + 2; // 2px mortar gap between stones
1138
- }
1330
+ /** Glass window · warm-oak frame + sill around clean glass — no
1331
+ * painted sky / landscape, just a pale cool pane with soft diagonal
1332
+ * reflection sheen so it reads as transparent glass rather than a
1333
+ * sealed board. A wood mullion cross splits it into four panes. */
1334
+ function drawWindowGlass(ctx, x, y, w, h, P) {
1335
+ const fr = 8;
1336
+ // Outer oak frame · dark edge, mid face, lit top, shaded bottom.
1337
+ ctx.fillStyle = P.WOOD_DK; ctx.fillRect(x - fr, y - fr, w + fr * 2, h + fr * 2);
1338
+ ctx.fillStyle = P.WOOD; ctx.fillRect(x - fr + 2, y - fr + 2, w + fr * 2 - 4, h + fr * 2 - 4);
1339
+ ctx.fillStyle = P.WOOD_HI; ctx.fillRect(x - fr + 2, y - fr + 2, w + fr * 2 - 4, 2);
1340
+ ctx.fillStyle = P.WOOD_DK; ctx.fillRect(x - fr + 2, y + h + fr - 4, w + fr * 2 - 4, 2);
1341
+
1342
+ // Glass pane · pale cool gradient, NOT a sky view.
1343
+ const g = ctx.createLinearGradient(0, y, 0, y + h);
1344
+ g.addColorStop(0, "#CAD6D8");
1345
+ g.addColorStop(1, "#E3E9E6");
1346
+ ctx.fillStyle = g; ctx.fillRect(x, y, w, h);
1347
+ // Inner shadow just under the head jamb so the glass sits recessed.
1348
+ ctx.fillStyle = "rgba(70,82,82,0.18)"; ctx.fillRect(x, y, w, 4);
1349
+
1350
+ // Reflection sheen · two soft slanted light streaks across the pane.
1351
+ ctx.save();
1352
+ ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
1353
+ ctx.fillStyle = "rgba(255,255,255,0.22)";
1354
+ const streak = (ox, sw) => {
1355
+ ctx.beginPath();
1356
+ ctx.moveTo(x + ox, y);
1357
+ ctx.lineTo(x + ox + sw, y);
1358
+ ctx.lineTo(x + ox + sw - h * 0.55, y + h);
1359
+ ctx.lineTo(x + ox - h * 0.55, y + h);
1360
+ ctx.closePath(); ctx.fill();
1361
+ };
1362
+ streak(w * 0.16, 20);
1363
+ streak(w * 0.44, 10);
1364
+ ctx.restore();
1365
+
1366
+ // Wood mullion cross → four panes, lit on the upper/left face.
1367
+ const cx = x + Math.round(w / 2);
1368
+ const cy = y + Math.round(h / 2);
1369
+ ctx.fillStyle = P.WOOD; ctx.fillRect(cx - 4, y, 8, h); ctx.fillRect(x, cy - 4, w, 8);
1370
+ ctx.fillStyle = P.WOOD_HI; ctx.fillRect(cx - 4, y, 2, h); ctx.fillRect(x, cy - 4, w, 2);
1371
+
1372
+ // Sill · a proud wood ledge under the window.
1373
+ ctx.fillStyle = P.WOOD; ctx.fillRect(x - fr - 4, y + h + fr, w + fr * 2 + 8, 8);
1374
+ ctx.fillStyle = P.WOOD_HI; ctx.fillRect(x - fr - 4, y + h + fr, w + fr * 2 + 8, 2);
1139
1375
  }
1140
1376
 
1141
- /** Moss blob · small irregular cluster of green pixels, roughly
1142
- * diamond-shaped so it reads as a growth patch instead of a
1143
- * rectangle. Picks a tone per blob from the supplied palette. */
1144
- function drawMossBlob(ctx, cx, cy, palette, rand) {
1145
- const color = palette[Math.floor(rand() * palette.length)];
1146
- const w = 10 + Math.floor(rand() * 18);
1147
- const h = 5 + Math.floor(rand() * 7);
1148
- ctx.fillStyle = color;
1149
- for (let py = 0; py < h; py++) {
1150
- const taper = Math.floor(Math.abs(py - h / 2) * 1.6);
1151
- const rowW = Math.max(2, w - taper);
1152
- const offset = Math.floor((w - rowW) / 2);
1153
- ctx.fillRect(cx + offset, cy + py, rowW, 1);
1154
- }
1155
- const hi = palette[Math.min(palette.length - 1, Math.floor(rand() * palette.length))];
1156
- ctx.fillStyle = hi;
1157
- ctx.fillRect(cx + Math.floor(w * 0.3), cy, 2, 1);
1158
- ctx.fillRect(cx + Math.floor(w * 0.6), cy + 1, 2, 1);
1377
+ /** Framed wall art · oak frame + a soft warm abstract that echoes the
1378
+ * room palette (a sage block + a terracotta accent over a cream field). */
1379
+ function drawWallArt(ctx, x, y, w, h, P) {
1380
+ ctx.fillStyle = P.WOOD_DK; ctx.fillRect(x - 4, y - 4, w + 8, h + 8);
1381
+ ctx.fillStyle = P.WOOD; ctx.fillRect(x - 4, y - 4, w + 8, 2);
1382
+ ctx.fillStyle = "#E8E0D2"; ctx.fillRect(x, y, w, h);
1383
+ ctx.fillStyle = "#C9B79A"; ctx.fillRect(x, y + Math.round(h * 0.55), w, Math.round(h * 0.45));
1384
+ ctx.fillStyle = "#A9B89A"; ctx.fillRect(x + Math.round(w * 0.15), y + Math.round(h * 0.30), Math.round(w * 0.4), Math.round(h * 0.3));
1385
+ ctx.fillStyle = "#C58A5E"; ctx.fillRect(x + Math.round(w * 0.62), y + Math.round(h * 0.18), Math.round(w * 0.22), Math.round(h * 0.5));
1386
+ ctx.fillStyle = "#8A7A60"; ctx.fillRect(x, y + Math.round(h * 0.55), w, 1);
1159
1387
  }
1160
1388
 
1161
1389
  /** Procedural pixel-art stone wall · modelled after
@@ -1173,140 +1401,174 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1173
1401
  const ctx = canvas.getContext("2d");
1174
1402
  ctx.imageSmoothingEnabled = false;
1175
1403
 
1176
- // Mortar base · near-black cool grey fills the canvas so any
1177
- // gap between stone blocks reads as a deep seam.
1178
- ctx.fillStyle = "#15171C";
1179
- ctx.fillRect(0, 0, W, H);
1404
+ // ── Modern corporate CURTAIN WALL · steel mullions + cool dusk
1405
+ // glass. Replaces the old stone-and-moss surface. Glass is painted
1406
+ // in discrete colour bands (pixel-art, not a smooth gradient) so
1407
+ // the chunky NearestFilter read carries through; distant warm dots
1408
+ // in the lower panes suggest a city at dusk behind the glazing. ──
1409
+ const STEEL_DEEP = "#23272E", STEEL = "#3A424C", STEEL_HI = "#6E7A88";
1410
+ const SPANDREL = "#2C3138";
1411
+ const GLASS = ["#7C8DA0", "#6A7C90", "#586A7E", "#48596C", "#3A4858"];
1412
+ const SHEEN = "rgba(200,220,235,0.18)";
1413
+ const CITY_LIGHT = ["#C9A86A", "#D8C088", "#9FB4C2"];
1414
+ const rand = mulberry32(13);
1180
1415
 
1181
- // Stone palette · mostly cool slate greys with a handful of
1182
- // warm rust blocks for character (matches the reference's few
1183
- // tan / amber stones scattered through the field).
1184
- const stonePalette = [
1185
- "#6B7382", "#5C636F", "#7A8290", "#535A65",
1186
- "#67707D", "#737B89", "#5F6772",
1187
- ];
1188
- const stoneWarm = ["#7E6B58", "#8B7560", "#74614E"];
1189
- const highlightTone = (hex) => {
1190
- // Lift R/G/B by ~28 to make a top-edge highlight without
1191
- // pulling away from the base hue. Clamp at 255.
1192
- const n = parseInt(hex.slice(1), 16);
1193
- const r = Math.min(255, ((n >> 16) & 0xff) + 28);
1194
- const g = Math.min(255, ((n >> 8) & 0xff) + 28);
1195
- const b = Math.min(255, (n & 0xff) + 28);
1196
- return `rgb(${r},${g},${b})`;
1197
- };
1198
- const shadowTone = (hex) => {
1199
- const n = parseInt(hex.slice(1), 16);
1200
- const r = Math.max(0, ((n >> 16) & 0xff) - 32);
1201
- const g = Math.max(0, ((n >> 8) & 0xff) - 32);
1202
- const b = Math.max(0, (n & 0xff) - 32);
1203
- return `rgb(${r},${g},${b})`;
1204
- };
1416
+ // Backdrop · deep steel shows in any gap behind the glazing.
1417
+ ctx.fillStyle = STEEL_DEEP;
1418
+ ctx.fillRect(0, 0, W, H);
1205
1419
 
1206
- // Stone rows · each row picks its own height (38-58px), then
1207
- // lays variable-width blocks across with mortar gaps. Row
1208
- // starts at a per-row x offset so neighbouring rows don't
1209
- // share vertical seams (broken-bond pattern, like masonry).
1210
- const mortar = 2;
1211
- const rand = mulberry32(13);
1212
- let y = 0;
1213
- let rowIdx = 0;
1214
- while (y < H) {
1215
- const rowH = 16 + Math.floor(rand() * 10);
1216
- const startOffset = Math.floor(rand() * 30);
1217
- let x = -startOffset;
1218
- while (x < W) {
1219
- const w = 24 + Math.floor(rand() * 28);
1220
- const useWarm = rand() < 0.10;
1221
- const base = useWarm
1222
- ? stoneWarm[Math.floor(rand() * stoneWarm.length)]
1223
- : stonePalette[Math.floor(rand() * stonePalette.length)];
1224
- const x0 = x + mortar;
1225
- const y0 = y + mortar;
1226
- const sw = w - mortar;
1227
- const sh = rowH - mortar;
1228
- // Block body.
1229
- ctx.fillStyle = base;
1230
- ctx.fillRect(x0, y0, sw, sh);
1231
- // Worn corners · clip a 2×2 chunk out of each corner so the
1232
- // block reads as a rounded river stone instead of a sharp
1233
- // rectangle. Mortar shows through the clip.
1234
- ctx.fillStyle = "#15171C";
1235
- ctx.fillRect(x0, y0, 2, 2);
1236
- ctx.fillRect(x0 + sw - 2, y0, 2, 2);
1237
- ctx.fillRect(x0, y0 + sh - 2, 2, 2);
1238
- ctx.fillRect(x0 + sw - 2, y0 + sh - 2, 2, 2);
1239
- // Top-edge highlight (skip the worn corners).
1240
- ctx.fillStyle = highlightTone(base);
1241
- ctx.fillRect(x0 + 2, y0, sw - 4, 1);
1242
- // Bottom-edge shadow.
1243
- ctx.fillStyle = shadowTone(base);
1244
- ctx.fillRect(x0 + 2, y0 + sh - 1, sw - 4, 1);
1245
- // A faint inner crack / blemish on ~30% of stones for
1246
- // texture variation.
1247
- if (rand() < 0.25 && sw > 12 && sh > 10) {
1248
- ctx.fillStyle = shadowTone(base);
1249
- const cx = x0 + 3 + Math.floor(rand() * Math.max(1, sw - 9));
1250
- const cy = y0 + 3 + Math.floor(rand() * Math.max(1, sh - 8));
1251
- const cl = 3 + Math.floor(rand() * 6);
1252
- if (rand() < 0.5) ctx.fillRect(cx, cy, cl, 1);
1253
- else ctx.fillRect(cx, cy, 1, cl);
1254
- }
1255
- x += w;
1420
+ // Header beam · top steel rail.
1421
+ ctx.fillStyle = STEEL; ctx.fillRect(0, 0, W, 20);
1422
+ ctx.fillStyle = STEEL_HI; ctx.fillRect(0, 0, W, 2);
1423
+ ctx.fillStyle = STEEL_DEEP; ctx.fillRect(0, 20, W, 3);
1424
+
1425
+ // Glazing · 5 bays × 4 rows of panes between header and spandrel.
1426
+ const glassTop = 23, glassBot = 470;
1427
+ const cols = 5, rows = 4;
1428
+ const mull = 8;
1429
+ const bayW = (W - mull) / cols;
1430
+ const paneRowH = (glassBot - glassTop - mull) / rows;
1431
+ for (let c = 0; c < cols; c++) {
1432
+ for (let r = 0; r < rows; r++) {
1433
+ const px = Math.round(mull + c * bayW);
1434
+ const py = Math.round(glassTop + mull + r * paneRowH);
1435
+ const pw = Math.round(bayW - mull);
1436
+ const ph = Math.round(paneRowH - mull);
1437
+ drawGlassPane(ctx, px, py, pw, ph, r, rows, GLASS, SHEEN, CITY_LIGHT, rand);
1256
1438
  }
1257
- y += rowH;
1258
- rowIdx += 1;
1259
1439
  }
1260
1440
 
1261
- // Moss patches · scatter green clusters along stone seams +
1262
- // pool moss at top + bottom of the wall (the reference has
1263
- // moss running along the upper edge and pooling at the floor).
1264
- const mossPalette = ["#5E7A3A", "#6E8E48", "#82A656", "#476830", "#7C9A48"];
1265
- const mossRand = mulberry32(91);
1266
- // Top moss band · several clusters along the top 36 px.
1267
- for (let n = 0; n < 28; n++) {
1268
- const mx = Math.floor(mossRand() * W);
1269
- const my = Math.floor(mossRand() * 30);
1270
- drawMossBlob(ctx, mx, my, mossPalette, mossRand);
1271
- }
1272
- // Bottom moss band · taller clusters along the bottom 50 px
1273
- // so the wall reads as "rooted in earth" at floor level.
1274
- for (let n = 0; n < 36; n++) {
1275
- const mx = Math.floor(mossRand() * W);
1276
- const my = H - 40 + Math.floor(mossRand() * 36);
1277
- drawMossBlob(ctx, mx, my, mossPalette, mossRand);
1278
- }
1279
- // Mid-wall moss · sparse clusters along internal seams.
1280
- for (let n = 0; n < 24; n++) {
1281
- const mx = Math.floor(mossRand() * W);
1282
- const my = 60 + Math.floor(mossRand() * (H - 120));
1283
- drawMossBlob(ctx, mx, my, mossPalette, mossRand);
1284
- }
1285
-
1286
- // Falling vines · a few thin vertical strands of green pixels
1287
- // hanging from the top down a third of the wall, mirroring the
1288
- // ivy strands in the reference.
1289
- const vinePalette = ["#5E7A3A", "#476830", "#3F5F2A"];
1290
- for (let v = 0; v < 9; v++) {
1291
- const vx = Math.floor(mossRand() * W);
1292
- const vy0 = Math.floor(mossRand() * 30);
1293
- const vLen = 60 + Math.floor(mossRand() * 110);
1294
- const tone = vinePalette[Math.floor(mossRand() * vinePalette.length)];
1295
- ctx.fillStyle = tone;
1296
- for (let py = 0; py < vLen; py++) {
1297
- // Wobble the vine a half pixel every few rows so it doesn't
1298
- // read as a solid rectangle.
1299
- const wobble = Math.sin(py * 0.18 + v) > 0 ? 1 : 0;
1300
- ctx.fillRect(vx + wobble, vy0 + py, 1, 1);
1441
+ // Mullions · steel bars over the grid seams (vertical + horizontal).
1442
+ for (let c = 0; c <= cols; c++) {
1443
+ const mx = Math.round(c * bayW);
1444
+ ctx.fillStyle = STEEL; ctx.fillRect(mx, glassTop, mull, glassBot - glassTop);
1445
+ ctx.fillStyle = STEEL_HI; ctx.fillRect(mx, glassTop, 1, glassBot - glassTop);
1446
+ ctx.fillStyle = STEEL_DEEP; ctx.fillRect(mx + mull - 1, glassTop, 1, glassBot - glassTop);
1447
+ }
1448
+ for (let r = 0; r <= rows; r++) {
1449
+ const my = Math.round(glassTop + r * paneRowH);
1450
+ ctx.fillStyle = STEEL; ctx.fillRect(0, my, W, mull);
1451
+ ctx.fillStyle = STEEL_HI; ctx.fillRect(0, my, W, 1);
1452
+ ctx.fillStyle = STEEL_DEEP; ctx.fillRect(0, my + mull - 1, W, 1);
1453
+ }
1454
+
1455
+ // Spandrel base · opaque steel / concrete panel at the floor seam,
1456
+ // with vertical seams aligned to the mullions above.
1457
+ ctx.fillStyle = SPANDREL; ctx.fillRect(0, glassBot, W, H - glassBot);
1458
+ ctx.fillStyle = STEEL_HI; ctx.fillRect(0, glassBot, W, 2);
1459
+ ctx.fillStyle = STEEL_DEEP; ctx.fillRect(0, H - 8, W, 8);
1460
+ for (let c = 1; c < cols; c++) {
1461
+ ctx.fillStyle = STEEL_DEEP;
1462
+ ctx.fillRect(Math.round(c * bayW + mull / 2) - 1, glassBot + 4, 2, H - glassBot - 12);
1463
+ }
1464
+
1465
+ const tex = new THREE.CanvasTexture(canvas);
1466
+ tex.magFilter = THREE.NearestFilter;
1467
+ tex.minFilter = THREE.NearestFilter;
1468
+ tex.generateMipmaps = false;
1469
+ tex.colorSpace = THREE.SRGBColorSpace;
1470
+ return tex;
1471
+ }
1472
+
1473
+ /** One curtain-wall glass pane · cool dusk colour in discrete bands
1474
+ * (pixel-art, no smooth gradient), a diagonal reflective sheen on
1475
+ * ~60% of panes, distant warm city-light dots in the lower rows,
1476
+ * and a thin recessed shadow on the right + bottom edges. `rowIdx`
1477
+ * grades the colour so upper rows read as sky, lower rows as deep
1478
+ * glass. */
1479
+ function drawGlassPane(ctx, x, y, w, h, rowIdx, rowCount, glass, sheen, lights, rand) {
1480
+ const bands = glass.length;
1481
+ const bandH = Math.ceil(h / bands);
1482
+ const startBand = Math.min(bands - 1,
1483
+ Math.round((rowIdx / Math.max(1, rowCount)) * (bands - 1)));
1484
+ for (let i = 0; i < bands; i++) {
1485
+ const bi = Math.min(bands - 1, startBand + Math.floor(i * 0.6));
1486
+ ctx.fillStyle = glass[bi];
1487
+ ctx.fillRect(x, y + i * bandH, w, bandH);
1488
+ }
1489
+ // Distant city lights · only the lower rows, a few warm dots.
1490
+ if (rowIdx >= rowCount - 2) {
1491
+ const n = 2 + Math.floor(rand() * 4);
1492
+ for (let k = 0; k < n; k++) {
1493
+ ctx.fillStyle = lights[Math.floor(rand() * lights.length)];
1494
+ const lx = x + 3 + Math.floor(rand() * Math.max(1, w - 6));
1495
+ const ly = y + Math.floor(h * 0.45) + Math.floor(rand() * Math.max(1, h * 0.45));
1496
+ ctx.fillRect(lx, ly, 1 + Math.floor(rand() * 2), 1);
1301
1497
  }
1302
- // Three small leaves along the vine.
1303
- for (let l = 0; l < 3; l++) {
1304
- const ly = vy0 + 14 + l * Math.floor(vLen / 3);
1305
- const lx = vx + (l % 2 === 0 ? 1 : -2);
1306
- ctx.fillRect(lx, ly, 3, 1);
1307
- ctx.fillRect(lx, ly + 1, 2, 1);
1498
+ }
1499
+ // Diagonal reflective sheen.
1500
+ if (rand() < 0.6) {
1501
+ ctx.fillStyle = sheen;
1502
+ const sx = x + Math.floor(rand() * w * 0.5);
1503
+ for (let s = 0; s < h; s++) {
1504
+ const xx = sx + Math.floor(s * 0.5);
1505
+ if (xx >= x && xx < x + w) ctx.fillRect(xx, y + s, 3, 1);
1308
1506
  }
1309
1507
  }
1508
+ // Recessed edge shadow (right + bottom) so the pane reads as glass
1509
+ // set behind its steel frame.
1510
+ ctx.fillStyle = "rgba(10,14,20,0.35)";
1511
+ ctx.fillRect(x + w - 1, y, 1, h);
1512
+ ctx.fillRect(x, y + h - 1, w, 1);
1513
+ }
1514
+
1515
+ /** Procedural pixel-art CRITIQUE wall · a dark MAHOGANY EXECUTIVE
1516
+ * PANEL room. Top-down: a dentil cornice capped with brass → a row
1517
+ * of tall raised mahogany panels (brass pin accents) → a brass
1518
+ * chair-rail datum → a row of shorter dado panels → a dark plinth
1519
+ * with a brass reveal. Formal + scrutinising — the boardroom that
1520
+ * finds the holes. Pairs with the brass-on-gunmetal metal-weave
1521
+ * floor. Same NearestFilter canvas-texture recipe as the others. */
1522
+ function buildCritiqueWallTexture() {
1523
+ const W = 1024, H = 512;
1524
+ const canvas = document.createElement("canvas");
1525
+ canvas.width = W;
1526
+ canvas.height = H;
1527
+ const ctx = canvas.getContext("2d");
1528
+ ctx.imageSmoothingEnabled = false;
1529
+
1530
+ const MAH = "#6B3F2F", MAH_HI = "#8A5038", MAH_DK = "#341A12", STILE = "#4A2A20";
1531
+ const BRASS = "#C9A85A", BRASS_HI = "#E6CC8E", BRASS_SH = "#94774A";
1532
+
1533
+ // Deep mahogany ground · panel gaps / reveals read as shadow.
1534
+ ctx.fillStyle = MAH_DK; ctx.fillRect(0, 0, W, H);
1535
+
1536
+ // Crown · cornice with a row of dentils + a brass reveal line.
1537
+ ctx.fillStyle = STILE; ctx.fillRect(0, 0, W, 18);
1538
+ ctx.fillStyle = MAH_HI; ctx.fillRect(0, 0, W, 1);
1539
+ ctx.fillStyle = MAH_DK; for (let dx = 4; dx < W; dx += 22) ctx.fillRect(dx, 12, 12, 6); // dentils
1540
+ ctx.fillStyle = BRASS_SH; ctx.fillRect(0, 24, W, 1);
1541
+ ctx.fillStyle = BRASS; ctx.fillRect(0, 22, W, 2);
1542
+ ctx.fillStyle = BRASS_HI; ctx.fillRect(0, 22, W, 1);
1543
+
1544
+ const panels = 5, pw = W / panels;
1545
+
1546
+ // Upper field · tall raised panels with brass pin accents.
1547
+ const upTop = 34, upBot = 300;
1548
+ for (let i = 0; i < panels; i++) {
1549
+ const x = Math.round(i * pw) + 6;
1550
+ drawRaisedPanel(ctx, x, upTop, Math.round(pw) - 12, upBot - upTop, MAH, MAH_HI, MAH_DK);
1551
+ ctx.fillStyle = BRASS;
1552
+ ctx.fillRect(x + 6, upTop + 8, 2, 2);
1553
+ ctx.fillRect(x + Math.round(pw) - 20, upTop + 8, 2, 2);
1554
+ }
1555
+
1556
+ // Chair-rail datum · a brass line over a dark reveal.
1557
+ ctx.fillStyle = STILE; ctx.fillRect(0, 300, W, 12);
1558
+ ctx.fillStyle = BRASS_SH; ctx.fillRect(0, 310, W, 1);
1559
+ ctx.fillStyle = BRASS; ctx.fillRect(0, 302, W, 2);
1560
+ ctx.fillStyle = BRASS_HI; ctx.fillRect(0, 302, W, 1);
1561
+
1562
+ // Lower dado · shorter raised panels.
1563
+ const loTop = 318, loBot = 486;
1564
+ for (let i = 0; i < panels; i++) {
1565
+ drawRaisedPanel(ctx, Math.round(i * pw) + 6, loTop, Math.round(pw) - 12, loBot - loTop, MAH, MAH_HI, MAH_DK);
1566
+ }
1567
+
1568
+ // Plinth · dark base band with a brass top reveal.
1569
+ ctx.fillStyle = STILE; ctx.fillRect(0, loBot, W, H - loBot);
1570
+ ctx.fillStyle = BRASS; ctx.fillRect(0, loBot, W, 2);
1571
+ ctx.fillStyle = BRASS_HI; ctx.fillRect(0, loBot, W, 1);
1310
1572
 
1311
1573
  const tex = new THREE.CanvasTexture(canvas);
1312
1574
  tex.magFilter = THREE.NearestFilter;
@@ -1316,13 +1578,26 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1316
1578
  return tex;
1317
1579
  }
1318
1580
 
1319
- /** Procedural pixel-art warm sandstone wall · modelled after
1320
- * `public/icons/wall2.png`. Same broken-bond layout as the
1321
- * constructive stone wall but in an amber / sandstone / terracotta
1322
- * palette · multi-tone stones range from pale sand to deep rust,
1323
- * with occasional brighter terracotta accents for visual punch.
1324
- * Used for the critique tone in place of the flat-paint wall. */
1325
- function buildCritiqueWallTexture() {
1581
+ /** A RAISED frame-and-panel rectangle · light top/left, dark
1582
+ * bottom/right bevels so the panel face reads as proud of the
1583
+ * surrounding stiles. Used for the critique mahogany wall. */
1584
+ function drawRaisedPanel(ctx, x, y, w, h, face, hi, lo) {
1585
+ ctx.fillStyle = lo; ctx.fillRect(x, y, w, h); // stile / recess border
1586
+ ctx.fillStyle = face; ctx.fillRect(x + 4, y + 4, w - 8, h - 8); // raised face
1587
+ ctx.fillStyle = hi; ctx.fillRect(x + 4, y + 4, w - 8, 2); // top bevel light
1588
+ ctx.fillStyle = hi; ctx.fillRect(x + 4, y + 4, 2, h - 8); // left bevel light
1589
+ ctx.fillStyle = lo; ctx.fillRect(x + 4, y + h - 6, w - 8, 2); // bottom bevel shade
1590
+ ctx.fillStyle = lo; ctx.fillRect(x + w - 6, y + 4, 2, h - 8); // right bevel shade
1591
+ }
1592
+
1593
+ /** Procedural pixel-art DEBATE wall · a warm-oak forum / chamber.
1594
+ * Top-down the texture reads: crown molding → upper field with
1595
+ * three tall daylight windows (wood mullions, sill, a faint sun
1596
+ * glow) → chair rail → frame-and-panel wainscot → baseboard. Cool
1597
+ * daylight in the windows plays against the warm wood + the warm
1598
+ * debate floor so the room reads "lit from outside". Same chunky
1599
+ * NearestFilter canvas-texture recipe as the brick / stone tones. */
1600
+ function buildDebateWallTexture() {
1326
1601
  const W = 1024, H = 512;
1327
1602
  const canvas = document.createElement("canvas");
1328
1603
  canvas.width = W;
@@ -1330,99 +1605,214 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1330
1605
  const ctx = canvas.getContext("2d");
1331
1606
  ctx.imageSmoothingEnabled = false;
1332
1607
 
1333
- // Mortar base · deep warm walnut · matches the existing
1334
- // critique trim colour family so the seams feel native.
1335
- ctx.fillStyle = "#2A1A0E";
1608
+ // Warm-oak palette.
1609
+ const OAK_DEEP = "#3F2C1B";
1610
+ const OAK_DARK = "#5A3F28";
1611
+ const OAK_MID = "#6E4F33";
1612
+ const OAK_BASE = "#80603E";
1613
+ const OAK_HI = "#9E7A4E";
1614
+ const OAK_HI2 = "#B58F5E";
1615
+
1616
+ const grain = mulberry32(57);
1617
+
1618
+ // ── Wall field · vertical plank paneling with grain + seams. ──
1619
+ ctx.fillStyle = OAK_MID;
1336
1620
  ctx.fillRect(0, 0, W, H);
1621
+ const plankW = 64;
1622
+ for (let x = 0; x < W; x += plankW) {
1623
+ const tone = [OAK_MID, OAK_BASE, OAK_DARK][Math.floor(grain() * 3)];
1624
+ ctx.fillStyle = tone;
1625
+ ctx.fillRect(x, 0, plankW, H);
1626
+ // Faint vertical grain streaks.
1627
+ for (let s = 0; s < 6; s++) {
1628
+ const gx = x + 4 + Math.floor(grain() * (plankW - 8));
1629
+ const gy = Math.floor(grain() * H);
1630
+ const gh = 30 + Math.floor(grain() * 120);
1631
+ ctx.globalAlpha = 0.22;
1632
+ ctx.fillStyle = grain() < 0.5 ? OAK_DEEP : OAK_HI;
1633
+ ctx.fillRect(gx, gy, 1, gh);
1634
+ ctx.globalAlpha = 1;
1635
+ }
1636
+ // Plank seam · dark right edge + soft highlight on the left.
1637
+ ctx.fillStyle = OAK_DEEP;
1638
+ ctx.fillRect(x + plankW - 1, 0, 1, H);
1639
+ ctx.globalAlpha = 0.3;
1640
+ ctx.fillStyle = OAK_HI;
1641
+ ctx.fillRect(x, 0, 1, H);
1642
+ ctx.globalAlpha = 1;
1643
+ }
1644
+
1645
+ // ── Crown molding · top band. ──
1646
+ ctx.fillStyle = OAK_DEEP; ctx.fillRect(0, 0, W, 22);
1647
+ ctx.fillStyle = OAK_HI2; ctx.fillRect(0, 20, W, 2);
1648
+ ctx.fillStyle = OAK_DARK; ctx.fillRect(0, 22, W, 4);
1649
+
1650
+ // ── Windows · three arched daylight openings set LOW on the wall so
1651
+ // they land inside the camera's initial framing (it frames the back
1652
+ // wall's lower band behind the seated directors). Sill sits ~1 world
1653
+ // unit off the floor. The tall wainscot that used to fill this band —
1654
+ // and hide the glass — is gone; just a baseboard grounds the floor. ──
1655
+ const winTop = 292, winH = 178, winW = 150;
1656
+ for (const frac of [0.2, 0.5, 0.8]) {
1657
+ drawDebateWindow(ctx, Math.round(W * frac - winW / 2), winTop, winW, winH, grain);
1658
+ }
1659
+
1660
+ // ── Baseboard · dark band at the floor seam (below the window sills). ──
1661
+ const baseTop = 490;
1662
+ ctx.fillStyle = OAK_DEEP; ctx.fillRect(0, baseTop, W, H - baseTop);
1663
+ ctx.fillStyle = OAK_HI; ctx.fillRect(0, baseTop, W, 2);
1664
+
1665
+ const tex = new THREE.CanvasTexture(canvas);
1666
+ tex.magFilter = THREE.NearestFilter;
1667
+ tex.minFilter = THREE.NearestFilter;
1668
+ tex.generateMipmaps = false;
1669
+ tex.colorSpace = THREE.SRGBColorSpace;
1670
+ return tex;
1671
+ }
1337
1672
 
1338
- // Stone palette · warm sandstone family, low-sat to mid-sat
1339
- // golden browns mixing pale sand and deep rust.
1340
- const stonePalette = [
1341
- "#B8895A", "#9C7340", "#C8A06C", "#8B5E32",
1342
- "#D9B07C", "#A87850", "#7A4F30", "#B07A48",
1673
+ /** One tall daylight window for the debate wall · wood frame + sill,
1674
+ * a banded cool-daylight sky, a faint sun glow, and a 3×4 mullion
1675
+ * grid. `x,y` is the top-left of the glass opening. */
1676
+ function drawDebateWindow(ctx, x, y, w, h, rand) {
1677
+ const FRAME = "#3F2C1B", FRAME_HI = "#7A5836", SILL = "#5A3F28";
1678
+ const fr = 10;
1679
+ // Outer wood frame + top/left bevel highlight.
1680
+ ctx.fillStyle = FRAME;
1681
+ ctx.fillRect(x - fr, y - fr, w + fr * 2, h + fr * 2);
1682
+ ctx.fillStyle = FRAME_HI;
1683
+ ctx.fillRect(x - fr, y - fr, w + fr * 2, 2);
1684
+ ctx.fillRect(x - fr, y - fr, 2, h + fr * 2);
1685
+
1686
+ // Sky · vertical daylight bands (pale blue → warm horizon).
1687
+ const sky = ["#A6C0D2", "#B4CBD9", "#C3D6E0", "#D3E1E8", "#E0EAEC"];
1688
+ const bandH = Math.ceil(h / sky.length);
1689
+ for (let i = 0; i < sky.length; i++) {
1690
+ ctx.fillStyle = sky[i];
1691
+ ctx.fillRect(x, y + i * bandH, w, bandH);
1692
+ }
1693
+ // Warm horizon glow near the bottom of the glass.
1694
+ ctx.fillStyle = "#ECDDC4";
1695
+ ctx.fillRect(x, y + h - 16, w, 16);
1696
+ // Faint sun disc · upper-left pane.
1697
+ ctx.globalAlpha = 0.55;
1698
+ ctx.fillStyle = "#FFF6E2";
1699
+ ctx.fillRect(x + 14, y + 16, 12, 12);
1700
+ ctx.globalAlpha = 1;
1701
+
1702
+ // Mullions · 2 vertical + 3 horizontal wood bars → 3×4 panes.
1703
+ const bar = 6;
1704
+ ctx.fillStyle = FRAME;
1705
+ for (let c = 1; c < 3; c++) {
1706
+ ctx.fillRect(x + Math.round((w * c) / 3) - bar / 2, y, bar, h);
1707
+ }
1708
+ for (let r = 1; r < 4; r++) {
1709
+ ctx.fillRect(x, y + Math.round((h * r) / 4) - bar / 2, w, bar);
1710
+ }
1711
+
1712
+ // Sill · wood ledge jutting below the frame.
1713
+ ctx.fillStyle = SILL;
1714
+ ctx.fillRect(x - fr - 4, y + h + fr, w + fr * 2 + 8, 8);
1715
+ ctx.fillStyle = FRAME_HI;
1716
+ ctx.fillRect(x - fr - 4, y + h + fr, w + fr * 2 + 8, 2);
1717
+ void rand;
1718
+ }
1719
+
1720
+ /** Lighten / darken a #rrggbb hex toward white / black · shared by
1721
+ * the library book-spine shading below. Returns an `rgb()` string. */
1722
+ function tintHex(hex, d) {
1723
+ const n = parseInt(hex.slice(1), 16);
1724
+ const r = Math.max(0, Math.min(255, ((n >> 16) & 0xff) + d));
1725
+ const g = Math.max(0, Math.min(255, ((n >> 8) & 0xff) + d));
1726
+ const b = Math.max(0, Math.min(255, (n & 0xff) + d));
1727
+ return `rgb(${r},${g},${b})`;
1728
+ }
1729
+
1730
+ /** Procedural pixel-art RESEARCH wall · a light-oak library. Top-down:
1731
+ * crown molding → built-in bookcases (oak shelf boards + case
1732
+ * uprights framing three bays, each shelf packed with varied book
1733
+ * spines — gilt titles, the odd horizontal stack, a potted plant for
1734
+ * warmth) → oak cabinet plinth + baseboard. Scholarly + warm-neutral
1735
+ * against the library's pale-marble floor. Same NearestFilter
1736
+ * canvas-texture recipe as the other tones. */
1737
+ function buildResearchWallTexture() {
1738
+ const W = 1024, H = 512;
1739
+ const canvas = document.createElement("canvas");
1740
+ canvas.width = W;
1741
+ canvas.height = H;
1742
+ const ctx = canvas.getContext("2d");
1743
+ ctx.imageSmoothingEnabled = false;
1744
+
1745
+ const OAK_DEEP = "#5A4326", OAK_DARK = "#6E5430", OAK_MID = "#8A6E44",
1746
+ OAK_HI = "#B0905A", OAK_HI2 = "#C8AC72", RECESS = "#352A1B";
1747
+ const SPINES = [
1748
+ "#7C3A33", "#3E6B4A", "#3A567A", "#A6814A", "#B5953F", "#5A4636",
1749
+ "#7A3050", "#356E6A", "#8A4A2E", "#4A4458", "#9C8A5A", "#6A8C46",
1343
1750
  ];
1344
- // Occasional brighter terracotta / amber stones for accent ·
1345
- // ~10% of stones pick from this set to mirror the reference's
1346
- // pop of orange-red here and there.
1347
- const stoneAccent = ["#C97448", "#D88A50", "#BA6634"];
1348
- const highlightTone = (hex) => {
1349
- const n = parseInt(hex.slice(1), 16);
1350
- const r = Math.min(255, ((n >> 16) & 0xff) + 30);
1351
- const g = Math.min(255, ((n >> 8) & 0xff) + 26);
1352
- const b = Math.min(255, (n & 0xff) + 18);
1353
- return `rgb(${r},${g},${b})`;
1354
- };
1355
- const shadowTone = (hex) => {
1356
- const n = parseInt(hex.slice(1), 16);
1357
- const r = Math.max(0, ((n >> 16) & 0xff) - 34);
1358
- const g = Math.max(0, ((n >> 8) & 0xff) - 28);
1359
- const b = Math.max(0, (n & 0xff) - 20);
1360
- return `rgb(${r},${g},${b})`;
1361
- };
1751
+ const GILT = "#C9A85A";
1752
+ const rand = mulberry32(91);
1362
1753
 
1363
- // Block layout · rectangular bond pattern. Slightly larger
1364
- // blocks than constructive (the reference reads as chunky
1365
- // hand-cut sandstone, not pebble cobble) and a touch more
1366
- // mortar so the seams catch the eye.
1367
- const mortar = 2;
1368
- const rand = mulberry32(29);
1369
- let y = 0;
1370
- while (y < H) {
1371
- const rowH = 17 + Math.floor(rand() * 8);
1372
- const startOffset = Math.floor(rand() * 40);
1373
- let x = -startOffset;
1374
- while (x < W) {
1375
- const w = 24 + Math.floor(rand() * 30);
1376
- const useAccent = rand() < 0.10;
1377
- const base = useAccent
1378
- ? stoneAccent[Math.floor(rand() * stoneAccent.length)]
1379
- : stonePalette[Math.floor(rand() * stonePalette.length)];
1380
- const x0 = x + mortar;
1381
- const y0 = y + mortar;
1382
- const sw = w - mortar;
1383
- const sh = rowH - mortar;
1384
- // Block body.
1385
- ctx.fillStyle = base;
1386
- ctx.fillRect(x0, y0, sw, sh);
1387
- // Worn corners · clip a 2×2 chunk so each block reads as
1388
- // a hand-cut sandstone instead of a sharp rectangle.
1389
- ctx.fillStyle = "#2A1A0E";
1390
- ctx.fillRect(x0, y0, 2, 2);
1391
- ctx.fillRect(x0 + sw - 2, y0, 2, 2);
1392
- ctx.fillRect(x0, y0 + sh - 2, 2, 2);
1393
- ctx.fillRect(x0 + sw - 2, y0 + sh - 2, 2, 2);
1394
- // Top-edge highlight (skip the worn corners).
1395
- ctx.fillStyle = highlightTone(base);
1396
- ctx.fillRect(x0 + 2, y0, sw - 4, 1);
1397
- // Bottom-edge shadow.
1398
- ctx.fillStyle = shadowTone(base);
1399
- ctx.fillRect(x0 + 2, y0 + sh - 1, sw - 4, 1);
1400
- // Inner blemish · ~30% of stones get a small chip / crack
1401
- // for hand-cut character.
1402
- if (rand() < 0.30 && sw > 14 && sh > 12) {
1403
- ctx.fillStyle = shadowTone(base);
1404
- const cx = x0 + 3 + Math.floor(rand() * Math.max(1, sw - 9));
1405
- const cy = y0 + 3 + Math.floor(rand() * Math.max(1, sh - 8));
1406
- const cl = 3 + Math.floor(rand() * 7);
1407
- if (rand() < 0.5) ctx.fillRect(cx, cy, cl, 1);
1408
- else ctx.fillRect(cx, cy, 1, cl);
1754
+ // Dark recess behind the shelves.
1755
+ ctx.fillStyle = RECESS;
1756
+ ctx.fillRect(0, 0, W, H);
1757
+
1758
+ // Crown molding.
1759
+ ctx.fillStyle = OAK_DEEP; ctx.fillRect(0, 0, W, 22);
1760
+ ctx.fillStyle = OAK_HI2; ctx.fillRect(0, 20, W, 2);
1761
+ ctx.fillStyle = OAK_DARK; ctx.fillRect(0, 22, W, 4);
1762
+
1763
+ // Bookcase field · 3 bays × a fixed shelf COUNT filling top→plinth.
1764
+ // Using a count (not a fixed row height) guarantees the rows reach
1765
+ // the plinth, so the lowest books land in the camera's initial
1766
+ // framing instead of bunching high and leaving the visible lower
1767
+ // band empty (the prior fixed 74px rows stopped ~70px short).
1768
+ const top = 30, bot = 486;
1769
+ const board = 9; // shelf-board thickness
1770
+ // 8 shelves (was 6) · shorter rows shrink the book heights from
1771
+ // ~1.15–1.5 world units (nearly as tall as a seated director) down
1772
+ // to ~0.7–1.0, so the shelving reads at a believable scale against
1773
+ // the figures and the table instead of looking oversized.
1774
+ const shelves = 8;
1775
+ const rowH = (bot - top) / shelves;
1776
+ const bays = [
1777
+ [6, Math.round(W / 3) - 6],
1778
+ [Math.round(W / 3) + 6, Math.round((2 * W) / 3) - 6],
1779
+ [Math.round((2 * W) / 3) + 6, W - 6],
1780
+ ];
1781
+ let plantPlaced = false;
1782
+ for (let s = 0; s < shelves; s++) {
1783
+ const yTop = Math.round(top + s * rowH);
1784
+ const gapH = Math.round(rowH) - board;
1785
+ for (const [bx0, bx1] of bays) {
1786
+ // Occasionally make ONE shelf in a bay a decorative niche
1787
+ // (potted plant) instead of books once per texture, for warmth.
1788
+ if (!plantPlaced && rand() < 0.12) {
1789
+ drawShelfPlant(ctx, Math.round((bx0 + bx1) / 2), yTop + gapH);
1790
+ plantPlaced = true;
1791
+ } else {
1792
+ drawBookRow(ctx, bx0, bx1, yTop, gapH, SPINES, GILT, rand);
1409
1793
  }
1410
- x += w;
1411
1794
  }
1412
- y += rowH;
1795
+ // Shelf board spanning the full width under this row.
1796
+ const by = yTop + gapH;
1797
+ ctx.fillStyle = OAK_MID; ctx.fillRect(0, by, W, board);
1798
+ ctx.fillStyle = OAK_HI; ctx.fillRect(0, by, W, 2); // lit front edge
1799
+ ctx.fillStyle = OAK_DEEP; ctx.fillRect(0, by + board - 1, W, 1); // under-shadow
1413
1800
  }
1414
1801
 
1415
- // Sparse moss accent · the reference has a hint of green only
1416
- // near the upper seams; keep it subtle (no bottom band, no
1417
- // vines) so the warm sandstone stays the dominant register.
1418
- const mossPalette = ["#5E7A3A", "#6E8E48", "#476830"];
1419
- const mossRand = mulberry32(73);
1420
- for (let n = 0; n < 12; n++) {
1421
- const mx = Math.floor(mossRand() * W);
1422
- const my = Math.floor(mossRand() * 36);
1423
- drawMossBlob(ctx, mx, my, mossPalette, mossRand);
1802
+ // Case uprights · vertical oak posts framing the 3 bays.
1803
+ const posts = [0, Math.round(W / 3), Math.round((2 * W) / 3), W - 12];
1804
+ for (const px of posts) {
1805
+ ctx.fillStyle = OAK_DARK; ctx.fillRect(px, 24, 12, bot - 24);
1806
+ ctx.fillStyle = OAK_HI; ctx.fillRect(px, 24, 2, bot - 24); // left highlight
1807
+ ctx.fillStyle = OAK_DEEP; ctx.fillRect(px + 10, 24, 2, bot - 24); // right shadow
1424
1808
  }
1425
1809
 
1810
+ // Cabinet plinth + baseboard.
1811
+ ctx.fillStyle = OAK_MID; ctx.fillRect(0, bot, W, H - bot);
1812
+ ctx.fillStyle = OAK_HI; ctx.fillRect(0, bot, W, 2); // top edge catch-light
1813
+ ctx.fillStyle = OAK_DEEP; ctx.fillRect(0, H - 10, W, 10); // dark plinth foot
1814
+ ctx.fillStyle = OAK_HI; ctx.fillRect(0, H - 10, W, 1);
1815
+
1426
1816
  const tex = new THREE.CanvasTexture(canvas);
1427
1817
  tex.magFilter = THREE.NearestFilter;
1428
1818
  tex.minFilter = THREE.NearestFilter;
@@ -1431,6 +1821,79 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
1431
1821
  return tex;
1432
1822
  }
1433
1823
 
1824
+ /** Fill one shelf gap (x0..x1, sitting on the board at yTop+gapH) with
1825
+ * a packed row of varied book spines · widths / heights / colours
1826
+ * vary, ~35% get a gilt title band, occasional horizontal stacks and
1827
+ * small gaps break the rhythm so it reads hand-shelved, not tiled. */
1828
+ function drawBookRow(ctx, x0, x1, yTop, gapH, spines, gilt, rand) {
1829
+ const floorY = yTop + gapH; // book bottoms sit here (on the board)
1830
+ let x = x0 + 2;
1831
+ while (x < x1 - 6) {
1832
+ const r = rand();
1833
+ if (r < 0.06) { x += 4 + Math.floor(rand() * 9); continue; } // gap
1834
+ if (r < 0.14 && x1 - x > 44) {
1835
+ // Horizontal stack of a few books laid flat.
1836
+ const sw = 26 + Math.floor(rand() * 16);
1837
+ let sy = floorY;
1838
+ const stk = 2 + Math.floor(rand() * 3);
1839
+ for (let k = 0; k < stk; k++) {
1840
+ const sh = 6 + Math.floor(rand() * 4);
1841
+ sy -= sh + 1;
1842
+ if (sy < yTop + 2) break;
1843
+ const c = spines[Math.floor(rand() * spines.length)];
1844
+ ctx.fillStyle = c; ctx.fillRect(x, sy, sw, sh);
1845
+ ctx.fillStyle = tintHex(c, -28); ctx.fillRect(x, sy + sh - 1, sw, 1);
1846
+ ctx.fillStyle = tintHex(c, 26); ctx.fillRect(x, sy, sw, 1);
1847
+ }
1848
+ x += sw + 3;
1849
+ continue;
1850
+ }
1851
+ // Upright spine.
1852
+ const sw = 9 + Math.floor(rand() * 14);
1853
+ const sh = gapH - 2 - Math.floor(rand() * 16);
1854
+ const sy = floorY - sh;
1855
+ const c = spines[Math.floor(rand() * spines.length)];
1856
+ ctx.fillStyle = c; ctx.fillRect(x, sy, sw, sh);
1857
+ ctx.fillStyle = tintHex(c, 24); ctx.fillRect(x, sy, 1, sh); // left catch-light
1858
+ ctx.fillStyle = tintHex(c, -30); ctx.fillRect(x + sw - 1, sy, 1, sh); // right shadow
1859
+ ctx.fillStyle = tintHex(c, -34); ctx.fillRect(x, sy, sw, 1); // top cap shadow
1860
+ if (rand() < 0.35 && sh > 26 && sw > 11) {
1861
+ ctx.fillStyle = gilt;
1862
+ const gy = sy + 7 + Math.floor(rand() * (sh - 20));
1863
+ ctx.fillRect(x + 2, gy, sw - 4, 1);
1864
+ if (rand() < 0.5) ctx.fillRect(x + 2, gy + 3, sw - 4, 1);
1865
+ }
1866
+ x += sw + 2;
1867
+ }
1868
+ }
1869
+
1870
+ /** A small potted plant sitting on a library shelf · terracotta pot +
1871
+ * a clump of leaves. `cx` is the pot centre, `baseY` the shelf-board
1872
+ * top the pot rests on. Pure decoration to warm the bookcase. */
1873
+ function drawShelfPlant(ctx, cx, baseY) {
1874
+ const POT = "#A85A38", POT_HI = "#C47A50", POT_DK = "#7A3E24";
1875
+ const LEAF = ["#4E7A3A", "#5E8E46", "#3E6830"];
1876
+ const potW = 16, potH = 14;
1877
+ const px = cx - potW / 2, py = baseY - potH;
1878
+ // Pot · tapered (narrower at the base).
1879
+ ctx.fillStyle = POT; ctx.fillRect(px, py, potW, potH);
1880
+ ctx.fillStyle = POT_DK; ctx.fillRect(px + 2, py + potH - 1, potW - 4, 1);
1881
+ ctx.fillStyle = POT_HI; ctx.fillRect(px, py, potW, 2); // rim catch-light
1882
+ ctx.fillStyle = POT_DK; ctx.fillRect(px + potW - 1, py, 1, potH); // right shade
1883
+ // Foliage · a few stacked leaf blobs above the rim.
1884
+ let lx = cx - 12, ly = py - 2;
1885
+ for (let i = 0; i < 14; i++) {
1886
+ const c = LEAF[i % LEAF.length];
1887
+ ctx.fillStyle = c;
1888
+ const bw = 3 + (i % 3);
1889
+ const bh = 3 + ((i + 1) % 3);
1890
+ const ox = (i * 7) % 22 - 10;
1891
+ const oy = -((i * 5) % 16);
1892
+ ctx.fillRect(cx + ox, py - 4 + oy, bw, bh);
1893
+ }
1894
+ void lx; void ly;
1895
+ }
1896
+
1434
1897
  /** Tiny deterministic PRNG (Mulberry32). Same seed → same sequence,
1435
1898
  * used so mountain silhouettes stay stable across rebuilds. */
1436
1899
  function mulberry32(a) {
@@ -2062,6 +2525,47 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2062
2525
  return g;
2063
2526
  }
2064
2527
 
2528
+ /** Procedural grayscale grain · horizontal streaks (run along the
2529
+ * table length) over a near-white base, with a few darker knots and
2530
+ * fine per-row noise. Used as `.map` so it MODULATES the per-tone
2531
+ * colour rather than replacing it · white→colour as-is, the ~0.82
2532
+ * streaks darken slightly to read as wood / moulded grain. */
2533
+ function buildTableGrainTexture() {
2534
+ const W = 512, H = 128;
2535
+ const canvas = document.createElement("canvas");
2536
+ canvas.width = W; canvas.height = H;
2537
+ const ctx = canvas.getContext("2d");
2538
+ const rand = mulberry32(73);
2539
+ // Near-white base · keeps the material colour intact between streaks.
2540
+ ctx.fillStyle = "#FAFAFA"; ctx.fillRect(0, 0, W, H);
2541
+ // Long horizontal grain streaks · slightly darker greys, varied
2542
+ // length / opacity so the grain reads organic, not striped.
2543
+ for (let i = 0; i < 90; i++) {
2544
+ const y = Math.floor(rand() * H);
2545
+ const len = 80 + Math.floor(rand() * (W - 80));
2546
+ const x = Math.floor(rand() * (W - len));
2547
+ const g = 200 + Math.floor(rand() * 38); // 0xC8..0xEE
2548
+ const a = 0.10 + rand() * 0.22;
2549
+ ctx.fillStyle = `rgba(${g - 40},${g - 46},${g - 54},${a.toFixed(3)})`;
2550
+ ctx.fillRect(x, y, len, 1);
2551
+ }
2552
+ // A handful of darker knots / figure swirls.
2553
+ for (let i = 0; i < 6; i++) {
2554
+ const cx = Math.floor(rand() * W);
2555
+ const cy = Math.floor(rand() * H);
2556
+ const r = 3 + Math.floor(rand() * 6);
2557
+ ctx.fillStyle = `rgba(120,108,92,${(0.10 + rand() * 0.12).toFixed(3)})`;
2558
+ ctx.beginPath(); ctx.ellipse(cx, cy, r, Math.max(1, r * 0.4), 0, 0, Math.PI * 2); ctx.fill();
2559
+ }
2560
+ const tex = new THREE.CanvasTexture(canvas);
2561
+ tex.wrapS = THREE.RepeatWrapping;
2562
+ tex.wrapT = THREE.RepeatWrapping;
2563
+ tex.repeat.set(2, 1); // denser grain along the long axis
2564
+ tex.colorSpace = THREE.SRGBColorSpace;
2565
+ tex.anisotropy = 4;
2566
+ return tex;
2567
+ }
2568
+
2065
2569
  function buildTable() {
2066
2570
  // Stacked-box voxel table · centre of the stage. Dimensions
2067
2571
  // tuned tight against the seat ring · side directors at
@@ -2074,87 +2578,377 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2074
2578
  const topH = 0.35;
2075
2579
  const bodyH = 0.9;
2076
2580
 
2581
+ // Materials are module-scope · refreshTable() retints them per tone
2582
+ // right after mount (and on every mode change), so the initial WOOD
2583
+ // colours here are just a placeholder until the first refresh.
2584
+ tableBodyMat = new THREE.MeshLambertMaterial({ color: WOOD.mid });
2585
+ tableTopMat = new THREE.MeshLambertMaterial({ color: WOOD.hi });
2586
+ tableShadeMat = new THREE.MeshLambertMaterial({ color: WOOD.shade });
2587
+
2077
2588
  // Body (mid wood) · sits with its top at y=topH baseline so chairs
2078
2589
  // can tuck under naturally.
2079
- const bodyGeo = new THREE.BoxGeometry(tableW, bodyH, tableD);
2080
- const bodyMat = new THREE.MeshLambertMaterial({ color: WOOD.mid });
2081
- const body = new THREE.Mesh(bodyGeo, bodyMat);
2590
+ const body = new THREE.Mesh(new THREE.BoxGeometry(tableW, bodyH, tableD), tableBodyMat);
2082
2591
  body.position.y = bodyH / 2;
2083
2592
  g.add(body);
2084
2593
 
2085
2594
  // Top slab (rim wood, slightly lighter) · sits flush above body
2086
- // for the chunky "two-layer" pixel-art read.
2087
- const topGeo = new THREE.BoxGeometry(tableW + 0.1, topH, tableD + 0.1);
2088
- const topMat = new THREE.MeshLambertMaterial({ color: WOOD.hi });
2089
- const top = new THREE.Mesh(topGeo, topMat);
2595
+ // for the chunky "two-layer" pixel-art read. constructive turns this
2596
+ // translucent (glass) via refreshTable.
2597
+ const top = new THREE.Mesh(new THREE.BoxGeometry(tableW + 0.1, topH, tableD + 0.1), tableTopMat);
2090
2598
  top.position.y = bodyH + topH / 2;
2091
2599
  g.add(top);
2092
2600
 
2093
2601
  // Bottom shadow slab (dark wood) · a thin dark strip under the body
2094
2602
  // grounds the table to the floor like the SVG's `.rt-table-floor`
2095
2603
  // ellipse does in 2D.
2096
- const shadeGeo = new THREE.BoxGeometry(tableW + 0.05, 0.08, tableD + 0.05);
2097
- const shadeMat = new THREE.MeshLambertMaterial({ color: WOOD.shade });
2098
- const shade = new THREE.Mesh(shadeGeo, shadeMat);
2604
+ const shade = new THREE.Mesh(new THREE.BoxGeometry(tableW + 0.05, 0.08, tableD + 0.05), tableShadeMat);
2099
2605
  shade.position.y = 0.04;
2100
2606
  g.add(shade);
2101
2607
 
2102
2608
  return g;
2103
2609
  }
2104
2610
 
2105
- function buildChair() {
2106
- // Voxel chair · mirrors the existing 2D chair sprite anatomy
2107
- // (4 legs + seat slab + back rail + 2 finials at the top
2108
- // corners of the back).
2611
+ /** Retint the shared table materials to the active tone + apply its
2612
+ * material finish · "wood" (opaque matte), "glass" (translucent top
2613
+ * over a steel body · constructive), "acrylic" (whole table
2614
+ * translucent), or "plastic" (opaque, vivid, with a small emissive
2615
+ * lift so the bright colour reads glossy · brainstorm). Cheap +
2616
+ * idempotent, safe every update() tick; mirrors refreshWallColors. */
2617
+ function refreshTable(mode) {
2618
+ if (!tableBodyMat || !tableTopMat || !tableShadeMat) return;
2619
+ if (!tableGrainTex) tableGrainTex = buildTableGrainTexture();
2620
+ const p = TABLE_PALETTE_BY_TONE[mode] || TABLE_PALETTE_BY_TONE.debate;
2621
+ const finish = p.material || "wood";
2622
+ tableBodyMat.color.setHex(p.body);
2623
+ tableShadeMat.color.setHex(p.shade);
2624
+ tableTopMat.color.setHex(p.top);
2625
+ const applyFinish = (mat, translucent, opacity) => {
2626
+ if (mat.transparent !== translucent) { mat.transparent = translucent; mat.needsUpdate = true; }
2627
+ mat.opacity = translucent ? opacity : 1;
2628
+ };
2629
+ // acrylic · body + top translucent; glass · only the top; else opaque.
2630
+ const bodyTranslucent = finish === "acrylic";
2631
+ const topTranslucent = finish === "acrylic" || finish === "glass";
2632
+ applyFinish(tableBodyMat, bodyTranslucent, 0.5);
2633
+ applyFinish(tableTopMat, topTranslucent, 0.55);
2634
+ applyFinish(tableShadeMat, finish === "acrylic", 0.4); // soften the floor shadow under see-through tables
2635
+ // Grain map · only on the OPAQUE faces. See-through glass / acrylic
2636
+ // surfaces stay clean (a texture on translucent glass reads as dirt).
2637
+ const setMap = (mat, on) => {
2638
+ const want = on ? tableGrainTex : null;
2639
+ if (mat.map !== want) { mat.map = want; mat.needsUpdate = true; }
2640
+ };
2641
+ setMap(tableBodyMat, !bodyTranslucent);
2642
+ setMap(tableTopMat, !topTranslucent);
2643
+ // plastic · a small emissive of the body/top colour so the vivid hue
2644
+ // reads as glossy moulded plastic, not flat matte wood. Kept low so
2645
+ // the table doesn't self-glow and wash the room out. Other finishes
2646
+ // reset emissive to black (no glow).
2647
+ const plastic = finish === "plastic";
2648
+ tableBodyMat.emissive.setHex(plastic ? p.body : 0x000000);
2649
+ tableTopMat.emissive.setHex(plastic ? p.top : 0x000000);
2650
+ tableBodyMat.emissiveIntensity = 0.10;
2651
+ tableTopMat.emissiveIntensity = 0.10;
2652
+ }
2653
+
2654
+ /** Brainstorm-only 3D furniture group · a chest of drawers + a low
2655
+ * sofa as box meshes against the back wall, scaled to harmonise with
2656
+ * the 6.5×1.25 table. Lit by the room lights (no emissive) so they
2657
+ * read with real box-shaded depth — the user's "3D feel". */
2658
+ function buildRoomFurniture(mode) {
2109
2659
  const g = new THREE.Group();
2660
+ const backZ = -STAGE_HALF_Z * 1.45; // back wall plane (≈ -8.7)
2661
+ if (mode === "brainstorm") {
2662
+ const chest = buildChestOfDrawers();
2663
+ chest.position.set(-3.4, 0, backZ + 0.36);
2664
+ g.add(chest);
2665
+ const sofa = buildLowSofa();
2666
+ sofa.position.set(2.9, 0, backZ + 0.6);
2667
+ g.add(sofa);
2668
+ } else if (mode === "debate") {
2669
+ const lectern = buildLectern(); // forum rostrum
2670
+ lectern.position.set(3.0, 0, backZ + 0.6);
2671
+ g.add(lectern);
2672
+ const sideboard = buildChestOfDrawers({ body: 0x7A5230, top: 0xB8884E, dk: 0x4A2E18, knob: 0x3A2410 });
2673
+ sideboard.position.set(-3.3, 0, backZ + 0.36);
2674
+ g.add(sideboard);
2675
+ } else if (mode === "research") {
2676
+ // The wall is already floor-to-ceiling bookcases, so no 3D shelf
2677
+ // here (it would just double up) — a floor globe is plenty.
2678
+ const globe = buildGlobe();
2679
+ globe.position.set(3.0, 0, backZ + 0.7);
2680
+ g.add(globe);
2681
+ } else if (mode === "constructive") {
2682
+ const credenza = buildChestOfDrawers({ body: 0x3A424C, top: 0x4A525C, dk: 0x23272E, knob: 0x8A929C });
2683
+ credenza.position.set(-3.3, 0, backZ + 0.36); // sleek steel credenza
2684
+ g.add(credenza);
2685
+ } else if (mode === "critique") {
2686
+ const credenza = buildChestOfDrawers({ body: 0x4A2A20, top: 0x6B3F2F, dk: 0x2A1612, knob: 0xC9A85A });
2687
+ credenza.position.set(-3.3, 0, backZ + 0.36); // mahogany + brass
2688
+ g.add(credenza);
2689
+ const globe = buildGlobe();
2690
+ globe.position.set(3.2, 0, backZ + 0.7);
2691
+ g.add(globe);
2692
+ }
2693
+ return g;
2694
+ }
2695
+
2696
+ /** Swap the back-wall furniture to the active tone · rebuilds only on
2697
+ * a tone change (disposing the previous group's geometry + materials)
2698
+ * so an unchanged tone is a no-op every update() tick. */
2699
+ function refreshFurniture(mode) {
2700
+ if (!scene) return;
2701
+ if (mode === roomFurnitureMode && roomFurniture) return;
2702
+ if (roomFurniture) {
2703
+ scene.remove(roomFurniture);
2704
+ roomFurniture.traverse((o) => {
2705
+ if (o.geometry) { try { o.geometry.dispose(); } catch (_) { /* */ } }
2706
+ if (o.material) { try { o.material.dispose(); } catch (_) { /* */ } }
2707
+ });
2708
+ }
2709
+ roomFurniture = buildRoomFurniture(mode);
2710
+ roomFurnitureMode = mode;
2711
+ scene.add(roomFurniture);
2712
+ }
2110
2713
 
2111
- // 4 legs · thin voxel posts at the seat's four corners.
2112
- // Heights up to the seat's underside (CHAIR_SEAT_H - seat-slab-half).
2113
- const legH = CHAIR_SEAT_H - 0.06; // seat slab is 0.12 tall, half = 0.06
2114
- const legGeo = new THREE.BoxGeometry(0.12, legH, 0.12);
2115
- const legMat = new THREE.MeshLambertMaterial({ color: 0x5A3A22 });
2116
- const legOffsetX = CHAIR_WIDTH / 2 - 0.08;
2117
- const legOffsetZ = CHAIR_DEPTH / 2 - 0.08;
2714
+ /** Mid-century chest of drawers / credenza · box body on splayed legs,
2715
+ * three drawer fronts proud of the face with paired knobs, a lighter
2716
+ * top slab. ~2.2w × 1.22h × 0.55d (top lands a touch below the table
2717
+ * top at y≈1.25). `pal` recolours it into a sideboard / steel credenza
2718
+ * / mahogany credenza; the default (no `pal`) is the warm-oak
2719
+ * brainstorm chest, which also gets a cozy vase + sprig on top. */
2720
+ function buildChestOfDrawers(pal) {
2721
+ const g = new THREE.Group();
2722
+ const W = 2.2, D = 0.55, legH = 0.2, bodyH = 0.92, topH = 0.1;
2723
+ const OAK = pal ? pal.body : 0xB08A5A;
2724
+ const OAK_HI = pal ? pal.top : 0xC8A472;
2725
+ const OAK_DK = pal ? pal.dk : 0x6E5230;
2726
+ const KNOB = pal ? pal.knob : 0x4A3422;
2727
+ const mat = (c) => new THREE.MeshLambertMaterial({ color: c });
2728
+ const legGeo = new THREE.BoxGeometry(0.1, legH, 0.1);
2729
+ for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
2730
+ const leg = new THREE.Mesh(legGeo, mat(OAK_DK));
2731
+ leg.position.set(sx * (W / 2 - 0.16), legH / 2, sz * (D / 2 - 0.12));
2732
+ g.add(leg);
2733
+ }
2734
+ const body = new THREE.Mesh(new THREE.BoxGeometry(W, bodyH, D), mat(OAK));
2735
+ body.position.set(0, legH + bodyH / 2, 0);
2736
+ g.add(body);
2737
+ const top = new THREE.Mesh(new THREE.BoxGeometry(W + 0.08, topH, D + 0.08), mat(OAK_HI));
2738
+ top.position.set(0, legH + bodyH + topH / 2, 0);
2739
+ g.add(top);
2740
+ const dn = 3, gap = 0.04, dh = (bodyH - gap * (dn + 1)) / dn, frontZ = D / 2 + 0.015;
2741
+ for (let i = 0; i < dn; i++) {
2742
+ const dy = legH + gap + dh / 2 + i * (dh + gap);
2743
+ const front = new THREE.Mesh(new THREE.BoxGeometry(W - 0.12, dh, 0.03), mat(OAK_HI));
2744
+ front.position.set(0, dy, frontZ);
2745
+ g.add(front);
2746
+ const knobGeo = new THREE.BoxGeometry(0.07, 0.07, 0.05);
2747
+ for (const kx of [-W * 0.2, W * 0.2]) {
2748
+ const knob = new THREE.Mesh(knobGeo, mat(KNOB));
2749
+ knob.position.set(kx, dy, frontZ + 0.03);
2750
+ g.add(knob);
2751
+ }
2752
+ }
2753
+ if (!pal) { // cozy vase + sprig only on the default oak chest
2754
+ const vase = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.2, 0.14), mat(0xC7B6A0));
2755
+ vase.position.set(-W * 0.28, legH + bodyH + topH + 0.1, 0);
2756
+ g.add(vase);
2757
+ const sprig = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.22, 0.04), mat(0x5E8A52));
2758
+ sprig.position.set(-W * 0.28, legH + bodyH + topH + 0.3, 0);
2759
+ g.add(sprig);
2760
+ }
2761
+ return g;
2762
+ }
2763
+
2764
+ /** Low sage sofa · seat base + backrest + two arms + cushions + wood
2765
+ * legs, all boxes. ~2.7w × 0.94h × 0.95d, faces +z (toward camera). */
2766
+ function buildLowSofa() {
2767
+ const g = new THREE.Group();
2768
+ const W = 2.7, D = 0.95, legH = 0.16, seatH = 0.28, backH = 0.5, armW = 0.26;
2769
+ const SAGE = 0x8CA07E, SAGE_HI = 0xA4B695, SAGE_DK = 0x6E835E, LEG = 0x6E5230;
2770
+ const mat = (c) => new THREE.MeshLambertMaterial({ color: c });
2771
+ const legGeo = new THREE.BoxGeometry(0.1, legH, 0.1);
2772
+ for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
2773
+ const leg = new THREE.Mesh(legGeo, mat(LEG));
2774
+ leg.position.set(sx * (W / 2 - 0.14), legH / 2, sz * (D / 2 - 0.12));
2775
+ g.add(leg);
2776
+ }
2777
+ const seat = new THREE.Mesh(new THREE.BoxGeometry(W, seatH, D), mat(SAGE_DK));
2778
+ seat.position.set(0, legH + seatH / 2, 0);
2779
+ g.add(seat);
2780
+ const back = new THREE.Mesh(new THREE.BoxGeometry(W, backH, 0.2), mat(SAGE));
2781
+ back.position.set(0, legH + seatH + backH / 2, -D / 2 + 0.1);
2782
+ g.add(back);
2783
+ const armGeo = new THREE.BoxGeometry(armW, seatH + 0.18, D);
2784
+ for (const sx of [-1, 1]) {
2785
+ const arm = new THREE.Mesh(armGeo, mat(SAGE_DK));
2786
+ arm.position.set(sx * (W / 2 - armW / 2), legH + (seatH + 0.18) / 2, 0);
2787
+ g.add(arm);
2788
+ }
2789
+ const innerW = W - armW * 2;
2790
+ const scN = 2, scW = innerW / scN;
2791
+ for (let i = 0; i < scN; i++) {
2792
+ const cush = new THREE.Mesh(new THREE.BoxGeometry(scW - 0.06, 0.12, D - 0.18), mat(SAGE_HI));
2793
+ cush.position.set(-innerW / 2 + scW * (i + 0.5), legH + seatH + 0.06, 0.04);
2794
+ g.add(cush);
2795
+ }
2796
+ const bcN = 3, bcW = innerW / bcN;
2797
+ for (let i = 0; i < bcN; i++) {
2798
+ const cush = new THREE.Mesh(new THREE.BoxGeometry(bcW - 0.06, backH - 0.1, 0.12), mat(SAGE_HI));
2799
+ cush.position.set(-innerW / 2 + bcW * (i + 0.5), legH + seatH + backH / 2, -D / 2 + 0.22);
2800
+ g.add(cush);
2801
+ }
2802
+ return g;
2803
+ }
2804
+
2805
+ /** Forum lectern (debate) · an oak column on a base with a slanted
2806
+ * reading top + a front lip. ~0.7w × 1.5h. */
2807
+ function buildLectern() {
2808
+ const g = new THREE.Group();
2809
+ const OAK = 0x7A5230, OAK_HI = 0xB8884E, OAK_DK = 0x4A2E18;
2810
+ const mat = (c) => new THREE.MeshLambertMaterial({ color: c });
2811
+ const base = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.1, 0.5), mat(OAK_DK)); base.position.set(0, 0.05, 0); g.add(base);
2812
+ const col = new THREE.Mesh(new THREE.BoxGeometry(0.34, 1.0, 0.34), mat(OAK)); col.position.set(0, 0.6, 0); g.add(col);
2813
+ const head = new THREE.Mesh(new THREE.BoxGeometry(0.72, 0.4, 0.5), mat(OAK)); head.position.set(0, 1.3, 0); head.rotation.x = -0.35; g.add(head);
2814
+ const lip = new THREE.Mesh(new THREE.BoxGeometry(0.72, 0.06, 0.08), mat(OAK_HI)); lip.position.set(0, 1.16, 0.22); g.add(lip);
2815
+ return g;
2816
+ }
2817
+
2818
+ /** Floor globe (research / critique) · a blue sphere with a faint land
2819
+ * overlay on a brass post + dark stand. ~1.1h. */
2820
+ function buildGlobe() {
2821
+ const g = new THREE.Group();
2822
+ const mat = (c) => new THREE.MeshLambertMaterial({ color: c });
2823
+ const stand = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.1, 0.32), mat(0x4A3422)); stand.position.set(0, 0.05, 0); g.add(stand);
2824
+ const post = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.42, 0.06), mat(0xC9A85A)); post.position.set(0, 0.3, 0); g.add(post);
2825
+ const ball = new THREE.Mesh(new THREE.SphereGeometry(0.32, 16, 12), mat(0x3A6B8A)); ball.position.set(0, 0.8, 0); g.add(ball);
2826
+ const land = new THREE.Mesh(new THREE.SphereGeometry(0.325, 10, 8),
2827
+ new THREE.MeshLambertMaterial({ color: 0x6E9A5A, transparent: true, opacity: 0.45 }));
2828
+ land.position.copy(ball.position); g.add(land);
2829
+ return g;
2830
+ }
2831
+
2832
+ /* ── Sheen armchair (brainstorm) ───────────────────────────────
2833
+ A cozy mid-century upholstered armchair, echoing three.js'
2834
+ `webgl_loader_gltf_sheen` SheenChair silhouette · splayed slim
2835
+ wooden legs, a plump velvet seat cushion, a leaned-back padded
2836
+ backrest with a lighter "sheen catch" inner panel, and two arm
2837
+ bolsters. The original is a smooth PBR velvet GLB; we keep the
2838
+ scene's box aesthetic but use MeshPhongMaterial on the cushions
2839
+ so a soft specular reads as velvet sheen under the room lights.
2840
+ Footprint stays within CHAIR_WIDTH so side seats don't collide.
2841
+
2842
+ Palette is tone-keyed (SHEEN_PALETTE_BY_TONE) so the same model
2843
+ dresses each room in its own fabric · warm ochre velvet for the
2844
+ forest-green/cream brainstorm room (green × ochre, the classic
2845
+ mid-century pairing), cool slate-blue velvet on charcoal metal
2846
+ legs for the steel-and-glass constructive room. */
2847
+ function buildSheenChair(pal) {
2848
+ const p = pal || SHEEN_PALETTE_BY_TONE.brainstorm;
2849
+ const g = new THREE.Group();
2850
+
2851
+ const VELVET = p.body; // velvet body
2852
+ const VELVET_LIT = p.lit; // sheen catch · lighter panel
2853
+ const VELVET_DK = p.shade; // arm + side shade
2854
+ const WALNUT = p.leg; // splayed legs
2855
+
2856
+ const velvet = (c) => new THREE.MeshPhongMaterial({
2857
+ color: c, specular: p.specular, shininess: 10,
2858
+ });
2859
+
2860
+ // Splayed slim wooden legs · tapered cylinders tilted outward
2861
+ // (mid-century hallmark). Top meets the cushion underside.
2862
+ const legLen = 0.44;
2863
+ const legGeo = new THREE.CylinderGeometry(0.032, 0.022, legLen, 10);
2864
+ const legMat = new THREE.MeshLambertMaterial({ color: WALNUT });
2865
+ const legX = CHAIR_WIDTH / 2 - 0.16;
2866
+ const legZ = CHAIR_DEPTH / 2 - 0.16;
2867
+ const splay = 0.16;
2118
2868
  for (const [sx, sz] of [[-1, -1], [+1, -1], [-1, +1], [+1, +1]]) {
2119
2869
  const leg = new THREE.Mesh(legGeo, legMat);
2120
- leg.position.set(sx * legOffsetX, legH / 2, sz * legOffsetZ);
2870
+ leg.position.set(sx * legX, legLen / 2, sz * legZ);
2871
+ leg.rotation.z = -sx * splay;
2872
+ leg.rotation.x = sz * splay;
2121
2873
  g.add(leg);
2122
2874
  }
2123
2875
 
2124
- // Seat slab
2125
- const seatGeo = new THREE.BoxGeometry(CHAIR_WIDTH, 0.12, CHAIR_DEPTH);
2126
- const seatMat = new THREE.MeshLambertMaterial({ color: 0x8B5E3B }); // chair-seat
2127
- const seat = new THREE.Mesh(seatGeo, seatMat);
2128
- seat.position.y = CHAIR_SEAT_H;
2129
- g.add(seat);
2876
+ // Upholstered shell · the rounded under-seat body the cushion
2877
+ // nests in. Sits just below the cushion top.
2878
+ const shell = new THREE.Mesh(
2879
+ new THREE.BoxGeometry(CHAIR_WIDTH, 0.2, CHAIR_DEPTH * 0.92),
2880
+ velvet(VELVET_DK),
2881
+ );
2882
+ shell.position.y = CHAIR_SEAT_H - 0.06;
2883
+ g.add(shell);
2130
2884
 
2131
- // Back rail (the upright part)
2132
- const backGeo = new THREE.BoxGeometry(CHAIR_WIDTH, CHAIR_BACK_H, CHAIR_BACK_T);
2133
- const backMat = new THREE.MeshLambertMaterial({ color: 0xC8A877 }); // chair-back
2134
- const back = new THREE.Mesh(backGeo, backMat);
2135
- back.position.set(0, CHAIR_SEAT_H + CHAIR_BACK_H / 2, -CHAIR_DEPTH / 2 + CHAIR_BACK_T / 2);
2136
- g.add(back);
2885
+ // Seat cushion · plump velvet box on top of the shell.
2886
+ const cushion = new THREE.Mesh(
2887
+ new THREE.BoxGeometry(CHAIR_WIDTH * 0.86, 0.16, CHAIR_DEPTH * 0.8),
2888
+ velvet(VELVET),
2889
+ );
2890
+ cushion.position.y = CHAIR_SEAT_H + 0.07;
2891
+ g.add(cushion);
2892
+
2893
+ // Backrest · padded, leaned back ~10°. Pivot at the cushion's
2894
+ // rear edge so the lean swings the top backward, not the base.
2895
+ const backH = 0.92;
2896
+ const backGroup = new THREE.Group();
2897
+ backGroup.position.set(0, CHAIR_SEAT_H + 0.06, -CHAIR_DEPTH / 2 + 0.16);
2898
+ // Recline · negative tilts the top AWAY from the camera (+z is
2899
+ // toward the viewer). A positive angle would lean the back
2900
+ // forward over the seat and clip through the director sprite.
2901
+ backGroup.rotation.x = -0.17;
2902
+ g.add(backGroup);
2137
2903
 
2138
- // Back shaded inner panel
2139
- const backShadeGeo = new THREE.BoxGeometry(CHAIR_WIDTH * 0.82, CHAIR_BACK_H * 0.78, 0.02);
2140
- const backShadeMat = new THREE.MeshLambertMaterial({ color: 0xA0814F }); // chair-back-shade
2141
- const backShade = new THREE.Mesh(backShadeGeo, backShadeMat);
2142
- backShade.position.set(0, CHAIR_SEAT_H + CHAIR_BACK_H / 2, -CHAIR_DEPTH / 2 + CHAIR_BACK_T + 0.01);
2143
- g.add(backShade);
2144
-
2145
- // Finials · two small cubes capping the top corners of the back.
2146
- const finialGeo = new THREE.BoxGeometry(0.14, 0.14, 0.14);
2147
- const finialMat = new THREE.MeshLambertMaterial({ color: 0x5A3A22 });
2148
- const finialL = new THREE.Mesh(finialGeo, finialMat);
2149
- finialL.position.set(-CHAIR_WIDTH / 2 + 0.07, CHAIR_SEAT_H + CHAIR_BACK_H + 0.05, -CHAIR_DEPTH / 2 + CHAIR_BACK_T / 2);
2150
- g.add(finialL);
2151
- const finialR = new THREE.Mesh(finialGeo, finialMat);
2152
- finialR.position.set(CHAIR_WIDTH / 2 - 0.07, CHAIR_SEAT_H + CHAIR_BACK_H + 0.05, -CHAIR_DEPTH / 2 + CHAIR_BACK_T / 2);
2153
- g.add(finialR);
2904
+ const back = new THREE.Mesh(
2905
+ new THREE.BoxGeometry(CHAIR_WIDTH * 0.9, backH, 0.16),
2906
+ velvet(VELVET),
2907
+ );
2908
+ back.position.y = backH / 2;
2909
+ backGroup.add(back);
2910
+
2911
+ // Sheen catch · a lighter velvet inner panel, the highlight the
2912
+ // SheenChair is named for. Slightly proud of the back face.
2913
+ const catchPanel = new THREE.Mesh(
2914
+ new THREE.BoxGeometry(CHAIR_WIDTH * 0.66, backH * 0.78, 0.03),
2915
+ velvet(VELVET_LIT),
2916
+ );
2917
+ catchPanel.position.set(0, backH / 2, 0.09);
2918
+ backGroup.add(catchPanel);
2919
+
2920
+ // Arm bolsters · two padded rolls along the sides, fronts rounded
2921
+ // by a capping cylinder. Kept thin so total width stays in budget.
2922
+ const armH = 0.2;
2923
+ const armY = CHAIR_SEAT_H + 0.18;
2924
+ const armDepth = CHAIR_DEPTH * 0.72;
2925
+ for (const sx of [-1, +1]) {
2926
+ const arm = new THREE.Mesh(
2927
+ new THREE.BoxGeometry(0.12, armH, armDepth),
2928
+ velvet(VELVET_DK),
2929
+ );
2930
+ arm.position.set(sx * (CHAIR_WIDTH / 2 - 0.02), armY, -0.04);
2931
+ g.add(arm);
2932
+ // Rounded front cap.
2933
+ const cap = new THREE.Mesh(
2934
+ new THREE.CylinderGeometry(armH / 2, armH / 2, 0.12, 12),
2935
+ velvet(VELVET_DK),
2936
+ );
2937
+ cap.rotation.z = Math.PI / 2;
2938
+ cap.position.set(sx * (CHAIR_WIDTH / 2 - 0.02), armY, -0.04 + armDepth / 2);
2939
+ g.add(cap);
2940
+ }
2154
2941
 
2155
2942
  return g;
2156
2943
  }
2157
2944
 
2945
+ /** Every seat is the upholstered sheen armchair now · the old voxel
2946
+ * chair was retired. Dressed in the room's velvet palette, falling
2947
+ * back to the brainstorm ochre for any unmapped tone. */
2948
+ function buildChair(mode) {
2949
+ return buildSheenChair(SHEEN_PALETTE_BY_TONE[mode]);
2950
+ }
2951
+
2158
2952
  /* ── Plants ────────────────────────────────────────────────
2159
2953
  Voxel translations of the two 2D corner SVG plants. Palette
2160
2954
  is taken verbatim from `.rt-plant-*` (deep green silhouette,
@@ -2316,6 +3110,35 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2316
3110
  }
2317
3111
 
2318
3112
  function buildDirectorFigure(member) {
3113
+ // 3D avatar · every seat (directors + chair) gets a rigged GLB
3114
+ // figure from the saved avatar3d config (or a deterministic
3115
+ // per-id default when un-customized). The chair was previously
3116
+ // excluded — moderator stayed a flat sprite — which left it
3117
+ // hovering on a different vertical axis than the voxel directors
3118
+ // sitting next to it. Including the chair makes every seat share
3119
+ // the same `AVATAR_SEAT_LIFT` so thigh-up clears the cushion
3120
+ // consistently. The user seat still falls back to a sprite when
3121
+ // they haven't customised one yet (no per-id default for the user).
3122
+ if (avatar3dReady && member && member.id) {
3123
+ const cfg = (member.avatar3d && typeof member.avatar3d === "object")
3124
+ ? member.avatar3d
3125
+ : (member.__isUser ? null : deriveDefaultAvatarConfig(member.id));
3126
+ if (cfg && isAvatar3DReady(cfg.model)) {
3127
+ try {
3128
+ const a = buildAvatar3D(member.id, {
3129
+ model: cfg.model, hairStyle: cfg.hairStyle, outfitStyle: cfg.outfitStyle,
3130
+ browStyle: cfg.browStyle, tieStyle: cfg.tieStyle,
3131
+ accessory: cfg.accessory, height: AVATAR_FIG_HEIGHT,
3132
+ skin: cfg.skin, hair: cfg.hair, brow: cfg.brow, outfit: cfg.outfit, tie: cfg.tie, eye: cfg.eye,
3133
+ });
3134
+ if (a) { a.userData.isAvatar3d = true; return a; }
3135
+ } catch (e) {
3136
+ console.warn("[voice-3d] avatar build failed; sprite fallback", e);
3137
+ }
3138
+ }
3139
+ // models not ready / build failed → fall through to the sprite
3140
+ }
3141
+
2319
3142
  // Single sprite billboard · the existing 8-bit director SVG
2320
3143
  // (head + face features + body + accessories all baked in)
2321
3144
  // sits inside the 3D chair as a 2D plane that always faces
@@ -2353,27 +3176,18 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2353
3176
  return g;
2354
3177
  }
2355
3178
 
2356
- /** Resolve a member to an avatar URL the sprite can texture:
2357
- * · directors / chair · `member.avatarPath` (SVG under /avatars/
2358
- * or a custom data: URL on the live record).
2359
- * · user seat · synthesised on demand from `member.__seed` via
2360
- * `AvatarSkill.generateDataUrl` so the user's pixel-art
2361
- * portrait (same one shown in chat + sidebar) appears in the
2362
- * 3D scene too.
2363
- * Returns `null` when neither path resolves · caller renders
2364
- * the voxel-cube fallback. */
3179
+ /** Resolve a member to an avatar URL the sprite can texture.
3180
+ * Returns whatever `member.avatarPath` carries (PNG portrait /
3181
+ * data URL / SVG file under /avatars/) or `null` to let the
3182
+ * caller fall back to the voxel-cube fallback. The legacy
3183
+ * AvatarSkill on-the-fly 8-bit SVG generation was retired in
3184
+ * favour of the 3D portrait pipeline (avatar-3d-snap.js); the
3185
+ * user seat is expected to either be a captured PNG (stored in
3186
+ * prefs.avatarUrl by the customizer) or have no avatarPath, in
3187
+ * which case the cube fallback renders. */
2365
3188
  function resolveAvatarPath(member) {
2366
3189
  if (!member) return null;
2367
3190
  if (member.avatarPath) return member.avatarPath;
2368
- if (member.__isUser && member.__seed
2369
- && window.AvatarSkill
2370
- && typeof window.AvatarSkill.generateDataUrl === "function") {
2371
- try {
2372
- return window.AvatarSkill.generateDataUrl(member.__seed);
2373
- } catch (_) {
2374
- return null;
2375
- }
2376
- }
2377
3191
  return null;
2378
3192
  }
2379
3193
 
@@ -2512,7 +3326,7 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2512
3326
  }
2513
3327
  }
2514
3328
 
2515
- function rebuildSeats(positions) {
3329
+ function rebuildSeats(positions, mode) {
2516
3330
  if (!chairGroup || !avatarGroup) return;
2517
3331
  // Clear + rebuild · in Phase 1 we don't diff. Seat count changes
2518
3332
  // are infrequent (member add / remove) and the per-frame cost
@@ -2532,10 +3346,13 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2532
3346
  while (avatarGroup.children.length) {
2533
3347
  const child = avatarGroup.children[0];
2534
3348
  avatarGroup.remove(child);
2535
- // Each child is now a `THREE.Group` (body + arms + tie + head).
2536
- // Walk its descendants and dispose materials + geometries. Heads
2537
- // use a per-face material ARRAY (one per BoxGeometry face) so
2538
- // unwrap the array before calling dispose on each.
3349
+ // A 3D avatar figure (buildAvatar3D group) SHARES its geometry with
3350
+ // the cached model template only its materials are per-instance
3351
+ // clones. Disposing that shared geometry would corrupt the template
3352
+ // and break every later build, so for avatar groups we dispose the
3353
+ // (cloned) materials only and leave geometry alone. Sprite / voxel
3354
+ // figures own their geometry outright and dispose it normally.
3355
+ const isAvatar3d = child.name === "avatar3d" || (child.userData && child.userData.isAvatar3d);
2539
3356
  child.traverse((node) => {
2540
3357
  if (node.material) {
2541
3358
  if (Array.isArray(node.material)) {
@@ -2544,7 +3361,9 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2544
3361
  try { node.material.dispose(); } catch (_) {}
2545
3362
  }
2546
3363
  }
2547
- if (node.geometry) {
3364
+ // Skip shared template geometry under avatar3d groups, EXCEPT the
3365
+ // mouth overlay (its SphereGeometry is per-instance, not shared).
3366
+ if (node.geometry && (!isAvatar3d || (node.userData && node.userData.isMouthOverlay))) {
2548
3367
  try { node.geometry.dispose(); } catch (_) {}
2549
3368
  }
2550
3369
  });
@@ -2567,7 +3386,7 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2567
3386
  // tradeoff is worth it: every occupant is square-on to the
2568
3387
  // viewer, no awkward side-profile chibis, no asymmetric
2569
3388
  // hand-of-cards arrangement.
2570
- const chair = buildChair();
3389
+ const chair = buildChair(mode);
2571
3390
  chair.position.set(wx, 0, wz);
2572
3391
  chairGroup.add(chair);
2573
3392
 
@@ -2575,9 +3394,48 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2575
3394
  // `THREE.Sprite` self-orients toward the camera every frame,
2576
3395
  // so no rotation needed regardless of chair / fig pose.
2577
3396
  const fig = buildDirectorFigure(m);
2578
- fig.position.set(wx, 0, wz);
3397
+ // 3D avatars are standing chibis · with feet at the floor (y=0) the seat
3398
+ // cushion (top ~0.60) buries the lower body to mid-thigh. Lift them so
3399
+ // more of the body clears the cushion (feet stay hidden behind it). The
3400
+ // sprite billboards keep y=0 (they're already framed for the floor).
3401
+ const figBaseY = (fig.userData && fig.userData.isAvatar3d) ? AVATAR_SEAT_LIFT : 0;
3402
+ fig.position.set(wx, figBaseY, wz);
2579
3403
  avatarGroup.add(fig);
2580
3404
 
3405
+ // Talking-mouth overlay (3D avatars only) · the avatars have a baked
3406
+ // smile + fixed lips and no jaw bone, so the mouth can't actually open
3407
+ // (dropping the teeth just hides them behind the lower lip). Instead we
3408
+ // overlay a dark ellipsoid just IN FRONT of the lips and grow/shrink it
3409
+ // while the seat speaks — a clearly-visible open/close talking mouth.
3410
+ let mouthOverlay = null;
3411
+ if (fig.userData && fig.userData.isAvatar3d) {
3412
+ fig.updateMatrixWorld(true);
3413
+ const mb = new THREE.Box3();
3414
+ let found = false;
3415
+ fig.traverse((o) => {
3416
+ if (o.isMesh && o.userData && (o.userData.avatarRole === "mouth" || o.userData.avatarRole === "teeth")) {
3417
+ mb.expandByObject(o); found = true;
3418
+ }
3419
+ });
3420
+ if (found) {
3421
+ const mc = mb.getCenter(new THREE.Vector3());
3422
+ const msz = mb.getSize(new THREE.Vector3());
3423
+ const ov = new THREE.Mesh(
3424
+ new THREE.SphereGeometry(1, 18, 12),
3425
+ new THREE.MeshStandardMaterial({ color: 0x3a1418, roughness: 0.55, metalness: 0 }),
3426
+ );
3427
+ ov.userData.isMouthOverlay = true;
3428
+ // Local position within fig (no rotation, unit scale) + a small
3429
+ // forward nudge so it sits in front of the lips, not inside the head.
3430
+ const local = fig.worldToLocal(mc.clone());
3431
+ ov.position.set(local.x, local.y, local.z + msz.z * 0.5 + 0.03);
3432
+ ov.scale.set(msz.x * 0.34, 0.001, 0.05); // (width, closed-height, depth)
3433
+ ov.visible = false;
3434
+ fig.add(ov);
3435
+ mouthOverlay = ov;
3436
+ }
3437
+ }
3438
+
2581
3439
  // ── Floor glow ring · sits flat just above the floor under
2582
3440
  // the chair. Hidden by default; refreshSpeakerOverlay flips
2583
3441
  // it on with a lime (speaking) / amber (thinking) tint when
@@ -2714,6 +3572,8 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2714
3572
  // 3D refs · the figure group (for idle-bob position
2715
3573
  // animation) + the floor glow ring (for speaker halo).
2716
3574
  fig,
3575
+ figBaseY,
3576
+ mouthOverlay,
2717
3577
  glowRing,
2718
3578
  // Stagger the idle-bob phase per seat so the cast looks
2719
3579
  // alive instead of metronome-synchronized. 0.7 rad apart
@@ -2836,6 +3696,84 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2836
3696
  * chair and its occupant land in sync.
2837
3697
  * Self-deactivates after the duration; subsequent ticks return
2838
3698
  * fast and the scene is in its resting state. */
3699
+ /** Kick off the speaker-change pulse · resolves the new speaker's
3700
+ * seat (if any), captures the dolly direction, and flips the
3701
+ * active flag. No-op when the entry animation is still running
3702
+ * (entry owns the camera) or when the new speaker is the user
3703
+ * (no seat figure to focus on). */
3704
+ function maybeTriggerSpeakerCameraPulse(speakerId) {
3705
+ if (entryActive) {
3706
+ lastPulseSpeakerId = speakerId; // skip but record so we don't re-pulse later
3707
+ return;
3708
+ }
3709
+ if (!camera || !cameraRestPos) return;
3710
+ if (lastPulseSpeakerId === undefined) {
3711
+ // First recognised speaker after mount · let entry animation
3712
+ // be the cinematic arrival; record so the SECOND speaker is
3713
+ // the first pulse target.
3714
+ lastPulseSpeakerId = speakerId;
3715
+ return;
3716
+ }
3717
+ if (lastPulseSpeakerId === speakerId) return;
3718
+ const seat = (overlaySeats || []).find((s) => s.id === speakerId);
3719
+ if (!seat || seat.isUser || !seat.worldPos) {
3720
+ // User taking a turn / unknown seat · skip the pulse but
3721
+ // remember the id so we don't keep retrying on every frame.
3722
+ lastPulseSpeakerId = speakerId;
3723
+ return;
3724
+ }
3725
+ // Direction from rest position toward the speaker's seat on
3726
+ // the XZ plane (no vertical component · we add lift separately).
3727
+ const dx = seat.worldPos.x - cameraRestPos.x;
3728
+ const dz = seat.worldPos.z - cameraRestPos.z;
3729
+ const mag = Math.hypot(dx, dz);
3730
+ if (mag < 0.0001) {
3731
+ // Seat is directly under the rest camera point — vanishingly
3732
+ // rare, but bail to avoid NaN.
3733
+ lastPulseSpeakerId = speakerId;
3734
+ return;
3735
+ }
3736
+ cameraPulseDirX = dx / mag;
3737
+ cameraPulseDirZ = dz / mag;
3738
+ cameraPulseStart = (typeof performance !== "undefined" ? performance.now() : Date.now());
3739
+ cameraPulseActive = true;
3740
+ lastPulseSpeakerId = speakerId;
3741
+ // Hand camera off from OrbitControls for the pulse window so
3742
+ // the user's drag-orbit doesn't fight the lerp · restored in
3743
+ // tickSpeakerCameraPulse() when the pulse completes.
3744
+ if (controls) controls.enabled = false;
3745
+ }
3746
+
3747
+ function tickSpeakerCameraPulse() {
3748
+ if (!cameraPulseActive || !camera || !cameraRestPos) return;
3749
+ const now = (typeof performance !== "undefined" ? performance.now() : Date.now());
3750
+ const t = Math.max(0, Math.min(1, (now - cameraPulseStart) / CAMERA_PULSE_DURATION_MS));
3751
+ // Bell curve: 0 at t=0, peaks at t=0.5, back to 0 at t=1.
3752
+ // sin(πt) keeps the camera anchored at rest at both ends so
3753
+ // there's no discontinuity when the pulse begins or ends.
3754
+ const bell = Math.sin(Math.PI * t);
3755
+ camera.position.set(
3756
+ cameraRestPos.x + cameraPulseDirX * CAMERA_PULSE_FORWARD * bell,
3757
+ cameraRestPos.y + CAMERA_PULSE_LIFT * bell,
3758
+ cameraRestPos.z + cameraPulseDirZ * CAMERA_PULSE_FORWARD * bell,
3759
+ );
3760
+ camera.lookAt(0, _mountCamLookY, 0);
3761
+ if (t >= 1) {
3762
+ // Snap exactly back to rest and hand control back.
3763
+ camera.position.copy(cameraRestPos);
3764
+ camera.lookAt(0, _mountCamLookY, 0);
3765
+ cameraPulseActive = false;
3766
+ if (controls) {
3767
+ controls.enabled = true;
3768
+ // Re-anchor OrbitControls' internal target spherical so the
3769
+ // next user drag starts from the rest pose, not from the
3770
+ // mid-pulse pose we never let it observe.
3771
+ if (controls.target) controls.target.set(0, _mountCamLookY, 0);
3772
+ try { controls.update(); } catch (_) { /* */ }
3773
+ }
3774
+ }
3775
+ }
3776
+
2839
3777
  function tickEntryAnimation() {
2840
3778
  if (!entryActive || !camera || !cameraRestPos) return;
2841
3779
  const now = (typeof performance !== "undefined" ? performance.now() : Date.now());
@@ -2915,24 +3853,54 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2915
3853
  function tickSeatAnimations() {
2916
3854
  if (!overlaySeats.length) return;
2917
3855
  const t = (typeof performance !== "undefined" ? performance.now() : Date.now()) * 0.001;
3856
+ const app = (typeof window !== "undefined") ? window.app : null;
3857
+ const canCheckAudio = !!(app && typeof app.isSpeakerAudible === "function");
2918
3858
  for (const seat of overlaySeats) {
2919
- const isSpeaking = activeSpeakerId && seat.id === activeSpeakerId;
2920
- // Idle bob · ±2.5 cm at ~0.4 Hz, suspended for the speaker.
3859
+ const isActive = activeSpeakerId && seat.id === activeSpeakerId;
3860
+ // The mouth only moves while actually SPEAKING and synced to the real
3861
+ // TTS audio when we can read it (the "speaking" stage state can start
3862
+ // before the audio does, which made mouths move silently). Fall back to
3863
+ // the stage state when no audio probe is available (e.g. older app).
3864
+ const isTalking = isActive && (canCheckAudio
3865
+ ? app.isSpeakerAudible(seat.id)
3866
+ : activeSpeakerState === "speaking");
3867
+ const isSpeaking = isActive; // kept for the idle-bob suspension below
3868
+ // Idle bob · ±2.5 cm at ~0.4 Hz, suspended for the speaker. Relative to
3869
+ // the seat's base Y (3D avatars are lifted onto the cushion).
2921
3870
  if (seat.fig) {
2922
- seat.fig.position.y = isSpeaking
2923
- ? 0
2924
- : Math.sin(t * 2.6 + seat.bobPhase) * 0.025;
2925
- // Speaking squash-blink · scale the figure group's height
2926
- // down briefly (anchored at the chair seat, so it compresses
2927
- // toward the body). Non-speakers and reduced-motion hold at 1.
2928
- let sy = 1;
2929
- if (isSpeaking && !prefersReducedMotion) {
2930
- const phase = (t + seat.bobPhase) % BLINK_PERIOD;
2931
- if (phase < BLINK_DUR) {
2932
- sy = 1 - BLINK_DEPTH * Math.sin((phase / BLINK_DUR) * Math.PI);
3871
+ const baseY = seat.figBaseY || 0;
3872
+ seat.fig.position.y = baseY + (isSpeaking ? 0 : Math.sin(t * 2.6 + seat.bobPhase) * 0.025);
3873
+
3874
+ const is3d = !!(seat.fig.userData && seat.fig.userData.isAvatar3d);
3875
+ if (is3d) {
3876
+ // 3D avatars · animate the REAL mouth (the overlay opening/closing)
3877
+ // while speaking, NOT a body squash. Keep the body scale at 1.
3878
+ if (seat.fig.scale.y !== 1) seat.fig.scale.y = 1;
3879
+ const ov = seat.mouthOverlay;
3880
+ if (ov) {
3881
+ if (isTalking && !prefersReducedMotion) {
3882
+ // Open/close at ~2.7 Hz with a mild wobble · reads as talking.
3883
+ const o = 0.5 + 0.5 * Math.sin(t * 17 + seat.bobPhase * 3);
3884
+ const wobble = 0.85 + 0.15 * Math.sin(t * 9.1 + seat.bobPhase);
3885
+ ov.scale.y = MOUTH_MIN + (MOUTH_OPEN - MOUTH_MIN) * o * wobble;
3886
+ ov.visible = true;
3887
+ } else {
3888
+ ov.visible = false; // thinking / silent · show the baked smile
3889
+ }
3890
+ }
3891
+ } else {
3892
+ // Sprite / voxel fallback · the legacy speaking squash-blink (no
3893
+ // mouth meshes to animate). Only while SPEAKING (not thinking);
3894
+ // non-speakers + reduced-motion hold at 1.
3895
+ let sy = 1;
3896
+ if (isTalking && !prefersReducedMotion) {
3897
+ const phase = (t + seat.bobPhase) % BLINK_PERIOD;
3898
+ if (phase < BLINK_DUR) {
3899
+ sy = 1 - BLINK_DEPTH * Math.sin((phase / BLINK_DUR) * Math.PI);
3900
+ }
2933
3901
  }
3902
+ if (seat.fig.scale.y !== sy) seat.fig.scale.y = sy;
2934
3903
  }
2935
- if (seat.fig.scale.y !== sy) seat.fig.scale.y = sy;
2936
3904
  }
2937
3905
  // Speaker halo pulse · 0.35 ↔ 0.75 at ~0.8 Hz (matches the
2938
3906
  // 2D `.rt-seat-speaking::before` glow rhythm).
@@ -3032,6 +4000,13 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
3032
4000
  // positions / glow-ring opacity; projection AFTER render so
3033
4001
  // the overlay DOM transforms align with what was just drawn.
3034
4002
  tickEntryAnimation();
4003
+ // Speaker-change pulse runs AFTER entry but BEFORE seat /
4004
+ // controls update · entry owns the camera fully when active,
4005
+ // then the pulse lerps freely (controls.enabled=false during
4006
+ // the window), then controls take back over once the pulse
4007
+ // settles. Seat animations don't touch the camera so order
4008
+ // between them is interchangeable.
4009
+ tickSpeakerCameraPulse();
3035
4010
  tickSeatAnimations();
3036
4011
  if (controls) controls.update();
3037
4012
  renderer.render(scene, camera);