@seatlayer/core 0.28.4 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1972 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/view3d/index.ts
31
+ var view3d_exports = {};
32
+ __export(view3d_exports, {
33
+ buildSceneModel: () => buildSceneModel,
34
+ mountVenue3D: () => mountVenue3D
35
+ });
36
+ module.exports = __toCommonJS(view3d_exports);
37
+ var import_ogl7 = require("ogl");
38
+
39
+ // src/view3d/gl/context.ts
40
+ var import_ogl = require("ogl");
41
+
42
+ // src/view3d/palette.ts
43
+ var SEAT_STATES = ["available", "held", "sold", "selected", "dimmed"];
44
+ function seatStateIndex(state) {
45
+ const i = SEAT_STATES.indexOf(state);
46
+ return i < 0 ? 0 : i;
47
+ }
48
+ var SEAT_STATE_COLORS = {
49
+ available: [0.24, 0.82, 0.52],
50
+ held: [0.95, 0.66, 0.22],
51
+ sold: [0.34, 0.39, 0.45],
52
+ selected: [0.24, 0.74, 1],
53
+ dimmed: [0.28, 0.32, 0.37]
54
+ };
55
+ function seatStateColorLUT() {
56
+ const out = [];
57
+ for (const s of SEAT_STATES) out.push(...SEAT_STATE_COLORS[s]);
58
+ return out;
59
+ }
60
+ function seatStateColorByIndex(index) {
61
+ const state = SEAT_STATES[index] ?? "available";
62
+ return SEAT_STATE_COLORS[state];
63
+ }
64
+ var STRUCTURE = {
65
+ ground: [0.07, 0.085, 0.11],
66
+ tierTop: [0.24, 0.28, 0.34],
67
+ tierWall: [0.17, 0.2, 0.25],
68
+ stageTop: [0.42, 0.36, 0.26],
69
+ // warm, slightly emissive read
70
+ stageWall: [0.26, 0.22, 0.16],
71
+ decorTop: [0.22, 0.25, 0.29],
72
+ decorWall: [0.15, 0.17, 0.2],
73
+ gaTop: [0.24, 0.28, 0.33],
74
+ gaWall: [0.16, 0.19, 0.23]
75
+ };
76
+ var BACKGROUND = {
77
+ top: [0.05, 0.06, 0.08],
78
+ bottom: [0.1, 0.12, 0.15]
79
+ };
80
+ function hexToRgb(hex) {
81
+ if (!hex) return null;
82
+ let h = hex.trim();
83
+ if (h[0] === "#") h = h.slice(1);
84
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
85
+ if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
86
+ const n = parseInt(h, 16);
87
+ return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
88
+ }
89
+ function mix(a, b, t) {
90
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
91
+ }
92
+ function desaturate(c, amount) {
93
+ const l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
94
+ return mix(c, [l, l, l], amount);
95
+ }
96
+ function scaleRgb(c, k) {
97
+ return [Math.min(1, c[0] * k), Math.min(1, c[1] * k), Math.min(1, c[2] * k)];
98
+ }
99
+
100
+ // src/view3d/gl/context.ts
101
+ function computeDpr() {
102
+ const raw = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
103
+ const mem = navigator.deviceMemory;
104
+ const cap = typeof mem === "number" && mem <= 4 ? 1.5 : 2;
105
+ return Math.min(raw, cap);
106
+ }
107
+ var GLContext = class {
108
+ constructor(container, opts) {
109
+ this.container = container;
110
+ this.canvas = document.createElement("canvas");
111
+ this.canvas.style.display = "block";
112
+ this.canvas.style.width = "100%";
113
+ this.canvas.style.height = "100%";
114
+ this.canvas.style.touchAction = "none";
115
+ this.renderer = new import_ogl.Renderer({
116
+ canvas: this.canvas,
117
+ dpr: computeDpr(),
118
+ alpha: false,
119
+ antialias: false,
120
+ depth: true,
121
+ stencil: false,
122
+ powerPreference: "high-performance",
123
+ webgl: 2
124
+ });
125
+ this.gl = this.renderer.gl;
126
+ this.gl.clearColor(BACKGROUND.top[0], BACKGROUND.top[1], BACKGROUND.top[2], 1);
127
+ container.appendChild(this.canvas);
128
+ this.lostHandler = (e) => {
129
+ e.preventDefault();
130
+ opts.onContextLost();
131
+ };
132
+ this.restoredHandler = () => opts.onContextRestored();
133
+ this.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
134
+ this.canvas.addEventListener("webglcontextrestored", this.restoredHandler, false);
135
+ this.resize();
136
+ }
137
+ /** Match the drawing buffer to the container's CSS box. */
138
+ resize() {
139
+ const w = Math.max(1, this.container.clientWidth || this.canvas.clientWidth || 1);
140
+ const h = Math.max(1, this.container.clientHeight || this.canvas.clientHeight || 1);
141
+ this.renderer.setSize(w, h);
142
+ return { width: w, height: h };
143
+ }
144
+ get pixelHeight() {
145
+ return this.renderer.height * this.renderer.dpr;
146
+ }
147
+ get aspect() {
148
+ return this.renderer.width / Math.max(1, this.renderer.height);
149
+ }
150
+ dispose() {
151
+ this.canvas.removeEventListener("webglcontextlost", this.lostHandler, false);
152
+ this.canvas.removeEventListener("webglcontextrestored", this.restoredHandler, false);
153
+ const ext = this.gl.getExtension("WEBGL_lose_context");
154
+ if (ext) ext.loseContext();
155
+ if (this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
156
+ }
157
+ /**
158
+ * Test hook: force a full loss→restore cycle. `restoreContext()` must be called
159
+ * only AFTER the browser has dispatched `webglcontextlost` (calling it too soon
160
+ * makes the browser drop the restore request), so we sequence it off a one-shot
161
+ * listener rather than a fixed timeout.
162
+ */
163
+ simulateContextLossCycle() {
164
+ const ext = this.gl.getExtension("WEBGL_lose_context");
165
+ if (!ext) return;
166
+ ext.loseContext();
167
+ setTimeout(() => {
168
+ if (ext.restoreContext) ext.restoreContext();
169
+ }, 300);
170
+ }
171
+ };
172
+
173
+ // src/view3d/camera/orbit.ts
174
+ var import_ogl2 = require("ogl");
175
+ var DEG = Math.PI / 180;
176
+ var POLAR_MIN = 15 * DEG;
177
+ var POLAR_MAX = 80 * DEG;
178
+ var DAMP = 0.12;
179
+ var FOV = 35;
180
+ var FRAME_MARGIN = 1.25;
181
+ var OrbitCamera = class {
182
+ constructor(gl, canvas, requestRender, onGesture) {
183
+ this.fovY = FOV;
184
+ this.target = new import_ogl2.Vec3();
185
+ this.azimuth = -30 * DEG;
186
+ this.polar = 55 * DEG;
187
+ this.distance = 10;
188
+ this.azT = -30 * DEG;
189
+ this.polT = 55 * DEG;
190
+ this.distT = 10;
191
+ this.minDist = 1;
192
+ this.maxDist = 100;
193
+ this.gestureFired = false;
194
+ this.dragging = false;
195
+ this.lastX = 0;
196
+ this.lastY = 0;
197
+ this.activePointers = /* @__PURE__ */ new Map();
198
+ this.pinchDist = 0;
199
+ this.camera = new import_ogl2.Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
200
+ this.canvas = canvas;
201
+ this.requestRender = requestRender;
202
+ this.onGesture = onGesture;
203
+ this.onPointerDown = (e) => {
204
+ try {
205
+ this.canvas.setPointerCapture?.(e.pointerId);
206
+ } catch {
207
+ }
208
+ this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
209
+ if (this.activePointers.size === 1) {
210
+ this.dragging = true;
211
+ this.lastX = e.clientX;
212
+ this.lastY = e.clientY;
213
+ } else if (this.activePointers.size === 2) {
214
+ this.dragging = false;
215
+ this.pinchDist = this.currentPinchDistance();
216
+ }
217
+ };
218
+ this.onPointerMove = (e) => {
219
+ if (!this.activePointers.has(e.pointerId)) return;
220
+ this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
221
+ if (this.activePointers.size >= 2) {
222
+ const d = this.currentPinchDistance();
223
+ if (this.pinchDist > 0) {
224
+ this.dollyBy(Math.exp((this.pinchDist - d) * 5e-3));
225
+ this.fireGesture();
226
+ }
227
+ this.pinchDist = d;
228
+ return;
229
+ }
230
+ if (!this.dragging) return;
231
+ const dx = e.clientX - this.lastX;
232
+ const dy = e.clientY - this.lastY;
233
+ this.lastX = e.clientX;
234
+ this.lastY = e.clientY;
235
+ if (dx !== 0 || dy !== 0) this.fireGesture();
236
+ this.azT -= dx * 6e-3;
237
+ this.polT = Math.max(POLAR_MIN, Math.min(POLAR_MAX, this.polT - dy * 6e-3));
238
+ this.requestRender();
239
+ };
240
+ this.onPointerUp = (e) => {
241
+ this.activePointers.delete(e.pointerId);
242
+ try {
243
+ this.canvas.releasePointerCapture?.(e.pointerId);
244
+ } catch {
245
+ }
246
+ if (this.activePointers.size < 2) this.pinchDist = 0;
247
+ if (this.activePointers.size === 0) this.dragging = false;
248
+ };
249
+ this.onWheel = (e) => {
250
+ e.preventDefault();
251
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
252
+ const norm2 = e.deltaY * unit / 100;
253
+ this.dollyBy(Math.exp(norm2 * 0.4));
254
+ this.fireGesture();
255
+ };
256
+ canvas.addEventListener("pointerdown", this.onPointerDown);
257
+ canvas.addEventListener("pointermove", this.onPointerMove);
258
+ canvas.addEventListener("pointerup", this.onPointerUp);
259
+ canvas.addEventListener("pointercancel", this.onPointerUp);
260
+ canvas.addEventListener("wheel", this.onWheel, { passive: false });
261
+ }
262
+ /** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
263
+ fireGesture() {
264
+ if (this.gestureFired) return;
265
+ this.gestureFired = true;
266
+ this.onGesture?.();
267
+ }
268
+ currentPinchDistance() {
269
+ const pts = [...this.activePointers.values()];
270
+ if (pts.length < 2) return 0;
271
+ return Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
272
+ }
273
+ /** Multiply target distance by `factor` (proportional zoom — same feel near
274
+ * and far), clamped so you can swoop right down among the seats. */
275
+ dollyBy(factor) {
276
+ this.distT = Math.max(this.minDist, Math.min(this.maxDist, this.distT * factor));
277
+ this.requestRender();
278
+ }
279
+ /**
280
+ * Fit a flattering 3/4 view to the bounds sphere. With `intro`, the camera
281
+ * STARTS nearly top-down (matching the 2D map's orientation) and further out,
282
+ * then the damped `update()` eases it up into the 3/4 architectural angle and
283
+ * dollies in — the venue "stands up" instead of teleporting (~600ms).
284
+ */
285
+ frame(bounds, intro = false) {
286
+ this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
287
+ const r = Math.max(1, bounds.radius);
288
+ const halfV = this.fovY * DEG / 2;
289
+ const aspect = this.camera.aspect || 1;
290
+ const halfH = Math.atan(Math.tan(halfV) * aspect);
291
+ const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
292
+ this.azT = -30 * DEG;
293
+ this.polT = 55 * DEG;
294
+ this.distT = fit * FRAME_MARGIN;
295
+ this.minDist = Math.max(2, r * 0.12);
296
+ this.maxDist = fit * 4;
297
+ if (intro) {
298
+ this.azimuth = this.azT;
299
+ this.polar = 12 * DEG;
300
+ this.distance = this.distT * 1.7;
301
+ } else {
302
+ this.azimuth = this.azT;
303
+ this.polar = this.polT;
304
+ this.distance = this.distT;
305
+ }
306
+ this.applyPosition();
307
+ }
308
+ setAspect(aspect) {
309
+ this.camera.perspective({ aspect });
310
+ }
311
+ /** Damp toward targets; returns true while still moving. */
312
+ update() {
313
+ const da = this.azT - this.azimuth;
314
+ const dp = this.polT - this.polar;
315
+ const dd = this.distT - this.distance;
316
+ const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
317
+ this.azimuth += da * DAMP;
318
+ this.polar += dp * DAMP;
319
+ this.distance += dd * DAMP;
320
+ if (moving) this.applyPosition();
321
+ return moving;
322
+ }
323
+ /** Distance from camera to target (for LOD). */
324
+ get currentDistance() {
325
+ return this.distance;
326
+ }
327
+ /**
328
+ * Re-derive the orbit's spherical state from the camera's CURRENT pose (after a
329
+ * cinematic flight leaves it somewhere arbitrary), so a subsequent drag damps
330
+ * from where it actually is with no snap. Does not move the camera.
331
+ */
332
+ syncFromCamera() {
333
+ const dx = this.camera.position.x - this.target.x;
334
+ const dy = this.camera.position.y - this.target.y;
335
+ const dz = this.camera.position.z - this.target.z;
336
+ const dist = Math.hypot(dx, dy, dz) || 1;
337
+ const polar = Math.max(POLAR_MIN, Math.min(POLAR_MAX, Math.acos(Math.max(-1, Math.min(1, dy / dist)))));
338
+ this.distance = this.distT = dist;
339
+ this.polar = this.polT = polar;
340
+ this.azimuth = this.azT = Math.atan2(dx, dz);
341
+ }
342
+ /** Point the orbit pivot at a new world target without moving the camera. */
343
+ setTarget(target) {
344
+ this.target.set(target[0], target[1], target[2]);
345
+ }
346
+ /** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
347
+ * flight ends looking at `target` (the venue focal), so re-pivot there first —
348
+ * otherwise the first drag would `lookAt(bounds.center)` and pop the aim. */
349
+ resumeAfterFlight(target) {
350
+ this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
351
+ if (target) this.target.set(target[0], target[1], target[2]);
352
+ this.syncFromCamera();
353
+ }
354
+ applyPosition() {
355
+ const sp = Math.sin(this.polar);
356
+ const x = this.target.x + this.distance * sp * Math.sin(this.azimuth);
357
+ const y = this.target.y + this.distance * Math.cos(this.polar);
358
+ const z = this.target.z + this.distance * sp * Math.cos(this.azimuth);
359
+ this.camera.position.set(x, y, z);
360
+ this.camera.lookAt(this.target);
361
+ }
362
+ dispose() {
363
+ this.canvas.removeEventListener("pointerdown", this.onPointerDown);
364
+ this.canvas.removeEventListener("pointermove", this.onPointerMove);
365
+ this.canvas.removeEventListener("pointerup", this.onPointerUp);
366
+ this.canvas.removeEventListener("pointercancel", this.onPointerUp);
367
+ this.canvas.removeEventListener("wheel", this.onWheel);
368
+ this.activePointers.clear();
369
+ }
370
+ };
371
+
372
+ // src/view3d/loop.ts
373
+ var RenderLoop = class {
374
+ /** `frame(dt)` renders one frame and returns true if another is needed. */
375
+ constructor(frame) {
376
+ this.rafId = 0;
377
+ this.running = false;
378
+ this.lastTime = 0;
379
+ this.fpsEma = 0;
380
+ this.rendered = 0;
381
+ this.tick = (now2) => {
382
+ const dt = this.lastTime ? (now2 - this.lastTime) / 1e3 : 1 / 60;
383
+ this.lastTime = now2;
384
+ if (dt > 0) {
385
+ const instFps = 1 / dt;
386
+ this.fpsEma = this.fpsEma ? this.fpsEma * 0.9 + instFps * 0.1 : instFps;
387
+ }
388
+ this.rendered++;
389
+ const again = this.frame(dt);
390
+ if (again) {
391
+ this.rafId = requestAnimationFrame(this.tick);
392
+ } else {
393
+ this.running = false;
394
+ this.rafId = 0;
395
+ }
396
+ };
397
+ this.frame = frame;
398
+ }
399
+ requestRender() {
400
+ if (this.running) return;
401
+ this.running = true;
402
+ this.lastTime = 0;
403
+ this.rafId = requestAnimationFrame(this.tick);
404
+ }
405
+ stats() {
406
+ return { fps: this.running ? Math.round(this.fpsEma) : 0, rendered: this.rendered, idle: !this.running };
407
+ }
408
+ stop() {
409
+ if (this.rafId) cancelAnimationFrame(this.rafId);
410
+ this.rafId = 0;
411
+ this.running = false;
412
+ }
413
+ };
414
+
415
+ // src/view3d/lod.ts
416
+ function computeSeatLod(distance, radius) {
417
+ const near = radius * 1.4;
418
+ const far = radius * 3.2;
419
+ if (distance <= near) return { scale: 1, fade: 0 };
420
+ const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
421
+ return {
422
+ scale: 1 - t * 0.4,
423
+ fade: t * 0.55
424
+ };
425
+ }
426
+
427
+ // src/core/units.ts
428
+ var METRES_PER_CHART_UNIT = 0.55 / 24;
429
+ var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
430
+ var LIFT_PER_STEP_WORLD = 58;
431
+ var TIER_HEIGHT_M = LIFT_PER_STEP_WORLD * METRES_PER_CHART_UNIT;
432
+ var SECTION_ELEVATION_TIER_MAX = 3;
433
+ var SECTION_HEIGHT_MIN_M = 0;
434
+ var SECTION_HEIGHT_MAX_M = 120;
435
+ var SECTION_RAKE_MIN_DEG = 0;
436
+ var SECTION_RAKE_MAX_DEG = 45;
437
+ var LEGACY_SECTION_ELEVATION_TIER_MAX = 7;
438
+ var SEATED_EYE_HEIGHT_M = 1.2;
439
+ function finiteClamped(value, min, max, fallback) {
440
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
441
+ }
442
+ function compatibleAutomaticTier(value) {
443
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return 0;
444
+ if (value <= LEGACY_SECTION_ELEVATION_TIER_MAX) return value;
445
+ return SECTION_ELEVATION_TIER_MAX;
446
+ }
447
+ function sectionGeometry(section, context = {}) {
448
+ const floorBaseHeightM = finiteClamped(
449
+ context.floorBaseHeightM,
450
+ SECTION_HEIGHT_MIN_M,
451
+ SECTION_HEIGHT_MAX_M,
452
+ 0
453
+ );
454
+ const automaticHeight = Math.min(
455
+ SECTION_HEIGHT_MAX_M,
456
+ floorBaseHeightM + compatibleAutomaticTier(section.elevation) * TIER_HEIGHT_M
457
+ );
458
+ const height = section.height === void 0 ? automaticHeight : finiteClamped(section.height, SECTION_HEIGHT_MIN_M, SECTION_HEIGHT_MAX_M, automaticHeight);
459
+ const rake = finiteClamped(section.rake, SECTION_RAKE_MIN_DEG, SECTION_RAKE_MAX_DEG, 0);
460
+ return { height, rake };
461
+ }
462
+
463
+ // src/view3d/scene/geometry.ts
464
+ var import_earcut = __toESM(require("earcut"), 1);
465
+ var M = METRES_PER_CHART_UNIT;
466
+ var MeshBuilder = class {
467
+ constructor() {
468
+ this.pos = [];
469
+ this.nor = [];
470
+ this.col = [];
471
+ }
472
+ /** One triangle with a shared (flat) normal and per-vertex colours. */
473
+ tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
474
+ this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
475
+ this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
476
+ this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
477
+ }
478
+ get vertexCount() {
479
+ return this.pos.length / 3;
480
+ }
481
+ build() {
482
+ return {
483
+ position: new Float32Array(this.pos),
484
+ normal: new Float32Array(this.nor),
485
+ color: new Float32Array(this.col),
486
+ count: this.pos.length / 3
487
+ };
488
+ }
489
+ };
490
+ function faceNormal(a, b, c) {
491
+ const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
492
+ const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
493
+ let nx = uy * vz - uz * vy;
494
+ let ny = uz * vx - ux * vz;
495
+ let nz = ux * vy - uy * vx;
496
+ const len = Math.hypot(nx, ny, nz) || 1;
497
+ nx /= len;
498
+ ny /= len;
499
+ nz /= len;
500
+ return [nx, ny, nz];
501
+ }
502
+ function triangulate(outline, holes) {
503
+ const pts = [...outline];
504
+ const flat = [];
505
+ for (const p of outline) flat.push(p.x, p.y);
506
+ const holeIndices = [];
507
+ if (holes) {
508
+ for (const hole of holes) {
509
+ if (hole.length < 3) continue;
510
+ holeIndices.push(pts.length);
511
+ for (const p of hole) {
512
+ pts.push(p);
513
+ flat.push(p.x, p.y);
514
+ }
515
+ }
516
+ }
517
+ const tris = (0, import_earcut.default)(flat, holeIndices.length ? holeIndices : void 0, 2);
518
+ return { pts, tris };
519
+ }
520
+ function centroid(pts) {
521
+ let x = 0, y = 0;
522
+ for (const p of pts) {
523
+ x += p.x;
524
+ y += p.y;
525
+ }
526
+ const n = pts.length || 1;
527
+ return { x: x / n, y: y / n };
528
+ }
529
+ function signedArea(pts) {
530
+ let a = 0;
531
+ for (let i = 0, n = pts.length; i < n; i++) {
532
+ const p = pts[i], q = pts[(i + 1) % n];
533
+ a += p.x * q.y - q.x * p.y;
534
+ }
535
+ return a / 2;
536
+ }
537
+ function toCCW(pts) {
538
+ return signedArea(pts) < 0 ? [...pts].reverse() : pts;
539
+ }
540
+ function extrudePrism(builder, outlineIn, holesIn, topY, bottomY, colTop, colWall, ao) {
541
+ if (!outlineIn || outlineIn.length < 3) return;
542
+ if (Math.abs(signedArea(outlineIn)) < 1e-4) return;
543
+ const outline = toCCW(outlineIn);
544
+ const holes = holesIn?.map((h) => toCCW(h)).filter((h) => h.length >= 3 && Math.abs(signedArea(h)) >= 1e-4);
545
+ const { pts, tris } = triangulate(outline, holes);
546
+ const cTop = [colTop[0] * ao.top, colTop[1] * ao.top, colTop[2] * ao.top];
547
+ const cBot = [colTop[0] * ao.bottomCap, colTop[1] * ao.bottomCap, colTop[2] * ao.bottomCap];
548
+ const cWallTop = [colWall[0] * ao.top, colWall[1] * ao.top, colWall[2] * ao.top];
549
+ const cWallBot = [colWall[0] * ao.wallBottom, colWall[1] * ao.wallBottom, colWall[2] * ao.wallBottom];
550
+ for (let i = 0; i < tris.length; i += 3) {
551
+ const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
552
+ const at = [a.x * M, topY(a), a.y * M];
553
+ const bt = [b.x * M, topY(b), b.y * M];
554
+ const ct = [c.x * M, topY(c), c.y * M];
555
+ let n = faceNormal(at, bt, ct);
556
+ if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
557
+ builder.tri(at, bt, ct, n, cTop);
558
+ const ab = [a.x * M, bottomY, a.y * M];
559
+ const bb = [b.x * M, bottomY, b.y * M];
560
+ const cb = [c.x * M, bottomY, c.y * M];
561
+ builder.tri(ab, cb, bb, [0, -1, 0], cBot);
562
+ }
563
+ const oc = centroid(outline);
564
+ const rings = [{ ring: outline, flip: false }];
565
+ if (holes) {
566
+ for (const h of holes) if (h.length >= 3) rings.push({ ring: h, flip: true });
567
+ }
568
+ for (const { ring, flip } of rings) {
569
+ for (let i = 0; i < ring.length; i++) {
570
+ const a = ring[i];
571
+ const b = ring[(i + 1) % ring.length];
572
+ const dx = (b.x - a.x) * M;
573
+ const dz = (b.y - a.y) * M;
574
+ let nx = dz, nz = -dx;
575
+ const nl = Math.hypot(nx, nz) || 1;
576
+ nx /= nl;
577
+ nz /= nl;
578
+ const mx = (a.x + b.x) / 2 - oc.x;
579
+ const mz = (a.y + b.y) / 2 - oc.y;
580
+ let dot = nx * mx + nz * mz;
581
+ if (flip) dot = -dot;
582
+ if (dot < 0) {
583
+ nx = -nx;
584
+ nz = -nz;
585
+ }
586
+ const n = [nx, 0, nz];
587
+ const aTop = [a.x * M, topY(a), a.y * M];
588
+ const bTop = [b.x * M, topY(b), b.y * M];
589
+ const aBot = [a.x * M, bottomY, a.y * M];
590
+ const bBot = [b.x * M, bottomY, b.y * M];
591
+ builder.tri(aTop, bTop, bBot, n, cWallTop, cWallTop, cWallBot);
592
+ builder.tri(aTop, bBot, aBot, n, cWallTop, cWallBot, cWallBot);
593
+ }
594
+ }
595
+ }
596
+ function mergeMeshData(parts) {
597
+ let total = 0;
598
+ for (const p of parts) total += p.count;
599
+ const position = new Float32Array(total * 3);
600
+ const normal = new Float32Array(total * 3);
601
+ const color = new Float32Array(total * 3);
602
+ let off = 0;
603
+ for (const p of parts) {
604
+ position.set(p.position, off * 3);
605
+ normal.set(p.normal, off * 3);
606
+ color.set(p.color, off * 3);
607
+ off += p.count;
608
+ }
609
+ return { position, normal, color, count: total };
610
+ }
611
+ function ellipsePolygon(cx, cy, rx, ry, seg = 28) {
612
+ const out = [];
613
+ for (let i = 0; i < seg; i++) {
614
+ const a = i / seg * Math.PI * 2;
615
+ out.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) });
616
+ }
617
+ return out;
618
+ }
619
+ function rectPolygon(x, y, w, h) {
620
+ return [
621
+ { x, y },
622
+ { x: x + w, y },
623
+ { x: x + w, y: y + h },
624
+ { x, y: y + h }
625
+ ];
626
+ }
627
+
628
+ // src/view3d/scene/seatInstances.ts
629
+ var SEAT_SURFACE_LIFT_M = 0.18;
630
+ function seatSurfaceY(seat) {
631
+ const eye = seat.eyeHeightM;
632
+ if (Number.isFinite(eye)) {
633
+ return Math.max(0, eye - SEATED_EYE_HEIGHT_M) + SEAT_SURFACE_LIFT_M;
634
+ }
635
+ return SEAT_SURFACE_LIFT_M;
636
+ }
637
+ function buildSeatInstances(seats, initial) {
638
+ const count = seats.length;
639
+ const iPosition = new Float32Array(count * 3);
640
+ const iState = new Float32Array(count);
641
+ const idToIndex = /* @__PURE__ */ new Map();
642
+ for (let i = 0; i < count; i++) {
643
+ const seat = seats[i];
644
+ iPosition[i * 3] = seat.x * M;
645
+ iPosition[i * 3 + 1] = seatSurfaceY(seat);
646
+ iPosition[i * 3 + 2] = seat.y * M;
647
+ iState[i] = seatStateIndex(initial ? initial(seat) : "available");
648
+ idToIndex.set(seat.id, i);
649
+ }
650
+ return { count, iPosition, iState, idToIndex };
651
+ }
652
+ function applySeatStates(data, updates) {
653
+ const changed = [];
654
+ for (const u of updates) {
655
+ const idx = data.idToIndex.get(u.seatId);
656
+ if (idx === void 0) continue;
657
+ const v = seatStateIndex(u.state);
658
+ if (data.iState[idx] !== v) {
659
+ data.iState[idx] = v;
660
+ changed.push(idx);
661
+ }
662
+ }
663
+ if (!changed.length) return [];
664
+ changed.sort((a, b) => a - b);
665
+ const runs = [];
666
+ let start = changed[0];
667
+ let prev = changed[0];
668
+ for (let i = 1; i < changed.length; i++) {
669
+ const idx = changed[i];
670
+ if (idx === prev) continue;
671
+ if (idx === prev + 1) {
672
+ prev = idx;
673
+ continue;
674
+ }
675
+ runs.push({ start, length: prev - start + 1 });
676
+ start = idx;
677
+ prev = idx;
678
+ }
679
+ runs.push({ start, length: prev - start + 1 });
680
+ return runs;
681
+ }
682
+
683
+ // src/view3d/scene/sceneModel.ts
684
+ function floorUnits(doc) {
685
+ if (doc.floors?.length) {
686
+ return doc.floors.map((f) => ({
687
+ objects: f.objects,
688
+ focal: f.focalPoint ?? doc.focalPoint,
689
+ baseHeightM: f.baseHeightM ?? 0
690
+ }));
691
+ }
692
+ return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
693
+ }
694
+ var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
695
+ var DECK_DROP_M = 0.28;
696
+ var MAX_TIER_RISE_M = 25;
697
+ function tintTop(fill, neutral) {
698
+ if (!fill) return neutral;
699
+ const muted = scaleRgb(desaturate(fill, 0.4), 0.62);
700
+ return mix(neutral, muted, 0.72);
701
+ }
702
+ function resolveSectionFills(doc, seats) {
703
+ const catColor = /* @__PURE__ */ new Map();
704
+ for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
705
+ const counts = /* @__PURE__ */ new Map();
706
+ for (const s of seats) {
707
+ if (!s.sectionId) continue;
708
+ let m = counts.get(s.sectionId);
709
+ if (!m) {
710
+ m = /* @__PURE__ */ new Map();
711
+ counts.set(s.sectionId, m);
712
+ }
713
+ m.set(s.categoryKey, (m.get(s.categoryKey) ?? 0) + 1);
714
+ }
715
+ const out = /* @__PURE__ */ new Map();
716
+ for (const [sid, byCat] of counts) {
717
+ let r = 0, g = 0, b = 0, w = 0;
718
+ for (const [key, n] of byCat) {
719
+ const rgb = hexToRgb(catColor.get(key));
720
+ if (!rgb) continue;
721
+ r += rgb[0] * n;
722
+ g += rgb[1] * n;
723
+ b += rgb[2] * n;
724
+ w += n;
725
+ }
726
+ if (w > 0) out.set(sid, [r / w, g / w, b / w]);
727
+ }
728
+ return out;
729
+ }
730
+ function sectionFill(section, byLogical) {
731
+ return hexToRgb(section.color) ?? byLogical.get(section.logicalSectionId ?? section.id) ?? null;
732
+ }
733
+ function buildTier(builder, section, unit, fill) {
734
+ if (!section.outline || section.outline.length < 3) return;
735
+ const geo = sectionGeometry(section, { floorBaseHeightM: unit.baseHeightM });
736
+ const bottomY = unit.baseHeightM;
737
+ const rakeRad = geo.rake * Math.PI / 180;
738
+ const flat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
739
+ const colTop = tintTop(fill, STRUCTURE.tierTop);
740
+ if (flat) {
741
+ const topY2 = bottomY + 0.05;
742
+ extrudePrism(builder, section.outline, section.holes, () => topY2, bottomY, colTop, STRUCTURE.tierWall, AO);
743
+ return;
744
+ }
745
+ let frontDist = Infinity;
746
+ for (const p of section.outline) {
747
+ const d = Math.hypot(p.x - unit.focal.x, p.y - unit.focal.y);
748
+ if (d < frontDist) frontDist = d;
749
+ }
750
+ const tan = Math.tan(rakeRad);
751
+ const topY = (p) => {
752
+ const d = Math.hypot(p.x - unit.focal.x, p.y - unit.focal.y);
753
+ const depthM = Math.max(0, d - frontDist) * M;
754
+ const rise = Math.min(depthM * tan, MAX_TIER_RISE_M);
755
+ return Math.max(bottomY + 0.05, geo.height + rise - DECK_DROP_M);
756
+ };
757
+ extrudePrism(builder, section.outline, section.holes, topY, bottomY, colTop, STRUCTURE.tierWall, AO);
758
+ }
759
+ function shapePolygon(shape) {
760
+ if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
761
+ if (shape.kind === "rect" && shape.width && shape.height) {
762
+ return rectPolygon(shape.x ?? 0, shape.y ?? 0, shape.width, shape.height);
763
+ }
764
+ if (shape.kind === "ellipse" && shape.width && shape.height) {
765
+ const cx = (shape.x ?? 0) + shape.width / 2;
766
+ const cy = (shape.y ?? 0) + shape.height / 2;
767
+ return ellipsePolygon(cx, cy, shape.width / 2, shape.height / 2);
768
+ }
769
+ return null;
770
+ }
771
+ function buildShape(builder, shape, base) {
772
+ const poly = shapePolygon(shape);
773
+ if (!poly) return;
774
+ const isStage = shape.role === "stage";
775
+ const height = isStage ? base + 1 : base + 0.25;
776
+ const colTop = isStage ? STRUCTURE.stageTop : STRUCTURE.decorTop;
777
+ const colWall = isStage ? STRUCTURE.stageWall : STRUCTURE.decorWall;
778
+ extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
779
+ }
780
+ function buildGa(builder, ga, base, fill) {
781
+ if (!ga.points || ga.points.length < 3) return;
782
+ const colTop = tintTop(fill, STRUCTURE.gaTop);
783
+ extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, STRUCTURE.gaWall, AO);
784
+ }
785
+ function chartFootprint(units, seats) {
786
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
787
+ const acc = (x, y) => {
788
+ if (x < minX) minX = x;
789
+ if (y < minY) minY = y;
790
+ if (x > maxX) maxX = x;
791
+ if (y > maxY) maxY = y;
792
+ };
793
+ for (const s of seats) acc(s.x, s.y);
794
+ for (const u of units) {
795
+ for (const o of u.objects) {
796
+ if (o.type === "section") for (const p of o.outline) acc(p.x, p.y);
797
+ else if (o.type === "shape" && o.points) for (const p of o.points) acc(p.x, p.y);
798
+ else if (o.type === "gaArea") for (const p of o.points) acc(p.x, p.y);
799
+ }
800
+ }
801
+ if (!Number.isFinite(minX)) {
802
+ minX = -100;
803
+ minY = -100;
804
+ maxX = 100;
805
+ maxY = 100;
806
+ }
807
+ return { minX, minY, maxX, maxY };
808
+ }
809
+ function buildSceneModel(input) {
810
+ const { doc, seats } = input;
811
+ const units = floorUnits(doc);
812
+ const builder = new MeshBuilder();
813
+ const fp = chartFootprint(units, seats);
814
+ const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
815
+ const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
816
+ extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, STRUCTURE.ground, STRUCTURE.ground, AO);
817
+ const sectionFills = resolveSectionFills(doc, seats);
818
+ const catColor = /* @__PURE__ */ new Map();
819
+ for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
820
+ for (const unit of units) {
821
+ for (const o of unit.objects) {
822
+ if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills));
823
+ else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM);
824
+ else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)));
825
+ }
826
+ }
827
+ const solids = mergeMeshData([builder.build()]);
828
+ const seatData = buildSeatInstances(seats, input.initialState);
829
+ const cx = (fp.minX + fp.maxX) / 2 * M;
830
+ const cz = (fp.minY + fp.maxY) / 2 * M;
831
+ const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
832
+ const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
833
+ return {
834
+ solids,
835
+ seats: seatData,
836
+ bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
837
+ stateColorLUT: seatStateColorLUT(),
838
+ seatCount: seats.length,
839
+ // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
840
+ focalWorld: [focal.x * M, 1.5, focal.y * M]
841
+ };
842
+ }
843
+
844
+ // src/view3d/scene/build.ts
845
+ var import_ogl4 = require("ogl");
846
+
847
+ // src/view3d/scene/materials.ts
848
+ var import_ogl3 = require("ogl");
849
+ var SOLID_VERT = (
850
+ /* glsl */
851
+ `#version 300 es
852
+ precision highp float;
853
+ in vec3 position;
854
+ in vec3 normal;
855
+ in vec3 color;
856
+ uniform mat4 modelViewMatrix;
857
+ uniform mat4 projectionMatrix;
858
+ uniform mat3 normalMatrix;
859
+ out vec3 vColor;
860
+ out vec3 vNormalView;
861
+ out vec3 vPosView;
862
+ void main() {
863
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
864
+ vPosView = mv.xyz;
865
+ vNormalView = normalize(normalMatrix * normal);
866
+ vColor = color;
867
+ gl_Position = projectionMatrix * mv;
868
+ }`
869
+ );
870
+ var SOLID_FRAG = (
871
+ /* glsl */
872
+ `#version 300 es
873
+ precision highp float;
874
+ in vec3 vColor;
875
+ in vec3 vNormalView;
876
+ in vec3 vPosView;
877
+ out vec4 fragColor;
878
+ void main() {
879
+ vec3 N = normalize(vNormalView);
880
+ vec3 V = normalize(-vPosView);
881
+ float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient
882
+ vec3 L = normalize(vec3(0.4, 0.85, 0.55)); // warm key, view space
883
+ float key = max(dot(N, L), 0.0);
884
+ vec3 base = vColor * (0.60 + 0.32 * hemi) + vColor * key * 0.32;
885
+ float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
886
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained
887
+ fragColor = vec4(base, 1.0);
888
+ }`
889
+ );
890
+ var SEAT_VERT = (
891
+ /* glsl */
892
+ `#version 300 es
893
+ precision highp float;
894
+ in vec2 position; // quad corner in [-1,1]
895
+ in vec3 iOffset; // per-instance world position
896
+ in vec3 iColor; // per-instance state colour (resolved CPU-side)
897
+ uniform mat4 modelViewMatrix;
898
+ uniform mat4 projectionMatrix;
899
+ uniform float uSeatRadius;
900
+ uniform float uSeatScale;
901
+ uniform float uMinPixels;
902
+ uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
903
+ out vec2 vUv;
904
+ out vec3 vColor;
905
+ void main() {
906
+ vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
907
+ float depth = max(-mv.z, 0.001);
908
+ float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
909
+ float r = max(uSeatRadius * uSeatScale, minR);
910
+ mv.xy += position * r; // camera-facing billboard
911
+ vUv = position;
912
+ vColor = iColor;
913
+ gl_Position = projectionMatrix * mv;
914
+ }`
915
+ );
916
+ var SEAT_FRAG = (
917
+ /* glsl */
918
+ `#version 300 es
919
+ precision highp float;
920
+ in vec2 vUv;
921
+ in vec3 vColor;
922
+ uniform float uSeatFade; // fade toward tier colour with distance (LOD)
923
+ uniform vec3 uFadeColor;
924
+ out vec4 fragColor;
925
+ void main() {
926
+ float d = length(vUv);
927
+ if (d > 1.0) discard;
928
+ float alpha = smoothstep(1.0, 0.72, d);
929
+ float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
930
+ vec3 c = vColor * shade;
931
+ c = mix(c, uFadeColor, uSeatFade);
932
+ fragColor = vec4(c, alpha);
933
+ }`
934
+ );
935
+ var BG_VERT = (
936
+ /* glsl */
937
+ `#version 300 es
938
+ precision highp float;
939
+ in vec2 position;
940
+ out vec2 vUv;
941
+ void main() {
942
+ vUv = position * 0.5 + 0.5;
943
+ gl_Position = vec4(position, 0.999, 1.0);
944
+ }`
945
+ );
946
+ var BG_FRAG = (
947
+ /* glsl */
948
+ `#version 300 es
949
+ precision highp float;
950
+ in vec2 vUv;
951
+ uniform vec3 uTop;
952
+ uniform vec3 uBottom;
953
+ out vec4 fragColor;
954
+ void main() {
955
+ vec3 col = mix(uBottom, uTop, vUv.y);
956
+ vec2 c = vUv - 0.5;
957
+ float vig = 1.0 - dot(c, c) * 0.85; // soft vignette
958
+ fragColor = vec4(col * vig, 1.0);
959
+ }`
960
+ );
961
+ var SEAT_PICK_VERT = (
962
+ /* glsl */
963
+ `#version 300 es
964
+ precision highp float;
965
+ in vec2 position;
966
+ in vec3 iOffset;
967
+ uniform mat4 modelViewMatrix;
968
+ uniform mat4 projectionMatrix;
969
+ uniform float uSeatRadius;
970
+ uniform float uSeatScale;
971
+ uniform float uMinPixels;
972
+ uniform float uPixelToWorld;
973
+ out vec2 vUv;
974
+ flat out vec3 vPick;
975
+ void main() {
976
+ int id = gl_InstanceID + 1; // 0 reserved for no-hit
977
+ vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
978
+ vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
979
+ float depth = max(-mv.z, 0.001);
980
+ float minR = uMinPixels * depth * uPixelToWorld;
981
+ float r = max(uSeatRadius * uSeatScale, minR);
982
+ mv.xy += position * r;
983
+ vUv = position;
984
+ gl_Position = projectionMatrix * mv;
985
+ }`
986
+ );
987
+ var SEAT_PICK_FRAG = (
988
+ /* glsl */
989
+ `#version 300 es
990
+ precision highp float;
991
+ in vec2 vUv;
992
+ flat in vec3 vPick;
993
+ out vec4 fragColor;
994
+ void main() {
995
+ if (length(vUv) > 1.0) discard; // round hit-mask matches the dot
996
+ fragColor = vec4(vPick, 1.0);
997
+ }`
998
+ );
999
+ var PICK_DEPTH_VERT = (
1000
+ /* glsl */
1001
+ `#version 300 es
1002
+ precision highp float;
1003
+ in vec3 position;
1004
+ uniform mat4 modelViewMatrix;
1005
+ uniform mat4 projectionMatrix;
1006
+ void main() {
1007
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1008
+ }`
1009
+ );
1010
+ var PICK_DEPTH_FRAG = (
1011
+ /* glsl */
1012
+ `#version 300 es
1013
+ precision highp float;
1014
+ out vec4 fragColor;
1015
+ void main() { fragColor = vec4(0.0, 0.0, 0.0, 1.0); }`
1016
+ );
1017
+ function createSeatPickProgram(gl) {
1018
+ return new import_ogl3.Program(gl, {
1019
+ vertex: SEAT_PICK_VERT,
1020
+ fragment: SEAT_PICK_FRAG,
1021
+ transparent: false,
1022
+ depthTest: true,
1023
+ depthWrite: true,
1024
+ cullFace: false,
1025
+ uniforms: {
1026
+ uSeatRadius: { value: 0.22 },
1027
+ uSeatScale: { value: 1 },
1028
+ uMinPixels: { value: 2.5 },
1029
+ uPixelToWorld: { value: 2e-3 }
1030
+ }
1031
+ });
1032
+ }
1033
+ function createPickDepthProgram(gl) {
1034
+ return new import_ogl3.Program(gl, {
1035
+ vertex: PICK_DEPTH_VERT,
1036
+ fragment: PICK_DEPTH_FRAG,
1037
+ transparent: false,
1038
+ depthTest: true,
1039
+ depthWrite: true,
1040
+ cullFace: false
1041
+ });
1042
+ }
1043
+ function createSolidProgram(gl) {
1044
+ return new import_ogl3.Program(gl, {
1045
+ // No backface culling: free-hand section polygons are stored in raw click
1046
+ // order (either winding), so a culled solid would render see-through. The
1047
+ // shader lights both faces and closed opaque prisms + depth test keep
1048
+ // overdraw negligible; extrudePrism also normalises winding as a belt.
1049
+ vertex: SOLID_VERT,
1050
+ fragment: SOLID_FRAG,
1051
+ cullFace: false,
1052
+ depthTest: true,
1053
+ depthWrite: true
1054
+ });
1055
+ }
1056
+ function createSeatProgram(gl) {
1057
+ return new import_ogl3.Program(gl, {
1058
+ vertex: SEAT_VERT,
1059
+ fragment: SEAT_FRAG,
1060
+ transparent: true,
1061
+ depthTest: true,
1062
+ depthWrite: false,
1063
+ cullFace: false,
1064
+ uniforms: {
1065
+ uSeatRadius: { value: 0.22 },
1066
+ uSeatScale: { value: 1 },
1067
+ uMinPixels: { value: 2.5 },
1068
+ uPixelToWorld: { value: 2e-3 },
1069
+ uSeatFade: { value: 0 },
1070
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
1071
+ }
1072
+ });
1073
+ }
1074
+ function createBackgroundProgram(gl, top, bottom) {
1075
+ return new import_ogl3.Program(gl, {
1076
+ vertex: BG_VERT,
1077
+ fragment: BG_FRAG,
1078
+ depthTest: false,
1079
+ depthWrite: false,
1080
+ cullFace: false,
1081
+ uniforms: {
1082
+ uTop: { value: new Float32Array(top) },
1083
+ uBottom: { value: new Float32Array(bottom) }
1084
+ }
1085
+ });
1086
+ }
1087
+
1088
+ // src/view3d/scene/build.ts
1089
+ function writeSeatColors(iColor, iState, start, count) {
1090
+ for (let i = start; i < start + count; i++) {
1091
+ const c = seatStateColorByIndex(iState[i]);
1092
+ iColor[i * 3] = c[0];
1093
+ iColor[i * 3 + 1] = c[1];
1094
+ iColor[i * 3 + 2] = c[2];
1095
+ }
1096
+ }
1097
+ var SEAT_QUAD = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
1098
+ var BG_TRI = new Float32Array([-1, -1, 3, -1, -1, 3]);
1099
+ function buildGpuScene(gl, model) {
1100
+ const main = new import_ogl4.Transform();
1101
+ const background = new import_ogl4.Transform();
1102
+ const bgGeo = new import_ogl4.Geometry(gl, { position: { size: 2, data: BG_TRI } });
1103
+ const bgProg = createBackgroundProgram(gl, BACKGROUND.top, BACKGROUND.bottom);
1104
+ const bgMesh = new import_ogl4.Mesh(gl, { geometry: bgGeo, program: bgProg });
1105
+ bgMesh.frustumCulled = false;
1106
+ bgMesh.setParent(background);
1107
+ const solidGeo = new import_ogl4.Geometry(gl, {
1108
+ position: { size: 3, data: model.solids.position },
1109
+ normal: { size: 3, data: model.solids.normal },
1110
+ color: { size: 3, data: model.solids.color }
1111
+ });
1112
+ const solidProg = createSolidProgram(gl);
1113
+ const solidMesh = new import_ogl4.Mesh(gl, { geometry: solidGeo, program: solidProg });
1114
+ solidMesh.frustumCulled = false;
1115
+ solidMesh.setParent(main);
1116
+ const seatProg = createSeatProgram(gl);
1117
+ const iColor = new Float32Array(model.seats.count * 3);
1118
+ writeSeatColors(iColor, model.seats.iState, 0, model.seats.count);
1119
+ const seatGeo = new import_ogl4.Geometry(gl, {
1120
+ position: { size: 2, data: SEAT_QUAD },
1121
+ iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
1122
+ iColor: { size: 3, data: iColor, instanced: 1 }
1123
+ });
1124
+ const seatMesh = new import_ogl4.Mesh(gl, { geometry: seatGeo, program: seatProg });
1125
+ seatMesh.frustumCulled = false;
1126
+ if (model.seats.count > 0) seatMesh.setParent(main);
1127
+ const colorAttr = seatGeo.attributes.iColor;
1128
+ return {
1129
+ main,
1130
+ background,
1131
+ seatProgram: seatProg,
1132
+ seatGeometry: seatGeo,
1133
+ solidGeometry: solidGeo,
1134
+ drawCalls: 3,
1135
+ uploadSeatStateRuns(runs) {
1136
+ if (!runs.length) return;
1137
+ for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length);
1138
+ const buffer = colorAttr.buffer;
1139
+ if (!buffer) {
1140
+ colorAttr.needsUpdate = true;
1141
+ return;
1142
+ }
1143
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
1144
+ for (const run of runs) {
1145
+ const sub2 = iColor.subarray(run.start * 3, (run.start + run.length) * 3);
1146
+ gl.bufferSubData(gl.ARRAY_BUFFER, run.start * 3 * Float32Array.BYTES_PER_ELEMENT, sub2);
1147
+ }
1148
+ },
1149
+ dispose() {
1150
+ bgGeo.remove();
1151
+ bgProg.remove();
1152
+ solidGeo.remove();
1153
+ solidProg.remove();
1154
+ seatGeo.remove();
1155
+ seatProg.remove();
1156
+ }
1157
+ };
1158
+ }
1159
+
1160
+ // src/view3d/pick/pickPipeline.ts
1161
+ var import_ogl5 = require("ogl");
1162
+
1163
+ // src/view3d/pick/encode.ts
1164
+ function decodePickRGB(r, g, b) {
1165
+ const id = r + (g << 8) + (b << 16);
1166
+ return id === 0 ? -1 : id - 1;
1167
+ }
1168
+ function pickNearestFromBuffer(pixels, boxW, boxH, centerI, centerJ, maxIndex) {
1169
+ let best = -1;
1170
+ let bestDist = Infinity;
1171
+ for (let j = 0; j < boxH; j++) {
1172
+ for (let i = 0; i < boxW; i++) {
1173
+ const o = (j * boxW + i) * 4;
1174
+ const idx = decodePickRGB(pixels[o], pixels[o + 1], pixels[o + 2]);
1175
+ if (idx < 0 || idx >= maxIndex) continue;
1176
+ const di = i - centerI;
1177
+ const dj = j - centerJ;
1178
+ const d = di * di + dj * dj;
1179
+ if (d < bestDist) {
1180
+ bestDist = d;
1181
+ best = idx;
1182
+ }
1183
+ }
1184
+ }
1185
+ return best;
1186
+ }
1187
+ function pickPixelCoords(clientX, clientY, rect, dpr, bufferWidth, bufferHeight) {
1188
+ const cssX = clientX - rect.left;
1189
+ const cssY = clientY - rect.top;
1190
+ const x = Math.round(cssX * dpr);
1191
+ const y = Math.round((rect.height - cssY) * dpr);
1192
+ return {
1193
+ x: Math.max(0, Math.min(bufferWidth - 1, x)),
1194
+ y: Math.max(0, Math.min(bufferHeight - 1, y))
1195
+ };
1196
+ }
1197
+
1198
+ // src/view3d/pick/pickPipeline.ts
1199
+ var SYNC_KEYS = ["uSeatRadius", "uSeatScale", "uMinPixels", "uPixelToWorld"];
1200
+ var PickPipeline = class {
1201
+ constructor(renderer, seatGeo, solidGeo, seatCount) {
1202
+ this.seatScene = new import_ogl5.Transform();
1203
+ this.solidScene = new import_ogl5.Transform();
1204
+ this.target = null;
1205
+ this.renderer = renderer;
1206
+ this.gl = renderer.gl;
1207
+ this.maxIndex = seatCount;
1208
+ this.seatProg = createSeatPickProgram(this.gl);
1209
+ this.depthProg = createPickDepthProgram(this.gl);
1210
+ const seatMesh = new import_ogl5.Mesh(this.gl, { geometry: seatGeo, program: this.seatProg });
1211
+ seatMesh.frustumCulled = false;
1212
+ seatMesh.setParent(this.seatScene);
1213
+ const solidMesh = new import_ogl5.Mesh(this.gl, { geometry: solidGeo, program: this.depthProg });
1214
+ solidMesh.frustumCulled = false;
1215
+ solidMesh.setParent(this.solidScene);
1216
+ }
1217
+ /** Match the display seat sizing so the pick mask lines up with the dots. */
1218
+ syncFromSeatProgram(seatProgram) {
1219
+ for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
1220
+ }
1221
+ ensureTarget() {
1222
+ const w = this.gl.drawingBufferWidth;
1223
+ const h = this.gl.drawingBufferHeight;
1224
+ if (this.target && (this.target.width !== w || this.target.height !== h)) {
1225
+ this.destroyTarget();
1226
+ }
1227
+ if (!this.target) {
1228
+ this.target = new import_ogl5.RenderTarget(this.gl, { width: w, height: h, depth: true });
1229
+ }
1230
+ return this.target;
1231
+ }
1232
+ destroyTarget() {
1233
+ if (!this.target) return;
1234
+ const gl = this.gl;
1235
+ if (this.target.buffer) gl.deleteFramebuffer(this.target.buffer);
1236
+ for (const t of this.target.textures ?? []) if (t.texture) gl.deleteTexture(t.texture);
1237
+ if (this.target.depthBuffer) gl.deleteRenderbuffer(this.target.depthBuffer);
1238
+ this.target = null;
1239
+ }
1240
+ /**
1241
+ * Read back the seat instance index NEAREST framebuffer pixel (px, py), or -1.
1242
+ * `radius` is the tap tolerance in buffer px: a box of side (2·radius+1) is
1243
+ * rendered + read so a tap that lands between the ~2px overview dots still
1244
+ * finds the closest seat. px/py/radius are bottom-left-origin buffer pixels.
1245
+ */
1246
+ pick(camera, px, py, radius) {
1247
+ const gl = this.gl;
1248
+ const target = this.ensureTarget();
1249
+ const bw = gl.drawingBufferWidth;
1250
+ const bh = gl.drawingBufferHeight;
1251
+ const x0 = Math.max(0, px - radius);
1252
+ const y0 = Math.max(0, py - radius);
1253
+ const boxW = Math.max(1, Math.min(bw, px + radius + 1) - x0);
1254
+ const boxH = Math.max(1, Math.min(bh, py + radius + 1) - y0);
1255
+ gl.enable(gl.SCISSOR_TEST);
1256
+ gl.scissor(x0, y0, boxW, boxH);
1257
+ const [br, bg, bb] = BACKGROUND.top;
1258
+ gl.clearColor(0, 0, 0, 1);
1259
+ this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
1260
+ this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
1261
+ gl.clearColor(br, bg, bb, 1);
1262
+ gl.disable(gl.SCISSOR_TEST);
1263
+ const buf = new Uint8Array(boxW * boxH * 4);
1264
+ this.renderer.bindFramebuffer(target);
1265
+ gl.readPixels(x0, y0, boxW, boxH, gl.RGBA, gl.UNSIGNED_BYTE, buf);
1266
+ this.renderer.bindFramebuffer();
1267
+ return pickNearestFromBuffer(buf, boxW, boxH, px - x0, py - y0, this.maxIndex);
1268
+ }
1269
+ dispose() {
1270
+ this.destroyTarget();
1271
+ this.seatProg.remove();
1272
+ this.depthProg.remove();
1273
+ }
1274
+ };
1275
+
1276
+ // src/view3d/pick/selection.ts
1277
+ function mergeAvailabilityIntoSelection(selection, updates) {
1278
+ const passthrough = [];
1279
+ for (const u of updates) {
1280
+ if (selection.has(u.seatId)) selection.set(u.seatId, seatStateIndex(u.state));
1281
+ else passthrough.push(u);
1282
+ }
1283
+ return passthrough;
1284
+ }
1285
+ function diffSelection(prev, desiredIds, baseStateIndex) {
1286
+ const desired = /* @__PURE__ */ new Set();
1287
+ for (const id of desiredIds) {
1288
+ if (prev.has(id) || baseStateIndex(id) !== void 0) desired.add(id);
1289
+ }
1290
+ const next = new Map(prev);
1291
+ const updates = [];
1292
+ for (const [id, base] of prev) {
1293
+ if (!desired.has(id)) {
1294
+ updates.push({ seatId: id, state: SEAT_STATES[base] ?? "available" });
1295
+ next.delete(id);
1296
+ }
1297
+ }
1298
+ for (const id of desired) {
1299
+ if (next.has(id)) continue;
1300
+ const base = baseStateIndex(id);
1301
+ if (base === void 0) continue;
1302
+ next.set(id, base);
1303
+ updates.push({ seatId: id, state: "selected" });
1304
+ }
1305
+ return { updates, next };
1306
+ }
1307
+
1308
+ // src/view3d/camera/cinematic.ts
1309
+ var import_ogl6 = require("ogl");
1310
+
1311
+ // src/view3d/camera/cinematicMath.ts
1312
+ var FLIGHT_DURATION_MS = 2500;
1313
+ var FOV_START = 35;
1314
+ var FOV_END = 28;
1315
+ var ORIENTATION_LEAD = 0.15;
1316
+ var BACK_M = 2.5;
1317
+ var ABOVE_EYE_M = 1.5;
1318
+ function sub(a, b) {
1319
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1320
+ }
1321
+ function add(a, b) {
1322
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
1323
+ }
1324
+ function scale(a, k) {
1325
+ return [a[0] * k, a[1] * k, a[2] * k];
1326
+ }
1327
+ function norm(a) {
1328
+ const l = Math.hypot(a[0], a[1], a[2]);
1329
+ return l > 1e-6 ? [a[0] / l, a[1] / l, a[2] / l] : [0, 0, 0];
1330
+ }
1331
+ function smootherstep(t) {
1332
+ const x = Math.max(0, Math.min(1, t));
1333
+ return x * x * x * (x * (x * 6 - 15) + 10);
1334
+ }
1335
+ function orientationLeadT(posT, lead) {
1336
+ return Math.max(0, Math.min(1, posT + lead));
1337
+ }
1338
+ function catmullRom(points, u) {
1339
+ const n = points.length;
1340
+ if (n === 0) return [0, 0, 0];
1341
+ if (n === 1) return [...points[0]];
1342
+ const cu = Math.max(0, Math.min(1, u));
1343
+ const segCount = n - 1;
1344
+ let seg = Math.floor(cu * segCount);
1345
+ if (seg >= segCount) seg = segCount - 1;
1346
+ const t = cu * segCount - seg;
1347
+ const p0 = points[Math.max(0, seg - 1)];
1348
+ const p1 = points[seg];
1349
+ const p2 = points[seg + 1];
1350
+ const p3 = points[Math.min(n - 1, seg + 2)];
1351
+ const t2 = t * t;
1352
+ const t3 = t2 * t;
1353
+ const out = [0, 0, 0];
1354
+ for (let i = 0; i < 3; i++) {
1355
+ out[i] = 0.5 * (2 * p1[i] + (-p0[i] + p2[i]) * t + (2 * p0[i] - 5 * p1[i] + 4 * p2[i] - p3[i]) * t2 + (-p0[i] + 3 * p1[i] - 3 * p2[i] + p3[i]) * t3);
1356
+ }
1357
+ return out;
1358
+ }
1359
+ function buildWaypoints(start, seatEye, focal, center, radius) {
1360
+ let away = norm(sub(seatEye, focal));
1361
+ if (away[0] === 0 && away[1] === 0 && away[2] === 0) away = [0, 0, 1];
1362
+ const finalPos = add(add(seatEye, scale(away, BACK_M)), [0, ABOVE_EYE_M, 0]);
1363
+ const horiz = [seatEye[0] - center[0], 0, seatEye[2] - center[2]];
1364
+ let hn = norm(horiz);
1365
+ if (hn[0] === 0 && hn[2] === 0) hn = [away[0], 0, away[2]];
1366
+ const r = Math.max(1, radius);
1367
+ const arc = [
1368
+ center[0] + hn[0] * r * 1.3,
1369
+ center[1] + r * 0.75,
1370
+ center[2] + hn[2] * r * 1.3
1371
+ ];
1372
+ return { waypoints: [start, arc, finalPos], finalPos };
1373
+ }
1374
+ function sampleFlight(waypoints, u, fovStart = FOV_START, fovEnd = FOV_END) {
1375
+ const eased = smootherstep(u);
1376
+ const pos = catmullRom(waypoints, eased);
1377
+ const fovT = smootherstep(Math.max(0, Math.min(1, (u - 0.66) / 0.34)));
1378
+ return { pos, fov: fovStart + (fovEnd - fovStart) * fovT, eased };
1379
+ }
1380
+
1381
+ // src/view3d/camera/cinematic.ts
1382
+ function lookAtQuat(camera, from, to) {
1383
+ const savedPos = camera.position.clone();
1384
+ const savedQuat = new import_ogl6.Quat().copy(camera.quaternion);
1385
+ camera.position.set(from[0], from[1], from[2]);
1386
+ camera.lookAt(new import_ogl6.Vec3(to[0], to[1], to[2]));
1387
+ const q = new import_ogl6.Quat().copy(camera.quaternion);
1388
+ camera.position.copy(savedPos);
1389
+ camera.quaternion.copy(savedQuat);
1390
+ return q;
1391
+ }
1392
+ var Cinematic = class {
1393
+ constructor(camera) {
1394
+ this.active = false;
1395
+ this.waypoints = [];
1396
+ this.startQuat = new import_ogl6.Quat();
1397
+ this.endQuat = new import_ogl6.Quat();
1398
+ this.outQuat = new import_ogl6.Quat();
1399
+ this.startTime = 0;
1400
+ this.duration = FLIGHT_DURATION_MS;
1401
+ this.resolveFn = null;
1402
+ this.camera = camera;
1403
+ }
1404
+ /** Begin (or retarget) a flight. Resolves when it lands or is cancelled. */
1405
+ start(waypoints, startQuat, endQuat, duration = FLIGHT_DURATION_MS) {
1406
+ this.settle();
1407
+ this.waypoints = waypoints;
1408
+ this.startQuat.copy(startQuat);
1409
+ this.endQuat.copy(endQuat);
1410
+ this.duration = duration;
1411
+ this.startTime = performance.now();
1412
+ this.active = true;
1413
+ return new Promise((res) => {
1414
+ this.resolveFn = res;
1415
+ });
1416
+ }
1417
+ /** Advance the flight, mutating the camera. Returns true while still flying. */
1418
+ update(now2) {
1419
+ if (!this.active) return false;
1420
+ const u = Math.min(1, (now2 - this.startTime) / this.duration);
1421
+ const { pos, fov, eased } = sampleFlight(this.waypoints, u);
1422
+ this.camera.position.set(pos[0], pos[1], pos[2]);
1423
+ this.outQuat.copy(this.startQuat).slerp(this.endQuat, orientationLeadT(eased, ORIENTATION_LEAD));
1424
+ this.camera.quaternion.copy(this.outQuat);
1425
+ this.camera.fov = fov;
1426
+ this.camera.updateProjectionMatrix();
1427
+ if (u >= 1) {
1428
+ this.settle();
1429
+ return false;
1430
+ }
1431
+ return true;
1432
+ }
1433
+ /** Stop where we are (no snap) — the camera keeps its current pose. */
1434
+ cancel() {
1435
+ this.settle();
1436
+ }
1437
+ settle() {
1438
+ this.active = false;
1439
+ const r = this.resolveFn;
1440
+ this.resolveFn = null;
1441
+ if (r) r();
1442
+ }
1443
+ };
1444
+
1445
+ // src/view3d/crossfade/panorama.ts
1446
+ var VFOV_DEG = 70;
1447
+ var MAX_PITCH_DEG = 35;
1448
+ function bearingToOffsetPx(bearingDeg, viewportW, bgW) {
1449
+ const col = (0.5 + bearingDeg / 360) * bgW;
1450
+ return viewportW / 2 - col;
1451
+ }
1452
+ function windowedBgHeight(viewportH, vfovDeg = VFOV_DEG) {
1453
+ return viewportH * (180 / vfovDeg);
1454
+ }
1455
+ function horizonOffsetPy(viewportH, bgH, pitchPx) {
1456
+ return (viewportH - bgH) / 2 + clampPitchPx(pitchPx, bgH);
1457
+ }
1458
+ function clampPitchPx(pitchPx, bgH) {
1459
+ const limit = MAX_PITCH_DEG / 180 * bgH;
1460
+ return Math.max(-limit, Math.min(limit, pitchPx));
1461
+ }
1462
+ function mountPanorama(container, view, opts = {}) {
1463
+ const fadeMs = opts.fadeMs ?? 400;
1464
+ const bearing = view.initialBearingDeg ?? 0;
1465
+ const root = document.createElement("div");
1466
+ root.setAttribute("role", "dialog");
1467
+ root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
1468
+ Object.assign(root.style, {
1469
+ position: "absolute",
1470
+ inset: "0",
1471
+ zIndex: "10",
1472
+ opacity: "0",
1473
+ transition: `opacity ${fadeMs}ms ease`,
1474
+ background: "#05070c",
1475
+ overflow: "hidden",
1476
+ touchAction: "none"
1477
+ });
1478
+ const pano = document.createElement("div");
1479
+ Object.assign(pano.style, {
1480
+ position: "absolute",
1481
+ inset: "0",
1482
+ backgroundImage: `url("${view.url}")`,
1483
+ backgroundRepeat: "repeat-x",
1484
+ cursor: "grab"
1485
+ });
1486
+ root.appendChild(pano);
1487
+ const closeBtn = document.createElement("button");
1488
+ closeBtn.type = "button";
1489
+ closeBtn.setAttribute("aria-label", "Close");
1490
+ closeBtn.textContent = "\u2715";
1491
+ Object.assign(closeBtn.style, {
1492
+ position: "absolute",
1493
+ top: "12px",
1494
+ right: "12px",
1495
+ zIndex: "2",
1496
+ width: "34px",
1497
+ height: "34px",
1498
+ borderRadius: "999px",
1499
+ cursor: "pointer",
1500
+ border: "1px solid rgba(255,255,255,0.25)",
1501
+ background: "rgba(8,12,18,0.6)",
1502
+ color: "#e6edf3",
1503
+ fontSize: "15px",
1504
+ lineHeight: "1"
1505
+ });
1506
+ root.appendChild(closeBtn);
1507
+ const hint = document.createElement("div");
1508
+ hint.textContent = "Drag to look around \xB7 Esc to close";
1509
+ Object.assign(hint.style, {
1510
+ position: "absolute",
1511
+ bottom: "12px",
1512
+ left: "0",
1513
+ right: "0",
1514
+ textAlign: "center",
1515
+ color: "rgba(230,237,243,0.7)",
1516
+ font: "12px ui-sans-serif, system-ui, sans-serif",
1517
+ pointerEvents: "none"
1518
+ });
1519
+ root.appendChild(hint);
1520
+ container.appendChild(root);
1521
+ let bgW = 0;
1522
+ let bgH = 0;
1523
+ let posX = 0;
1524
+ let pitchPx = 0;
1525
+ const layout = () => {
1526
+ const vh = root.clientHeight || 1;
1527
+ const vw = root.clientWidth || 1;
1528
+ const natW = img.naturalWidth || vw * 2;
1529
+ const natH = img.naturalHeight || vh;
1530
+ bgH = windowedBgHeight(vh);
1531
+ bgW = bgH * (natW / natH);
1532
+ pano.style.backgroundSize = `${bgW}px ${bgH}px`;
1533
+ if (!posInitialised) {
1534
+ posX = bearingToOffsetPx(bearing, vw, bgW);
1535
+ posInitialised = true;
1536
+ }
1537
+ pitchPx = clampPitchPx(pitchPx, bgH);
1538
+ pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
1539
+ };
1540
+ let posInitialised = false;
1541
+ const applyPos = () => {
1542
+ const vh = root.clientHeight || 1;
1543
+ pitchPx = clampPitchPx(pitchPx, bgH);
1544
+ pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
1545
+ };
1546
+ const img = new Image();
1547
+ img.onload = layout;
1548
+ img.src = view.url;
1549
+ requestAnimationFrame(layout);
1550
+ let dragging = false;
1551
+ let lastX = 0;
1552
+ let lastY = 0;
1553
+ const onDown = (e) => {
1554
+ dragging = true;
1555
+ lastX = e.clientX;
1556
+ lastY = e.clientY;
1557
+ pano.style.cursor = "grabbing";
1558
+ try {
1559
+ pano.setPointerCapture?.(e.pointerId);
1560
+ } catch {
1561
+ }
1562
+ };
1563
+ const onMove = (e) => {
1564
+ if (!dragging) return;
1565
+ posX += e.clientX - lastX;
1566
+ pitchPx += e.clientY - lastY;
1567
+ lastX = e.clientX;
1568
+ lastY = e.clientY;
1569
+ applyPos();
1570
+ };
1571
+ const onUp = (e) => {
1572
+ dragging = false;
1573
+ pano.style.cursor = "grab";
1574
+ try {
1575
+ pano.releasePointerCapture?.(e.pointerId);
1576
+ } catch {
1577
+ }
1578
+ };
1579
+ pano.addEventListener("pointerdown", onDown);
1580
+ pano.addEventListener("pointermove", onMove);
1581
+ pano.addEventListener("pointerup", onUp);
1582
+ pano.addEventListener("pointercancel", onUp);
1583
+ let closed = false;
1584
+ let disposed = false;
1585
+ let fadeTimer = 0;
1586
+ const removeListeners = () => {
1587
+ pano.removeEventListener("pointerdown", onDown);
1588
+ pano.removeEventListener("pointermove", onMove);
1589
+ pano.removeEventListener("pointerup", onUp);
1590
+ pano.removeEventListener("pointercancel", onUp);
1591
+ window.removeEventListener("keydown", onKey);
1592
+ };
1593
+ const teardown = () => {
1594
+ if (fadeTimer) {
1595
+ window.clearTimeout(fadeTimer);
1596
+ fadeTimer = 0;
1597
+ }
1598
+ removeListeners();
1599
+ if (root.parentNode) root.parentNode.removeChild(root);
1600
+ };
1601
+ const close = () => {
1602
+ if (closed) return;
1603
+ closed = true;
1604
+ root.style.opacity = "0";
1605
+ const done = () => {
1606
+ fadeTimer = 0;
1607
+ if (disposed) return;
1608
+ teardown();
1609
+ opts.onClose?.();
1610
+ };
1611
+ fadeTimer = window.setTimeout(done, fadeMs);
1612
+ };
1613
+ const onKey = (e) => {
1614
+ if (e.key === "Escape") {
1615
+ e.stopPropagation();
1616
+ close();
1617
+ }
1618
+ };
1619
+ window.addEventListener("keydown", onKey);
1620
+ closeBtn.addEventListener("click", close);
1621
+ requestAnimationFrame(() => {
1622
+ root.style.opacity = "1";
1623
+ });
1624
+ return {
1625
+ close,
1626
+ dispose() {
1627
+ closed = true;
1628
+ disposed = true;
1629
+ teardown();
1630
+ }
1631
+ };
1632
+ }
1633
+
1634
+ // src/view3d/analytics.ts
1635
+ var now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1636
+ var Analytics3D = class {
1637
+ constructor(cb) {
1638
+ this.orbitLatched = false;
1639
+ this.panoramaOpenedAt = 0;
1640
+ this.cb = cb;
1641
+ }
1642
+ /** The single guarded emit point — analytics must never throw into the loop. */
1643
+ emit(event, props) {
1644
+ if (!this.cb) return;
1645
+ try {
1646
+ this.cb(event, props);
1647
+ } catch {
1648
+ }
1649
+ }
1650
+ opened(seats, hasHeights) {
1651
+ this.emit("3d_opened", { seats, hasHeights });
1652
+ }
1653
+ /** First user-driven orbit/dolly per mount only (the intro ease is not user
1654
+ * input, so callers must gate this on real pointer/wheel gestures). */
1655
+ orbitEngaged() {
1656
+ if (this.orbitLatched) return;
1657
+ this.orbitLatched = true;
1658
+ this.emit("3d_orbit_engaged");
1659
+ }
1660
+ seatPicked(seatId, sectionId) {
1661
+ this.emit("3d_seat_picked", { seatId, sectionId });
1662
+ }
1663
+ cinematicPlayed(durationMs) {
1664
+ this.emit("3d_cinematic_played", { durationMs, reducedMotion: false });
1665
+ }
1666
+ cinematicSkipped() {
1667
+ this.emit("3d_cinematic_skipped", { reducedMotion: true });
1668
+ }
1669
+ cinematicCancelled() {
1670
+ this.emit("3d_cinematic_cancelled");
1671
+ }
1672
+ panoramaOpened() {
1673
+ this.panoramaOpenedAt = now();
1674
+ this.emit("3d_panorama_opened");
1675
+ }
1676
+ panoramaClosed() {
1677
+ const viewMs = this.panoramaOpenedAt ? Math.round(now() - this.panoramaOpenedAt) : 0;
1678
+ this.panoramaOpenedAt = 0;
1679
+ this.emit("3d_panorama_closed", { viewMs });
1680
+ }
1681
+ };
1682
+
1683
+ // src/view3d/index.ts
1684
+ var SEAT_EYE_ABOVE_DECK = 1.02;
1685
+ var DEG2 = Math.PI / 180;
1686
+ var TAP_SLOP = 6;
1687
+ var TAP_MS = 500;
1688
+ function mountVenue3D(container, input, opts = {}) {
1689
+ const model = buildSceneModel(input);
1690
+ const analytics = new Analytics3D(opts.onAnalytics);
1691
+ const seatIdByIndex = new Array(model.seats.count);
1692
+ for (const [id, idx] of model.seats.idToIndex) seatIdByIndex[idx] = id;
1693
+ const sectionIdBySeatId = /* @__PURE__ */ new Map();
1694
+ for (const s of input.seats) sectionIdBySeatId.set(s.id, s.sectionId);
1695
+ const hasHeights = (() => {
1696
+ if (input.doc.floors?.some((f) => (f.baseHeightM ?? 0) > 0)) return true;
1697
+ const objs = input.doc.floors?.flatMap((f) => f.objects) ?? input.doc.objects;
1698
+ return objs.some((o) => o.type === "section" && ((o.height ?? 0) > 0 || (o.rake ?? 0) > 0));
1699
+ })();
1700
+ let gpu = null;
1701
+ let pick = null;
1702
+ let contextLost = false;
1703
+ let frozen = false;
1704
+ let disposed = false;
1705
+ let selection = /* @__PURE__ */ new Map();
1706
+ let panorama = null;
1707
+ const prefetch = /* @__PURE__ */ new Map();
1708
+ let flightGen = 0;
1709
+ let reducedForced = null;
1710
+ const rebuildGpu = () => {
1711
+ gpu = buildGpuScene(glctx.gl, model);
1712
+ pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
1713
+ };
1714
+ const glctx = new GLContext(container, {
1715
+ onContextLost: () => {
1716
+ contextLost = true;
1717
+ loop.stop();
1718
+ gpu = null;
1719
+ pick = null;
1720
+ },
1721
+ onContextRestored: () => {
1722
+ rebuildGpu();
1723
+ contextLost = false;
1724
+ loop.requestRender();
1725
+ }
1726
+ });
1727
+ const orbit = new OrbitCamera(
1728
+ glctx.gl,
1729
+ glctx.canvas,
1730
+ () => loop.requestRender(),
1731
+ () => analytics.orbitEngaged()
1732
+ // first real drag/wheel/pinch (not the intro ease)
1733
+ );
1734
+ orbit.setAspect(glctx.aspect);
1735
+ orbit.frame(model.bounds, true);
1736
+ const cinematic = new Cinematic(orbit.camera);
1737
+ rebuildGpu();
1738
+ const loop = new RenderLoop(() => {
1739
+ if (contextLost || !gpu || frozen) return false;
1740
+ const flying = cinematic.active;
1741
+ const moving = flying ? cinematic.update(performance.now()) : orbit.update();
1742
+ const lod = computeSeatLod(orbit.currentDistance, model.bounds.radius);
1743
+ const u = gpu.seatProgram.uniforms;
1744
+ u.uSeatScale.value = lod.scale;
1745
+ u.uSeatFade.value = lod.fade;
1746
+ u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
1747
+ glctx.renderer.render({ scene: gpu.background, clear: true });
1748
+ glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
1749
+ return moving;
1750
+ });
1751
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
1752
+ ro?.observe(container);
1753
+ const setSelection = (ids) => {
1754
+ const baseStateIndex = (id) => {
1755
+ const idx = model.seats.idToIndex.get(id);
1756
+ return idx === void 0 ? void 0 : model.seats.iState[idx];
1757
+ };
1758
+ const { updates, next } = diffSelection(selection, ids, baseStateIndex);
1759
+ selection = next;
1760
+ if (updates.length) {
1761
+ const runs = applySeatStates(model.seats, updates);
1762
+ if (gpu) gpu.uploadSeatStateRuns(runs);
1763
+ loop.requestRender();
1764
+ }
1765
+ };
1766
+ const reducedMotion = () => {
1767
+ if (reducedForced !== null) return reducedForced;
1768
+ return typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1769
+ };
1770
+ const PREFETCH_CAP = 8;
1771
+ const ensureSeatView = (seatId) => {
1772
+ if (!opts.getSeatView) return null;
1773
+ let p = prefetch.get(seatId);
1774
+ if (!p) {
1775
+ p = Promise.resolve(opts.getSeatView(seatId));
1776
+ prefetch.set(seatId, p);
1777
+ while (prefetch.size > PREFETCH_CAP) {
1778
+ const oldest = prefetch.keys().next().value;
1779
+ if (oldest === void 0) break;
1780
+ prefetch.delete(oldest);
1781
+ }
1782
+ }
1783
+ return p;
1784
+ };
1785
+ const seatEyeWorld = (idx) => [
1786
+ model.seats.iPosition[idx * 3],
1787
+ model.seats.iPosition[idx * 3 + 1] + SEAT_EYE_ABOVE_DECK,
1788
+ model.seats.iPosition[idx * 3 + 2]
1789
+ ];
1790
+ const placeCameraFinal = (finalPos, focal) => {
1791
+ orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
1792
+ orbit.camera.lookAt(new import_ogl7.Vec3(focal[0], focal[1], focal[2]));
1793
+ orbit.camera.fov = FOV_END;
1794
+ orbit.camera.updateProjectionMatrix();
1795
+ };
1796
+ const openPanorama = async (seatId, fadeMs, gen) => {
1797
+ const viewPromise = ensureSeatView(seatId);
1798
+ if (!viewPromise) {
1799
+ orbit.syncFromCamera();
1800
+ return;
1801
+ }
1802
+ frozen = true;
1803
+ loop.stop();
1804
+ let view;
1805
+ try {
1806
+ view = await viewPromise;
1807
+ } catch {
1808
+ if (!disposed && gen === flightGen) {
1809
+ frozen = false;
1810
+ orbit.resumeAfterFlight(model.focalWorld);
1811
+ loop.requestRender();
1812
+ }
1813
+ return;
1814
+ }
1815
+ if (disposed || gen !== flightGen) return;
1816
+ panorama = mountPanorama(container, view, {
1817
+ fadeMs,
1818
+ seatLabel: seatId,
1819
+ onClose: () => {
1820
+ panorama = null;
1821
+ frozen = false;
1822
+ analytics.panoramaClosed();
1823
+ orbit.resumeAfterFlight(model.focalWorld);
1824
+ loop.requestRender();
1825
+ }
1826
+ });
1827
+ analytics.panoramaOpened();
1828
+ };
1829
+ const cancelFlight = () => {
1830
+ flightGen++;
1831
+ if (cinematic.active) {
1832
+ cinematic.cancel();
1833
+ orbit.resumeAfterFlight(model.focalWorld);
1834
+ }
1835
+ };
1836
+ const flyToSeat = (seatId) => {
1837
+ if (disposed || !gpu) return Promise.resolve();
1838
+ const idx = model.seats.idToIndex.get(seatId);
1839
+ if (idx === void 0) return Promise.resolve();
1840
+ if (panorama) {
1841
+ panorama.dispose();
1842
+ panorama = null;
1843
+ }
1844
+ frozen = false;
1845
+ const gen = ++flightGen;
1846
+ const seatEye = seatEyeWorld(idx);
1847
+ const focal = model.focalWorld;
1848
+ const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
1849
+ const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
1850
+ if (reducedMotion()) {
1851
+ placeCameraFinal(finalPos, focal);
1852
+ loop.requestRender();
1853
+ analytics.cinematicSkipped();
1854
+ return openPanorama(seatId, 300, gen).then(() => {
1855
+ if (!disposed) orbit.syncFromCamera();
1856
+ });
1857
+ }
1858
+ const startQuat = new import_ogl7.Quat().copy(orbit.camera.quaternion);
1859
+ const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
1860
+ loop.requestRender();
1861
+ return cinematic.start(waypoints, startQuat, endQuat).then(() => {
1862
+ if (disposed || gen !== flightGen) return;
1863
+ analytics.cinematicPlayed(FLIGHT_DURATION_MS);
1864
+ return openPanorama(seatId, 400, gen);
1865
+ });
1866
+ };
1867
+ let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
1868
+ const onDown = (e) => {
1869
+ if (downId !== -1) return;
1870
+ downId = e.pointerId;
1871
+ downX = e.clientX;
1872
+ downY = e.clientY;
1873
+ downT = performance.now();
1874
+ moved = false;
1875
+ suppressTap = cinematic.active;
1876
+ if (cinematic.active) {
1877
+ analytics.cinematicCancelled();
1878
+ cancelFlight();
1879
+ }
1880
+ };
1881
+ const onMove = (e) => {
1882
+ if (e.pointerId !== downId) return;
1883
+ if (Math.hypot(e.clientX - downX, e.clientY - downY) > TAP_SLOP) moved = true;
1884
+ };
1885
+ const onUp = (e) => {
1886
+ if (e.pointerId !== downId) return;
1887
+ const isTap = !moved && performance.now() - downT < TAP_MS;
1888
+ downId = -1;
1889
+ if (suppressTap) {
1890
+ suppressTap = false;
1891
+ return;
1892
+ }
1893
+ if (!isTap || !gpu || !pick) return;
1894
+ pick.syncFromSeatProgram(gpu.seatProgram);
1895
+ const rect = glctx.canvas.getBoundingClientRect();
1896
+ const dpr = glctx.renderer.dpr;
1897
+ const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
1898
+ const radius = Math.max(2, Math.round(8 * dpr));
1899
+ const idx = pick.pick(orbit.camera, x, y, radius);
1900
+ if (idx < 0 || idx >= seatIdByIndex.length) {
1901
+ if (selection.size) setSelection([]);
1902
+ return;
1903
+ }
1904
+ const seatId = seatIdByIndex[idx];
1905
+ if (selection.has(seatId) && selection.size === 1) setSelection([]);
1906
+ else setSelection([seatId]);
1907
+ ensureSeatView(seatId);
1908
+ analytics.seatPicked(seatId, sectionIdBySeatId.get(seatId));
1909
+ opts.onSeatPick?.(seatId);
1910
+ };
1911
+ glctx.canvas.addEventListener("pointerdown", onDown);
1912
+ glctx.canvas.addEventListener("pointermove", onMove);
1913
+ glctx.canvas.addEventListener("pointerup", onUp);
1914
+ glctx.canvas.addEventListener("pointercancel", onUp);
1915
+ loop.requestRender();
1916
+ const handle = {
1917
+ setAvailability(updates) {
1918
+ const passthrough = mergeAvailabilityIntoSelection(selection, updates);
1919
+ const runs = applySeatStates(model.seats, passthrough);
1920
+ if (runs.length && gpu) gpu.uploadSeatStateRuns(runs);
1921
+ loop.requestRender();
1922
+ },
1923
+ setSelection,
1924
+ flyToSeat,
1925
+ resize() {
1926
+ const { width, height } = glctx.resize();
1927
+ orbit.setAspect(width / Math.max(1, height));
1928
+ loop.requestRender();
1929
+ },
1930
+ stats() {
1931
+ return {
1932
+ ...loop.stats(),
1933
+ drawCalls: gpu ? gpu.drawCalls : 0,
1934
+ seatCount: model.seatCount
1935
+ };
1936
+ },
1937
+ loseContextForTest() {
1938
+ glctx.simulateContextLossCycle();
1939
+ },
1940
+ setReducedMotionForTest(value) {
1941
+ reducedForced = value;
1942
+ },
1943
+ dispose() {
1944
+ disposed = true;
1945
+ cancelFlight();
1946
+ loop.stop();
1947
+ ro?.disconnect();
1948
+ if (panorama) {
1949
+ panorama.dispose();
1950
+ panorama = null;
1951
+ }
1952
+ glctx.canvas.removeEventListener("pointerdown", onDown);
1953
+ glctx.canvas.removeEventListener("pointermove", onMove);
1954
+ glctx.canvas.removeEventListener("pointerup", onUp);
1955
+ glctx.canvas.removeEventListener("pointercancel", onUp);
1956
+ orbit.dispose();
1957
+ if (pick) pick.dispose();
1958
+ if (gpu) gpu.dispose();
1959
+ gpu = null;
1960
+ pick = null;
1961
+ glctx.dispose();
1962
+ }
1963
+ };
1964
+ analytics.opened(model.seatCount, hasHeights);
1965
+ return handle;
1966
+ }
1967
+ // Annotate the CommonJS export names for ESM import in node:
1968
+ 0 && (module.exports = {
1969
+ buildSceneModel,
1970
+ mountVenue3D
1971
+ });
1972
+ //# sourceMappingURL=index.cjs.map