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.
- package/dist/boot.js +1415 -91
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1415 -91
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1271 -81
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/__avatar3d_test.html +156 -0
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +331 -41
- package/public/agent-profile.js +499 -75
- package/public/app-updater.css +1 -1
- package/public/app.js +2090 -547
- package/public/avatar-3d-snap.js +205 -0
- package/public/avatar-3d.js +792 -0
- package/public/avatar-customizer.html +274 -0
- package/public/avatar3d-editor.css +240 -0
- package/public/avatar3d-editor.js +481 -0
- package/public/avatars/3d/chair.png +0 -0
- package/public/avatars/3d/first-principles.png +0 -0
- package/public/avatars/3d/historian.png +0 -0
- package/public/avatars/3d/long-horizon.png +0 -0
- package/public/avatars/3d/phenomenologist.png +0 -0
- package/public/avatars/3d/socrates.png +0 -0
- package/public/avatars/3d/user-empathy.png +0 -0
- package/public/avatars/3d/value-investor.png +0 -0
- package/public/core-avatars.js +86 -0
- package/public/home-3d-loader.js +15 -4
- package/public/home-3d-mock.js +18 -7
- package/public/home.html +80 -18
- package/public/i18n.js +279 -4
- package/public/icons/avatar_1779855104027.glb +0 -0
- package/public/icons/logo.png +0 -0
- package/public/icons/new-style.glb +0 -0
- package/public/icons/new-style2.glb +0 -0
- package/public/icons/new-style3.glb +0 -0
- package/public/icons/new-style4.glb +0 -0
- package/public/icons/new-style5.glb +0 -0
- package/public/icons/office.glb +0 -0
- package/public/icons/stuff.glb +0 -0
- package/public/index.html +203 -182
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/new-agent.js +46 -20
- package/public/office-viewer.html +340 -0
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/room-settings.css +24 -9
- package/public/stuff-viewer.html +330 -0
- package/public/thread.css +1211 -0
- package/public/user-settings.css +16 -19
- package/public/user-settings.js +86 -78
- package/public/vendor/BufferGeometryUtils.js +1434 -0
- package/public/vendor/DRACOLoader.js +739 -0
- package/public/vendor/GLTFLoader.js +4860 -0
- package/public/vendor/RoomEnvironment.js +185 -0
- package/public/vendor/SkeletonUtils.js +496 -0
- package/public/vendor/draco/draco_decoder.js +34 -0
- package/public/vendor/draco/draco_decoder.wasm +0 -0
- package/public/vendor/draco/draco_encoder.js +33 -0
- package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
- package/public/vendor/meshopt_decoder.module.js +196 -0
- package/public/voice-3d-banner.js +12 -0
- package/public/voice-3d.js +1407 -432
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/voice-replay.js +21 -0
- package/public/avatar-skill.js +0 -629
- package/public/icons/folded-sidebar.png +0 -0
package/public/voice-3d.js
CHANGED
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
|
|
29
29
|
Toggle
|
|
30
30
|
──────
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
994
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
/**
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
function
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
/**
|
|
1142
|
-
*
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
ctx.fillStyle =
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
//
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
//
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
const
|
|
1220
|
-
const
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
//
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
const my = Math.
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
for (let
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
/**
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
//
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
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
|
-
//
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
|
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
|
-
|
|
2088
|
-
const
|
|
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
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
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 *
|
|
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
|
-
//
|
|
2125
|
-
|
|
2126
|
-
const
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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
|
-
//
|
|
2132
|
-
const
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
//
|
|
2146
|
-
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
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
|
-
*
|
|
2358
|
-
*
|
|
2359
|
-
*
|
|
2360
|
-
*
|
|
2361
|
-
*
|
|
2362
|
-
*
|
|
2363
|
-
*
|
|
2364
|
-
* the
|
|
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
|
-
//
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2920
|
-
//
|
|
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.
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
const
|
|
2931
|
-
if (
|
|
2932
|
-
|
|
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);
|