-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathServerRoom.tsx
More file actions
1857 lines (1754 loc) · 81.1 KB
/
Copy pathServerRoom.tsx
File metadata and controls
1857 lines (1754 loc) · 81.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { AISLE_ORDER, CLUSTER_DISPLAY, projects } from '@/data/projects';
import { isAutomatedEnvironment } from '@/utils/isAutomated';
import {
Grid,
Html,
MeshReflectorMaterial,
Sparkles,
Stars,
useCursor,
useGLTF,
useTexture,
} from '@react-three/drei';
import { useFrame, useThree, type ThreeEvent } from '@react-three/fiber';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
AdditiveBlending,
AmbientLight,
BufferGeometry,
CanvasTexture,
CircleGeometry,
Color,
ConeGeometry,
DirectionalLight,
DoubleSide,
Group,
HemisphereLight,
Mesh,
MeshStandardMaterial,
Object3D,
PointLight,
SRGBColorSpace,
Vector3,
type Material,
type ShaderMaterial,
} from 'three';
import { aisleScroll } from './aisleScroll';
import { assertAnchorCoverage, collectAnchors, type SceneAnchor } from './anchors';
import { resolveClick, type ClickTarget } from './clickResolver';
import { createConsoleMaterial, type ConsoleUniforms } from './consoleShader';
import { createOperatorHoloMaterial, type OperatorHoloUniforms } from './operatorHoloShader';
import { MODEL_URLS, type SceneVariant } from './sceneVariant';
import { createWaveBeamMaterial, type WaveBeamUniforms } from './waveBeamShader';
import { createWaveFloorMaterial, type WaveFloorUniforms } from './waveFloorShader';
import { DistantRacks } from './components/DistantRacks';
// Preload the one glb both variants now resolve to (portrait used to
// load a separate amphitheater file — retired, see sceneVariant.ts).
// Second arg = self-hosted Draco decoder path (public/draco/); the glb
// ships KHR_draco_mesh_compression, so the decoder must be available or
// the scene won't parse. Self-hosted (not the gstatic CDN) to keep the
// site free of third-party runtime dependencies.
useGLTF.preload(MODEL_URLS.landscape, '/draco/');
// Materials whose emission should bypass ACES tonemapping.
function isUntonedMaterial(name: string): boolean {
return name === 'M_Screen' || name.startsWith('M_Cable_') || name.startsWith('M_StatusLED_');
}
// Hover behavior tuning. DIM is aggressive on purpose — we want the
// hovered rack to feel spotlit, everything else to recede into shadow.
const HOVER_INTENSITY_MULTIPLIER = 1.8;
const DIM_INTENSITY_MULTIPLIER = 0.12;
const HOVER_TIME_CONSTANT = 0.07;
// Bright fluorescent-quality lighting designed to light dark
// concept-art surfaces enough that they actually read. Intensities
// pushed ~30% above the prior baseline because dark-blue base colors
// reflect only a small fraction of incident light per channel.
const LIGHTS = {
hemi: { idle: 2.2, dim: 0.55 },
ambient: { idle: 0.95, dim: 0.22 },
pointKey: { idle: 1.2, dim: 0.18 }, // central cyan accent
topDown: { idle: 3.4, dim: 0.85 },
ceilingGrid: { idle: 8.0, dim: 1.6 },
};
// Four ceiling light positions in a symmetric grid above the room.
// Same height (4.4m), even spread so coverage is uniform left-to-right
// and front-to-back rather than biased to one corner.
const CEILING_LIGHTS = [
[-3.5, 4.4, -3.5],
[3.5, 4.4, -3.5],
[-3.5, 4.4, 3.5],
[3.5, 4.4, 3.5],
] as const;
interface Interactive {
mat: MeshStandardMaterial;
base: number;
hover: number;
dim: number;
current: number;
hoverKey: string;
}
function hoverKeyForState(state: ClickTarget): string | null {
if (state === null) return null;
if (state.kind === 'terminal') return 'terminal';
if (state.kind === 'linkedin') return 'linkedin';
return `project:${state.projectId}`;
}
function hoverKeyForMesh(name: string): string | null {
const m = name.match(/^Screen_(.+)$/);
if (m) return `project:${m[1]}`;
if (name === 'Monitor') return 'terminal';
return null;
}
// Deterministic 32-bit hash over a string. Used so the per-LED random
// pattern is stable across reloads — same input → same colour / dim
// state. (xmur3-derived; small, fast, no collisions for our 72 keys.)
function strHash(s: string): number {
let h = 1779033703 ^ s.length;
for (let i = 0; i < s.length; i++) {
h = Math.imul(h ^ s.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return (h ^ (h >>> 16)) >>> 0;
}
// Parse "StatusLED_<projectId>_r<row>_c<col>" → projectId, or null
// if the mesh name isn't a rack LED.
function ledProjectId(name: string): string | null {
const m = name.match(/^StatusLED_(.+?)_r\d+_c\d+$/);
return m ? m[1] : null;
}
// ADSR-shaped pulse for the wave. t ∈ [0, 1].
// attack [0, 0.08] → 0 → 1.0 (snap rise)
// sustain [0.08, 0.40] → 1.0 (hold)
// decay [0.40, 1.0] → 1.0 → 0 (quadratic ease-out)
// Replaces the prior sin(πt) bump — the snap-rise + hold + slow-decay
// shape reads as a "drop" rather than a soft swell, which is what the
// techno-club aesthetic needs.
function adsrPulse(t: number): number {
if (t <= 0 || t >= 1) return 0;
if (t < 0.08) return t / 0.08;
if (t < 0.4) return 1.0;
const dt = (t - 0.4) / 0.6;
return 1.0 - dt * dt;
}
// Strobe attack: 1.0 → 0.0 linear fade over the first 15 % of the
// pulse window (~150 ms at WAVE_PULSE_DUR_S=1.0). Mixes the beam +
// disc + rack-body emissive colour toward white during this window
// so each slot hit reads with a camera-flash quality before settling
// into the cluster colour for the sustain + decay.
function strobeFlash(t: number): number {
if (t <= 0 || t >= 0.15) return 0;
return 1.0 - t / 0.15;
}
// Per-cluster wave colours — one distinct hue per cluster so the idle
// wave reads as a category sweep, not a wash. Earlier these were locked
// to the room's two-tone cyan/magenta, which forced analyst to share
// quant's cyan (two of four clusters indistinguishable). Now full-
// spectrum: each cluster owns a hue, tuned to a consistent neon
// luminance so none dominates. The cyan/magenta cables still anchor the
// room; quant + swe stay near them, analyst takes a warm gold and
// security a mint green to spread the four apart.
//
// Project-level accent colours (per projects.ts) are separate — they
// drive each rack's floor-glow + LED tint for per-project identity,
// independent of the cluster wave.
const WAVE_CLUSTER_COLORS: Record<string, string> = {
quant: '#36d4ff', // cyan
swe: '#ff5cc8', // pink
analyst: '#ffc24c', // gold (was cyan — broke the quant collision)
security: '#46e85c', // emerald green — kept clear of quant's blue-cyan
ml: '#a06bff', // violet — AI/ML wing, clear of the other four hues
};
function waveColorForProject(projectId: string, byId: Map<string, { cluster?: string }>): string {
const project = byId.get(projectId);
const cluster = project?.cluster ?? 'quant';
return WAVE_CLUSTER_COLORS[cluster] ?? WAVE_CLUSTER_COLORS.quant;
}
// Deterministic transforms for the distant-rack scatter. Generated
// DistantRacks is imported from ./components/DistantRacks
// Project ids in the order they should appear down the portrait aisle —
// closest to camera first, receding into the fog. Quant cluster anchors
// the front because OCaml LOB / qforge are the strongest "headline" tech
// surfaces; swe + analyst + security + AI/ML clusters follow in cluster
// groupings so the colour-coding reads as you walk.
export { AISLE_ORDER };
// Aisle geometry. Racks line both sides of a centre corridor, each pair
// sharing the same Z position. AISLE_HALF_WIDTH is the lateral offset
// from the corridor centreline to each rack's pivot — 1.5m gives a ~3m
// walkway that reads as a real data-centre aisle without crowding the
// front pair at the camera. Z_START sits a hair behind the (relocated)
// terminal desk so the first pair reads as the user's first step into
// the hall.
export const AISLE_SPACING = 2.6;
export const AISLE_Z_START = 1.0;
const AISLE_TERMINAL_Z = 4.2;
// Operator hologram parks just past the last rack, at the end of the
// corridor, facing back up the aisle (+Z) as the walk's destination.
// The backwall terminus sits behind it, capping the corridor.
// Exported so the scroll-camera end (Scene.tsx) derives its reach from the
// actual aisle length — adding racks to AISLE_ORDER must not strand the tail
// of the corridor beyond where the scroll can travel (it did: the AI/ML wing
// past the cybersec racks became unreachable on mobile).
export const AISLE_HOLO_Z = AISLE_Z_START - AISLE_ORDER.length * AISLE_SPACING;
const AISLE_TERMINUS_Z = AISLE_HOLO_Z - 1.8;
// 1.2 m half-width (2.4 m aisle) keeps the rack bodies inside the
// narrow ~18° portrait horizontal half-FOV until the camera is within
// ~3.7 m of them, instead of dropping off at ~4.6 m with the original
// 1.5 m half-width. Closer cut-off lets the opacity rule peak labels
// while the rack is still 4–5 m ahead — when the rack body fills a
// meaningful chunk of the frame — instead of pushing peak readability
// out to the 7–12 m range where the labelled rack reads as "the one
// far down the aisle" rather than "the one I'm walking past."
const AISLE_HALF_WIDTH = 1.2;
// Per-rack-id sets of mesh-name predicates: anything matching gets moved
// into the rack's transform group when we re-lay-out for portrait.
function isRackMesh(name: string, id: string): boolean {
return name === `Rack_${id}` || name === `Screen_${id}` || name.startsWith(`StatusLED_${id}_`);
}
// Whitelist of mesh-name patterns that should remain visible after the
// portrait aisle layout is applied. Walls, ceiling beams, and other
// authored "room" geometry get hidden — the racks now stand in a fogged
// void rather than inside the four-walled landscape room, so anything
// not in the whitelist would float in place of a missing wall.
//
// Cable_* meshes are absent from this list because the room ships
// without cabling in either variant — see the traverse below. So
// portrait needs no cable rule of its own.
//
// Keyboard_* — the desk keyboard has ~80 individual <Mesh> children
// (one per key + body). Each is a separate draw call on mobile GPUs,
// and the portrait camera puts the desk at z≈4 — too far for the
// individual keys to be legible anyway. Keep landscape (close-up
// camera reads them), drop on portrait.
function isPortraitKeepMesh(name: string): boolean {
if (
name.startsWith('Rack_') ||
name.startsWith('Screen_') ||
name.startsWith('StatusLED_') ||
name.startsWith('BackgroundTower_') ||
name.startsWith('DeskNameplate_') ||
name === 'Monitor' ||
name === 'OperatorHolo' ||
name === 'HoloPedestal' ||
name === 'Desk' ||
name === 'Floor' ||
name === 'DistantRackBody' ||
name === 'DistantRackLED'
)
return true;
return false;
}
// Determine which wall a landscape-glb rack lives on, given its anchor
// world position. Returns the anchor-plane axis-aligned unit vector
// pointing *outward from the wall* (i.e. the rack's forward direction
// in the landscape composition).
function wallNormalFor(anchorPos: Vector3): Vector3 {
const ANCHOR_PLANE = 4.7;
const distToLeft = Math.abs(anchorPos.x + ANCHOR_PLANE);
const distToRight = Math.abs(anchorPos.x - ANCHOR_PLANE);
const distToBack = Math.abs(anchorPos.z + ANCHOR_PLANE);
const distToFront = Math.abs(anchorPos.z - ANCHOR_PLANE);
const minDist = Math.min(distToLeft, distToRight, distToBack, distToFront);
if (minDist === distToLeft) return new Vector3(1, 0, 0); // left wall faces +X
if (minDist === distToRight) return new Vector3(-1, 0, 0); // right wall faces -X
// Back wall AND the AI/ML front wing both face +Z (the front-wing racks are
// turned to face the entrance camera), so both fall through to +Z. distToFront
// is kept in the min only so a front anchor isn't misread as a side wall.
return new Vector3(0, 0, 1);
}
// Apply the portrait aisle layout to a *cloned* scene. Mutates the scene
// in place: each rack (Rack_<id> + Screen_<id> + StatusLED_<id>_*) and
// its anchor empty get reparented into a per-rack Group, which is then
// positioned along the -Z axis and rotated to face +Z. The Monitor + Desk
// pair move forward to z=AISLE_TERMINAL_Z. Non-whitelisted geometry
// (walls, ceiling, decorative trim) is hidden so the aisle reads in the
// fogged void rather than as racks poking through an empty room.
function applyAisleLayout(scene: Object3D): void {
// Cache anchor refs by id so we can update them in lockstep with the
// meshes they pin. Anchors are Object3D empties named "anchor_<id>".
const anchorByName = new Map<string, Object3D>();
scene.traverse((node) => {
if (node.name.startsWith('anchor_')) {
anchorByName.set(node.name.slice('anchor_'.length), node);
}
});
// First pass: collect each rack's meshes. We do this before any
// reparenting because traversal order during attach() can skip nodes
// that have moved subtrees.
const meshesByRack = new Map<string, Object3D[]>();
for (const id of AISLE_ORDER) {
meshesByRack.set(id, []);
}
scene.traverse((node) => {
if (!(node instanceof Mesh)) return;
for (const id of AISLE_ORDER) {
if (isRackMesh(node.name, id)) {
meshesByRack.get(id)!.push(node);
return;
}
}
});
// Second pass: per rack, build a transform group at the rack's
// original pivot, reparent meshes (+ anchor) into it via attach()
// (which preserves world transforms), translate to the *left* side of
// the aisle (x = −AISLE_HALF_WIDTH) facing +X, then deep-clone the
// group to the *right* side facing −X. The clone shares geometries
// and materials with the original; the per-mesh material clone in
// useLayoutEffect runs *after* this and gives each its own instance.
const tmpWorld = new Vector3();
for (let i = 0; i < AISLE_ORDER.length; i++) {
const id = AISLE_ORDER[i];
const anchor = anchorByName.get(id);
if (!anchor) continue;
anchor.getWorldPosition(tmpWorld);
const normal = wallNormalFor(tmpWorld);
// Rack body sits ~1m *behind* its anchor (anchor is authored 1m in
// front of the rack face per the Blender contract).
const origPivot = tmpWorld.clone().sub(normal);
// Rotation that turns the rack's outward normal into +X — the
// direction a left-side rack must face to address the corridor.
const leftAngle = Math.PI / 2 - Math.atan2(normal.x, normal.z);
const targetZ = AISLE_Z_START - i * AISLE_SPACING;
const group = new Group();
group.position.copy(origPivot);
scene.add(group);
group.attach(anchor);
for (const mesh of meshesByRack.get(id) ?? []) {
group.attach(mesh);
}
// Left side of the aisle, rack face pointing +X (toward corridor).
group.position.set(-AISLE_HALF_WIDTH, origPivot.y, targetZ);
group.rotation.y = leftAngle;
// Mirror clone on the right side. The 180° rotation flips the
// outward normal from +X to −X so the rack faces the corridor from
// the right wall.
const mirror = group.clone(true);
scene.add(mirror);
mirror.position.set(AISLE_HALF_WIDTH, origPivot.y, targetZ);
// Normalise into [0, 2π): a right-wall rack gives leftAngle = π, so the
// naive leftAngle + π = 2π. clone() copied the group's Ry(π) quaternion,
// and assigning rotation.y to *exactly* 2π fails to re-sync the
// quaternion off that value, leaving the stale Ry(π) — which dropped the
// mirror to x≈0.8 instead of 1.6. Wrapping 2π → 0 forces a real change
// and a clean identity rotation. set() (not `.y =`) guarantees the
// Euler→quaternion sync fires.
mirror.rotation.set(0, (leftAngle + Math.PI) % (2 * Math.PI), 0);
// Strip anchor names from the mirror so collectAnchors finds only
// the original — one label per project, anchored off the left side.
// The mirror's meshes keep their real names (Rack_<id> etc.) so
// hovering or clicking either side still resolves to the project.
mirror.traverse((node) => {
if (node.name.startsWith('anchor_')) {
node.name = '_mirror_' + node.name;
}
});
}
// Pull the terminal/desk forward so it sits *in front of* the first
// aisle rack. The terminal anchor name is `anchor_terminal`; the
// Monitor + Desk meshes share its frame in the authored scene.
const terminalAnchor = anchorByName.get('terminal');
if (terminalAnchor) {
terminalAnchor.getWorldPosition(tmpWorld);
const termPivot = tmpWorld.clone();
// Pre-collect Monitor/Desk before reparenting — calling attach()
// splices the node out of its parent's children array, which
// would corrupt the traverse iteration if done inline. Keyboard_*
// meshes used to be in here too, but they're now hidden on
// portrait via isPortraitKeepMesh (mobile-perf cut), so there's
// no point reparenting them.
const termMeshes: Object3D[] = [];
scene.traverse((node) => {
if (!(node instanceof Mesh)) return;
if (node.name === 'Monitor' || node.name === 'Desk') {
termMeshes.push(node);
}
});
const termGroup = new Group();
termGroup.position.copy(termPivot);
scene.add(termGroup);
termGroup.attach(terminalAnchor);
for (const m of termMeshes) termGroup.attach(m);
termGroup.position.set(0, termPivot.y, AISLE_TERMINAL_Z);
}
// Park the operator hologram at the far end of the corridor, facing
// back up the aisle (+Z) so it greets the user as the destination
// rather than riding the desk to the front. OperatorHolo + HoloPedestal
// are authored as children of Desk, so they were pulled forward with
// the terminal group above; lift them into their own pivot, drop it at
// the aisle end, and spin it so the portrait faces the approaching
// camera. Names are preserved, so the LinkedIn click target still
// resolves (clickResolver walks up to OperatorHolo / HoloPedestal).
const holo = scene.getObjectByName('OperatorHolo');
const pedestal = scene.getObjectByName('HoloPedestal');
if (holo && pedestal) {
pedestal.getWorldPosition(tmpWorld);
const holoGroup = new Group();
holoGroup.position.copy(tmpWorld); // pivot at the pedestal base
scene.add(holoGroup);
holoGroup.attach(holo);
holoGroup.attach(pedestal);
holoGroup.position.set(0, tmpWorld.y, AISLE_HOLO_Z);
// The portrait is a flat sheet (1.1 × 1.48) authored facing −Y (down)
// so the elevated landscape orbit camera could read it; in the
// horizontal aisle that is edge-on/invisible. holoGroup carries no
// rotation, so a clean Rx(90°) stands the sheet upright — its +Y
// geometry normal rotates to +Z — facing back up the corridor toward
// the approaching user.
holo.rotation.set(Math.PI / 2, 0, 0);
}
// Hide everything not in the keep-list. Run *after* the rack
// repositions so we don't accidentally hide meshes we were about to
// move.
scene.traverse((node) => {
if (!(node instanceof Mesh)) return;
if (!isPortraitKeepMesh(node.name)) {
node.visible = false;
}
});
}
interface ServerRoomProps {
onAnchorsReady?: (anchors: Map<string, SceneAnchor>) => void;
onSelect?: (target: ClickTarget) => void;
panelOpen?: boolean;
isMobile?: boolean;
variant?: SceneVariant;
}
export function ServerRoom({
onAnchorsReady,
onSelect,
panelOpen,
isMobile = false,
variant = 'landscape',
}: ServerRoomProps) {
const isAutomated = useMemo(() => isAutomatedEnvironment(), []);
const { scene: originalScene } = useGLTF(MODEL_URLS[variant], '/draco/');
// Portrait viewports get a procedural aisle layout (racks repositioned
// into a single -Z column with the desk pulled forward) baked onto a
// cloned scene. Landscape uses the authored geometry unchanged. Clone
// is keyed on the loaded glb identity so a re-load (variant flip,
// HMR) produces a fresh transform.
const scene = useMemo(() => {
if (variant !== 'portrait') return originalScene;
const cloned = originalScene.clone(true);
try {
applyAisleLayout(cloned);
} catch (err) {
console.error('[aisle] applyAisleLayout threw:', err);
}
return cloned;
}, [originalScene, variant]);
const { scene: rootScene, gl, invalidate } = useThree();
useEffect(() => {
invalidate();
}, [invalidate, scene]);
// Preload every project logo as a texture and crank the anisotropy
// to the GPU max. Without this the rack badges are sampled with
// basic trilinear filtering and blur out at the grazing camera
// angles the portrait aisle creates (camera in the corridor, badges
// on side-wall planes at ~70° off-axis — classic anisotropy case).
const logoUrlMap = useMemo<Record<string, string>>(() => {
const out: Record<string, string> = {};
for (const p of projects) if (p.logo) out[p.id] = p.logo;
return out;
}, []);
const logoTextures = useTexture(logoUrlMap) as Record<string, import('three').Texture>;
// Operator portrait — fed into the OperatorHolo shader as uTexture.
// Single-image useTexture call; the SRGB + anisotropy fix-up happens
// alongside the rack logos below.
const pfpTexture = useTexture('/LinkedIn_PFP.webp') as import('three').Texture;
useLayoutEffect(() => {
// Anisotropic filtering + sRGB colour space. The colour-space
// step matters because drei's <Image> internally sets
// SRGBColorSpace on the texture; useTexture leaves it at the
// default LinearSRGBColorSpace, so logos rendered through the
// sRGB output pipeline come out gamma-uncorrected and look washed
// out / desaturated.
//
// Anisotropy: cap at 4× on mobile (was max — usually 16). Visually
// indistinguishable at the small viewport size + small badge size,
// but the texture-sample cost on a phone GPU is non-trivial.
const max = gl.capabilities.getMaxAnisotropy();
const target = isMobile ? Math.min(4, max) : max;
for (const t of [...Object.values(logoTextures), pfpTexture]) {
let touched = false;
if (t.anisotropy !== target) {
t.anisotropy = target;
touched = true;
}
if (t.colorSpace !== SRGBColorSpace) {
t.colorSpace = SRGBColorSpace;
touched = true;
}
if (touched) t.needsUpdate = true;
}
// PFP-only: glTF UV convention has origin at bottom-left; useTexture
// ships with flipY=true (matches drei <Image> and most CSS contexts)
// which inverts the photo when sampled by the GLB-authored plane's
// UVs. The logos don't hit this because they're rendered through
// drei's <Image> with its own UV handling. Force flipY=false here so
// the holo plane reads the texture right-side up.
if (pfpTexture.flipY) {
// eslint-disable-next-line react-hooks/immutability -- useLayoutEffect setup; one-time texture config mutation
pfpTexture.flipY = false;
pfpTexture.needsUpdate = true;
}
}, [logoTextures, pfpTexture, gl, isMobile]);
const interactivesRef = useRef<Interactive[]>([]);
// Cached BackgroundTower_*_strip material refs + their per-strip
// phase/speed. Populated once in the useLayoutEffect below; the
// useFrame pulse loop iterates this array instead of doing a full
// scene.traverse() every frame. (Earlier version traversed each
// frame; that's a measurable cost when the scene has hundreds of
// meshes after applyAisleLayout.)
const towerStripsRef = useRef<
{
mat: MeshStandardMaterial;
phase: number;
speed: number;
}[]
>([]);
// Wall-clock accumulator for the BackgroundTower_*_strip pulse in
// useFrame. We don't cache material refs — instead we traverse the
// scene each frame and modify materials in place (cached refs were
// causing the modified material not to reach the renderer; see the
// useFrame block for details).
const elapsedRef = useRef(0);
const monitorShaderRef = useRef<(ShaderMaterial & { uniforms: ConsoleUniforms }) | null>(null);
const operatorHoloShaderRef = useRef<
(ShaderMaterial & { uniforms: OperatorHoloUniforms }) | null
>(null);
const [hover, setHover] = useState<ClickTarget>(null);
const [anchorMap, setAnchorMap] = useState<Map<string, SceneAnchor>>(new Map());
// Track aisle-scroll progress so rack labels can fade based on how
// close the camera is to each rack — not a fixed "front 3 racks
// only" rule, which was the previous behaviour and only ever showed
// the quant cluster. Subscribing here re-renders the label list on
// every scroll tick; React handles the 9 Html reconciliations
// cheaply.
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => aisleScroll.subscribe(setScrollProgress), []);
// Idle-attractor wave. After IDLE_BEFORE_WAVE_MS of no interaction
// a moving spotlight travels rack-to-rack along the aisle — the
// rack at the peak of its pulse goes bright, *everything else
// dims*, so the highlighted rack reads as a focal spot rather
// than a slow ripple. Resets on any real input; skipped while a
// panel is open or while the user is hovering.
//
// Implementation lerps each rack's target between WAVE_DIM and
// WAVE_BRIGHT based on its sin-curve pulse intensity (0 outside
// its own window, 1 at peak). Stacks on the existing hover-state
// lerp by replacing it entirely while the wave is active — hover
// is already suppressed during the wave, so the two never need to
// compose.
const IDLE_BEFORE_WAVE_MS = 15_000;
// 1.0 s per-slot pulse (down from 1.2): the ADSR curve has a faster
// attack + shorter sustain so the slot reads as a hit rather than
// a swell, and 1.0 keeps the total wave duration tight at ~4.55 s
// in portrait.
const WAVE_PULSE_DUR_S = 1.0;
// Per-slot delay differs by variant:
// portrait → 15 individual racks, 0.35 s apart (rack-by-rack)
// landscape → 5 cluster groups, 0.70 s apart (a wall at a time)
// Landscape uses cluster groups because the racks aren't on a line
// in 3D — they're on the four walls (quant=back, swe+analyst=left,
// security=right, AI/ML=front). A rack-by-rack sweep would bounce
// around the room; a cluster sweep tells the "categories of work"
// story spatially, since each cluster owns its own wall.
const WAVE_RACK_DELAY_S = 0.35;
const WAVE_CLUSTER_DELAY_S = 0.7;
const lastInteractionRef = useRef<number>(0);
const waveStartRef = useRef<number | null>(null);
// Variant-aware "which time slot does this rack fire in?" map.
// Portrait = 15 slots, one per rack in AISLE_ORDER. Landscape = 5
// slots, one per cluster. The terminal counts as quant (slot 0)
// in landscape so the desk lights up with the first wave step.
// Keys here MUST match the `hoverKey` format set by hoverKeyForMesh,
// which is `project:<id>` for racks and `"terminal"` for the desk
// monitor. Storing raw ids was the bug that kept every rack pinned
// to the dim target — the lookup missed and waveIntensity stayed 0.
const slotIndexByKey = useMemo(() => {
const m = new Map<string, number>();
if (variant === 'portrait') {
AISLE_ORDER.forEach((id, i) => m.set(`project:${id}`, i));
} else {
const order = ['quant', 'swe', 'analyst', 'security', 'ml'] as const;
for (const p of projects) {
const idx = order.indexOf(p.cluster as (typeof order)[number]);
if (idx >= 0) m.set(`project:${p.id}`, idx);
}
m.set('terminal', 0);
}
return m;
}, [variant]);
// One volumetric cone beam per slot, apex at the ceiling and base on
// the floor, positioned over the rack pair for that slot. Pulsed by
// the slot's ADSR window during a wave; intensity 0 otherwise.
const slotBeamsRef = useRef<
{
mesh: Mesh;
uniforms: WaveBeamUniforms;
slot: number;
}[]
>([]);
// One floor disc per slot, sat ~2 cm above the reflective floor.
// Radial-gradient shader produces a "neon puddle" under the rack
// pair during its slot; the disc is in the scene above the floor
// so the MeshReflectorMaterial's mirror reflection picks it up too.
const slotDiscsRef = useRef<
{
mesh: Mesh;
uniforms: WaveFloorUniforms;
slot: number;
}[]
>([]);
// Per-slot rack body materials. Cloned at scene-build time so each
// slot's body emissive is independently driven by the wave. Each
// entry holds both the original and the mirrored rack's material
// (same slot fires both racks of the pair in portrait).
const slotBodiesRef = useRef<
{
materials: MeshStandardMaterial[];
accentColor: Color;
slot: number;
}[]
>([]);
const waveSlotDelayS = variant === 'portrait' ? WAVE_RACK_DELAY_S : WAVE_CLUSTER_DELAY_S;
const waveSlotCount = variant === 'portrait' ? AISLE_ORDER.length : 5;
const WAVE_TOTAL_S = waveSlotCount * waveSlotDelayS + WAVE_PULSE_DUR_S + 0.4;
// Universal "user did something" listener. Captures pointer/touch/
// key/wheel at the document level, plus aisleScroll progress
// changes (touch-swipe-aisle on portrait). Any of these resets the
// idle timer and cancels an in-flight wave.
//
// Diagnostic: each handler logs which source fired so we can spot
// a runaway reset (e.g. a synth event firing every frame) when the
// wave never elapses despite no apparent user input.
useEffect(() => {
lastInteractionRef.current = performance.now();
const reset = () => {
lastInteractionRef.current = performance.now();
waveStartRef.current = null;
};
const onPointer = () => reset();
const onTouch = () => reset();
const onKey = () => reset();
const onWheel = () => reset();
const onScrollProgress = () => reset();
document.addEventListener('pointerdown', onPointer);
document.addEventListener('touchstart', onTouch, { passive: true });
document.addEventListener('keydown', onKey);
document.addEventListener('wheel', onWheel, { passive: true });
const unsubScroll = aisleScroll.subscribe(onScrollProgress);
return () => {
document.removeEventListener('pointerdown', onPointer);
document.removeEventListener('touchstart', onTouch);
document.removeEventListener('keydown', onKey);
document.removeEventListener('wheel', onWheel);
unsubScroll();
};
}, []);
// Panel open / close also counts as activity — we don't want the
// wave to start mid-panel-read or fire the instant a panel closes.
useEffect(() => {
lastInteractionRef.current = performance.now();
waveStartRef.current = null;
}, [panelOpen]);
// Soft radial glow sprite (white → transparent) used additively for
// the pool of light each portrait ceiling fixture casts on the
// ceiling grid above it. One texture, reused by every fixture.
const ceilGlowTex = useMemo(() => {
const c = document.createElement('canvas');
c.width = c.height = 128;
const ctx = c.getContext('2d')!;
const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
g.addColorStop(0, 'rgba(255,255,255,0.85)');
g.addColorStop(0.45, 'rgba(255,255,255,0.26)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 128);
const tex = new CanvasTexture(c);
tex.colorSpace = SRGBColorSpace;
return tex;
}, []);
// Force-fire hook for the hidden `wave` console command. Lets you
// verify the visual effect without sitting through the 15 s idle
// window — useful for both QA and debugging when the timer-based
// path mysteriously doesn't kick in.
useEffect(() => {
const onForce = () => {
waveStartRef.current = performance.now();
// Push lastInteractionRef forward so the next idle window
// starts counting from when the wave finishes, not from the
// moment of the forced fire.
lastInteractionRef.current = performance.now();
};
window.addEventListener('ov-force-wave', onForce);
return () => window.removeEventListener('ov-force-wave', onForce);
}, []);
const projectsById = useMemo(() => new Map(projects.map((p) => [p.id, p])), []);
// Mobile-adjusted light intensities. We compensate for the dropped
// ceiling-grid lights by boosting the remaining top-down sources;
// the hover-spotlight lerp uses these so the brighter idle isn't
// undone every frame.
const lightLevels = useMemo(() => {
const m = isMobile;
return {
hemi: { idle: LIGHTS.hemi.idle * (m ? 1.45 : 1), dim: LIGHTS.hemi.dim * (m ? 1.45 : 1) },
ambient: {
idle: LIGHTS.ambient.idle * (m ? 1.4 : 1),
dim: LIGHTS.ambient.dim * (m ? 1.4 : 1),
},
key: { idle: LIGHTS.pointKey.idle, dim: LIGHTS.pointKey.dim },
topDown: {
idle: LIGHTS.topDown.idle * (m ? 1.5 : 1),
dim: LIGHTS.topDown.dim * (m ? 1.5 : 1),
},
ceiling: { idle: LIGHTS.ceilingGrid.idle, dim: LIGHTS.ceilingGrid.dim },
};
}, [isMobile]);
const hemiRef = useRef<HemisphereLight | null>(null);
const ambientRef = useRef<AmbientLight | null>(null);
const keyRef = useRef<PointLight | null>(null);
const topDownRef = useRef<DirectionalLight | null>(null);
const ceilingRefs = useRef<(PointLight | null)[]>([null, null, null, null]);
// Templates harvested from the glb: their geometry + material is
// reused by InstancedMesh in <DistantRacks> below. State so the
// first render after the glb loads triggers the rack scatter.
const [distantTemplates, setDistantTemplates] = useState<{
bodyGeom: BufferGeometry | null;
bodyMat: Material | null;
ledGeom: BufferGeometry | null;
ledMat: Material | null;
}>({ bodyGeom: null, bodyMat: null, ledGeom: null, ledMat: null });
useCursor(hover !== null);
// eslint-disable-next-line react-hooks/immutability -- useLayoutEffect reads outer `scene` only; rootScene from useThree not mutated here
useLayoutEffect(() => {
const interactives: Interactive[] = [];
const bodyMap = new Map<
number,
{
materials: MeshStandardMaterial[];
accentColor: Color;
slot: number;
}
>();
let bodyGeom: BufferGeometry | null = null;
let bodyMat: Material | null = null;
let ledGeom: BufferGeometry | null = null;
let ledMat: Material | null = null;
scene.traverse((obj) => {
if (!(obj instanceof Mesh)) return;
// BackgroundTower_*_strip meshes: cache the material + per-
// strip phase/speed once. The useFrame pulse loop reads from
// towerStripsRef and skips the per-frame scene.traverse + per-
// strip strHash that earlier versions did. Setting toneMapped
// here (instead of every frame) is also a small win.
if (obj.name.startsWith('BackgroundTower_') && obj.name.endsWith('_strip')) {
const mat = obj.material;
if (mat instanceof MeshStandardMaterial) {
mat.toneMapped = false;
const h = strHash(obj.name);
towerStripsRef.current.push({
mat,
phase: ((h % 1000) / 1000) * Math.PI * 2,
speed: 0.6 + (((h >>> 8) % 100) / 100) * 1.2,
});
}
return;
}
// The static glb floor is hidden — a separate JSX <mesh> with
// MeshReflectorMaterial (below) renders the reflective floor
// instead, so the racks and cables actually mirror onto it.
if (obj.name === 'Floor') {
obj.visible = false;
return;
}
// The room reads better with no cabling at all, so the ten
// authored Cable_* runs stay hidden. They're straight 110m
// extrusions that pass clean through the walls and out into the
// void — decorative striping rather than cabling that serves
// anything. A procedural replacement was built and rejected too
// (see JOURNAL 2026-08-17). Hidden rather than stripped from the
// glb, so restoring them is deleting this block.
if (obj.name.startsWith('Cable_')) {
obj.visible = false;
return;
}
// The two DistantRack* templates are authored in Blender at a
// parked location far outside the room. Hide them and stash
// refs to their geometry + material so InstancedMesh can scatter
// copies in the void.
if (obj.name === 'DistantRackBody') {
obj.visible = false;
bodyGeom = obj.geometry;
bodyMat = obj.material as Material;
return;
}
if (obj.name === 'DistantRackLED') {
obj.visible = false;
ledGeom = obj.geometry;
// Clone the material so the dim-for-distance treatment doesn't
// affect any future render of the original template mesh.
const src = obj.material as MeshStandardMaterial;
const cloned = src.clone();
cloned.toneMapped = false;
// The Blender material had emissionStrength=4.0 so the LEDs
// would read in the bake. At runtime they overpowered the main
// racks; knock the runtime emission way down so the distant
// strips read as faint ambient hint, not headlamps.
cloned.emissiveIntensity = 0.45;
ledMat = cloned;
return;
}
// The Monitor mesh gets the console-panel shader, replacing its
// baked M_Monitor material entirely. Renders a control-panel HUD
// (oscilloscope traces, bar graph, status dots) so the desk
// monitor reads as "this is actively driving the room." From
// here on it's hover-driven through uniforms, not emissive.
if (obj.name === 'Monitor') {
const consoleMat = createConsoleMaterial(isMobile);
obj.material = consoleMat;
monitorShaderRef.current = consoleMat;
return;
}
// The OperatorHolo plane (above the keyboard, parented to Desk in
// Blender) gets its placeholder M_OperatorHolo material swapped
// for the holo shader: cyan-tinted greyscale of LinkedIn_PFP.png,
// additive blend, scan lines + vignette + slow flicker so it
// reads as a projected operator-ID hologram rather than a flat
// photo pinned to the air.
if (obj.name === 'OperatorHolo') {
const holoMat = createOperatorHoloMaterial(pfpTexture);
obj.material = holoMat;
operatorHoloShaderRef.current = holoMat;
return;
}
// Rack body wash. Each Rack_<id> mesh (original + portrait
// mirror share the same name) gets its material cloned so the
// wave can crank emissive without leaking to other racks. The
// cloned material is stored by slot — both rack-pair materials
// for a given slot fire together. Emissive starts white so the
// strobe attack can swap to accent without a colour pop.
//
// CRITICAL: the source M_Bake_Rack_<id> material ships with an
// emissiveTexture (baked lighting). Three.js multiplies the
// emissive output by that texture per-fragment, so wherever the
// baked texture is black, our wash colour × intensity is gated
// to zero — the racks never glow no matter how high we push
// emissiveIntensity. Nulling emissiveMap on the clone makes
// the wash paint uniformly across the rack body.
if (obj.name.startsWith('Rack_')) {
const projectId = obj.name.slice('Rack_'.length);
const slot = slotIndexByKey.get(`project:${projectId}`);
if (slot === undefined) return;
const accent = waveColorForProject(projectId, projectsById);
const accentColor = new Color(accent);
const body =
obj.material instanceof MeshStandardMaterial
? obj.material.clone()
: new MeshStandardMaterial({ color: 0x111111 });
body.emissive = new Color(1, 1, 1);
body.emissiveIntensity = 0;
body.emissiveMap = null;
body.toneMapped = false;
obj.material = body;
let entry = bodyMap.get(slot);
if (!entry) {
entry = { materials: [], accentColor, slot };
bodyMap.set(slot, entry);
}
entry.materials.push(body);
return;
}
const mat = obj.material;
if (!(mat instanceof MeshStandardMaterial)) return;
if (!isUntonedMaterial(mat.name)) return;
// Clone so per-mesh emission lerps don't leak.
const cloned = mat.clone();
cloned.toneMapped = false;
obj.material = cloned;
// Per-rack LED variation — deterministic recolour / dim / hide
// so the six racks read as distinct identities instead of six
// identical amber + green columns. Driven by the per-mesh hash
// and the project's signature accent colour.
const ledId = ledProjectId(obj.name);
if (ledId !== null) {
const project = projectsById.get(ledId);
const accent = project?.color;
const h = strHash(obj.name);
const pick = h % 100;
// 8% of LEDs are off (varied density per rack)
if (pick < 8) {
obj.visible = false;
return;
}
// 12% are dim (idle / heartbeat look)
const dimFactor = pick < 20 ? 0.25 : 1.0;
// Color selection: 55% project, 25% amber (original), 20% green
const slot = (h >>> 8) % 100;
if (slot < 55 && accent) {
cloned.emissive = new Color(accent);
cloned.color = new Color(accent).multiplyScalar(0.35);
} else if (slot < 80) {
cloned.emissive = new Color('#ffb347');
cloned.color = new Color('#7a4f1e');
} else {
cloned.emissive = new Color('#3aff8a');
cloned.color = new Color('#1a5a32');
}
cloned.emissiveIntensity = (cloned.emissiveIntensity ?? 1.0) * dimFactor;
return;
}
const key = hoverKeyForMesh(obj.name);
if (key === null) return;
const base = cloned.emissiveIntensity ?? 1.0;
interactives.push({
mat: cloned,
base,
hover: base * HOVER_INTENSITY_MULTIPLIER,
dim: base * DIM_INTENSITY_MULTIPLIER,
current: base,
hoverKey: key,
});
});
interactivesRef.current = interactives;
if (bodyGeom && bodyMat && ledGeom && ledMat) {
setDistantTemplates({ bodyGeom, bodyMat, ledGeom, ledMat });
}
// Set once: no HDRI environment — its directional cast was bleeding
// into the reflective floor. Previously this was inside useFrame
// and ran every frame; same value, same effect, no need to re-set.
/* eslint-disable react-hooks/immutability -- useLayoutEffect setup; one-time three.js scene config mutation */
rootScene.environmentIntensity = 0;
/* eslint-enable react-hooks/immutability */
// Wave beams + floor discs — portrait-only for now. One downward-
// pointing cone per slot at corridor centerline (apex y=4.4, base
// y=0) plus one disc just above the floor (y=0.02). Shared geometry
// per layer keeps GPU buffer count low; per-slot material instances
// own the uniforms.
const beams: { mesh: Mesh; uniforms: WaveBeamUniforms; slot: number }[] = [];
const discs: { mesh: Mesh; uniforms: WaveFloorUniforms; slot: number }[] = [];
let sharedBeamGeom: ConeGeometry | null = null;
let sharedDiscGeom: CircleGeometry | null = null;
if (variant === 'portrait') {
const beamHeight = 4.4;
const beamRadius = 1.8;
const discRadius = 2.2;
sharedBeamGeom = new ConeGeometry(beamRadius, beamHeight, 24, 4, true);