PageSourceSearch

https://tmatthewkim.github.io/js/museum.js?v=6

js tmatthewkim.github.io collected 2026-10-03 10:05:19 UTC 71,934 bytes, 1,732 lines download raw bytes

1/**
2 * MUSEUM GEOMETRY
3 * Grand natural history museum inspired by the Field Museum.
4 * Single grand hall with four exhibit wings in a cross layout.
5 */
6
7const Museum = (() => {
8    const WALL_HEIGHT = 8;
9    const WALL_THICKNESS = 0.4;
10    const DOOR_WIDTH = 4.5;
11    const DOOR_HEIGHT = 5.5;
12
13    // Grand hall — one big open space (Field Museum style)
14    const GRAND_HALL_WIDTH = 24;
15    const GRAND_HALL_DEPTH = 24;
16
17    // Exhibit wings
18    const HALL_WIDTH = 12;
19    const HALL_DEPTH = 22;
20
21    const collisionWalls = [];
22    const rooms = [];
23
24    // ===================== HIGH-QUALITY PROCEDURAL TEXTURES =====================
25
26    function createMarbleTexture(light, dark, tileCount) {
27        const canvas = document.createElement('canvas');
28        canvas.width = 512;
29        canvas.height = 512;
30        const ctx = canvas.getContext('2d');
31        const ts = canvas.width / tileCount;
32
33        for (let tx = 0; tx < tileCount; tx++) {
34            for (let ty = 0; ty < tileCount; ty++) {
35                const isL = (tx + ty) % 2 === 0;
36                const c = isL ? light : dark;
37                ctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
38                ctx.fillRect(tx * ts, ty * ts, ts, ts);
39
40                // Marble veining
41                for (let v = 0; v < 3; v++) {
42                    const sx = tx * ts + Math.random() * ts;
43                    const sy = ty * ts + Math.random() * ts;
44                    const grad = ctx.createLinearGradient(sx, sy, sx + Math.random() * 80 - 40, sy + Math.random() * 80 - 40);
45                    grad.addColorStop(0, `rgba(${c[0] + 30},${c[1] + 25},${c[2] + 20},0.15)`);
46                    grad.addColorStop(1, `rgba(${c[0] - 10},${c[1] - 10},${c[2] - 8},0.05)`);
47                    ctx.strokeStyle = grad;
48                    ctx.lineWidth = 0.5 + Math.random() * 1.5;
49                    ctx.beginPath();
50                    ctx.moveTo(sx, sy);
51                    const cp1x = sx + Math.random() * 100 - 50;
52                    const cp1y = sy + Math.random() * 100 - 50;
53                    const cp2x = sx + Math.random() * 120 - 60;
54                    const cp2y = sy + Math.random() * 120 - 60;
55                    ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, sx + Math.random() * 140 - 70, sy + Math.random() * 140 - 70);
56                    ctx.stroke();
57                }
58
59                // Subtle color variation patches
60                for (let p = 0; p < 2; p++) {
61                    const px = tx * ts + Math.random() * ts;
62                    const py = ty * ts + Math.random() * ts;
63                    const r = 10 + Math.random() * 20;
64                    const grd = ctx.createRadialGradient(px, py, 0, px, py, r);
65                    grd.addColorStop(0, `rgba(${c[0] + 15},${c[1] + 12},${c[2] + 10},0.12)`);
66                    grd.addColorStop(1, 'rgba(0,0,0,0)');
67                    ctx.fillStyle = grd;
68                    ctx.fillRect(px - r, py - r, r * 2, r * 2);
69                }
70
71                // Grout lines
72                ctx.strokeStyle = `rgba(${Math.min(c[0] - 30, 255)},${Math.min(c[1] - 30, 255)},${Math.min(c[2] - 25, 255)},0.4)`;
73                ctx.lineWidth = 2;
74                ctx.strokeRect(tx * ts, ty * ts, ts, ts);
75            }
76        }
77
78        const texture = new THREE.CanvasTexture(canvas);
79        texture.wrapS = THREE.RepeatWrapping;
80        texture.wrapT = THREE.RepeatWrapping;
81        texture.anisotropy = 4;
82        return texture;
83    }
84
85    function createStoneWallTexture(baseColor) {
86        const canvas = document.createElement('canvas');
87        canvas.width = 512;
88        canvas.height = 512;
89        const ctx = canvas.getContext('2d');
90        const r = (baseColor >> 16) & 0xff;
91        const g = (baseColor >> 8) & 0xff;
92        const b = baseColor & 0xff;
93
94        ctx.fillStyle = `rgb(${r},${g},${b})`;
95        ctx.fillRect(0, 0, 512, 512);
96
97        // Stone block pattern
98        const blockH = 64;
99        for (let row = 0; row < 8; row++) {
100            const offset = (row % 2) * 40;
101            const y = row * blockH;
102            ctx.strokeStyle = `rgba(${r - 20},${g - 20},${b - 18},0.2)`;
103            ctx.lineWidth = 1;
104            ctx.beginPath();
105            ctx.moveTo(0, y);
106            ctx.lineTo(512, y);
107            ctx.stroke();
108
109            for (let col = 0; col < 5; col++) {
110                const x = offset + col * 110;
111                ctx.beginPath();
112                ctx.moveTo(x, y);
113                ctx.lineTo(x, y + blockH);
114                ctx.stroke();
115            }
116        }
117
118        // Surface variation
119        for (let i = 0; i < 200; i++) {
120            const px = Math.random() * 512;
121            const py = Math.random() * 512;
122            const v = Math.random() * 10 - 5;
123            ctx.fillStyle = `rgba(${r + v},${g + v},${b + v},0.25)`;
124            ctx.fillRect(px, py, 3 + Math.random() * 3, 3 + Math.random() * 3);
125        }
126
127        const texture = new THREE.CanvasTexture(canvas);
128        texture.wrapS = THREE.RepeatWrapping;
129        texture.wrapT = THREE.RepeatWrapping;
130        return texture;
131    }
132
133    function createCeilingTexture() {
134        const canvas = document.createElement('canvas');
135        canvas.width = 512;
136        canvas.height = 512;
137        const ctx = canvas.getContext('2d');
138
139        ctx.fillStyle = '#e8e2d8';
140        ctx.fillRect(0, 0, 512, 512);
141
142        // Ornate coffered panels
143        const ps = 128;
144        for (let x = 0; x < 4; x++) {
145            for (let y = 0; y < 4; y++) {
146                const px = x * ps, py = y * ps;
147                ctx.strokeStyle = 'rgba(180,170,155,0.7)';
148                ctx.lineWidth = 4;
149                ctx.strokeRect(px + 6, py + 6, ps - 12, ps - 12);
150                ctx.fillStyle = 'rgba(220,215,205,0.5)';
151                ctx.fillRect(px + 14, py + 14, ps - 28, ps - 28);
152                ctx.strokeStyle = 'rgba(195,185,170,0.5)';
153                ctx.lineWidth = 2;
154                ctx.strokeRect(px + 14, py + 14, ps - 28, ps - 28);
155                ctx.strokeStyle = 'rgba(190,180,165,0.3)';
156                ctx.lineWidth = 1;
157                ctx.beginPath();
158                ctx.arc(px + ps / 2, py + ps / 2, 15, 0, Math.PI * 2);
159                ctx.stroke();
160            }
161        }
162
163        const texture = new THREE.CanvasTexture(canvas);
164        texture.wrapS = THREE.RepeatWrapping;
165        texture.wrapT = THREE.RepeatWrapping;
166        return texture;
167    }
168
169    // ===================== MATERIAL CACHE =====================
170
171    let floorTex, ceilTex;
172    const wallTexCache = {};
173
174    function floorMat() {
175        if (!floorTex) floorTex = createMarbleTexture([195, 185, 168], [155, 142, 125], 4);
176        return new THREE.MeshStandardMaterial({
177            map: floorTex, roughness: 0.18, metalness: 0.25, side: THREE.DoubleSide,
178            envMapIntensity: 0.6
179        });
180    }
181
182    function ceilMat() {
183        if (!ceilTex) ceilTex = createCeilingTexture();
184        return new THREE.MeshStandardMaterial({
185            map: ceilTex, roughness: 0.95, metalness: 0.02, side: THREE.DoubleSide
186        });
187    }
188
189    function wallMat(color) {
190        if (!wallTexCache[color]) wallTexCache[color] = createStoneWallTexture(color);
191        return new THREE.MeshStandardMaterial({
192            map: wallTexCache[color], roughness: 0.75, metalness: 0.05, side: THREE.DoubleSide
193        });
194    }
195
196    const _woodMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e, roughness: 0.55, metalness: 0.08 });
197    const _darkWoodMat = new THREE.MeshStandardMaterial({ color: 0x3a2210, roughness: 0.5, metalness: 0.1 });
198    const _stoneMat = new THREE.MeshStandardMaterial({ color: 0xd0c8b8, roughness: 0.4, metalness: 0.12 });
199    const _goldMat = new THREE.MeshStandardMaterial({ color: 0xc9a96e, roughness: 0.25, metalness: 0.65, emissive: 0x3a2800, emissiveIntensity: 0.3 });
200    const _darkMetalMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.3, metalness: 0.8 });
201    const _marbleMat = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.3, metalness: 0.12 });
202    const _glassMat = new THREE.MeshStandardMaterial({
203        color: 0xccddee, roughness: 0.05, metalness: 0.3, transparent: true, op
203acity: 0.15, emissive: 0x112233, emissiveIntensity: 0.08
204    });
205    const _leatherMat = new THREE.MeshStandardMaterial({ color: 0x4a3020, roughness: 0.7, metalness: 0.05 });
206    const _carpetMat = new THREE.MeshStandardMaterial({ color: 0x6b2020, roughness: 0.95, metalness: 0 });
207
208    // Wall colors
209    const COLORS = {
210        hall: 0xc2b8a5,
211        trilobite: 0xb8a890,
212        archaeopteryx: 0xa5b598,
213        neanderthal: 0xb0a0b5,
214        info: 0xa5a5b8,
215    };
216
217    // ===================== CORE GEOMETRY =====================
218
219    function addWall(scene, x, y, z, w, h, d, color, col = true) {
220        const wall = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wallMat(color));
221        wall.position.set(x, y, z);
222        wall.receiveShadow = true;
223        scene.add(wall);
224        if (col) collisionWalls.push(wall);
225        return wall;
226    }
227
228    function addFloor(scene, x, z, w, d) {
229        const f = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorMat());
230        f.rotation.x = -Math.PI / 2;
231        f.position.set(x, 0, z);
232        f.receiveShadow = true;
233        scene.add(f);
234    }
235
236    function addCeiling(scene, x, z, w, d) {
237        const c = new THREE.Mesh(new THREE.PlaneGeometry(w, d), ceilMat());
238        c.rotation.x = Math.PI / 2;
239        c.position.set(x, WALL_HEIGHT, z);
240        scene.add(c);
241    }
242
243    // ===================== ARCHITECTURAL ELEMENTS =====================
244
245    function addColumn(scene, x, z, height) {
246        const h = height || WALL_HEIGHT;
247        const plinth = new THREE.Mesh(new THREE.BoxGeometry(1, 0.2, 1), _stoneMat);
248        plinth.position.set(x, 0.1, z);
249        scene.add(plinth);
250        const base = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.45, 0.35, 10), _stoneMat);
251        base.position.set(x, 0.375, z);
252        scene.add(base);
253        const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.35, h - 1.2, 10), _marbleMat);
254        shaft.position.set(x, h / 2, z);
255        shaft.castShadow = true;
256        scene.add(shaft);
257        const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.3, 0.4, 10), _stoneMat);
258        cap.position.set(x, h - 0.55, z);
259        scene.add(cap);
260        const ab = new THREE.Mesh(new THREE.BoxGeometry(1, 0.15, 1), _stoneMat);
261        ab.position.set(x, h - 0.275, z);
262        scene.add(ab);
263        const cb = new THREE.Mesh(new THREE.BoxGeometry(0.8, h, 0.8));
264        cb.position.set(x, h / 2, z);
265        cb.visible = false;
266        scene.add(cb);
267        collisionWalls.push(cb);
268    }
269
270    function addBaseboard(scene, x, z, len, rot) {
271        const b = new THREE.Mesh(new THREE.BoxGeometry(len, 0.25, 0.12), _darkWoodMat);
272        b.position.set(x, 0.125, z);
273        if (rot) b.rotation.y = rot;
274        scene.add(b);
275    }
276
277    function addCrownMolding(scene, x, z, len, rot) {
278        const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.18, 0.18), _stoneMat);
279        m.position.set(x, WALL_HEIGHT - 0.09, z);
280        if (rot) m.rotation.y = rot;
281        scene.add(m);
282    }
283
284    function addTrim(scene, cx, cz, w, d, doorWalls) {
285        const hw = w / 2, hd = d / 2, off = 0.18;
286        const dw = doorWalls || {};
287
288        function addSplit(fn, posX, posZ, len, rot, hasDoor) {
289            if (!hasDoor) { fn(scene, posX, posZ, len, rot); return; }
290            const segLen = (len - DOOR_WIDTH) / 2 - 0.1;
291            if (segLen <= 0) return;
292            if (rot) {
293                fn(scene, posX, posZ - len / 2 + segLen / 2, segLen, rot);
294                fn(scene, posX, posZ + len / 2 - segLen / 2, segLen, rot);
295            } else {
296                fn(scene, posX - len / 2 + segLen / 2, posZ, segLen, rot);
297                fn(scene, posX + len / 2 - segLen / 2, posZ, segLen, rot);
298            }
299        }
300
301        addSplit(addBaseboard, cx, cz - hd + off, w, 0, dw.north);
302        addSplit(addBaseboard, cx, cz + hd - off, w, 0, dw.south);
303        addSplit(addBaseboard, cx - hw + off, cz, d, Math.PI / 2, dw.west);
304        addSplit(addBaseboard, cx + hw - off, cz, d, Math.PI / 2, dw.east);
305        addSplit(addCrownMolding, cx, cz - hd + off, w, 0, dw.north);
306        addSplit(addCrownMolding, cx, cz + hd - off, w, 0, dw.south);
307        addSplit(addCrownMolding, cx - hw + off, cz, d, Math.PI / 2, dw.west);
308        addSplit(addCrownMolding, cx + hw - off, cz, d, Math.PI / 2, dw.east);
309    }
310
311    function addDoorFrame(scene, x, z, rotated) {
312        const thick = 0.18, fd = WALL_THICKNESS + 0.15;
313        if (!rotated) {
314            scene.add(new THREE.Mesh(new THREE.BoxGeometry(thick, DOOR_HEIGHT, fd), _goldMat).translateX(x - DOOR_WIDTH / 2).translateY(DOOR_HEIGHT / 2).translateZ(z));
315            scene.add(new THREE.Mesh(new THREE.BoxGeometry(thick, DOOR_HEIGHT, fd), _goldMat).translateX(x + DOOR_WIDTH / 2).translateY(DOOR_HEIGHT / 2).translateZ(z));
316            scene.add(new THREE.Mesh(new THREE.BoxGeometry(DOOR_WIDTH + thick * 2, thick * 1.5, fd), _goldMat).translateX(x).translateY(DOOR_HEIGHT + thick * 0.5).translateZ(z));
317        } else {
318            scene.add(new THREE.Mesh(new THREE.BoxGeometry(fd, DOOR_HEIGHT, thick), _goldMat).translateX(x).translateY(DOOR_HEIGHT / 2).translateZ(z - DOOR_WIDTH / 2));
319            scene.add(new THREE.Mesh(new THREE.BoxGeometry(fd, DOOR_HEIGHT, thick), _goldMat).translateX(x).translateY(DOOR_HEIGHT / 2).translateZ(z + DOOR_WIDTH / 2));
320            scene.add(new THREE.Mesh(new THREE.BoxGeometry(fd, thick * 1.5, DOOR_WIDTH + thick * 2), _goldMat).translateX(x).translateY(DOOR_HEIGHT + thick * 0.5).translateZ(z));
321        }
322    }
323
324    function addRoomSign(scene, text, x, y, z, rot) {
325        const canvas = document.createElement('canvas');
326        canvas.width = 512;
327        canvas.height = 128;
328        const ctx = canvas.getContext('2d');
329        ctx.fillStyle = '#2a2218';
330        ctx.fillRect(0, 0, 512, 128);
331        ctx.strokeStyle = '#c9a96e';
332        ctx.lineWidth = 3;
333        ctx.strokeRect(6, 6, 500, 116);
334        ctx.strokeStyle = '#c9a96e';
335        ctx.lineWidth = 1;
336        ctx.strokeRect(12, 12, 488, 104);
337        ctx.fillStyle = '#e8d5b7';
338        ctx.font = '32px Georgia, serif';
339        ctx.textAlign = 'center';
340        ctx.textBaseline = 'middle';
341        ctx.fillText(text, 256, 64);
342
343        const sign = new THREE.Mesh(
344            new THREE.PlaneGeometry(3.2, 0.8),
345            new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas) })
346        );
347        sign.position.set(x, y, z);
348        if (rot) sign.rotation.y = rot;
349        scene.add(sign);
350    }
351
352    function addWallSconce(scene, x, y, z) {
353        scene.add(new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.2, 0.16), _goldMat).translateX(x).translateY(y).translateZ(z));
354        const shade = new THREE.Mesh(
355            new THREE.ConeGeometry(0.14, 0.22, 6, 1, true),
356            new THREE.MeshStandardMaterial({ color: 0xfff0dd, emissive: 0xffe8cc, emissiveIntensity: 0.8, roughness: 0.4 })
357        );
358        shade.position.set(x, y + 0.14, z);
359        shade.rotation.x = Math.PI;
360        scene.add(shade);
361
362        // Warm point light (cheap, no shadows)
363        const pl = new THREE.PointLight(0xffe0b0, 0.3, 8);
364        pl.position.set(x, y - 0.5, z);
365        scene.add(pl);
366
367        // Subtle glow orb
368        const glowOrb = new THREE.Mesh(
369            new THREE.SphereGeometry(0.06, 6, 6),
370            new THREE.MeshBasicMaterial({ color: 0xffe8cc, transparent: true, opacity: 0.6 })
371        );
372        glowOrb.position.set(x, y + 0.05, z);
373        scene.add(glowOrb);
374    }
375
376    // ===================== FURNITURE =====================
377
378    function addBench(scene, x, z, rot) {
379        const group = new THREE.Group();
380        const seat = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.08, 0.75), _leatherMat);
381        seat.position.y = 0.52;
382        seat.castShadow = true;
383        group.add(seat);
384        const lg = new THREE.CylinderGeometry(0.035, 0.035, 0.52, 6);
385        [[-1.1, -0.3], [-1.1, 0.3], [1.1, -0.3], [1.1, 0.3]].forEach(([lx, lz]) => {
386            group.add(new THREE.Mesh(lg, _darkMetalMat).translateX(lx).translateY(0.26).translateZ(lz));
387        });
388        group.add(new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.03, 0.03), _darkMetalMat).translateY(0.2));
389        group.position.set(x, 0, z);
390        if (rot) group.rotation.y = rot;
391        scene.add(group);
392        const col = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.6, 0.75));
393        col.position.set(x, 0.3, z);
394        if (rot) col.rotation.y = rot;
395        col.visible = false;
396        scene.add(col);
397        collisionWalls.push(col);
398    }
399
400    function addPottedPlant(scene, x, z) {
401        const pot = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.22, 0.6, 8), new THREE.MeshStandardMaterial({ color: 0x6b4226, roughness: 0.8 }));
402        pot.position.set(x, 0.3, z);
403        pot.castShadow = true;
404        scene.add(pot);
405        const leafMat = new THREE.MeshStandardMaterial({ color: 0x3d6b35, roughness: 0.8 });
406        [[0, 0.95, 0, 0.2], [0.15, 0.82, 0.1, 0.16], [-0.12, 0.85, -0.08, 0.17], [0.08, 0.75, -0.12, 0.14], [-0.1, 0.78, 0.12, 0.15]].forEach(([px, py, pz, s]) => {
407            scene.add(new THREE.Mesh(new THREE.SphereGeometry(s, 6, 6), leafMat).translateX(x + px).translateY(py).translateZ(z + pz));
408        });
409        const col = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 1.2, 6));
410        col.position.set(x, 0.6, z);
411        col.visible = false;
412        scene.add(col);
413        collisionWalls.push(col);
414    }
415
416    function addRopeBarrier(scene, x1, z1, x2, z2) {
417        [{ x: x1, z: z1 }, { x: x2, z: z2 }].forEach(({ x, z }) => {
418            scene.add(new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.95, 6), _goldMat).translateX(x).translateY(0.475).translateZ(z));
419            scene.add(new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.15, 0.06, 8), _goldMat).translateX(x).translateY(0.03).translateZ(z));
420            scene.add(new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 6), _goldMat).translateX(x).translateY(0.97).translateZ(z));
421        });
422        const pts = [];
423        const dx = x2 - x1, dz = z2 - z1;
424        for (let t = 0; t <= 1; t += 0.1) pts.push(new THREE.Vector3(x1 + dx * t, 0.85 - Math.sin(t * Math.PI) * 0.1, z1 + dz * t));
425        scene.add(new THREE.Mesh(
426            new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 8, 0.018, 4, false),
427            new THREE.MeshStandardMaterial({ color: 0x8B0000, roughness: 0.6 })
428        ));
429    }
430
431    // ===================== GRAND HALL FURNITURE =====================
432
433    function addReceptionDesk(scene, x, z) {
434        const group = new THREE.Group();
435        group.add(new THREE.Mesh(new THREE.BoxGeometry(5, 1.1, 0.15), _darkWoodMat).translateY(0.55).translateZ(-0.6));
436        group.add(new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.06, 1.3), _marbleMat).translateY(1.12));
437        group.add(new THREE.Mesh(new THREE.BoxGeometry(5, 0.9, 0.1), _darkWoodMat).translateY(0.45).translateZ(0.55));
438        group.add(new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.1, 1.2), _darkWoodMat).translateX(-2.45).translateY(0.55));
439        group.add(new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.1, 1.2), _darkWoodMat).translateX(2.45).translateY(0.55));
440        group.add(new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.35, 0.4), _darkWoodMat).translateY(1.3).translateZ(-0.45));
441        group.add(new THREE.Mesh(new THREE.BoxGeometry(5.1, 0.04, 0.04), _goldMat).translateY(1.14).translateZ(-0.62));
442
443        const canvas = document.createElement('canvas');
444        canvas.width = 256;
445        canvas.height = 48;
446        const ctx = canvas.getContext('2d');
447        ctx.fillStyle = '#2a2218';
448        ctx.fillRect(0, 0, 256, 48);
449        ctx.fillStyle = '#c9a96e';
450        ctx.font = '22px Georgia, serif';
451        ctx.textAlign = 'center';
452        ctx.textBaseline = 'middle';
453        ctx.fillText('ADMISSIONS', 128, 24);
454        const signMesh = new THREE.Mesh(
455            new THREE.PlaneGeometry(1.5, 0.28),
456            new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas) })
457        );
458        signMesh.position.set(0, 1.52, -0.66);
459        group.add(signMesh);
460
461        group.position.set(x, 0, z);
462        scene.add(group);
463
464        const col = new THREE.Mesh(new THREE.BoxGeometry(5.2, 1.5, 1.3));
465        col.position.set(x, 0.75, z);
466        col.visible = false;
467        scene.add(col);
468        collisionWalls.push(col);
469    }
470
471    function addDisplayCase(scene, x, z, label) {
472        const ped = new THREE.Mesh(new THREE.BoxGeometry(1, 0.9, 1), _stoneMat);
473        ped.position.set(x, 0.45, z);
474        ped.castShadow = true;
475        scene.add(ped);
476        collisionWalls.push(ped);
477        const glass = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.8), _glassMat);
478        glass.position.set(x, 1.2, z);
479        scene.add(glass);
480        if (label) {
481            const canvas = document.createElement('canvas');
482            canvas.width = 256;
483            canvas.height = 64;
484            const ctx = canvas.getContext('2d');
485            ctx.fillStyle = '#2a2218';
486            ctx.fillRect(0, 0, 256, 64);
487            ctx.strokeStyle = '#c9a96e';
488            ctx.lineWidth = 2;
489            ctx.strokeRect(4, 4, 248, 56);
490            ctx.fillStyle = '#e8d5b7';
491            ctx.font = '16px Georgia, serif';
492            ctx.textAlign = 'center';
493            ctx.textBaseline = 'middle';
494            ctx.fillText(label, 128, 32);
495            const plaque = new THREE.Mesh(
496                new THREE.PlaneGeometry(0.8, 0.2),
497                new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas) })
498            );
499            plaque.position.set(x, 0.5, z + 0.51);
500            scene.add(plaque);
501        }
502    }
503
504    function addInfoKiosk(scene, x, z) {
505        const group = new THREE.Group();
506        group.add(new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.3, 6), _darkMetalMat).translateY(0.65));
507        group.add(new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.22, 0.04, 8), _darkMetalMat).translateY(0.02));
508        group.add(new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.9, 0.04), _darkWoodMat).translateY(1.55));
509
510        const canvas = document.createElement('canvas');
511        canvas.width = 512;
512        canvas.height = 384;
513        const ctx = canvas.getContext('2d');
514        ctx.fillStyle = '#1e1a14';
515        ctx.fillRect(0, 0, 512, 384);
516        ctx.strokeStyle = '#c9a96e';
517        ctx.lineWidth = 2;
518        ctx.strokeRect(10, 10, 492, 364);
519        ctx.fillStyle = '#c9a96e';
520        ctx.font = 'bold 22px Georgia, serif';
521        ctx.textAlign = 'center';
522        ctx.fillText('MUSEUM DIRECTORY', 256, 50);
523        ctx.font = '16px Georgia, serif';
524        ctx.fillStyle = '#e8d5b7';
525        ctx.textAlign = 'left';
526        const dirs = [
527            '\u2190  Trilobite Wing \u2014 Foundations',
528            '\u2192  Archaeopteryx Wing \u2014 Growth',
529            '\u2191  Neanderthal Wing \u2014 Engagement',
530            '\u2193  Info Wing \u2014 About',
531        ];
532        dirs.forEach((d, i) => ctx.fillText(d, 40, 110 + i * 45));
533        ctx.font = 'italic 14px Georgia, serif';
534        ctx.fillStyle = '#a89b8c';
535        ctx.textAlign = 'center';
536        ctx.fillText('Matthew Kim\'s Museum of Science Communication', 256, 350);
537
538        const board = new THREE.Mesh(
539            new THREE.PlaneGeometry(1.15, 0.86),
540            new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas) })
541        );
542        board.position.set(0, 1.55, 0.025);
543        group.add(board);
544
545        group.position.set(x, 0, z);
546        scene.add(group);
547
548        const col = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 2, 6));
549        col.position.set(x, 1, z);
550        col.visible = false;
551        scene.add(col);
552        collisionWalls.push(col);
553    }
554
555    function addWelcomeBanner(scene, x, y, z, rot) {
556        const canvas = document.createElement('canvas');
557        canvas.width = 1024;
558        canvas.height = 256;
559        const ctx = canvas.getContext('2d');
560        ctx.fillStyle = '#2a2218';
561        ctx.fillRect(0, 0, 1024, 256);
562        ctx.strokeStyle = '#c9a96e';
563        ctx.lineWidth = 4;
564        ctx.strokeRect(8, 8, 1008, 240);
565        ctx.strokeStyle = '#c9a96e';
566        ctx.lineWidth = 1;
567        ctx.strokeRect(16, 16, 992, 224);
568        ctx.fillStyle = '#e8d5b7';
569        ctx.font = 'bold 48px Georgia, serif';
570        ctx.textAlign = 'center';
571        ctx.fillText('MATTHEW KIM\'S MUSEUM OF SCIENCE COMMUNICATION', 512, 110);
572        ctx.font = 'italic 28px Georgia, serif';
573        ctx.fillStyle = '#a89b8c';
574        ctx.fillText('A Notation in Science Communication ePortfolio', 512, 170);
575        ctx.font = '20px Georgia, serif';
576        ctx.fillStyle = '#8a7d6e';
577        ctx.fillText('Stanford University', 512, 210);
578
579        const banner = new THREE.Mesh(
580            new THREE.PlaneGeometry(8, 2),
581            new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas) })
582        );
583        banner.position.set(x, y, z);
584        if (rot) banner.rotation.y = rot;
585        scene.add(banner);
586    }
587
588    function addCarpetRunner(scene, x, z, w, d) {
589        const carpet = new THREE.Mesh(new THREE.PlaneGeometry(w, d), _carpetMat);
590        carpet.rotation.x = -Math.PI / 2;
591        carpet.position.set(x, 0.01, z);
592        scene.add(carpet);
593        const borderMat = new THREE.MeshStandardMaterial({ color: 0xc9a96e, roughness: 0.6 });
594        const bw = 0.08;
595        const b1 = new THREE.Mesh(new THREE.PlaneGeometry(w, bw), borderMat);
596        b1.rotation.x = -Math.PI / 2;
597        b1.position.set(x, 0.012, z - d / 2);
598        scene.add(b1);
599        const b2 = new THREE.Mesh(new THREE.PlaneGeometry(w, bw), borderMat);
600        b2.rotation.x = -Math.PI / 2;
601        b2.position.set(x, 0.012, z + d / 2);
602        scene.add(b2);
603    }
604
605    // ===================== WALL WITH DOOR =====================
606
607    function wallWithDoor(scene, wallLen, wallColor, doorX, doorZ, rotated) {
608        const hy = WALL_HEIGHT / 2;
609        const sectionLen = (wallLen - DOOR_WIDTH) / 2;
610
611        if (!rotated) {
612            addWall(scene, doorX - DOOR_WIDTH / 2 - sectionLen / 2, hy, doorZ, sectionLen, WALL_HEIGHT, WALL_THICKNESS, wallColor);
613            addWall(scene, doorX + DOOR_WIDTH / 2 + sectionLen / 2, hy, doorZ, sectionLen, WALL_HEIGHT, WALL_THICKNESS, wallColor);
614            addWall(scene, doorX, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, doorZ, DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT, WALL_THICKNESS, wallColor);
615        } else {
616            addWall(scene, doorX, hy, doorZ - DOOR_WIDTH / 2 - sectionLen / 2, WALL_THICKNESS, WALL_HEIGHT, sectionLen, wallColor);
617            addWall(scene, doorX, hy, doorZ + DOOR_WIDTH / 2 + sectionLen / 2, WALL_THICKNESS, WALL_HEIGHT, sectionLen, wallColor);
618            addWall(scene, doorX, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, doorZ, WALL_THICKNESS, WALL_HEIGHT - DOOR_HEIGHT, DOOR_WIDTH, wallColor);
619        }
620        addDoorFrame(scene, doorX, doorZ, rotated);
621    }
622
623    // ===================== GRAND HALL =====================
624
625    function buildGrandHall(scene) {
626        const hw = GRAND_HALL_WIDTH / 2;  // 12
627        const hd = GRAND_HALL_DEPTH / 2;  // 12
628        const hy = WALL_HEIGHT / 2;
629
630        // Floor & ceiling
631        addFloor(scene, 0, 0, GRAND_HALL_WIDTH, GRAND_HALL_DEPTH);
632        addCeiling(scene, 0, 0, GRAND_HALL_WIDTH, GRAND_HALL_DEPTH);
633
634        // NORTH wall — door to Neanderthal
635        wallWithDoor(scene, GRAND_HALL_WIDTH, COLORS.hall, 0, -hd, false);
636        addRoomSign(scene, 'Neanderthal Wing', 0, DOOR_HEIGHT + 0.6, -hd + 0.25, 0);
637
638        // SOUTH wall — door to Info Wing
639        wallWithDoor(scene, GRAND_HALL_WIDTH, COLORS.hall, 0, hd, false);
640        addRoomSign(scene, 'Info Wing', 0, DOOR_HEIGHT + 0.6, hd - 0.25, Math.PI);
641
642        // WEST wall — door to Trilobite
643        wallWithDoor(scene, GRAND_HALL_DEPTH, COLORS.hall, -hw, 0, true);
644        addRoomSign(scene, 'Trilobite Wing', -hw + 0.25, DOOR_HEIGHT + 0.6, 0, Math.PI / 2);
645
646        // EAST wall — door to Archaeopteryx
647        wallWithDoor(scene, GRAND_HALL_DEPTH, COLORS.hall, hw, 0, true);
648        addRoomSign(scene, 'Archaeopteryx Wing', hw - 0.25, DOOR_HEIGHT + 0.6, 0, -Math.PI / 2);
649
650        // COLONNADE — Field Museum style rows of columns
651        [-8, -3, 3, 8].forEach(z => {
652            addColumn(scene, -9, z);
653            addColumn(scene, 9, z);
654        });
655
656        // ENTRANCE AREA (south section, z > 4)
657
658        // Carpet runners on either side of skeleton platform
659        addCarpetRunner(scene, 0, 8, 3, 7);   // south (entrance side)
660        addCarpetRunner(scene, 0, -8, 3, 7);  // north
661
662        // Plants in corners
663        addPottedPlant(scene, -hw + 2, hd - 2);
664        addPottedPlant(scene, hw - 2, hd - 2);
665        addPottedPlant(scene, -hw + 2, -hd + 2);
666        addPottedPlant(scene, hw - 2, -hd + 2);
667
668        // Benches between columns
669        addBench(scene, -9, 6, Math.PI / 2);
670        addBench(scene, 9, 6, Math.PI / 2);
671        addBench(scene, -9, -6, Math.PI / 2);
672        addBench(scene, 9, -6, Math.PI / 2);
673
674        // Sconces on walls
675        addWallSconce(scene, -hw + 0.35, 5.5, -7);
676        addWallSconce(scene, -hw + 0.35, 5.5, 7);
677        addWallSconce(scene, hw - 0.35, 5.5, -7);
678        addWallSconce(scene, hw - 0.35, 5.5, 7);
679        addWallSconce(scene, -7, 5.5, -hd + 0.35);
680        addWallSconce(scene, 7, 5.5, -hd + 0.35);
681        addWallSconce(scene, -7, 5.5, hd - 0.35);
682        addWallSconce(scene, 7, 5.5, hd - 0.35);
683
684        // Trim
685        addTrim(scene, 0, 0, GRAND_HALL_WIDTH, GRAND_HALL_DEPTH, { north: true, south: true, west: true, east: true });
686
687        rooms.push({ name: 'Grand Hall', minX: -hw, maxX: hw, minZ: -hd, maxZ: hd });
688    }
689
690    // ===================== EXHIBIT HALLS =====================
691
692    function buildExhibitHall(scene, offsetX, offsetZ, axis, wallColor, roomName) {
693        const hw = HALL_WIDTH / 2;
694        const hd = HALL_DEPTH / 2;
695        const hy = WALL_HEIGHT / 2;
696
697        let cx, cz;
698        if (axis === 'x') {
699            cx = offsetX + (offsetX < 0 ? -hd : hd);
700            cz = offsetZ;
701            addFloor(scene, cx, cz, HALL_DEPTH, HALL_WIDTH);
702            addCeiling(scene, cx, cz, HALL_DEPTH, HALL_WIDTH);
703            addWall(scene, cx, hy, cz - hw, HALL_DEPTH, WALL_HEIGHT, WALL_THICKNESS, wallColor);
704            addWall(scene, cx, hy, cz + hw, HALL_DEPTH, WALL_HEIGHT, WALL_THICKNESS, wallColor);
705            const farX = offsetX < 0 ? cx - hd : cx + hd;
706            addWall(scene, farX, hy, cz, WALL_THICKNESS, WALL_HEIGHT, HALL_WIDTH, wallColor);
707            addTrim(scene, cx, cz, HALL_DEPTH, HALL_WIDTH, offsetX < 0 ? { east: true } : { west: true });
708            addBench(scene, cx + (offsetX < 0 ? 5 : -5), cz, Math.PI / 2);
709            if (offsetX < 0) addRopeBarrier(scene, farX + 2, cz - 2, farX + 2, cz + 2);
710            else addRopeBarrier(scene, farX - 2, cz - 2, farX - 2, cz + 2);
711            addWallSconce(scene, cx - 4, 5, cz - hw + 0.35);
712            addWallSconce(scene, cx + 4, 5, cz - hw + 0.35);
713            addWallSconce(scene, cx - 4, 5, cz + hw - 0.35);
714            addWallSconce(scene, cx + 4, 5, cz + hw - 0.35);
715            rooms.push({ name: roomName, minX: cx - hd, maxX: cx + hd, minZ: cz - hw, maxZ: cz + hw });
716        } else {
717            cx = offsetX;
718            cz = offsetZ + (offsetZ < 0 ? -hd : hd);
719            addFloor(scene, cx, cz, HALL_WIDTH, HALL_DEPTH);
720            addCeiling(scene, cx, cz, HALL_WIDTH, HALL_DEPTH);
721            addWall(scene, cx - hw, hy, cz, WALL_THICKNESS, WALL_HEIGHT, HALL_DEPTH, wallColor);
722            addWall(scene, cx + hw, hy, cz, WALL_THICKNESS, WALL_HEIGHT, HALL_DEPTH, wallColor);
723            const farZ = offsetZ < 0 ? cz - hd : cz + hd;
724            addWall(scene, cx, hy, farZ, HALL_WIDTH, WALL_HEIGHT, WALL_THICKNESS, wallColor);
725            addTrim(scene, cx, cz, HALL_WIDTH, HALL_DEPTH, offsetZ < 0 ? { south: true } : { north: true });
726            addBench(scene, cx, cz + (offsetZ < 0 ? 5 : -5), 0);
727            if (offsetZ < 0) addRopeBarrier(scene, cx - 2, farZ + 2, cx + 2, farZ + 2);
728            else addRopeBarrier(scene, cx - 2, farZ - 2, cx + 2, farZ - 2);
729            addWallSconce(scene, cx - hw + 0.35, 5, cz - 4);
730            addWallSconce(scene, cx - hw + 0.35, 5, cz + 4);
731            addWallSconce(scene, cx + hw - 0.35, 5, cz - 4);
732            addWallSconce(scene, cx + hw - 0.35, 5, cz + 4);
733            rooms.push({ name: roomName, minX: cx - hw, maxX: cx + hw, minZ: cz - hd, maxZ: cz + hd });
734        }
735        return { cx, cz, axis };
736    }
737
738    // ===================== LIGHTING =====================
739
740    function addLighting(scene) {
741        scene.add(new THREE.HemisphereLight(0xdde8f0, 0x6b5840, 0.45));
742        scene.add(new THREE.AmbientLight(0xfff5e6, 0.25));
743
744        // Grand hall center light (shadow-casting) — main dramatic light
745        const mainLight = new THREE.PointLight(0xffe8cc, 1.2, 40);
746        mainLight.position.set(0, WALL_HEIGHT - 0.5, 0);
747        mainLight.castShadow = true;
748        mainLight.shadow.mapSize.width = 512;
749        mainLight.shadow.mapSize.height = 512;
750        mainLight.shadow.bias = -0.002;
751        mainLight.shadow.radius = 3;
752        scene.add(mainLight);
753        animatedLights.push(mainLight);
754
755        // (light beams removed for performance)
756
757        // One light per exhibit hall (no accent lights for performance)
758        const hw = GRAND_HALL_WIDTH / 2;
759        const hd = GRAND_HALL_DEPTH / 2;
760        const wingLights = [
761            { pos: [-hw - HALL_DEPTH / 2, 0], color: 0xffe0c0 },  // Trilobite warm
762            { pos: [hw + HALL_DEPTH / 2, 0], color: 0xe8f0dd },    // Archaeopteryx green
763            { pos: [0, -hd - HALL_DEPTH / 2], color: 0xe0d8f0 },   // Neanderthal violet
764            { pos: [0, hd + HALL_DEPTH / 2], color: 0xd8e0f0 },    // Info blue
765        ];
766        wingLights.forEach(({ pos, color }) => {
767            const l = new THREE.PointLight(color, 0.8, 30);
768            l.position.set(pos[0], WALL_HEIGHT - 0.5, pos[1]);
769            scene.add(l);
770        });
771
772        // Directional fill light for soft overall shadows
773        const dirLight = new THREE.DirectionalLight(0xffeedd, 0.15);
774        dirLight.position.set(5, WALL_HEIGHT, 5);
775        scene.add(dirLight);
776    }
777
778    const animatedLights = [];
779
780    function addLightBeam(scene, x, z, color, radius, height) {
781        const beamGeo = new THREE.CylinderGeometry(radius * 0.3, radius, height, 8, 1, true);
782        const beamMat = new THREE.MeshBasicMaterial({
783            color: color,
784            transparent: true,
785            opacity: 0.018,
786            side: THREE.DoubleSide,
787            depthWrite: false
788        });
789        const beam = new THREE.Mesh(beamGeo, beamMat);
790        beam.position.set(x, height / 2, z);
791        scene.add(beam);
792    }
793
794    function getAnimatedLights() { return animatedLights; }
795
796    // ===================== FOSSIL CENTERPIECES =====================
797
798    const _boneMat = new THREE.MeshStandardMaterial({ color: 0xe8dcc8, roughness: 0.6, metalness: 0.05 });
799    const _darkBoneMat = new THREE.MeshStandardMaterial({ color: 0xc8b8a0, roughness: 0.65, metalness: 0.05 });
800    const _fossilMat = new THREE.MeshStandardMaterial({ color: 0xd4c8b0, roughness: 0.55, metalness: 0.08 });
801
802    function addFossilPedestal(scene, x, z) {
803        // Tiered base
804        scene.add(new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.5, 0.12, 16), _marbleMat).translateX(x).translateY(0.06).translateZ(z));
805        scene.add(new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.3, 0.12, 16), _marbleMat).translateX(x).translateY(0.18).translateZ(z));
806        const body = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 1.0, 0.7, 12), _stoneMat);
807        body.position.set(x, 0.59, z);
808        body.castShadow = true;
809        scene.add(body);
810        collisionWalls.push(body);
811        // Display platform top
812        scene.add(new THREE.Mesh(new THREE.CylinderGeometry(1.0, 0.9, 0.08, 16), _marbleMat).translateX(x).translateY(0.98).translateZ(z));
813
814        // Point light on fossil (cheaper than SpotLight)
815        const pl = new THREE.PointLight(0xfff0dd, 0.6, 10);
816        pl.position.set(x, WALL_HEIGHT - 1, z);
817        scene.add(pl);
818
819        return { x, z, topY: 1.02 };
820    }
821
822    // Wing image paths (for overlay click-through)
823    const WING_IMAGES = {
824        trilobite: 'assets/images/trilobite.png',
825        archaeopteryx: 'assets/images/archaeopteryx.png',
826        neanderthal: 'assets/images/neanderthal.png'
827    };
828    // Base64 image data loaded from wing-images.js (WING_IMAGE_DATA global)
829
830    function addWallSign(scene, hall, exhibitKey, wingName, subtitle, description) {
831        const { cx, cz, axis } = hall;
832        const hd = HALL_DEPTH / 2;
833
834        // Determine far wall position and sign orientation
835        // Wall center is at farX/farZ, inner face is WALL_THICKNESS/2 closer to center
836        const wallOff = WALL_THICKNESS / 2 + 0.05; // just in front of inner wall face
837        let signX, signZ, rotY;
838        if (axis === 'x') {
839            const farX = cx > 0 ? cx + hd : cx - hd;
840            signX = farX + (cx > 0 ? -wallOff : wallOff);
841            signZ = cz;
842            rotY = cx > 0 ? -Math.PI / 2 : Math.PI / 2;
843        } else {
844            const farZ = cz > 0 ? cz + hd : cz - hd;
845            signX = cx;
846            signZ = farZ + (cz > 0 ? -wallOff : wallOff);
847            rotY = cz > 0 ? Math.PI : 0;
848        }
849
850        const group = new THREE.Group();
851
852        // === PAINTING (framed image on wall) ===
853        // Native aspect ratios: trilobite 4:3 landscape, others ~3:4 portrait
854        const IMG_ASPECTS = { trilobite: 512/384, archaeopteryx: 512/685, neanderthal: 512/678 };
855        const aspect = IMG_ASPECTS[exhibitKey] || (4/3);
856        const maxDim = 3.5; // max width or height in world units
857        let imgW, imgH;
858        if (aspect >= 1) {
859            imgW = maxDim;
860            imgH = maxDim / aspect;
861        } else {
862            imgH = maxDim;
863            imgW = maxDim * aspect;
864        }
865        const imgCenterY = 1.0 + imgH / 2 + 0.8; // bottom of painting ~1.8m up
866
867        // Gold frame around painting
868        const frameMat = _goldMat;
869        const frameThick = 0.15;
870        const frameTop = new THREE.Mesh(new THREE.BoxGeometry(imgW + frameThick * 2, frameThick, 0.08), frameMat);
871        frameTop.position.set(0, imgCenterY + imgH / 2 + frameThick / 2, 0);
872        group.add(frameTop);
873        const frameBot = new THREE.Mesh(new THREE.BoxGeometry(imgW + frameThick * 2, frameThick, 0.08), frameMat);
874        frameBot.position.set(0, imgCenterY - imgH / 2 - frameThick / 2, 0);
875        group.add(frameBot);
876        const frameLeft = new THREE.Mesh(new THREE.BoxGeometry(frameThick, imgH + frameThick * 2, 0.08), frameMat);
877        frameLeft.position.set(-imgW / 2 - frameThick / 2, imgCenterY, 0);
878        group.add(frameLeft);
879        const frameRight = new THREE.Mesh(new THREE.BoxGeometry(frameThick, imgH + frameThick * 2, 0.08), frameMat);
880        frameRight.position.set(imgW / 2 + frameThick / 2, imgCenterY, 0);
881        group.add(frameRight);
882
883        // Image plane — use base64 data URL drawn onto canvas (works on file://)
884        const imgPath = WING_IMAGES[exhibitKey];
885        const imgDataURL = (typeof WING_IMAGE_DATA !== 'undefined') ? WING_IMAGE_DATA[exhibitKey] : null;
886        const canvasW = 512, canvasH = Math.round(512 / aspect);
887        const imgCanvas = document.createElement('canvas');
888        imgCanvas.width = canvasW;
889        imgCanvas.height = canvasH;
890        const imgCtx = imgCanvas.getContext('2d');
891        imgCtx.fillStyle = '#1a1610';
892        imgCtx.fillRect(0, 0, canvasW, canvasH);
893        const imgTex = new THREE.CanvasTexture(imgCanvas);
894        const imgMat = new THREE.MeshBasicMaterial({ map: imgTex, side: THREE.DoubleSide });
895        const imgPlane = new THREE.Mesh(new THREE.PlaneGeometry(imgW, imgH), imgMat);
896        imgPlane.position.set(0, imgCenterY, 0.05);
897        group.add(imgPlane);
898
899        if (imgDataURL) {
900            const img = new Image();
901            img.onload = function() {
902                imgCtx.drawImage(img, 0, 0, canvasW, canvasH);
903                imgTex.needsUpdate = true;
904            };
905            img.src = imgDataURL;
906        }
907
908        // === MUSEUM PLACARD (small label below painting) ===
909        const placardW = Math.min(imgW, 2.4), placardH = 0.7;
910        const placardY = imgCenterY - imgH / 2 - frameThick - 0.15 - placardH / 2;
911
912        // Placard canvas
913        const canvas = document.createElement('canvas');
914        canvas.width = 512;
915        canvas.height = 160;
916        const ctx = canvas.getContext('2d');
917
918        // White/cream background like a real museum label
919        ctx.fillStyle = '#f5f0e8';
920        ctx.fillRect(0, 0, 512, 160);
921
922        // Thin border
923        ctx.strokeStyle = '#c9a96e';
924        ctx.lineWidth = 2;
925        ctx.strokeRect(2, 2, 508, 156);
926
927        // Wing name (bold)
928        ctx.fillStyle = '#1a1610';
929        ctx.font = 'bold 24px Georgia, serif';
930        ctx.textAlign = 'center';
931        ctx.fillText(wingName, 256, 35);
932
933        // Subtitle (italic)
934        ctx.fillStyle = '#5a4a30';
935        ctx.font = 'italic 17px Georgia, serif';
936        ctx.fillText(subtitle, 256, 60);
937
938        // Thin line
939        ctx.strokeStyle = '#c9a96e';
940        ctx.lineWidth = 0.5;
941        ctx.beginPath();
942        ctx.moveTo(80, 70);
943        ctx.lineTo(432, 70);
944        ctx.stroke();
945
946        // Short description (truncated to 2 lines)
947        ctx.fillStyle = '#3a3020';
948        ctx.font = '13px Georgia, serif';
949        ctx.textAlign = 'center';
950        const words = description.split(' ');
951        let line1 = '', line2 = '';
952        let onLine1 = true;
953        for (const word of words) {
954            if (onLine1) {
955                const test = line1 + word + ' ';
956                if (ctx.measureText(test).width > 440) {
957                    onLine1 = false;
958                    line2 = word + ' ';
959                } else {
960                    line1 = test;
961                }
962            } else {
963                const test = line2 + word + ' ';
964                if (ctx.measureText(test).width > 430) {
965                    line2 = line2.trim() + '...';
966                    break;
967                }
968                line2 = test;
969            }
970        }
971        ctx.fillText(line1.trim(), 256, 92);
972        if (line2) ctx.fillText(line2.trim(), 256, 112);
973
974        // Click hint
975        ctx.fillStyle = '#9a8a6a';
976        ctx.font = '11px Helvetica, sans-serif';
977        ctx.fillText('Click to read more', 256, 145);
978
979        const placardTex = new THREE.CanvasTexture(canvas);
980
981        // Placard backing (slight depth)
982        const placardBack = new THREE.Mesh(
983            new THREE.BoxGeometry(placardW + 0.06, placardH + 0.06, 0.03),
984            new THREE.MeshStandardMaterial({ color: 0x2a2520, roughness: 0.7 })
985        );
986        placardBack.position.set(0, placardY, 0.01);
987        group.add(placardBack);
988
989        // Placard face
990        const placardFace = new THREE.Mesh(
991            new THREE.PlaneGeometry(placardW, placardH),
992            new THREE.MeshBasicMaterial({ map: placardTex, side: THREE.DoubleSide })
993        );
994        placardFace.position.set(0, placardY, 0.03);
995        group.add(placardFace);
996
997        group.position.set(signX, 0, signZ);
998        group.rotation.y = rotY;
999        scene.add(group);
1000
1001        // Update world matrix for raycasting
1002        group.updateMatrixWorld(true);
1003
1004        // Register as clickable exhibit
1005        const overlayData = {
1006            title: wingName,
1007            content: '<p style="color:#c9a96e; font-size:0.85rem; margin-bottom:1rem; letter-spacing:0.1em;">' + subtitle + '</p><p>' + description + '</p>',
1008            image: imgPath,
1009            reflection: null,
1010            type: 'info'
1011        };
1012        if (imgPlane) Exhibits.registerExhibitMesh(imgPlane, overlayData);
1013        Exhibits.registerExhibitMesh(placardFace, overlayData);
1014    }
1015
1016    function buildTrilobiteFossil(scene, ped) {
1017        const group = new THREE.Group();
1018        // Central body — segmented ellipsoid
1019        for (let i = 0; i < 9; i++) {
1020            const t = i / 8;
1021            const segW = 0.35 * Math.sin(t * Math.PI) + 0.12;
1022            const segH = 0.06;
1023            const seg = new THREE.Mesh(
1024                new THREE.CylinderGeometry(segW, segW * 0.95, segH, 8),
1025                _fossilMat
1026            );
1027            seg.position.z = (t - 0.5) * 1.0;
1028            seg.position.y = 0.04;
1029            seg.castShadow = true;
1030            group.add(seg);
1031
1032            // Ridge line down center
1033            const ridge = new THREE.Mesh(
1034                new THREE.CylinderGeometry(segW * 0.35, segW * 0.3, segH + 0.02, 6),
1035                _boneMat
1036            );
1037            ridge.position.z = seg.position.z;
1038            ridge.position.y = 0.07;
1039            group.add(ridge);
1040
1041            // Leg protrusions on each side
1042            if (i > 0 && i < 8) {
1043                [-1, 1].forEach(side => {
1044                    const leg = new THREE.Mesh(
1045                        new THREE.BoxGeometry(segW * 0.7, 0.015, 0.03),
1046                        _darkBoneMat
1047                    );
1048                    leg.position.set(side * (segW + segW * 0.3), 0.02, seg.position.z);
1049                    leg.rotation.z = side * 0.3;
1050                    group.add(leg);
1051                });
1052            }
1053        }
1054
1055        // Head shield (cephalon)
1056        const head = new THREE.Mesh(
1057            new THREE.SphereGeometry(0.22, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2),
1058            _fossilMat
1059        );
1060        head.position.set(0, 0.04, -0.55);
1061        head.rotation.x = -0.2;
1062        group.add(head);
1063
1064        // Eyes
1065        [-1, 1].forEach(side => {
1066            const eye = new THREE.Mesh(
1067                new THREE.SphereGeometry(0.04, 6, 6),
1068                new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.3, metalness: 0.4 })
1069            );
1070            eye.position.set(side * 0.12, 0.1, -0.52);
1071            group.add(eye);
1072        });
1073
1074        // Tail (pygidium)
1075        const tail = new THREE.Mesh(
1076            new THREE.ConeGeometry(0.15, 0.25, 6),
1077            _fossilMat
1078        );
1079        tail.position.set(0, 0.04, 0.6);
1080        tail.rotation.x = Math.PI / 2;
1081        group.add(tail);
1082
1083        group.position.set(ped.x, ped.topY, ped.z);
1084        group.rotation.x = -0.15;
1085        scene.add(group);
1086    }
1087
1088    function buildArchaeopteryxFossil(scene, ped) {
1089        const group = new THREE.Group();
1090        const bone = _boneMat;
1091
1092        // --- Running pose: body leaning forward, one leg forward, wings back ---
1093
1094        // Spine (tilted forward ~40 degrees from horizontal)
1095        const spinePoints = [];
1096        for (let i = 0; i <= 8; i++) {
1097            const t = i / 8;
1098            spinePoints.push(new THREE.Vector3(0, t * 0.22, t * 0.38));
1099        }
1100        const spineCurve = new THREE.CatmullRomCurve3(spinePoints);
1101        const spine = new THREE.Mesh(new THREE.TubeGeometry(spineCurve, 10, 0.018, 5, false), bone);
1102        group.add(spine);
1103
1104        // Ribcage (along tilted spine)
1105        for (let i = 0; i < 5; i++) {
1106            [-1, 1].forEach(side => {
1107                const rib = new THREE.Mesh(
1108                    new THREE.TorusGeometry(0.06 + i * 0.004, 0.007, 4, 8, Math.PI * 0.55),
1109                    _darkBoneMat
1110                );
1111                const t = (i + 1) / 6;
1112                rib.position.set(0, t * 0.22, t * 0.38);
1113                rib.rotation.y = side * Math.PI / 2;
1114                rib.rotation.z = 0.5; // match spine tilt
1115                group.add(rib);
1116            });
1117        }
1118
1119        // Pelvis
1120        const pelvis = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.012, 4, 8, Math.PI), _darkBoneMat);
1121        pelvis.position.set(0, 0.02, 0.04);
1122        pelvis.rotation.x = Math.PI / 2;
1123        group.add(pelvis);
1124
1125        // Short neck (Archaeopteryx had a relatively short neck)
1126        const neckPts = [
1127            new THREE.Vector3(0, 0.22, 0.38),
1128            new THREE.Vector3(0, 0.27, 0.42),
1129            new THREE.Vector3(0, 0.30, 0.44),
1130        ];
1131        const neckCurve = new THREE.CatmullRomCurve3(neckPts);
1132        group.add(new THREE.Mesh(new THREE.TubeGeometry(neckCurve, 6, 0.012, 5, false), bone));
1133
1134        // Skull (compact, thrust forward — Archaeopteryx had a small toothed head)
1135        const skull = new THREE.Mesh(new THREE.SphereGeometry(0.04, 8, 6), bone);
1136        skull.position.set(0, 0.31, 0.46);
1137        skull.scale.set(0.85, 0.7, 1.3);
1138        group.add(skull);
1139
1140        // Toothed jaw (distinctive Archaeopteryx feature — teeth, not a beak)
1141        const jaw = new THREE.Mesh(new THREE.ConeGeometry(0.012, 0.06, 4), bone);
1142        jaw.position.set(0, 0.30, 0.52);
1143        jaw.rotation.x = Math.PI / 2;
1144        group.add(jaw);
1145
1146        // Tiny teeth along jaw
1147        for (let t = 0; t < 3; t++) {
1148            const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.003, 0.012, 3),
1149                new THREE.MeshStandardMaterial({ color: 0xf0e8d0, roughness: 0.4 }));
1150            tooth.position.set((t - 1) * 0.008, 0.285, 0.50 + t * 0.005);
1151            group.add(tooth);
1152        }
1153
1154        // Eyes
1155        const eyeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.3 });
1156        [-1, 1].forEach(side => {
1157            const eye = new THREE.Mesh(new THREE.SphereGeometry(0.008, 4, 4), eyeMat);
1158            eye.position.set(side * 0.025, 0.32, 0.49);
1159            group.add(eye);
1160        });
1161
1162        // Wings (swept back in running pose, partially folded)
1163        const featherMat = new THREE.MeshStandardMaterial({
1164            color: 0xb8a888, roughness: 0.7, side: THREE.DoubleSide, transparent: true, opacity: 0.55
1165        });
1166        [-1, 1].forEach(side => {
1167            // Humerus (upper arm, swept back)
1168            const humerus = new THREE.Mesh(new THREE.CylinderGeometry(0.009, 0.007, 0.14, 4), bone);
1169            humerus.position.set(side * 0.09, 0.16, 0.26);
1170            humerus.rotation.z = side * 0.8;
1171            humerus.rotation.x = -0.4;
1172            group.add(humerus);
1173
1174            // Radius/ulna (forearm, folded)
1175            const radius = new THREE.Mesh(new THREE.CylinderGeometry(0.007, 0.005, 0.13, 4), bone);
1176            radius.position.set(side * 0.20, 0.12, 0.18);
1177            radius.rotation.z = side * 0.4;
1178            radius.rotation.x = -0.5;
1179            group.add(radius);
1180
1181            // Clawed fingers (3 clawed digits — key Archaeopteryx feature)
1182            for (let c = 0; c < 3; c++) {
1183                const claw = new THREE.Mesh(new THREE.ConeGeometry(0.004, 0.03, 3), _darkBoneMat);
1184                claw.position.set(side * (0.27 + c * 0.01), 0.09 - c * 0.01, 0.12 - c * 0.02);
1185                claw.rotation.z = side * 0.3;
1186                claw.rotation.x = -0.3;
1187                group.add(claw);
1188            }
1189
1190            // Feather impressions along wing (trailing back)
1191            for (let f = 0; f < 6; f++) {
1192                const feather = new THREE.Mesh(new THREE.PlaneGeometry(0.025, 0.10), featherMat);
1193                feather.position.set(side * (0.11 + f * 0.035), 0.14 - f * 0.008, 0.22 - f * 0.025);
1194                feather.rotation.z = side * (0.25 + f * 0.06);
1195                feather.rotation.x = -0.35;
1196                group.add(feather);
1197            }
1198        });
1199
1200        // Bird legs using connected curves (proper bird anatomy:
1201        // femur short & close to body, tibiotarsus long going down,
1202        // tarsometatarsus angling forward to feet)
1203
1204        // LEFT LEG — forward stride
1205        const lLegPts = [
1206            new THREE.Vector3(-0.03, 0.0, 0.06),   // hip joint at pelvis
1207            new THREE.Vector3(-0.035, -0.08, 0.10), // femur end / knee
1208            new THREE.Vector3(-0.035, -0.22, 0.06), // tibiotarsus down
1209            new THREE.Vector3(-0.035, -0.28, 0.12), // ankle (bird "knee")
1210            new THREE.Vector3(-0.035, -0.32, 0.14), // foot
1211        ];
1212        const lLegCurve = new THREE.CatmullRomCurve3(lLegPts);
1213        const lLeg = new THREE.Mesh(new THREE.TubeGeometry(lLegCurve, 10, 0.008, 4, false), bone);
1214        group.add(lLeg);
1215
1216        // Left foot talons
1217        for (let t = 0; t < 3; t++) {
1218            const talon = new THREE.Mesh(new THREE.ConeGeometry(0.004, 0.03, 3), _darkBoneMat);
1219            talon.position.set(-0.035 + (t - 1) * 0.012, -0.34, 0.16 + t * 0.006);
1220            talon.rotation.x = 0.5;
1221            group.add(talon);
1222        }
1223
1224        // RIGHT LEG — back stride (pushing off)
1225        const rLegPts = [
1226            new THREE.Vector3(0.03, 0.0, 0.02),     // hip joint at pelvis
1227            new THREE.Vector3(0.035, -0.07, -0.03),  // femur end / knee
1228            new THREE.Vector3(0.035, -0.22, -0.06),  // tibiotarsus down
1229            new THREE.Vector3(0.035, -0.28, -0.02),  // ankle
1230            new THREE.Vector3(0.035, -0.32, 0.01),   // foot
1231        ];
1232        const rLegCurve = new THREE.CatmullRomCurve3(rLegPts);
1233        const rLeg = new THREE.Mesh(new THREE.TubeGeometry(rLegCurve, 10, 0.008, 4, false), bone);
1234        group.add(rLeg);
1235
1236        // Right foot talons
1237        for (let t = 0; t < 3; t++) {
1238            const talon = new THREE.Mesh(new THREE.ConeGeometry(0.004, 0.03, 3), _darkBoneMat);
1239            talon.position.set(0.035 + (t - 1) * 0.012, -0.34, 0.03 + t * 0.006);
1240            talon.rotation.x = 0.5;
1241            group.add(talon);
1242        }
1243
1244        // Tail (extending back for balance, slightly raised)
1245        for (let i = 0; i < 10; i++) {
1246            const vert = new THREE.Mesh(
1247                new THREE.SphereGeometry(0.012 - i * 0.0008, 4, 4),
1248                bone
1249            );
1250            vert.position.set(0, 0.02 + i * 0.008, -0.05 - i * 0.03);
1251            group.add(vert);
1252        }
1253
1254        // Tail feather fan (spread behind for balance)
1255        for (let f = 0; f < 5; f++) {
1256            const tf = new THREE.Mesh(new THREE.PlaneGeometry(0.025, 0.1), featherMat);
1257            tf.position.set((f - 2) * 0.02, 0.10, -0.35);
1258            tf.rotation.x = -0.3;
1259            group.add(tf);
1260        }
1261
1262        // Mount rod (thin metal rod from body down to pedestal)
1263        const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, 0.4, 4), _darkMetalMat);
1264        rod.position.set(0, -0.18, 0.04);
1265        group.add(rod);
1266
1267        group.position.set(ped.x, ped.topY + 0.45, ped.z);
1268        group.scale.set(1.5, 1.5, 1.5);
1269        scene.add(group);
1270    }
1271
1272    function buildNeanderthalSkull(scene, ped) {
1273        const group = new THREE.Group();
1274
1275        // Cranium — elongated sphere with pronounced brow
1276        const cranium = new THREE.Mesh(
1277            new THREE.SphereGeometry(0.22, 12, 10),
1278            _fossilMat
1279        );
1280        cranium.scale.set(1, 0.9, 1.15);
1281        cranium.position.y = 0.15;
1282        cranium.castShadow = true;
1283        group.add(cranium);
1284
1285        // Brow ridge — prominent
1286        const brow = new THREE.Mesh(
1287            new THREE.TorusGeometry(0.16, 0.035, 6, 12, Math.PI),
1288            _boneMat
1289        );
1290        brow.position.set(0, 0.12, -0.17);
1291        brow.rotation.x = 0.3;
1292        group.add(brow);
1293
1294        // Eye sockets
1295        const eyeSocketMat = new THREE.MeshStandardMaterial({ color: 0x1a1512, roughness: 0.9 });
1296        [-1, 1].forEach(side => {
1297            const socket = new THREE.Mesh(
1298                new THREE.SphereGeometry(0.055, 8, 8),
1299                eyeSocketMat
1300            );
1301            socket.position.set(side * 0.08, 0.08, -0.2);
1302            socket.scale.z = 0.5;
1303            group.add(socket);
1304        });
1305
1306        // Nasal cavity
1307        const nose = new THREE.Mesh(
1308            new THREE.BoxGeometry(0.05, 0.06, 0.04),
1309            eyeSocketMat
1310        );
1311        nose.position.set(0, 0.01, -0.22);
1312        group.add(nose);
1313
1314        // Cheekbones
1315        [-1, 1].forEach(side => {
1316            const cheek = new THREE.Mesh(
1317                new THREE.SphereGeometry(0.04, 6, 6),
1318                _fossilMat
1319            );
1320            cheek.position.set(side * 0.15, 0.02, -0.16);
1321            group.add(cheek);
1322        });
1323
1324        // Upper jaw (maxilla)
1325        const maxilla = new THREE.Mesh(
1326            new THREE.BoxGeometry(0.16, 0.05, 0.1),
1327            _boneMat
1328        );
1329        maxilla.position.set(0, -0.05, -0.18);
1330        group.add(maxilla);
1331
1332        // Teeth row
1333        for (let i = 0; i < 6; i++) {
1334            const tooth = new THREE.Mesh(
1335                new THREE.BoxGeometry(0.018, 0.025, 0.015),
1336                new THREE.MeshStandardMaterial({ color: 0xf0e8d0, roughness: 0.4 })
1337            );
1338            tooth.position.set(-0.05 + i * 0.02, -0.085, -0.2);
1339            group.add(tooth);
1340        }
1341
1342        // Lower jaw (mandible)
1343        const jaw = new THREE.Mesh(
1344            new THREE.TorusGeometry(0.1, 0.025, 6, 10, Math.PI),
1345            _boneMat
1346        );
1347        jaw.position.set(0, -0.1, -0.14);
1348        jaw.rotation.x = Math.PI;
1349        jaw.rotation.z = Math.PI;
1350        group.add(jaw);
1351
1352        // Chin (not very pronounced for Neanderthal — receding)
1353        const chin = new THREE.Mesh(
1354            new THREE.SphereGeometry(0.03, 6, 6),
1355            _boneMat
1356        );
1357        chin.position.set(0, -0.13, -0.18);
1358        group.add(chin);
1359
1360        // Temporal bones (sides)
1361        [-1, 1].forEach(side => {
1362            const temporal = new THREE.Mesh(
1363                new THREE.SphereGeometry(0.06, 6, 6),
1364                _fossilMat
1365            );
1366            temporal.position.set(side * 0.2, 0.1, -0.02);
1367            temporal.scale.set(0.5, 0.8, 1);
1368            group.add(temporal);
1369        });
1370
1371        group.position.set(ped.x, ped.topY + 0.15, ped.z);
1372        group.rotation.y = Math.PI;
1373        group.scale.set(1.4, 1.4, 1.4);
1374        scene.add(group);
1375    }
1376
1377    // ===================== SKELETON CENTERPIECE (GRAND HALL) =====================
1378
1379    function buildTRexSkeleton(scene) {
1380        // Initial collision box — oversized to block NPCs before model loads; auto-fitted later
1381        const collBox = new THREE.Mesh(new THREE.BoxGeometry(5, 5, 8));
1382        collBox.position.set(0, 2, 0);
1383        collBox.visible = false;
1384        scene.add(collBox);
1385        collisionWalls.push(collBox);
1386
1387        // Lighting for the centerpiece
1388        const spotAbove = new THREE.PointLight(0xfff5dd, 0.9, 18);
1389        spotAbove.position.set(0, WALL_HEIGHT - 0.5, 0);
1390        scene.add(spotAbove);
1391
1392        // Load GLB model (includes its own stand)
1393        const dracoLoader = new THREE.DRACOLoader();
1394        dracoLoader.setDecoderPath('https://cdn.jsdelivr.net/npm/[email protected]/examples/js/libs/draco/');
1395        const loader = new THREE.GLTFLoader();
1396        loader.setDRACOLoader(dracoLoader);
1397        const boneMaterial = new THREE.MeshLambertMaterial({
1398            color: 0xd8ccb4
1399        });
1400
1401        loader.load(
1402            'assets/bambiraptor-skeleton.glb',
1403            function (gltf) {
1404                const model = gltf.scene;
1405
1406                // Apply bone material to all meshes (no shadows for performance)
1407                model.traverse(function (child) {
1408                    if (child.isMesh) {
1409                        child.material = boneMaterial;
1410                    }
1411                });
1412
1413                // Wrap in a pivot so rotation doesn't fight with positioning
1414                const pivot = new THREE.Group();
1415                pivot.add(model);
1416
1417                // Fix orientation: Fusion 360 Z-up → Three.js Y-up
1418                model.rotation.x = -Math.PI / 2;
1419                // Rotate pivot so skull faces +Z (info wing) — skull was at +X
1420                pivot.rotation.y = -Math.PI / 2;
1421
1422                // Compute bounding box after rotation
1423                const box = new THREE.Box3().setFromObject(pivot);
1424                const size = new THREE.Vector3();
1425                const center = new THREE.Vector3();
1426                box.getSize(size);
1427                box.getCenter(center);
1428
1429                // Scale to fit Grand Hall (target ~6 units tall)
1430                const maxDim = Math.max(size.x, size.y, size.z);
1431                const scale = 6.0 / maxDim;
1432                pivot.scale.set(scale, scale, scale);
1433
1434                // Re-center after scaling
1435                box.setFromObject(pivot);
1436                box.getSize(size);
1437                box.getCenter(center);
1438
1439                // Position: centered on X/Z, base on floor
1440                pivot.position.x = -center.x;
1441                pivot.position.y = -box.min.y;
1442                pivot.position.z = -center.z;
1443
1444                scene.add(pivot);
1445
1446                // Auto-fit collision box to the actual model with margin
1447                collBox.geometry.dispose();
1448                collBox.geometry = new THREE.BoxGeometry(size.x + 0.6, size.y, size.z + 0.6);
1449                collBox.position.set(0, size.y / 2, 0);
1450                collBox.updateMatrixWorld(true);
1451            },
1452            null,
1453            function (err) {
1454                console.error('Model load error:', err);
1455            }
1456        );
1457    }
1458
1459    function addFossilCenterpiece(scene, hall, exhibitKey) {
1460        const data = MUSEUM_DATA.exhibits[exhibitKey];
1461        if (!data) return;
1462
1463        const { cx, cz, axis } = hall;
1464        const ped = addFossilPedestal(scene, cx, cz);
1465
1466        // Build the appropriate fossil
1467        if (exhibitKey === 'trilobite') buildTrilobiteFossil(scene, ped);
1468        else if (exhibitKey === 'archaeopteryx') buildArchaeopteryxFossil(scene, ped);
1469        else if (exhibitKey === 'neanderthal') buildNeanderthalSkull(scene, ped);
1470
1471        // Wall-mounted info sign on the far wall of the wing
1472        addWallSign(scene, hall, exhibitKey, data.name, data.subtitle, data.description);
1473    }
1474
1475    // ===================== BUILD =====================
1476
1477    function build(scene) {
1478        buildGrandHall(scene);
1479        buildTRexSkeleton(scene);
1480        buildWelcomeSign(scene);
1481        buildBambiraptorSign(scene);
1482
1483        const hw = GRAND_HALL_WIDTH / 2;
1484        const hd = GRAND_HALL_DEPTH / 2;
1485
1486        // Four exhibit wings branching off the grand hall
1487        const trilobiteHall = buildExhibitHall(scene, -hw, 0, 'x', COLORS.trilobite, 'Trilobite Wing');
1488        const archaeopteryxHall = buildExhibitHall(scene, hw, 0, 'x', COLORS.archaeopteryx, 'Archaeopteryx Wing');
1489        const neanderthalHall = buildExhibitHall(scene, 0, -hd, 'z', COLORS.neanderthal, 'Neanderthal Wing');
1490        const infoHall = buildExhibitHall(scene, 0, hd, 'z', COLORS.info, 'Info Wing');
1491
1492        addLighting(scene);
1493
1494        // Fossil centerpieces in each exhibit wing
1495        addFossilCenterpiece(scene, trilobiteHall, 'trilobite');
1496        addFossilCenterpiece(scene, archaeopteryxHall, 'archaeopteryx');
1497        addFossilCenterpiece(scene, neanderthalHall, 'neanderthal');
1498
1499        return {
1500            halls: { trilobite: trilobiteHall, archaeopteryx: archaeopteryxHall, neanderthal: neanderthalHall, info: infoHall }
1501        };
1502    }
1503
1504    function getCollisionWalls() { return collisionWalls; }
1505
1506    function getCurrentRoom(x, z) {
1507        for (const room of rooms) {
1508            if (x >= room.minX && x <= room.maxX && z >= room.minZ && z <= room.maxZ) return room.name;
1509        }
1510        return 'Grand Hall';
1511    }
1512
1513    function getRooms() { return rooms; }
1514
1515    // Tilted lectern-style info sign next to the skeleton centerpiece
1516    let _welcomeSignFace = null;
1517
1518    function buildWelcomeSign(scene) {
1519        const signX = -2.2, signZ = 0;
1520        const signW = 1.6, signH = 1.15;
1521        const postHeight = 0.85;
1522        const tiltAngle = -0.55; // ~31 degrees, lectern tilt
1523
1524        // Outer group: position + face toward the skeleton (+X)
1525        const signGroup = new THREE.Group();
1526        signGroup.position.set(signX, 0, signZ);
1527        signGroup.rotation.y = -Math.PI / 2; // local +Z → world +X
1528
1529        // Base plate
1530        const base = new THREE.Mesh(
1531            new THREE.CylinderGeometry(0.22, 0.25, 0.04, 12),
1532            _darkMetalMat
1533        );
1534        base.position.y = 0.02;
1535        signGroup.add(base);
1536
1537        // Vertical post
1538        const post = new THREE.Mesh(
1539            new THREE.CylinderGeometry(0.03, 0.03, postHeight, 8),
1540            _darkMetalMat
1541        );
1542        post.position.y = postHeight / 2;
1543        signGroup.add(post);
1544
1545        // Canvas texture for the reading surface
1546        const canvas = document.createElement('canvas');
1547        canvas.width = 640;
1548        canvas.height = 480;
1549        const ctx = canvas.getContext('2d');
1550        ctx.fillStyle = '#1a1712';
1551        ctx.fillRect(0, 0, 640, 480);
1552        ctx.strokeStyle = '#c9a96e';
1553        ctx.lineWidth = 3;
1554        ctx.strokeRect(12, 12, 616, 456);
1555        ctx.fillStyle = '#c9a96e';
1556        ctx.font = 'italic 46px Georgia, serif';
1557        ctx.textAlign = 'center';
1558        ctx.fillText('Welcome', 320, 80);
1559        ctx.strokeStyle = 'rgba(201,169,110,0.4)';
1560        ctx.lineWidth = 1;
1561        ctx.beginPath();
1562        ctx.moveTo(120, 108);
1563        ctx.lineTo(520, 108);
1564        ctx.stroke();
1565        ctx.fillStyle = '#c0b3a1';
1566        ctx.font = '24px Georgia, serif';
1567        ctx.fillText("Matthew Kim's Museum of", 320, 165);
1568        ctx.fillText('Science Communication', 320, 198);
1569        ctx.fillStyle = '#a89b8c';
1570        ctx.font = 'italic 20px Georgia, serif';
1571        ctx.fillText('A Notation in Science Communication ePortfolio', 320, 260);
1572        ctx.fillStyle = '#7a7060';
1573        ctx.font = '19px Helvetica, sans-serif';
1574        ctx.fillText('Click to read the welcome letter', 320, 440);
1575        const tex = new THREE.CanvasTexture(canvas);
1576
1577        // Sub-group for the tilted surface — everything tilts together
1578        const surface = new THREE.Group();
1579        surface.position.y = postHeight;
1580        surface.rotation.x = tiltAngle;
1581        signGroup.add(surface);
1582
1583        // Sign body (solid box — also the raycasting target)
1584        const backMat = new THREE.MeshStandardMaterial({ color: 0x1a1712, roughness: 0.4, metalness: 0.3 });
1585        const signBox = new THREE.Mesh(
1586            new THREE.BoxGeometry(signW, signH, 0.04),
1587            backMat
1588        );
1589        surface.add(signBox);
1590
1591        // Text overlay on the front face of the box
1592        const textPlane = new THREE.Mesh(
1593            new THREE.PlaneGeometry(signW - 0.02, signH - 0.02),
1594            new THREE.MeshBasicMaterial({ map: tex, side: THREE.DoubleSide })
1595        );
1596        textPlane.position.z = 0.025; // slightly in front of the box face
1597        surface.add(textPlane);
1598
1599        // Gold trim frame edges
1600        const trimMat = new THREE.MeshStandardMaterial({ color: 0xc9a96e, roughness: 0.3, metalness: 0.6 });
1601        const bw = 0.04; // border width
1602        // Top bar
1603        surface.add(new THREE.Mesh(new THREE.BoxGeometry(signW + bw * 2, bw, 0.05), trimMat)
1604            .translateY(signH / 2 + bw / 2));
1605        // Bottom bar
1606        surface.add(new THREE.Mesh(new THREE.BoxGeometry(signW + bw * 2, bw, 0.05), trimMat)
1607            .translateY(-signH / 2 - bw / 2));
1608        // Left bar
1609        surface.add(new THREE.Mesh(new THREE.BoxGeometry(bw, signH + bw * 2, 0.05), trimMat)
1610            .translateX(-signW / 2 - bw / 2));
1611        // Right bar
1612        surface.add(new THREE.Mesh(new THREE.BoxGeometry(bw, signH + bw * 2, 0.05), trimMat)
1613            .translateX(signW / 2 + bw / 2));
1614
1615        scene.add(signGroup);
1616        signGroup.updateMatrixWorld(true); // critical for raycasting before first render
1617
1618        _welcomeSignFace = signBox;
1619    }
1620
1621    function getWelcomeSignMesh() {
1622        return _welcomeSignFace;
1623    }
1624
1625    // Tilted lectern-style info sign on the opposite side of the skeleton
1626    let _bambiraptorSignFace = null;
1627
1628    function buildBambiraptorSign(scene) {
1629        const signX = 2.2, signZ = 0;
1630        const signW = 1.6, signH = 1.15;
1631        const postHeight = 0.85;
1632        const tiltAngle = -0.55;
1633
1634        // Outer group: position + face away from skeleton (+X), mirroring welcome sign
1635        const signGroup = new THREE.Group();
1636        signGroup.position.set(signX, 0, signZ);
1637        signGroup.rotation.y = Math.PI / 2;
1638
1639        // Base plate
1640        const base = new THREE.Mesh(
1641            new THREE.CylinderGeometry(0.22, 0.25, 0.04, 12),
1642            _darkMetalMat
1643        );
1644        base.position.y = 0.02;
1645        signGroup.add(base);
1646
1647        // Vertical post
1648        const post = new THREE.Mesh(
1649            new THREE.CylinderGeometry(0.03, 0.03, postHeight, 8),
1650            _darkMetalMat
1651        );
1652        post.position.y = postHeight / 2;
1653        signGroup.add(post);
1654
1655        // Canvas texture for the reading surface
1656        const canvas = document.createElement('canvas');
1657        canvas.width = 640;
1658        canvas.height = 480;
1659        const ctx = canvas.getContext('2d');
1660        ctx.fillStyle = '#1a1712';
1661        ctx.fillRect(0, 0, 640, 480);
1662        ctx.strokeStyle = '#c9a96e';
1663        ctx.lineWidth = 3;
1664        ctx.strokeRect(12, 12, 616, 456);
1665        ctx.fillStyle = '#c9a96e';
1666        ctx.font = 'italic 36px Georgia, serif';
1667        ctx.textAlign = 'center';
1668        ctx.fillText('Bambiraptor feinbergi', 320, 80);
1669        ctx.strokeStyle = 'rgba(201,169,110,0.4)';
1670        ctx.lineWidth = 1;
1671        ctx.beginPath();
1672        ctx.moveTo(120, 108);
1673        ctx.lineTo(520, 108);
1674        ctx.stroke();
1675        ctx.fillStyle = '#c0b3a1';
1676        ctx.font = '24px Georgia, serif';
1677        ctx.fillText('Skeleton CAD Model', 320, 165);
1678        ctx.fillStyle = '#a89b8c';
1679        ctx.font = 'italic 19px Georgia, serif';
1680        ctx.fillText('Introduction to Engineering Design', 320, 220);
1681        ctx.fillText('Senior Year of High School', 320, 250);
1682        ctx.fillStyle = '#7a7060';
1683        ctx.font = '19px Helvetica, sans-serif';
1684        ctx.fillText('Click to read more', 320, 440);
1685        const tex = new THREE.CanvasTexture(canvas);
1686
1687        // Sub-group for the tilted surface
1688        const surface = new THREE.Group();
1689        surface.position.y = postHeight;
1690        surface.rotation.x = tiltAngle;
1691        signGroup.add(surface);
1692
1693        // Sign body (raycasting target)
1694        const backMat = new THREE.MeshStandardMaterial({ color: 0x1a1712, roughness: 0.4, metalness: 0.3 });
1695        const signBox = new THREE.Mesh(
1696            new THREE.BoxGeometry(signW, signH, 0.04),
1697            backMat
1698        );
1699        surface.add(signBox);
1700
1701        // Text overlay
1702        const textPlane = new THREE.Mesh(
1703            new THREE.PlaneGeometry(signW - 0.02, signH - 0.02),
1704            new THREE.MeshBasicMaterial({ map: tex, side: THREE.DoubleSide })
1705        );
1706        textPlane.position.z = 0.025;
1707        surface.add(textPlane);
1708
1709        // Gold trim frame edges
1710        const trimMat = new THREE.MeshStandardMaterial({ color: 0xc9a96e, roughness: 0.3, metalness: 0.6 });
1711        const bw = 0.04;
1712        surface.add(new THREE.Mesh(new THREE.BoxGeometry(signW + bw * 2, bw, 0.05), trimMat)
1713            .translateY(signH / 2 + bw / 2));
1714        surface.add(new THREE.Mesh(new THREE.BoxGeometry(signW + bw * 2, bw, 0.05), trimMat)
1715            .translateY(-signH / 2 - bw / 2));
1716        surface.add(new THREE.Mesh(new THREE.BoxGeometry(bw, signH + bw * 2, 0.05), trimMat)
1717            .translateX(-signW / 2 - bw / 2));
1718        surface.add(new THREE.Mesh(new THREE.BoxGeometry(bw, signH + bw * 2, 0.05), trimMat)
1719            .translateX(signW / 2 + bw / 2));
1720
1721        scene.add(signGroup);
1722        signGroup.updateMatrixWorld(true);
1723
1724        _bambiraptorSignFace = signBox;
1725    }
1726
1727    function getBambiraptorSignMesh() {
1728        return _bambiraptorSignFace;
1729    }
1730
1731    return { build, getCollisionWalls, getCurrentRoom, getRooms, getAnimatedLights, getWelcomeSignMesh, getBambiraptorSignMesh, WALL_HEIGHT, HALL_WIDTH, HALL_DEPTH };
1732})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.