@seatlayer/core 0.31.0 → 0.33.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.
@@ -34,7 +34,7 @@ __export(view3d_exports, {
34
34
  mountVenue3D: () => mountVenue3D
35
35
  });
36
36
  module.exports = __toCommonJS(view3d_exports);
37
- var import_ogl7 = require("ogl");
37
+ var import_ogl8 = require("ogl");
38
38
 
39
39
  // src/view3d/gl/context.ts
40
40
  var import_ogl = require("ogl");
@@ -52,13 +52,36 @@ var SEAT_STATE_COLORS = {
52
52
  selected: [0.24, 0.74, 1],
53
53
  dimmed: [0.28, 0.32, 0.37]
54
54
  };
55
+ function upholsteryTone(rgb) {
56
+ const luma = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
57
+ const SAT = 0.42;
58
+ const VALUE = 0.62;
59
+ return [
60
+ (luma + (rgb[0] - luma) * SAT) * VALUE,
61
+ (luma + (rgb[1] - luma) * SAT) * VALUE,
62
+ (luma + (rgb[2] - luma) * SAT) * VALUE
63
+ ];
64
+ }
55
65
  var STRUCTURE = {
56
66
  ground: [0.07, 0.085, 0.11],
57
67
  tierTop: [0.24, 0.28, 0.34],
58
68
  tierWall: [0.17, 0.2, 0.25],
59
- stageTop: [0.42, 0.36, 0.26],
60
- // warm, slightly emissive read
61
- stageWall: [0.26, 0.22, 0.16],
69
+ /**
70
+ * The lit performance surface.
71
+ *
72
+ * This was [0.42, 0.36, 0.26] and commented "slightly emissive read", which it
73
+ * was not — under the scene's rig it resolved to a mid-brown slab, and from a
74
+ * seat the stage was the DIMMEST large surface in a dark hall. That is exactly
75
+ * backwards: the stage is the one place in a venue with light pointed at it,
76
+ * and it is what a buyer's eye should land on when they arrive at their seat.
77
+ *
78
+ * Bright and warm enough to hold that role against the surrounding structure,
79
+ * which sits around 0.24. An authored `fill` still tints it (see `buildShape`),
80
+ * so an organizer's own stage colour survives — it is just no longer lit like
81
+ * a basement.
82
+ */
83
+ stageTop: [0.86, 0.64, 0.36],
84
+ stageWall: [0.34, 0.26, 0.18],
62
85
  decorTop: [0.22, 0.25, 0.29],
63
86
  decorWall: [0.15, 0.17, 0.2],
64
87
  gaTop: [0.24, 0.28, 0.33],
@@ -207,10 +230,18 @@ var OrbitCamera = class {
207
230
  this.maxDist = 100;
208
231
  this.gestureFired = false;
209
232
  this.dragging = false;
233
+ this.panning = false;
210
234
  this.lastX = 0;
211
235
  this.lastY = 0;
212
236
  this.activePointers = /* @__PURE__ */ new Map();
213
237
  this.pinchDist = 0;
238
+ this.pinchCx = 0;
239
+ this.pinchCy = 0;
240
+ /** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
241
+ this.targetT = new import_ogl2.Vec3();
242
+ /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
243
+ this.panAnchor = new import_ogl2.Vec3();
244
+ this.panLimit = 0;
214
245
  this.camera = new import_ogl2.Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
215
246
  this.canvas = canvas;
216
247
  this.requestRender = requestRender;
@@ -222,12 +253,17 @@ var OrbitCamera = class {
222
253
  }
223
254
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
224
255
  if (this.activePointers.size === 1) {
225
- this.dragging = true;
256
+ this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
257
+ this.dragging = !this.panning;
226
258
  this.lastX = e.clientX;
227
259
  this.lastY = e.clientY;
228
260
  } else if (this.activePointers.size === 2) {
229
261
  this.dragging = false;
262
+ this.panning = false;
230
263
  this.pinchDist = this.currentPinchDistance();
264
+ const c = this.pinchCentroid();
265
+ this.pinchCx = c.x;
266
+ this.pinchCy = c.y;
231
267
  }
232
268
  };
233
269
  this.onPointerMove = (e) => {
@@ -240,11 +276,24 @@ var OrbitCamera = class {
240
276
  this.fireGesture();
241
277
  }
242
278
  this.pinchDist = d;
279
+ const c = this.pinchCentroid();
280
+ this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
281
+ this.pinchCx = c.x;
282
+ this.pinchCy = c.y;
243
283
  return;
244
284
  }
245
- if (!this.dragging) return;
246
285
  const dx = e.clientX - this.lastX;
247
286
  const dy = e.clientY - this.lastY;
287
+ if (this.panning) {
288
+ this.lastX = e.clientX;
289
+ this.lastY = e.clientY;
290
+ if (dx !== 0 || dy !== 0) {
291
+ this.panBy(dx, dy);
292
+ this.fireGesture();
293
+ }
294
+ return;
295
+ }
296
+ if (!this.dragging) return;
248
297
  this.lastX = e.clientX;
249
298
  this.lastY = e.clientY;
250
299
  if (dx !== 0 || dy !== 0) this.fireGesture();
@@ -259,7 +308,13 @@ var OrbitCamera = class {
259
308
  } catch {
260
309
  }
261
310
  if (this.activePointers.size < 2) this.pinchDist = 0;
262
- if (this.activePointers.size === 0) this.dragging = false;
311
+ if (this.activePointers.size === 0) {
312
+ this.dragging = false;
313
+ this.panning = false;
314
+ }
315
+ };
316
+ this.onContextMenu = (e) => {
317
+ e.preventDefault();
263
318
  };
264
319
  this.onWheel = (e) => {
265
320
  e.preventDefault();
@@ -273,6 +328,55 @@ var OrbitCamera = class {
273
328
  canvas.addEventListener("pointerup", this.onPointerUp);
274
329
  canvas.addEventListener("pointercancel", this.onPointerUp);
275
330
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
331
+ canvas.addEventListener("contextmenu", this.onContextMenu);
332
+ }
333
+ pinchCentroid() {
334
+ const pts = [...this.activePointers.values()];
335
+ if (!pts.length) return { x: 0, y: 0 };
336
+ let x = 0;
337
+ let y = 0;
338
+ for (const p of pts) {
339
+ x += p.x;
340
+ y += p.y;
341
+ }
342
+ return { x: x / pts.length, y: y / pts.length };
343
+ }
344
+ /**
345
+ * Slide the orbit pivot across the camera's own screen plane.
346
+ *
347
+ * Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
348
+ * under the cursor whatever the zoom: at the overview a drag sweeps the whole
349
+ * bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
350
+ * would be unusable at one end or the other.
351
+ */
352
+ panBy(dxPx, dyPx) {
353
+ const h = this.canvas.clientHeight || 1;
354
+ const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
355
+ const sinA = Math.sin(this.azimuth);
356
+ const cosA = Math.cos(this.azimuth);
357
+ const rightX = cosA;
358
+ const rightZ = -sinA;
359
+ const cp = Math.cos(this.polar);
360
+ const sp = Math.sin(this.polar);
361
+ const fwdX = -sinA * cp;
362
+ const fwdZ = -cosA * cp;
363
+ this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
364
+ this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
365
+ this.targetT.y += dyPx * sp * perPx;
366
+ this.clampPan();
367
+ this.requestRender();
368
+ }
369
+ /** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
370
+ * venue entirely — the Overview chip should never be the only way back. */
371
+ clampPan() {
372
+ if (this.panLimit <= 0) return;
373
+ const lim = this.panLimit;
374
+ const cx = this.panAnchor.x;
375
+ const cy = this.panAnchor.y;
376
+ const cz = this.panAnchor.z;
377
+ this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
378
+ this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
379
+ this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
276
380
  }
277
381
  /** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
278
382
  fireGesture() {
@@ -299,6 +403,9 @@ var OrbitCamera = class {
299
403
  */
300
404
  frame(bounds, intro = false, stageAzimuth) {
301
405
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
406
+ this.targetT.copy(this.target);
407
+ this.panAnchor.copy(this.target);
408
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
302
409
  const r = Math.max(1, bounds.radius);
303
410
  const halfV = this.fovY * DEG / 2;
304
411
  const aspect = this.camera.aspect || 1;
@@ -330,6 +437,9 @@ var OrbitCamera = class {
330
437
  */
331
438
  frameSoft(bounds, stageAzimuth) {
332
439
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
440
+ this.targetT.copy(this.target);
441
+ this.panAnchor.copy(this.target);
442
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
333
443
  this.syncFromCamera();
334
444
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
335
445
  const r = Math.max(1, bounds.radius);
@@ -346,10 +456,17 @@ var OrbitCamera = class {
346
456
  const da = this.azT - this.azimuth;
347
457
  const dp = this.polT - this.polar;
348
458
  const dd = this.distT - this.distance;
349
- const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
459
+ const tx = this.targetT.x - this.target.x;
460
+ const ty = this.targetT.y - this.target.y;
461
+ const tz = this.targetT.z - this.target.z;
462
+ const panEps = Math.max(1e-4, this.distance * 1e-4);
463
+ const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4 || Math.abs(tx) > panEps || Math.abs(ty) > panEps || Math.abs(tz) > panEps;
350
464
  this.azimuth += da * DAMP;
351
465
  this.polar += dp * DAMP;
352
466
  this.distance += dd * DAMP;
467
+ this.target.x += tx * DAMP;
468
+ this.target.y += ty * DAMP;
469
+ this.target.z += tz * DAMP;
353
470
  if (moving) this.applyPosition();
354
471
  return moving;
355
472
  }
@@ -375,6 +492,7 @@ var OrbitCamera = class {
375
492
  /** Point the orbit pivot at a new world target without moving the camera. */
376
493
  setTarget(target) {
377
494
  this.target.set(target[0], target[1], target[2]);
495
+ this.targetT.copy(this.target);
378
496
  }
379
497
  /** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
380
498
  * flight ends looking at `target` (the venue focal), so re-pivot there first —
@@ -382,6 +500,7 @@ var OrbitCamera = class {
382
500
  resumeAfterFlight(target) {
383
501
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
384
502
  if (target) this.target.set(target[0], target[1], target[2]);
503
+ this.targetT.copy(this.target);
385
504
  this.syncFromCamera();
386
505
  }
387
506
  applyPosition() {
@@ -398,6 +517,7 @@ var OrbitCamera = class {
398
517
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
399
518
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
400
519
  this.canvas.removeEventListener("wheel", this.onWheel);
520
+ this.canvas.removeEventListener("contextmenu", this.onContextMenu);
401
521
  this.activePointers.clear();
402
522
  }
403
523
  };
@@ -446,17 +566,129 @@ var RenderLoop = class {
446
566
  };
447
567
 
448
568
  // src/view3d/lod.ts
569
+ var SEAT_MIN_PIXELS_NEAR = 2.5;
570
+ var SEAT_MIN_PIXELS_FAR = 1.15;
571
+ var CHAIR_FULL_M = 12;
572
+ var CHAIR_NONE_M = 22;
573
+ var CHAIR_GATHER_M = 30;
574
+ var CHAIR_REBUILD_M = 4;
575
+ var CHAIR_MAX_INSTANCES = 8192;
576
+ var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
449
577
  function computeSeatLod(distance, radius) {
450
578
  const near = radius * 1.4;
451
579
  const far = radius * 3.2;
452
- if (distance <= near) return { scale: 1, fade: 0 };
580
+ if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
453
581
  const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
454
582
  return {
455
583
  scale: 1 - t * 0.4,
456
- fade: t * 0.55
584
+ fade: t * 0.55,
585
+ // Tapered on the same ramp as the fade: as the block starts reading by its
586
+ // tier tint, the dots stop fighting each other for pixels.
587
+ minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
457
588
  };
458
589
  }
459
590
 
591
+ // src/view3d/scene/nearField.ts
592
+ var SEATS_PER_CELL = 4;
593
+ var NearFieldIndex = class {
594
+ constructor(iPosition, count) {
595
+ this.minX = 0;
596
+ this.minZ = 0;
597
+ this.cell = 1;
598
+ this.cols = 1;
599
+ this.rows = 1;
600
+ /** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
601
+ this.cellStart = null;
602
+ this.cellItems = null;
603
+ this.iPosition = iPosition;
604
+ this.count = count;
605
+ }
606
+ /** Built on demand; safe to call repeatedly. */
607
+ ensureGrid() {
608
+ if (this.cellStart || this.count === 0) return;
609
+ const p = this.iPosition;
610
+ let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
611
+ for (let i = 0; i < this.count; i++) {
612
+ const x = p[i * 3], z = p[i * 3 + 2];
613
+ if (x < minX) minX = x;
614
+ if (x > maxX) maxX = x;
615
+ if (z < minZ) minZ = z;
616
+ if (z > maxZ) maxZ = z;
617
+ }
618
+ const w = Math.max(maxX - minX, 1e-3);
619
+ const h = Math.max(maxZ - minZ, 1e-3);
620
+ this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
621
+ this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
622
+ this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
623
+ this.minX = minX;
624
+ this.minZ = minZ;
625
+ const nCells = this.cols * this.rows;
626
+ const start = new Int32Array(nCells + 1);
627
+ const cellOf = (i) => {
628
+ const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
629
+ const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
630
+ return cz * this.cols + cx;
631
+ };
632
+ for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
633
+ for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
634
+ const items = new Int32Array(this.count);
635
+ const cursor = start.slice(0, nCells);
636
+ for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
637
+ this.cellStart = start;
638
+ this.cellItems = items;
639
+ }
640
+ /**
641
+ * Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
642
+ * nearest cell-ring first, and return how many were written.
643
+ *
644
+ * Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
645
+ * bites it drops the OUTERMOST seats, which are the ones already past the fade
646
+ * band and drawing nothing. A cap that truncated in index order would instead
647
+ * punch holes in the row you are sitting in.
648
+ *
649
+ * Note this is a horizontal (XZ) query and ignores height. A stacked venue's
650
+ * upper tier is therefore gathered along with the stalls beneath it — which is
651
+ * correct, because the fade weight is re-derived from true view depth in the
652
+ * shader anyway. The grid's only job is to bound the candidate set.
653
+ */
654
+ gather(camX, camZ, radius, out) {
655
+ this.ensureGrid();
656
+ const start = this.cellStart;
657
+ const items = this.cellItems;
658
+ if (!start || !items) return 0;
659
+ const cap = out.length;
660
+ const r2 = radius * radius;
661
+ const cx = Math.floor((camX - this.minX) / this.cell);
662
+ const cz = Math.floor((camZ - this.minZ) / this.cell);
663
+ const maxRing = Math.ceil(radius / this.cell) + 1;
664
+ const p = this.iPosition;
665
+ let n = 0;
666
+ for (let ring = 0; ring <= maxRing && n < cap; ring++) {
667
+ const z0 = cz - ring, z1 = cz + ring;
668
+ const x0 = cx - ring, x1 = cx + ring;
669
+ for (let gz = z0; gz <= z1 && n < cap; gz++) {
670
+ if (gz < 0 || gz >= this.rows) continue;
671
+ const edge = gz === z0 || gz === z1;
672
+ for (let gx = x0; gx <= x1 && n < cap; gx++) {
673
+ if (!edge && gx !== x0 && gx !== x1) {
674
+ gx = x1 - 1;
675
+ continue;
676
+ }
677
+ if (gx < 0 || gx >= this.cols) continue;
678
+ const c = gz * this.cols + gx;
679
+ for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
680
+ const i = items[k];
681
+ const dx = p[i * 3] - camX;
682
+ const dz = p[i * 3 + 2] - camZ;
683
+ if (dx * dx + dz * dz <= r2) out[n++] = i;
684
+ }
685
+ }
686
+ }
687
+ }
688
+ return n;
689
+ }
690
+ };
691
+
460
692
  // src/core/types.ts
461
693
  var ACCESSIBILITY_TYPES = [
462
694
  { key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
@@ -483,6 +715,12 @@ function accessibilityRingColor(types) {
483
715
  const primary = types?.[0];
484
716
  return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
485
717
  }
718
+ var SEAT_COMMERCIAL_MARKS = [
719
+ { key: "obstructedView", label: "Obstructed view", short: "Obstructed", icon: "\u26D4" },
720
+ { key: "restrictedView", label: "Restricted view", short: "Restricted", icon: "\u{1F441}" },
721
+ { key: "premium", label: "Premium seat", short: "Premium", icon: "\u2605" }
722
+ ];
723
+ var SEAT_COMMERCIAL_LABEL = new Map(SEAT_COMMERCIAL_MARKS.map((mark) => [mark.key, mark]));
486
724
  var SURROUNDINGS_SHAPE_ROLES = [
487
725
  "reference-focal",
488
726
  "bar",
@@ -959,8 +1197,195 @@ function rectPolygon(x, y, w, h) {
959
1197
  ];
960
1198
  }
961
1199
 
1200
+ // src/view3d/scene/seatChair.ts
1201
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2, body: 3, head: 4 };
1202
+ var CHAIR_PART_OCCUPANT_MIN = CHAIR_PART.body;
1203
+ var CHAIR_PITCH_FRACTION = 0.44;
1204
+ var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1205
+ var CHAIR_HALF_WIDTH_MAX_M = 0.3;
1206
+ var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
1207
+ function chairHalfWidth(pitchM) {
1208
+ if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
1209
+ return CHAIR_HALF_WIDTH_DEFAULT_M;
1210
+ }
1211
+ return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
1212
+ }
1213
+ var BACK_RAKE_SLOPE = 0.21;
1214
+ var PAD_BACK_GAP_M = 0.05;
1215
+ var PAD_TOP_M = 0.45;
1216
+ var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1217
+ var BOXES = [
1218
+ // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1219
+ // over the deck and the row reads as hovering trays.
1220
+ { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
1221
+ // Seat pad — a full seat width across and about as deep, which is what a real
1222
+ // one is. Its depth is bounded by the same pitch as its width, because the
1223
+ // pitch measure is the tighter of the in-row and row-to-row spacings, so a
1224
+ // tightly-raked tier cannot drive a pad into the back of the row in front.
1225
+ { part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
1226
+ // Back panel — thin, raked, and the tallest thing in the row, so it is what
1227
+ // carries the state colour when you look along a row from behind.
1228
+ {
1229
+ part: CHAIR_PART.back,
1230
+ min: [-1, BACK_BASE_M, -1],
1231
+ max: [1, 0.92, -0.72]
1232
+ },
1233
+ // --- the occupant ---------------------------------------------------------
1234
+ //
1235
+ // A hall with every seat empty reads as an architectural model, not a venue.
1236
+ // The 2048-px panorama this replaced drew a crowd; losing it was the price of
1237
+ // sharpness, and this is how it is bought back — in geometry, where there is
1238
+ // no resolution ceiling.
1239
+ //
1240
+ // Deliberately two blocks and no limbs. At the range these are visible a
1241
+ // person is a torso and a head, and every extra part multiplies by the number
1242
+ // of occupied seats in view. The silhouette is what carries it, exactly as it
1243
+ // does in the generated panorama's head-and-shoulder figures.
1244
+ //
1245
+ // Sized as a seated adult against the 0.92 m chair back: hips at the pad top,
1246
+ // shoulders just above the back panel, head clear of it. Torso is narrower
1247
+ // than the chair so neighbours never interpenetrate at any pitch, and it sits
1248
+ // forward of the back panel rather than inside it.
1249
+ {
1250
+ part: CHAIR_PART.body,
1251
+ min: [-0.66, PAD_TOP_M, -0.58],
1252
+ max: [0.66, 1, 0.26]
1253
+ },
1254
+ // The head is deliberately SMALL. Sized by eye against the chair it came out
1255
+ // near-cubic and read as Lego; a real head is about 0.16 m across and 0.22 m
1256
+ // tall, which against a 0.24 m chair half-width is roughly a third of the
1257
+ // chair's width and clearly taller than it is wide. Getting this ratio wrong
1258
+ // is what makes a crowd look like toys rather than people.
1259
+ {
1260
+ part: CHAIR_PART.head,
1261
+ min: [-0.34, 1.03, -0.4],
1262
+ max: [0.34, 1.27, 0.02]
1263
+ }
1264
+ ];
1265
+ var FACES = [
1266
+ // +X
1267
+ { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
1268
+ // -X
1269
+ { n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
1270
+ // +Y
1271
+ { n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
1272
+ // -Y
1273
+ { n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
1274
+ // +Z
1275
+ { n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
1276
+ // -Z
1277
+ { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1278
+ ];
1279
+ function buildChairMesh() {
1280
+ const vertexCount = BOXES.length * FACES.length * 4;
1281
+ const indexCount = BOXES.length * FACES.length * 6;
1282
+ const position = new Float32Array(vertexCount * 3);
1283
+ const normal = new Float32Array(vertexCount * 3);
1284
+ const part = new Float32Array(vertexCount);
1285
+ const index = new Uint16Array(indexCount);
1286
+ let v = 0;
1287
+ let t = 0;
1288
+ for (const box of BOXES) {
1289
+ for (const face of FACES) {
1290
+ const base = v;
1291
+ const corner3 = new Float32Array(12);
1292
+ for (let ci = 0; ci < 4; ci++) {
1293
+ const corner = face.c[ci];
1294
+ for (let a = 0; a < 3; a++) {
1295
+ corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1296
+ }
1297
+ }
1298
+ const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1299
+ const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1300
+ let nx = ay * bz - az * by;
1301
+ let ny = az * bx - ax * bz;
1302
+ let nz = ax * by - ay * bx;
1303
+ const nl = Math.hypot(nx, ny, nz);
1304
+ if (nl > 1e-9) {
1305
+ nx /= nl;
1306
+ ny /= nl;
1307
+ nz /= nl;
1308
+ } else {
1309
+ nx = face.n[0];
1310
+ ny = face.n[1];
1311
+ nz = face.n[2];
1312
+ }
1313
+ if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1314
+ nx = -nx;
1315
+ ny = -ny;
1316
+ nz = -nz;
1317
+ }
1318
+ for (let ci = 0; ci < 4; ci++) {
1319
+ position[v * 3] = corner3[ci * 3];
1320
+ position[v * 3 + 1] = corner3[ci * 3 + 1];
1321
+ position[v * 3 + 2] = corner3[ci * 3 + 2];
1322
+ normal[v * 3] = nx;
1323
+ normal[v * 3 + 1] = ny;
1324
+ normal[v * 3 + 2] = nz;
1325
+ part[v] = box.part;
1326
+ v++;
1327
+ }
1328
+ index[t++] = base;
1329
+ index[t++] = base + 1;
1330
+ index[t++] = base + 2;
1331
+ index[t++] = base;
1332
+ index[t++] = base + 2;
1333
+ index[t++] = base + 3;
1334
+ }
1335
+ }
1336
+ return { position, normal, part, index, vertexCount, indexCount };
1337
+ }
1338
+ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1339
+ const yaw = new Float32Array(count);
1340
+ const px = (i) => iPosition[i * 3];
1341
+ const pz = (i) => iPosition[i * 3 + 2];
1342
+ let runStart = 0;
1343
+ const flushRun = (start, end) => {
1344
+ const n = end - start;
1345
+ for (let i = start; i < end; i++) {
1346
+ const [fx, fz] = focal(i);
1347
+ let dx = fx - px(i);
1348
+ let dz = fz - pz(i);
1349
+ if (n >= 2) {
1350
+ const a = Math.max(start, i - 1);
1351
+ const b = Math.min(end - 1, i + 1);
1352
+ const tx = px(b) - px(a);
1353
+ const tz = pz(b) - pz(a);
1354
+ const tl = Math.hypot(tx, tz);
1355
+ if (tl > 1e-6) {
1356
+ let nx = -tz / tl;
1357
+ let nz = tx / tl;
1358
+ if (nx * dx + nz * dz < 0) {
1359
+ nx = -nx;
1360
+ nz = -nz;
1361
+ }
1362
+ dx = nx;
1363
+ dz = nz;
1364
+ }
1365
+ }
1366
+ yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
1367
+ }
1368
+ };
1369
+ for (let i = 1; i <= count; i++) {
1370
+ if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
1371
+ flushRun(runStart, i);
1372
+ runStart = i;
1373
+ }
1374
+ }
1375
+ return yaw;
1376
+ }
1377
+
962
1378
  // src/view3d/scene/seatInstances.ts
963
1379
  var SEAT_DOT_RADIUS_M = 0.22;
1380
+ function seatOccupantSeed(stateIndex, seatIndex) {
1381
+ const state = SEAT_STATES[stateIndex];
1382
+ if (state !== "sold" && state !== "held") return -1;
1383
+ let h = (seatIndex + 1) * 2654435761;
1384
+ h ^= h >>> 15;
1385
+ h = Math.imul(h, 2246822519);
1386
+ h ^= h >>> 13;
1387
+ return (h >>> 0) % 1e5 / 1e5;
1388
+ }
964
1389
  var SEAT_PITCH_FRACTION = 0.42;
965
1390
  function nearestNeighbourSpacing(seats) {
966
1391
  const n = seats.length;
@@ -1023,11 +1448,13 @@ function seatSurfaceY(seat) {
1023
1448
  if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
1024
1449
  return 0;
1025
1450
  }
1026
- function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1451
+ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor) {
1027
1452
  const count = seats.length;
1028
1453
  const iPosition = new Float32Array(count * 3);
1029
1454
  const iState = new Float32Array(count);
1455
+ const iCategory = new Float32Array(count * 3);
1030
1456
  const iMaxRadius = new Float32Array(count);
1457
+ const iChairWidth = new Float32Array(count);
1031
1458
  const iRing = new Float32Array(count * 3);
1032
1459
  const idToIndex = /* @__PURE__ */ new Map();
1033
1460
  const spacing = nearestNeighbourSpacing(seats);
@@ -1036,10 +1463,15 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1036
1463
  const resolved = surfaces?.seatPitchU(i);
1037
1464
  const pitchM = (resolved ?? spacing[i]) * M;
1038
1465
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1466
+ iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
1039
1467
  iPosition[i * 3] = seat.x * M;
1040
1468
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
1041
1469
  iPosition[i * 3 + 2] = seat.y * M;
1042
1470
  iState[i] = seatStateIndex(initial ? initial(seat) : "available");
1471
+ const cat = hexToRgb(categoryColor?.get(seat.categoryKey) ?? "#6e7bff") ?? [0.43, 0.48, 1];
1472
+ iCategory[i * 3] = cat[0];
1473
+ iCategory[i * 3 + 1] = cat[1];
1474
+ iCategory[i * 3 + 2] = cat[2];
1043
1475
  if (seat.accessibility?.length) {
1044
1476
  const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
1045
1477
  if (rgb) {
@@ -1054,10 +1486,13 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1054
1486
  count,
1055
1487
  iPosition,
1056
1488
  iState,
1489
+ iCategory,
1057
1490
  iMaxRadius,
1058
1491
  iRing,
1059
1492
  idToIndex,
1060
- iFloor: seatFloor ?? new Float32Array(count)
1493
+ iChairWidth,
1494
+ iFloor: seatFloor ?? new Float32Array(count),
1495
+ iYaw: new Float32Array(count)
1061
1496
  };
1062
1497
  }
1063
1498
  var RUN_MERGE_GAP = 64;
@@ -1192,6 +1627,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
1192
1627
  }
1193
1628
  return kept;
1194
1629
  }
1630
+ var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
1631
+ function focusScore(screen, worldDistance, width, height) {
1632
+ const half = Math.max(1, Math.min(width, height) * 0.5);
1633
+ const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
1634
+ return worldDistance * (1 + 1.5 * off);
1635
+ }
1636
+ function pickDenseLabels(items, separationX, separationY, budget) {
1637
+ const ordered = [...items].sort((a, b) => a.focus - b.focus);
1638
+ const kept = [];
1639
+ for (const item of ordered) {
1640
+ if (kept.length >= budget) break;
1641
+ let clash = false;
1642
+ for (const k of kept) {
1643
+ if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
1644
+ clash = true;
1645
+ break;
1646
+ }
1647
+ }
1648
+ if (!clash) kept.push(item);
1649
+ }
1650
+ return kept;
1651
+ }
1195
1652
  function centroidOf(points) {
1196
1653
  if (!points.length) return null;
1197
1654
  let x = 0, y = 0;
@@ -1732,195 +2189,6 @@ function buildSectionRake(rows, focal) {
1732
2189
  return rowsRake(fits);
1733
2190
  }
1734
2191
 
1735
- // src/view3d/scene/surface.ts
1736
- var FLAT_SLAB_TOP_M = 0.05;
1737
- var CAP_MAX_ERROR_M = 0.05;
1738
- var SEAT_CLEARANCE_M = 0.15;
1739
- var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1740
- function bboxOf(pts) {
1741
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1742
- for (const p of pts) {
1743
- if (p.x < minX) minX = p.x;
1744
- if (p.y < minY) minY = p.y;
1745
- if (p.x > maxX) maxX = p.x;
1746
- if (p.y > maxY) maxY = p.y;
1747
- }
1748
- return { minX, minY, maxX, maxY };
1749
- }
1750
- var MAX_TIER_RISE_M = 25;
1751
- function buildVenueSurfaces(units, seats) {
1752
- const bySection = /* @__PURE__ */ new Map();
1753
- const seatOwner = new Array(seats.length).fill(null);
1754
- const seatDeck = new Float64Array(seats.length);
1755
- const seatRowLevel = new Array(seats.length).fill(void 0);
1756
- const seatPitch = new Array(seats.length).fill(void 0);
1757
- const acc = /* @__PURE__ */ new Map();
1758
- const boxes = [];
1759
- for (const unit of units) {
1760
- for (const o of unit.objects) {
1761
- if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1762
- const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
1763
- acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
1764
- boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
1765
- }
1766
- }
1767
- for (let i = 0; i < seats.length; i++) {
1768
- const s = seats[i];
1769
- for (const b of boxes) {
1770
- if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
1771
- if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
1772
- seatOwner[i] = b.id;
1773
- const a = acc.get(b.id);
1774
- a.hasSeats = true;
1775
- const f = s.focalPoint ?? b.unit.focal;
1776
- const d = Math.hypot(s.x - f.x, s.y - f.y);
1777
- if (d < a.frontU) a.frontU = d;
1778
- a.seatIndices.push(i);
1779
- const rowKey = s.rowId || `__seat-${i}`;
1780
- const arr = a.rows.get(rowKey);
1781
- if (arr) arr.push({ x: s.x, y: s.y });
1782
- else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
1783
- break;
1784
- }
1785
- }
1786
- for (const [id, a] of acc) {
1787
- const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
1788
- const bottomY = a.unit.baseHeightM;
1789
- const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
1790
- const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
1791
- const kind = a.section.surfaceKind;
1792
- const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
1793
- const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
1794
- const needsRake = structure.rows.length < 2;
1795
- const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
1796
- let frontU = Infinity;
1797
- if (rake) {
1798
- if (a.hasSeats) {
1799
- for (const pts of a.rows.values()) {
1800
- for (const p of pts) {
1801
- const d = rake.depthAt(p.x, p.y);
1802
- if (d < frontU) frontU = d;
1803
- }
1804
- }
1805
- }
1806
- if (!a.hasSeats || !Number.isFinite(frontU)) {
1807
- frontU = Infinity;
1808
- for (const p of a.section.outline) {
1809
- const d = rake.depthAt(p.x, p.y);
1810
- if (d < frontU) frontU = d;
1811
- }
1812
- }
1813
- }
1814
- const flatTop = bottomY + FLAT_SLAB_TOP_M;
1815
- const baseFloor = bottomY + FLAT_SLAB_TOP_M;
1816
- const levelFor = (depthU) => {
1817
- const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
1818
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1819
- return Math.max(baseFloor, geo.height + rise);
1820
- };
1821
- const levelForBlockDepth = (blockDepthU) => {
1822
- const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
1823
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1824
- return Math.max(baseFloor, geo.height + rise);
1825
- };
1826
- const rowLevels = flat ? [] : structure.rows.map((r) => ({
1827
- pts: r.pts,
1828
- y: levelForBlockDepth(r.blockDepth),
1829
- depth: r.blockDepth,
1830
- blockId: r.blockId
1831
- }));
1832
- const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
1833
- const rowBounds = rowLevels.map((r) => {
1834
- let cx = 0, cy = 0;
1835
- for (const p of r.pts) {
1836
- cx += p.x;
1837
- cy += p.y;
1838
- }
1839
- const n = r.pts.length || 1;
1840
- cx /= n;
1841
- cy /= n;
1842
- let rad = 0;
1843
- for (const p of r.pts) {
1844
- const d = Math.hypot(p.x - cx, p.y - cy);
1845
- if (d > rad) rad = d;
1846
- }
1847
- return { cx, cy, rad };
1848
- });
1849
- const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
1850
- let best = Infinity, bestY = landingY;
1851
- for (let i = 0; i < rowLevels.length; i++) {
1852
- const b = rowBounds[i];
1853
- if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
1854
- const d = distanceToPolyline(rowLevels[i].pts, x, y);
1855
- if (d < best) {
1856
- best = d;
1857
- bestY = rowLevels[i].y;
1858
- }
1859
- }
1860
- return bestY;
1861
- } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
1862
- const UP = [0, 1, 0];
1863
- const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
1864
- if (!rake) return UP;
1865
- const d = rake.depthAt(x, y);
1866
- if (d <= frontU) return UP;
1867
- const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
1868
- if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
1869
- if (geo.height + depthM * rakeTan <= baseFloor) return UP;
1870
- const [gx, gy] = rake.gradientAt(x, y);
1871
- if (gx === 0 && gy === 0) return UP;
1872
- const inv = 1 / Math.hypot(rakeTan, 1);
1873
- return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
1874
- };
1875
- if (!flat) {
1876
- for (const r of structure.rows) {
1877
- const y = levelForBlockDepth(r.blockDepth);
1878
- for (const si of r.seatIndices) seatRowLevel[si] = y;
1879
- }
1880
- }
1881
- for (const r of structure.rows) {
1882
- const gaps = [];
1883
- for (let k = 1; k < r.pts.length; k++) {
1884
- const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
1885
- if (d > 1e-6) gaps.push(d);
1886
- }
1887
- gaps.sort((x, y) => x - y);
1888
- const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
1889
- let across = Infinity;
1890
- const probe = r.pts[Math.floor(r.pts.length / 2)];
1891
- if (probe) {
1892
- for (const other of structure.rows) {
1893
- if (other === r || other.blockId !== r.blockId) continue;
1894
- const d = distanceToPolyline(other.pts, probe.x, probe.y);
1895
- if (d > 1e-6 && d < across) across = d;
1896
- }
1897
- }
1898
- const pitch = Math.min(along, Math.max(across, along * 0.5));
1899
- if (Number.isFinite(pitch) && pitch > 0) {
1900
- for (const si of r.seatIndices) seatPitch[si] = pitch;
1901
- }
1902
- }
1903
- bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1904
- }
1905
- for (let i = 0; i < seats.length; i++) {
1906
- const ownerId = seatOwner[i];
1907
- const s = seats[i];
1908
- if (ownerId) {
1909
- const own = seatRowLevel[i];
1910
- seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
1911
- continue;
1912
- }
1913
- const eye = s.eyeHeightM;
1914
- seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1915
- }
1916
- return {
1917
- bySection,
1918
- seatOwner,
1919
- seatDeckY: (i) => seatDeck[i],
1920
- seatPitchU: (i) => seatPitch[i]
1921
- };
1922
- }
1923
-
1924
2192
  // src/view3d/scene/deckBands.ts
1925
2193
  var import_polygon_clipping2 = __toESM(require("polygon-clipping"), 1);
1926
2194
  var import_earcut2 = __toESM(require("earcut"), 1);
@@ -1969,6 +2237,68 @@ function extendEnds(pts, by) {
1969
2237
  });
1970
2238
  return out;
1971
2239
  }
2240
+ var CORNER_STEP_RAD = Math.PI / 12;
2241
+ function convexHull(pts) {
2242
+ if (pts.length < 3) return [...pts];
2243
+ const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
2244
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
2245
+ const half = (src) => {
2246
+ const out = [];
2247
+ for (const p of src) {
2248
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
2249
+ out.push(p);
2250
+ }
2251
+ out.pop();
2252
+ return out;
2253
+ };
2254
+ const hull = [...half(s), ...half([...s].reverse())];
2255
+ return hull.length >= 3 ? hull : [...pts];
2256
+ }
2257
+ function seatClusterPatch(pts, pad) {
2258
+ if (!pts.length || !(pad > 0)) return [];
2259
+ const hull = convexHull(pts);
2260
+ const arc = (v, from, to, out2) => {
2261
+ let sweep = to - from;
2262
+ while (sweep < 0) sweep += Math.PI * 2;
2263
+ while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
2264
+ const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
2265
+ const r = pad / Math.cos(sweep / steps / 2);
2266
+ for (let k = 0; k <= steps; k++) {
2267
+ const a = from + sweep * k / steps;
2268
+ out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
2269
+ }
2270
+ };
2271
+ if (hull.length < 3) {
2272
+ let cx = 0, cy = 0;
2273
+ for (const p of hull) {
2274
+ cx += p.x;
2275
+ cy += p.y;
2276
+ }
2277
+ cx /= hull.length || 1;
2278
+ cy /= hull.length || 1;
2279
+ let far = 0;
2280
+ for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
2281
+ const out2 = [];
2282
+ const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
2283
+ const r = (far + pad) / Math.cos(Math.PI / steps);
2284
+ for (let k = 0; k < steps; k++) {
2285
+ const a = k / steps * Math.PI * 2;
2286
+ out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
2287
+ }
2288
+ return out2;
2289
+ }
2290
+ const n = hull.length;
2291
+ const edgeAngle = [];
2292
+ for (let i = 0; i < n; i++) {
2293
+ const a = hull[i], b = hull[(i + 1) % n];
2294
+ edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
2295
+ }
2296
+ const out = [];
2297
+ for (let i = 0; i < n; i++) {
2298
+ arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
2299
+ }
2300
+ return dedupeAdjacent(out);
2301
+ }
1972
2302
  function distToPolyline(pts, x, y) {
1973
2303
  if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1974
2304
  let best = Infinity;
@@ -2099,7 +2429,14 @@ function deckFootprints(rows, focal, shared) {
2099
2429
  else byBlock.set(rows[i].blockId, [i]);
2100
2430
  }
2101
2431
  const out = [];
2102
- for (const [, indices] of byBlock) {
2432
+ for (const [, allIndices] of byBlock) {
2433
+ const indices = [];
2434
+ for (const i of allIndices) {
2435
+ const patch = rows[i].patch;
2436
+ if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
2437
+ else indices.push(i);
2438
+ }
2439
+ if (!indices.length) continue;
2103
2440
  indices.sort((a, b) => rows[a].depth - rows[b].depth);
2104
2441
  const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
2105
2442
  const usable = ribbons.filter((r) => r !== null);
@@ -2227,15 +2564,18 @@ var ClipTest = class {
2227
2564
  return false;
2228
2565
  }
2229
2566
  };
2230
- function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
2231
- if (clipTest.containsAll(quad)) {
2567
+ function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
2568
+ if (poly.length < 3) return;
2569
+ if (clipTest.containsAll(poly)) {
2232
2570
  const UPF = [0, 1, 0];
2233
- const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
2234
- builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
2235
- builder.tri([a.x * M, y, a.y * M], [c.x * M, y, c.y * M], [d.x * M, y, d.y * M], UPF, color);
2571
+ const a = poly[0];
2572
+ for (let i = 1; i + 1 < poly.length; i++) {
2573
+ const b = poly[i], c = poly[i + 1];
2574
+ builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
2575
+ }
2236
2576
  return;
2237
2577
  }
2238
- const ring = quad.map((p) => [p.x, p.y]);
2578
+ const ring = poly.map((p) => [p.x, p.y]);
2239
2579
  ring.push(ring[0]);
2240
2580
  let pieces;
2241
2581
  try {
@@ -2244,9 +2584,9 @@ function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
2244
2584
  return;
2245
2585
  }
2246
2586
  const UP = [0, 1, 0];
2247
- for (const poly of pieces) {
2248
- if (!poly.length || poly[0].length < 4) continue;
2249
- const outer = poly[0];
2587
+ for (const poly2 of pieces) {
2588
+ if (!poly2.length || poly2[0].length < 4) continue;
2589
+ const outer = poly2[0];
2250
2590
  const flat = [];
2251
2591
  const pts = [];
2252
2592
  for (let i = 0; i < outer.length - 1; i++) {
@@ -2275,6 +2615,46 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2275
2615
  const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2276
2616
  for (let i = 0; i < rows.length; i++) {
2277
2617
  const row = rows[i];
2618
+ if (row.patch && row.patch.length >= 3) {
2619
+ const patch = [...row.patch];
2620
+ const belowY2 = nbrs[i].belowY ?? landingY;
2621
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2622
+ else {
2623
+ const a = patch[0];
2624
+ for (let k = 1; k + 1 < patch.length; k++) {
2625
+ const b = patch[k], c = patch[k + 1];
2626
+ builder.tri([a.x * M, row.y, a.y * M], [b.x * M, row.y, b.y * M], [c.x * M, row.y, c.y * M], UP, colors.tread);
2627
+ }
2628
+ }
2629
+ if (row.y > belowY2 + MIN_RISER_M) {
2630
+ let cx = 0, cy = 0;
2631
+ for (const p of patch) {
2632
+ cx += p.x;
2633
+ cy += p.y;
2634
+ }
2635
+ cx /= patch.length;
2636
+ cy /= patch.length;
2637
+ for (let k = 0; k < patch.length; k++) {
2638
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2639
+ const dx = q.x - p.x, dy = q.y - p.y;
2640
+ const len = Math.hypot(dx, dy);
2641
+ if (len < 1e-6) continue;
2642
+ let nx = dy / len, ny = -dx / len;
2643
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2644
+ nx = -nx;
2645
+ ny = -ny;
2646
+ }
2647
+ const rn = [nx, 0, ny];
2648
+ const pt = [p.x * M, row.y, p.y * M];
2649
+ const qt = [q.x * M, row.y, q.y * M];
2650
+ const pb = [p.x * M, belowY2, p.y * M];
2651
+ const qb = [q.x * M, belowY2, q.y * M];
2652
+ builder.tri(pt, qt, qb, rn, colors.riser);
2653
+ builder.tri(pt, qb, pb, rn, colors.riser);
2654
+ }
2655
+ }
2656
+ continue;
2657
+ }
2278
2658
  const rib = ribbonOf(rows, i, nbrs, focal);
2279
2659
  if (!rib) continue;
2280
2660
  const { pts, nrm, front, back } = rib;
@@ -2289,7 +2669,7 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2289
2669
  const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2290
2670
  const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2291
2671
  if (clipRing && clipTest) {
2292
- emitClippedQuad(builder, clipRing, clipTest, [
2672
+ emitClippedPoly(builder, clipRing, clipTest, [
2293
2673
  { x: p.x - np[0] * front, y: p.y - np[1] * front },
2294
2674
  { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2295
2675
  { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
@@ -2310,6 +2690,208 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2310
2690
  }
2311
2691
  }
2312
2692
 
2693
+ // src/view3d/scene/surface.ts
2694
+ var FLAT_SLAB_TOP_M = 0.05;
2695
+ var CAP_MAX_ERROR_M = 0.05;
2696
+ var SEAT_CLEARANCE_M = 0.15;
2697
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
2698
+ function bboxOf(pts) {
2699
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2700
+ for (const p of pts) {
2701
+ if (p.x < minX) minX = p.x;
2702
+ if (p.y < minY) minY = p.y;
2703
+ if (p.x > maxX) maxX = p.x;
2704
+ if (p.y > maxY) maxY = p.y;
2705
+ }
2706
+ return { minX, minY, maxX, maxY };
2707
+ }
2708
+ function pointInRing2(ring, x, y) {
2709
+ let inside = false;
2710
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2711
+ const a = ring[i], b = ring[j];
2712
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
2713
+ }
2714
+ return inside;
2715
+ }
2716
+ var MAX_TIER_RISE_M = 25;
2717
+ function buildVenueSurfaces(units, seats) {
2718
+ const bySection = /* @__PURE__ */ new Map();
2719
+ const seatOwner = new Array(seats.length).fill(null);
2720
+ const seatDeck = new Float64Array(seats.length);
2721
+ const seatRowLevel = new Array(seats.length).fill(void 0);
2722
+ const seatPitch = new Array(seats.length).fill(void 0);
2723
+ const acc = /* @__PURE__ */ new Map();
2724
+ const boxes = [];
2725
+ const tableRowIds = /* @__PURE__ */ new Set();
2726
+ for (const unit of units) {
2727
+ for (const o of unit.objects) {
2728
+ if (o.type === "table") tableRowIds.add(o.id);
2729
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2730
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2731
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2732
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2733
+ }
2734
+ }
2735
+ for (let i = 0; i < seats.length; i++) {
2736
+ const s = seats[i];
2737
+ for (const b of boxes) {
2738
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2739
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2740
+ seatOwner[i] = b.id;
2741
+ const a = acc.get(b.id);
2742
+ a.hasSeats = true;
2743
+ const f = s.focalPoint ?? b.unit.focal;
2744
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
2745
+ if (d < a.frontU) a.frontU = d;
2746
+ a.seatIndices.push(i);
2747
+ const rowKey = s.rowId || `__seat-${i}`;
2748
+ const arr = a.rows.get(rowKey);
2749
+ if (arr) arr.push({ x: s.x, y: s.y });
2750
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
2751
+ break;
2752
+ }
2753
+ }
2754
+ for (const [id, a] of acc) {
2755
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
2756
+ const bottomY = a.unit.baseHeightM;
2757
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
2758
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
2759
+ const kind = a.section.surfaceKind;
2760
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
2761
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
2762
+ const needsRake = structure.rows.length < 2;
2763
+ const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
2764
+ let frontU = Infinity;
2765
+ if (rake) {
2766
+ if (a.hasSeats) {
2767
+ for (const pts of a.rows.values()) {
2768
+ for (const p of pts) {
2769
+ const d = rake.depthAt(p.x, p.y);
2770
+ if (d < frontU) frontU = d;
2771
+ }
2772
+ }
2773
+ }
2774
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
2775
+ frontU = Infinity;
2776
+ for (const p of a.section.outline) {
2777
+ const d = rake.depthAt(p.x, p.y);
2778
+ if (d < frontU) frontU = d;
2779
+ }
2780
+ }
2781
+ }
2782
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
2783
+ const flatTop = Math.max(baseFloor, geo.height);
2784
+ const levelFor = (depthU) => {
2785
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
2786
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2787
+ return Math.max(baseFloor, geo.height + rise);
2788
+ };
2789
+ const levelForBlockDepth = (blockDepthU) => {
2790
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
2791
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2792
+ return Math.max(baseFloor, geo.height + rise);
2793
+ };
2794
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
2795
+ pts: r.pts,
2796
+ y: levelForBlockDepth(r.blockDepth),
2797
+ depth: r.blockDepth,
2798
+ blockId: r.blockId,
2799
+ ...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
2800
+ }));
2801
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
2802
+ const rowBounds = rowLevels.map((r) => {
2803
+ const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
2804
+ let cx = 0, cy = 0;
2805
+ for (const p of ext) {
2806
+ cx += p.x;
2807
+ cy += p.y;
2808
+ }
2809
+ const n = ext.length || 1;
2810
+ cx /= n;
2811
+ cy /= n;
2812
+ let rad = 0;
2813
+ for (const p of ext) {
2814
+ const d = Math.hypot(p.x - cx, p.y - cy);
2815
+ if (d > rad) rad = d;
2816
+ }
2817
+ return { cx, cy, rad };
2818
+ });
2819
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
2820
+ let best = Infinity, bestY = landingY;
2821
+ for (let i = 0; i < rowLevels.length; i++) {
2822
+ const b = rowBounds[i];
2823
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
2824
+ const patch = rowLevels[i].patch;
2825
+ const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
2826
+ if (d < best) {
2827
+ best = d;
2828
+ bestY = rowLevels[i].y;
2829
+ }
2830
+ }
2831
+ return bestY;
2832
+ } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
2833
+ const UP = [0, 1, 0];
2834
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
2835
+ if (!rake) return UP;
2836
+ const d = rake.depthAt(x, y);
2837
+ if (d <= frontU) return UP;
2838
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
2839
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
2840
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
2841
+ const [gx, gy] = rake.gradientAt(x, y);
2842
+ if (gx === 0 && gy === 0) return UP;
2843
+ const inv = 1 / Math.hypot(rakeTan, 1);
2844
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
2845
+ };
2846
+ if (!flat) {
2847
+ for (const r of structure.rows) {
2848
+ const y = levelForBlockDepth(r.blockDepth);
2849
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
2850
+ }
2851
+ }
2852
+ for (const r of structure.rows) {
2853
+ const gaps = [];
2854
+ for (let k = 1; k < r.pts.length; k++) {
2855
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
2856
+ if (d > 1e-6) gaps.push(d);
2857
+ }
2858
+ gaps.sort((x, y) => x - y);
2859
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
2860
+ let across = Infinity;
2861
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
2862
+ if (probe) {
2863
+ for (const other of structure.rows) {
2864
+ if (other === r || other.blockId !== r.blockId) continue;
2865
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
2866
+ if (d > 1e-6 && d < across) across = d;
2867
+ }
2868
+ }
2869
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
2870
+ if (Number.isFinite(pitch) && pitch > 0) {
2871
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
2872
+ }
2873
+ }
2874
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
2875
+ }
2876
+ for (let i = 0; i < seats.length; i++) {
2877
+ const ownerId = seatOwner[i];
2878
+ const s = seats[i];
2879
+ if (ownerId) {
2880
+ const own = seatRowLevel[i];
2881
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
2882
+ continue;
2883
+ }
2884
+ const eye = s.eyeHeightM;
2885
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
2886
+ }
2887
+ return {
2888
+ bySection,
2889
+ seatOwner,
2890
+ seatDeckY: (i) => seatDeck[i],
2891
+ seatPitchU: (i) => seatPitch[i]
2892
+ };
2893
+ }
2894
+
2313
2895
  // src/view3d/scene/sceneModel.ts
2314
2896
  function floorUnits(doc) {
2315
2897
  if (doc.floors?.length) {
@@ -2475,22 +3057,39 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2475
3057
  }
2476
3058
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
2477
3059
  const topN = (p) => surface.normalAt(p.x, p.y);
2478
- for (const ring of claimed.subtract(outline)) {
3060
+ const level = surface.flat ? surface.landingY : void 0;
3061
+ for (const ring of claimed.subtract(outline, level)) {
2479
3062
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
2480
3063
  }
2481
3064
  }
3065
+ function coplanarLevels(a, b) {
3066
+ if (a === void 0 || b === void 0) return true;
3067
+ return Math.abs(a - b) < 1e-3;
3068
+ }
2482
3069
  var ClaimedArea = class {
2483
3070
  constructor() {
2484
3071
  this.rings = [];
2485
3072
  this.boxes = [];
3073
+ /** Constant deck height of each claim, or undefined when it is not level. */
3074
+ this.levels = [];
2486
3075
  }
2487
- /** `ring` minus everything claimed so far; then claim what is returned. */
2488
- subtract(ring) {
3076
+ /**
3077
+ * `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
3078
+ *
3079
+ * `level` is the claim's constant deck height when it has one. Two decks only
3080
+ * z-fight when they are coplanar, so only a coplanar claim may take ground
3081
+ * away — an elevated box hanging over a ground-level section overlaps it in
3082
+ * plan and must still draw its whole floor, or the box loses the part of its
3083
+ * deck that shares a footprint with whatever is underneath it. Passing
3084
+ * undefined (a surface with no single height) keeps the original
3085
+ * clip-against-everything behaviour.
3086
+ */
3087
+ subtract(ring, level) {
2489
3088
  const closed = ring.map((p) => [p.x, p.y]);
2490
3089
  if (closed.length < 3) return [];
2491
3090
  closed.push(closed[0]);
2492
3091
  const box = bboxOfRing(ring);
2493
- const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
3092
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
2494
3093
  let pieces = [[closed]];
2495
3094
  if (overlapping.length) {
2496
3095
  try {
@@ -2502,6 +3101,7 @@ var ClaimedArea = class {
2502
3101
  }
2503
3102
  this.rings.push([closed]);
2504
3103
  this.boxes.push(box);
3104
+ this.levels.push(level);
2505
3105
  const out = [];
2506
3106
  for (const poly of pieces) {
2507
3107
  if (!poly.length) continue;
@@ -2594,11 +3194,27 @@ function shapePolygon(shape) {
2594
3194
  }
2595
3195
  return null;
2596
3196
  }
2597
- function buildShape(builder, shape, base, S) {
3197
+ function buildShape(builder, shape, base, S, stages) {
2598
3198
  const poly = shapePolygon(shape);
2599
3199
  if (!poly) return;
2600
3200
  const isStage = shape.role === "stage";
2601
3201
  const height = isStage ? base + 1 : base + 0.25;
3202
+ if (isStage && stages) {
3203
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
3204
+ for (const pt of poly) {
3205
+ if (pt.x < minX) minX = pt.x;
3206
+ if (pt.x > maxX) maxX = pt.x;
3207
+ if (pt.y < minZ) minZ = pt.y;
3208
+ if (pt.y > maxZ) maxZ = pt.y;
3209
+ }
3210
+ stages.push({
3211
+ cx: (minX + maxX) / 2 * M,
3212
+ cz: (minZ + maxZ) / 2 * M,
3213
+ y: height,
3214
+ halfX: Math.max((maxX - minX) / 2 * M, 0.5),
3215
+ halfZ: Math.max((maxZ - minZ) / 2 * M, 0.5)
3216
+ });
3217
+ }
2602
3218
  const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
2603
3219
  const colWall = isStage ? S.stageWall : S.decorWall;
2604
3220
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
@@ -2708,6 +3324,7 @@ function buildSceneModel(input) {
2708
3324
  const sectionFills = resolveSectionFills(doc, seats);
2709
3325
  const catColor = /* @__PURE__ */ new Map();
2710
3326
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
3327
+ const stageBounds = [];
2711
3328
  const surfaces = buildVenueSurfaces(units, seats);
2712
3329
  const seatFloor = new Float32Array(seats.length);
2713
3330
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
@@ -2717,7 +3334,7 @@ function buildSceneModel(input) {
2717
3334
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
2718
3335
  for (const o of unit.objects) {
2719
3336
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2720
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
3337
+ else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S, stageBounds);
2721
3338
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2722
3339
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2723
3340
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
@@ -2726,7 +3343,7 @@ function buildSceneModel(input) {
2726
3343
  }
2727
3344
  const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
2728
3345
  const solids = mergeMeshData([builder.build()]);
2729
- const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
3346
+ const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor, catColor);
2730
3347
  const zoneDefs = doc.zones ?? [];
2731
3348
  const zones = [];
2732
3349
  if (zoneDefs.length) {
@@ -2957,6 +3574,15 @@ function buildSceneModel(input) {
2957
3574
  });
2958
3575
  }
2959
3576
  }
3577
+ seatData.iYaw = computeSeatYaw(
3578
+ seatData.iPosition,
3579
+ seats.length,
3580
+ (i) => seats[i].rowId,
3581
+ (i) => {
3582
+ const f = units[seatFloor[i]]?.focal ?? focal;
3583
+ return [f.x * M, f.y * M];
3584
+ }
3585
+ );
2960
3586
  const cx = (fp.minX + fp.maxX) / 2 * M;
2961
3587
  const cz = (fp.minY + fp.maxY) / 2 * M;
2962
3588
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
@@ -2970,6 +3596,7 @@ function buildSceneModel(input) {
2970
3596
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
2971
3597
  focalWorld: [focal.x * M, 1.5, focal.y * M],
2972
3598
  zones,
3599
+ stages: stageBounds,
2973
3600
  sections,
2974
3601
  labels,
2975
3602
  floors
@@ -2994,7 +3621,11 @@ var KIND_STYLE = {
2994
3621
  var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
2995
3622
  var DENSE_SEPARATION = {
2996
3623
  row: { x: 62, y: 16 },
2997
- seat: { x: 24, y: 13 }
3624
+ // Widened from 24: at close range seat labels stopped overlapping at all, so
3625
+ // the separation had no work left to do and the budget was carrying the whole
3626
+ // load. A wider box means the few labels that ARE kept are spread across the
3627
+ // seating instead of clustering into one stack.
3628
+ seat: { x: 46, y: 18 }
2998
3629
  };
2999
3630
  var LabelOverlay = class {
3000
3631
  constructor(container, opts = {}) {
@@ -3011,6 +3642,20 @@ var LabelOverlay = class {
3011
3642
  if (opts.fontFamily) s.fontFamily = opts.fontFamily;
3012
3643
  container.appendChild(this.root);
3013
3644
  }
3645
+ /**
3646
+ * Hide or show the whole overlay without disturbing which labels exist.
3647
+ *
3648
+ * The in-scene panorama sphere is GL, so it draws BEHIND every DOM label —
3649
+ * row and seat labels floated on top of a 360 photo until this existed. The
3650
+ * old DOM panorama never needed it because its opaque div covered them.
3651
+ *
3652
+ * Deliberately not `setLabels([])`: that destroys the nodes and the declutter
3653
+ * state, so closing the panorama would rebuild and re-rank the whole overlay
3654
+ * and flash. Visibility is a view concern, not a data one.
3655
+ */
3656
+ setVisible(visible) {
3657
+ this.root.style.visibility = visible ? "" : "hidden";
3658
+ }
3014
3659
  setLabels(labels) {
3015
3660
  this.labels = labels;
3016
3661
  for (const [id, node] of this.nodes) {
@@ -3025,7 +3670,7 @@ var LabelOverlay = class {
3025
3670
  *
3026
3671
  * `viewProjection` is column-major, as OGL supplies it.
3027
3672
  */
3028
- update(viewProjection, width, height, cameraDistance, venueRadius) {
3673
+ update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
3029
3674
  if (!this.labels.length) return;
3030
3675
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
3031
3676
  const candidates = [];
@@ -3033,15 +3678,24 @@ var LabelOverlay = class {
3033
3678
  if (!kinds.has(label.kind)) continue;
3034
3679
  const screen = projectToScreen(viewProjection, label.anchor, width, height);
3035
3680
  if (!screen.visible) continue;
3036
- candidates.push({ label, screen });
3681
+ const world = cameraWorld ? Math.hypot(
3682
+ label.anchor[0] - cameraWorld[0],
3683
+ label.anchor[1] - cameraWorld[1],
3684
+ label.anchor[2] - cameraWorld[2]
3685
+ ) : 1;
3686
+ candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
3037
3687
  }
3038
3688
  const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
3039
3689
  const kept = [
3040
3690
  ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
3041
- ...["row", "seat"].flatMap((kind) => cullOverlapping(
3691
+ // The dense rungs are BUDGETED, not merely deduplicated — see
3692
+ // DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
3693
+ // the camera gets.
3694
+ ...["row", "seat"].flatMap((kind) => pickDenseLabels(
3042
3695
  candidates.filter((c) => c.label.kind === kind),
3043
3696
  DENSE_SEPARATION[kind].x,
3044
- DENSE_SEPARATION[kind].y
3697
+ DENSE_SEPARATION[kind].y,
3698
+ DENSE_LABEL_BUDGET[kind]
3045
3699
  ))
3046
3700
  ];
3047
3701
  const keptIds = new Set(kept.map((k) => k.label.id));
@@ -3090,6 +3744,13 @@ var import_ogl4 = require("ogl");
3090
3744
 
3091
3745
  // src/view3d/scene/materials.ts
3092
3746
  var import_ogl3 = require("ogl");
3747
+ var CHAIR_WEIGHT_GLSL = (
3748
+ /* glsl */
3749
+ `
3750
+ float chairWeight(float depth) {
3751
+ return 1.0 - smoothstep(uChairFull, uChairNone, depth);
3752
+ }`
3753
+ );
3093
3754
  var SOLID_VERT = (
3094
3755
  /* glsl */
3095
3756
  `#version 300 es
@@ -3169,14 +3830,23 @@ uniform float uSeatScale;
3169
3830
  uniform float uMinPixels;
3170
3831
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
3171
3832
  uniform float uFocusFloor; // -1 = show every floor
3833
+ uniform float uChairFull; // view depth at which the chair mesh is full size
3834
+ uniform float uChairNone; // ...and at which it has scaled away entirely
3172
3835
  out vec2 vUv;
3173
3836
  out vec3 vColor;
3174
3837
  out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
3175
3838
  out vec3 vRing;
3176
3839
  out float vDim;
3840
+ out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3841
+ ${CHAIR_WEIGHT_GLSL}
3177
3842
  void main() {
3178
3843
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
3179
3844
  float depth = max(-mv.z, 0.001);
3845
+ // Hand the seat over to the chair mesh as it comes into range. Derived from
3846
+ // this instance's OWN depth rather than from a global uniform, so a row two
3847
+ // metres away and the far side of the bowl resolve differently in the same
3848
+ // frame \u2014 which is the entire point of a ladder over a switch.
3849
+ vDotWeight = 1.0 - chairWeight(depth);
3180
3850
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
3181
3851
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
3182
3852
  // unbounded growth is what merges neighbouring rows into one mass at range.
@@ -3210,6 +3880,7 @@ in vec3 vColor;
3210
3880
  in float vBudget;
3211
3881
  in vec3 vRing;
3212
3882
  in float vDim;
3883
+ in float vDotWeight;
3213
3884
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
3214
3885
  uniform vec3 uFadeColor;
3215
3886
  out vec4 fragColor;
@@ -3234,9 +3905,173 @@ void main() {
3234
3905
  // Seats on an unfocused floor recede with their structure.
3235
3906
  c = mix(c, uFadeColor, vDim * 0.75);
3236
3907
  alpha *= mix(1.0, 0.30, vDim);
3908
+ // Yield to the chair. The chair grows out of this exact point, so through the
3909
+ // band the dot is always at least as big as the chair inside it and the seat
3910
+ // never thins out to nothing in between.
3911
+ alpha *= vDotWeight;
3912
+ if (alpha <= 0.0) discard;
3237
3913
  fragColor = vec4(c, alpha);
3238
3914
  }`
3239
3915
  );
3916
+ var CHAIR_VERT = (
3917
+ /* glsl */
3918
+ `#version 300 es
3919
+ precision highp float;
3920
+ in vec3 position; // local: x/z in units of the seat radius, y in METRES
3921
+ in vec3 normal;
3922
+ in float part; // 0 = pedestal, 1 = pad, 2 = back, 3 = body, 4 = head
3923
+ in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3924
+ in vec3 iColor; // per-instance state colour
3925
+ in float iRadius; // per-instance horizontal half-width, world metres
3926
+ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3927
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3928
+ in float iFloor;
3929
+ in float iSeed; // <0 = seat is empty; else per-person hash in [0,1)
3930
+ uniform mat4 modelViewMatrix;
3931
+ uniform mat4 projectionMatrix;
3932
+ uniform float uChairFull;
3933
+ uniform float uChairNone;
3934
+ uniform float uFocusFloor;
3935
+ uniform float uBackRake; // metres of z per metre of rise, above uBackBase
3936
+ uniform float uBackBase; // local height at which the back starts
3937
+ out vec3 vColor;
3938
+ out vec3 vNormalWorld;
3939
+ out vec3 vNormalView;
3940
+ out vec3 vPosView;
3941
+ out float vPart;
3942
+ out float vHeight; // local height in metres, for the vertical occlusion ramp
3943
+ out vec3 vRing;
3944
+ out float vDim;
3945
+ out float vOccupant; // 1 = this vertex belongs to a person, not to the chair
3946
+ out vec3 vOccupantTint;
3947
+ ${CHAIR_WEIGHT_GLSL}
3948
+ void main() {
3949
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
3950
+ float w = chairWeight(max(-anchor.z, 0.001));
3951
+ // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
3952
+ // chairs, but nobody gets a short one (people are the same height at every
3953
+ // seat pitch).
3954
+ vec3 p = position;
3955
+ // The occupant. Parts 3 and 4 are a person; everything below is furniture.
3956
+ //
3957
+ // One instanced mesh draws both an empty seat and a taken one, because the
3958
+ // alternative \u2014 a second geometry and a second draw call gathered per frame \u2014
3959
+ // would double the near-field cost to show what is already known per instance.
3960
+ // An unoccupied seat collapses its person to a point at the seat, which the
3961
+ // rasteriser discards, exactly as the chair itself collapses at w=0.
3962
+ float occupant = step(2.5, part);
3963
+ float taken = step(0.0, iSeed);
3964
+ vOccupant = occupant;
3965
+ if (occupant > 0.5) {
3966
+ if (taken < 0.5) {
3967
+ p = vec3(0.0); // empty seat: no person
3968
+ } else {
3969
+ // Vary the build so a sold-out row is people rather than a rank of
3970
+ // identical mannequins: +/-6% height and +/-8% width off the hash.
3971
+ p.y *= 0.94 + 0.12 * iSeed;
3972
+ p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
3973
+ }
3974
+ }
3975
+ p.xz *= iRadius;
3976
+ // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3977
+ // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3978
+ // width and the same chair would lean 20 degrees on a wide stadium row and
3979
+ // 6 on a tight theatre one.
3980
+ // Only the BACK panel rakes. The old test (part > 1.5) meant "the back", and
3981
+ // now also catches the occupant \u2014 leaning a person by the panel's rule would
3982
+ // translate their head backwards by a rake measured from the panel's base.
3983
+ float rake = (part > 1.5 && part < 2.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3984
+ p.z -= rake;
3985
+ // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3986
+ // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
3987
+ // the growth so the chair is already near full size while the dot is still
3988
+ // half there; see chairScale() in lod.ts.
3989
+ p *= sqrt(w);
3990
+ float c = cos(iYaw), s = sin(iYaw);
3991
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
3992
+ // Normals under the same two transforms, in reverse and inverted-transposed.
3993
+ // The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
3994
+ // unchanged; and the rake is a shear, whose normal transform adds a y term.
3995
+ // Skipping either lights the raked back as though it were still vertical.
3996
+ vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3997
+ if (part > 1.5 && part < 2.5) n.y += uBackRake * n.z;
3998
+ n = normalize(n);
3999
+ vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
4000
+ vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
4001
+ vPosView = mv.xyz;
4002
+ vNormalWorld = rn;
4003
+ vNormalView = normalize(mat3(modelViewMatrix) * rn);
4004
+ vColor = iColor;
4005
+ // Occupant colour, resolved here so the fragment stage needs no extra
4006
+ // varyings beyond this one. A person is NOT painted in the seat's state
4007
+ // colour: a sold seat is red, and a hall of red people reads as a warning,
4008
+ // not an audience. Hair/clothing for the body, a warm tone for the head,
4009
+ // both varied by the same per-person hash.
4010
+ float t = fract(iSeed * 3.71);
4011
+ vec3 clothes = mix(vec3(0.13, 0.15, 0.20), vec3(0.34, 0.30, 0.36), t);
4012
+ vec3 skin = mix(vec3(0.52, 0.38, 0.29), vec3(0.86, 0.70, 0.58), fract(iSeed * 11.3));
4013
+ vOccupantTint = (part > 3.5) ? skin : clothes;
4014
+ vPart = part;
4015
+ vHeight = position.y;
4016
+ vRing = iRing;
4017
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
4018
+ gl_Position = projectionMatrix * mv;
4019
+ }`
4020
+ );
4021
+ var CHAIR_FRAG = (
4022
+ /* glsl */
4023
+ `#version 300 es
4024
+ precision highp float;
4025
+ in vec3 vColor;
4026
+ in vec3 vNormalWorld;
4027
+ in vec3 vNormalView;
4028
+ in vec3 vPosView;
4029
+ in float vPart;
4030
+ in float vHeight;
4031
+ in vec3 vRing;
4032
+ in float vDim;
4033
+ in float vOccupant;
4034
+ in vec3 vOccupantTint;
4035
+ uniform vec3 uKeyDir;
4036
+ uniform vec3 uFadeColor;
4037
+ out vec4 fragColor;
4038
+ void main() {
4039
+ vec3 N = normalize(vNormalWorld);
4040
+ vec3 V = normalize(-vPosView);
4041
+ // The solids' rig, unchanged, so the chairs sit in the venue's light.
4042
+ float hemi = 0.5 + 0.5 * N.y;
4043
+ float key = max(dot(N, uKeyDir), 0.0);
4044
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
4045
+ float fill = max(dot(N, fillDir), 0.0);
4046
+ vec3 tint = vColor;
4047
+ if (vOccupant > 0.5) tint = vOccupantTint;
4048
+ // The accommodation ring, kept legible once the dot (which drew it) is gone:
4049
+ // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
4050
+ // survives to close range instead of vanishing exactly when the buyer arrives.
4051
+ float ringMask = step(0.001, dot(vRing, vRing));
4052
+ if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
4053
+ // Pad brightest, back a step below it, pedestal darkest. Three untextured
4054
+ // boxes only read as one object if they are separated tonally \u2014 with a single
4055
+ // flat colour the chair silhouettes as a crate.
4056
+ // A person is lit as a person, not as upholstery: no part shading, and less
4057
+ // of the pad's sheen, so a head does not read as a polished box.
4058
+ float partShade = vOccupant > 0.5 ? 0.95 : (vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80));
4059
+ // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
4060
+ // sits to the deck the less sky it can actually see. This is the depth cue \u2014
4061
+ // without it the pad top, the back and the deck all resolve to the same flat
4062
+ // value and the row loses its form entirely.
4063
+ // Occupants rise above the 0.92 m chair back, so their ramp uses their own
4064
+ // height or every head would clamp to full brightness and float.
4065
+ float ao = mix(0.58, 1.0, clamp(vHeight / (vOccupant > 0.5 ? 1.30 : 0.92), 0.0, 1.0));
4066
+ vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
4067
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
4068
+ // A brighter rim than the solids get: it picks out every chair's own edge,
4069
+ // which is what stops a block of them merging into one mass up close.
4070
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
4071
+ base = mix(base, uFadeColor, vDim * 0.75);
4072
+ fragColor = vec4(base, 1.0);
4073
+ }`
4074
+ );
3240
4075
  var BG_VERT = (
3241
4076
  /* glsl */
3242
4077
  `#version 300 es
@@ -3333,7 +4168,7 @@ function createSeatPickProgram(gl) {
3333
4168
  uniforms: {
3334
4169
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
3335
4170
  uSeatScale: { value: 1 },
3336
- uMinPixels: { value: 2.5 },
4171
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
3337
4172
  uPixelToWorld: { value: 2e-3 }
3338
4173
  }
3339
4174
  });
@@ -3378,11 +4213,32 @@ function createSeatProgram(gl) {
3378
4213
  uniforms: {
3379
4214
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
3380
4215
  uSeatScale: { value: 1 },
3381
- uMinPixels: { value: 2.5 },
4216
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
3382
4217
  uPixelToWorld: { value: 2e-3 },
3383
4218
  uSeatFade: { value: 0 },
3384
4219
  uFocusFloor: { value: -1 },
3385
- uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
4220
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4221
+ uChairFull: { value: CHAIR_FULL_M },
4222
+ uChairNone: { value: CHAIR_NONE_M }
4223
+ }
4224
+ });
4225
+ }
4226
+ function createChairProgram(gl) {
4227
+ return new import_ogl3.Program(gl, {
4228
+ vertex: CHAIR_VERT,
4229
+ fragment: CHAIR_FRAG,
4230
+ transparent: false,
4231
+ depthTest: true,
4232
+ depthWrite: true,
4233
+ cullFace: false,
4234
+ uniforms: {
4235
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
4236
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4237
+ uChairFull: { value: CHAIR_FULL_M },
4238
+ uChairNone: { value: CHAIR_NONE_M },
4239
+ uBackRake: { value: BACK_RAKE_SLOPE },
4240
+ uBackBase: { value: BACK_BASE_M },
4241
+ uFocusFloor: { value: -1 }
3386
4242
  }
3387
4243
  });
3388
4244
  }
@@ -3401,12 +4257,13 @@ function createBackgroundProgram(gl, top, bottom) {
3401
4257
  }
3402
4258
 
3403
4259
  // src/view3d/scene/build.ts
3404
- function writeSeatColors(iColor, iState, start, count, states) {
4260
+ function writeSeatColors(iColor, iState, start, count, states, iCategory) {
3405
4261
  for (let i = start; i < start + count; i++) {
3406
- const c = states[iState[i]] ?? states[0];
3407
- iColor[i * 3] = c[0];
3408
- iColor[i * 3 + 1] = c[1];
3409
- iColor[i * 3 + 2] = c[2];
4262
+ const useCategory = iCategory && iState[i] === 0;
4263
+ const c = useCategory ? null : states[iState[i]] ?? states[0];
4264
+ iColor[i * 3] = c ? c[0] : iCategory[i * 3];
4265
+ iColor[i * 3 + 1] = c ? c[1] : iCategory[i * 3 + 1];
4266
+ iColor[i * 3 + 2] = c ? c[2] : iCategory[i * 3 + 2];
3410
4267
  }
3411
4268
  }
3412
4269
  var SEAT_QUAD = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
@@ -3432,7 +4289,7 @@ function buildGpuScene(gl, model) {
3432
4289
  const seatProg = createSeatProgram(gl);
3433
4290
  const iColor = new Float32Array(model.seats.count * 3);
3434
4291
  const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
3435
- writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
4292
+ writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors, model.seats.iCategory);
3436
4293
  const seatGeo = new import_ogl4.Geometry(gl, {
3437
4294
  position: { size: 2, data: SEAT_QUAD },
3438
4295
  iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
@@ -3448,17 +4305,105 @@ function buildGpuScene(gl, model) {
3448
4305
  seatMesh.frustumCulled = false;
3449
4306
  if (model.seats.count > 0) seatMesh.setParent(main);
3450
4307
  const colorAttr = seatGeo.attributes.iColor;
3451
- return {
4308
+ const chairBase = buildChairMesh();
4309
+ const chairProg = createChairProgram(gl);
4310
+ const CAP = CHAIR_MAX_INSTANCES;
4311
+ const cOffset = new Float32Array(CAP * 3);
4312
+ const cColor = new Float32Array(CAP * 3);
4313
+ const cRadius = new Float32Array(CAP);
4314
+ const cYaw = new Float32Array(CAP);
4315
+ const cRing = new Float32Array(CAP * 3);
4316
+ const cFloor = new Float32Array(CAP);
4317
+ const cSeed = new Float32Array(CAP);
4318
+ const chairGeo = new import_ogl4.Geometry(gl, {
4319
+ position: { size: 3, data: chairBase.position },
4320
+ normal: { size: 3, data: chairBase.normal },
4321
+ part: { size: 1, data: chairBase.part },
4322
+ index: { data: chairBase.index },
4323
+ iOffset: { size: 3, data: cOffset, instanced: 1 },
4324
+ iColor: { size: 3, data: cColor, instanced: 1 },
4325
+ iRadius: { size: 1, data: cRadius, instanced: 1 },
4326
+ iYaw: { size: 1, data: cYaw, instanced: 1 },
4327
+ iRing: { size: 3, data: cRing, instanced: 1 },
4328
+ iFloor: { size: 1, data: cFloor, instanced: 1 },
4329
+ iSeed: { size: 1, data: cSeed, instanced: 1 }
4330
+ });
4331
+ const chairMesh = new import_ogl4.Mesh(gl, { geometry: chairGeo, program: chairProg });
4332
+ chairMesh.frustumCulled = false;
4333
+ let nearCount = 0;
4334
+ let nearIndices = new Int32Array(0);
4335
+ const writeChairColors = () => {
4336
+ const src = model.seats;
4337
+ for (let k = 0; k < nearCount; k++) {
4338
+ const i = nearIndices[k];
4339
+ const useCategory = src.iCategory && src.iState[i] === 0;
4340
+ const c = useCategory ? null : stateColors[src.iState[i]] ?? stateColors[0];
4341
+ if (c) {
4342
+ cColor[k * 3] = c[0];
4343
+ cColor[k * 3 + 1] = c[1];
4344
+ cColor[k * 3 + 2] = c[2];
4345
+ } else {
4346
+ const u = upholsteryTone([src.iCategory[i * 3], src.iCategory[i * 3 + 1], src.iCategory[i * 3 + 2]]);
4347
+ cColor[k * 3] = u[0];
4348
+ cColor[k * 3 + 1] = u[1];
4349
+ cColor[k * 3 + 2] = u[2];
4350
+ }
4351
+ }
4352
+ chairGeo.attributes.iColor.needsUpdate = true;
4353
+ };
4354
+ const scene = {
3452
4355
  main,
3453
4356
  background,
3454
4357
  seatProgram: seatProg,
3455
4358
  solidProgram: solidProg,
4359
+ chairProgram: chairProg,
3456
4360
  seatGeometry: seatGeo,
3457
4361
  solidGeometry: solidGeo,
3458
4362
  drawCalls: 3,
4363
+ setNearSeats(indices, count) {
4364
+ const n = Math.min(count, CAP);
4365
+ nearIndices = indices;
4366
+ nearCount = n;
4367
+ if (n === 0) {
4368
+ if (chairMesh.parent) chairMesh.setParent(null);
4369
+ chairGeo.instancedCount = 0;
4370
+ scene.drawCalls = 3;
4371
+ return;
4372
+ }
4373
+ const src = model.seats;
4374
+ for (let k = 0; k < n; k++) {
4375
+ const i = indices[k];
4376
+ cOffset[k * 3] = src.iPosition[i * 3];
4377
+ cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
4378
+ cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
4379
+ cRadius[k] = src.iChairWidth[i];
4380
+ cYaw[k] = src.iYaw[i];
4381
+ cRing[k * 3] = src.iRing[i * 3];
4382
+ cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
4383
+ cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
4384
+ cFloor[k] = src.iFloor[i];
4385
+ cSeed[k] = seatOccupantSeed(src.iState[i], i);
4386
+ }
4387
+ writeChairColors();
4388
+ chairGeo.attributes.iOffset.needsUpdate = true;
4389
+ chairGeo.attributes.iRadius.needsUpdate = true;
4390
+ chairGeo.attributes.iYaw.needsUpdate = true;
4391
+ chairGeo.attributes.iRing.needsUpdate = true;
4392
+ chairGeo.attributes.iFloor.needsUpdate = true;
4393
+ chairGeo.attributes.iSeed.needsUpdate = true;
4394
+ chairGeo.instancedCount = n;
4395
+ if (!chairMesh.parent) chairMesh.setParent(main);
4396
+ scene.drawCalls = 4;
4397
+ },
4398
+ nearSeatCount() {
4399
+ return nearCount;
4400
+ },
3459
4401
  uploadSeatStateRuns(runs) {
3460
4402
  if (!runs.length) return;
3461
- for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
4403
+ if (nearCount) writeChairColors();
4404
+ for (const run of runs) {
4405
+ writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors, model.seats.iCategory);
4406
+ }
3462
4407
  const buffer = colorAttr.buffer;
3463
4408
  if (!buffer) {
3464
4409
  colorAttr.needsUpdate = true;
@@ -3477,8 +4422,11 @@ function buildGpuScene(gl, model) {
3477
4422
  solidProg.remove();
3478
4423
  seatGeo.remove();
3479
4424
  seatProg.remove();
4425
+ chairGeo.remove();
4426
+ chairProg.remove();
3480
4427
  }
3481
4428
  };
4429
+ return scene;
3482
4430
  }
3483
4431
 
3484
4432
  // src/view3d/pick/pickPipeline.ts
@@ -3961,6 +4909,317 @@ function mountPanorama(container, view, opts = {}) {
3961
4909
  };
3962
4910
  }
3963
4911
 
4912
+ // src/view3d/scene/panoSphere.ts
4913
+ var import_ogl7 = require("ogl");
4914
+ var PANO_SEGMENTS_LON = 64;
4915
+ var PANO_SEGMENTS_LAT = 32;
4916
+ var PANO_RADIUS_M = 500;
4917
+ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEGMENTS_LAT, radius = PANO_RADIUS_M) {
4918
+ const lonCount = segmentsLon + 1;
4919
+ const latCount = segmentsLat + 1;
4920
+ const vertexCount = lonCount * latCount;
4921
+ const position = new Float32Array(vertexCount * 3);
4922
+ const uv = new Float32Array(vertexCount * 2);
4923
+ for (let iLat = 0; iLat < latCount; iLat++) {
4924
+ const v = iLat / segmentsLat;
4925
+ const polar = v * Math.PI;
4926
+ const sinPolar = Math.sin(polar);
4927
+ const cosPolar = Math.cos(polar);
4928
+ for (let iLon = 0; iLon < lonCount; iLon++) {
4929
+ const u = iLon / segmentsLon;
4930
+ const azimuth = (u - 0.5) * Math.PI * 2;
4931
+ const i = iLat * lonCount + iLon;
4932
+ position[i * 3] = radius * sinPolar * Math.sin(azimuth);
4933
+ position[i * 3 + 1] = radius * cosPolar;
4934
+ position[i * 3 + 2] = -radius * sinPolar * Math.cos(azimuth);
4935
+ uv[i * 2] = u;
4936
+ uv[i * 2 + 1] = v;
4937
+ }
4938
+ }
4939
+ const index = new Uint16Array(segmentsLon * segmentsLat * 6);
4940
+ let w = 0;
4941
+ for (let iLat = 0; iLat < segmentsLat; iLat++) {
4942
+ for (let iLon = 0; iLon < segmentsLon; iLon++) {
4943
+ const a = iLat * lonCount + iLon;
4944
+ const b = a + lonCount;
4945
+ index[w++] = a;
4946
+ index[w++] = b;
4947
+ index[w++] = a + 1;
4948
+ index[w++] = a + 1;
4949
+ index[w++] = b;
4950
+ index[w++] = b + 1;
4951
+ }
4952
+ }
4953
+ return { position, uv, index };
4954
+ }
4955
+ function bearingPitchToDirection(bearingDeg, pitchDeg) {
4956
+ const yaw = bearingDeg * Math.PI / 180;
4957
+ const pitch = pitchDeg * Math.PI / 180;
4958
+ const cosPitch = Math.cos(pitch);
4959
+ return [cosPitch * Math.sin(yaw), Math.sin(pitch), -cosPitch * Math.cos(yaw)];
4960
+ }
4961
+ var PANO_VERT = (
4962
+ /* glsl */
4963
+ `#version 300 es
4964
+ precision highp float;
4965
+ in vec3 position;
4966
+ in vec2 uv;
4967
+ uniform mat4 modelViewMatrix;
4968
+ uniform mat4 projectionMatrix;
4969
+ out vec2 vUv;
4970
+ void main() {
4971
+ vUv = uv;
4972
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
4973
+ }`
4974
+ );
4975
+ var PANO_FRAG = (
4976
+ /* glsl */
4977
+ `#version 300 es
4978
+ precision highp float;
4979
+ in vec2 vUv;
4980
+ uniform sampler2D uPano;
4981
+ uniform float uOpacity;
4982
+ out vec4 fragColor;
4983
+ void main() {
4984
+ vec3 rgb = texture(uPano, vUv).rgb;
4985
+ fragColor = vec4(rgb, uOpacity);
4986
+ }`
4987
+ );
4988
+ function createPanoSphere(gl, image) {
4989
+ const { position, uv, index } = buildPanoSphere();
4990
+ const geometry = new import_ogl7.Geometry(gl, {
4991
+ position: { size: 3, data: position },
4992
+ uv: { size: 2, data: uv },
4993
+ index: { data: index }
4994
+ });
4995
+ const texture = new import_ogl7.Texture(gl, {
4996
+ image,
4997
+ // The seam column is duplicated in geometry, so CLAMP is correct and avoids
4998
+ // a wrapped bilinear tap bleeding the far edge of the image into it.
4999
+ wrapS: gl.CLAMP_TO_EDGE,
5000
+ wrapT: gl.CLAMP_TO_EDGE,
5001
+ generateMipmaps: true,
5002
+ // MUST be false, and this is the one line that decides whether the whole
5003
+ // panorama is upside down.
5004
+ //
5005
+ // OGL defaults `flipY` to true for 2D textures, which is right for the usual
5006
+ // case: a UV of 0 means the BOTTOM of a quad, an image's first row is its
5007
+ // TOP, and flipping on upload reconciles the two. This sphere is the other
5008
+ // case. Its `v` is authored in IMAGE space — v=0 is the top of the equirect
5009
+ // and maps to the top of the sphere (`buildPanoSphere`, and the mapping
5010
+ // `generatePanorama` and the 2D viewer both use). Flipping on upload as well
5011
+ // applies the correction twice: the stadium pitch renders on the ceiling and
5012
+ // the audience sits overhead.
5013
+ //
5014
+ // The geometry tests state the v mapping and pass either way — they never
5015
+ // touch GL — so this is not something a unit test can defend. It was caught
5016
+ // by looking at a stadium.
5017
+ flipY: false
5018
+ });
5019
+ const program = new import_ogl7.Program(gl, {
5020
+ vertex: PANO_VERT,
5021
+ fragment: PANO_FRAG,
5022
+ uniforms: { uPano: { value: texture }, uOpacity: { value: 0 } },
5023
+ transparent: true,
5024
+ depthTest: false,
5025
+ depthWrite: false
5026
+ });
5027
+ const mesh = new import_ogl7.Mesh(gl, { geometry, program });
5028
+ return {
5029
+ mesh,
5030
+ setOpacity(value) {
5031
+ program.uniforms.uOpacity.value = Math.max(0, Math.min(1, value));
5032
+ },
5033
+ dispose() {
5034
+ mesh.setParent(null);
5035
+ geometry.remove();
5036
+ if (texture.texture) gl.deleteTexture(texture.texture);
5037
+ program.remove();
5038
+ }
5039
+ };
5040
+ }
5041
+
5042
+ // src/view3d/crossfade/panoramaSphere.ts
5043
+ var DEG_PER_PX = 0.15;
5044
+ function loadImage(url) {
5045
+ return new Promise((resolve, reject) => {
5046
+ const img = new Image();
5047
+ img.crossOrigin = "anonymous";
5048
+ img.onload = () => resolve(img);
5049
+ img.onerror = () => reject(new Error("panorama_image_failed"));
5050
+ img.src = url;
5051
+ });
5052
+ }
5053
+ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5054
+ const fadeMs = opts.fadeMs ?? 400;
5055
+ let bearing = view?.initialBearingDeg ?? 0;
5056
+ let pitch = 0;
5057
+ let disposed = false;
5058
+ if (!view && deps.focalWorld) {
5059
+ const p = deps.camera.position;
5060
+ const dx = deps.focalWorld[0] - p.x;
5061
+ const dy = deps.focalWorld[1] - p.y;
5062
+ const dz = deps.focalWorld[2] - p.z;
5063
+ const flat = Math.hypot(dx, dz);
5064
+ if (flat > 1e-3) {
5065
+ bearing = Math.atan2(dx, -dz) * 180 / Math.PI;
5066
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, Math.atan2(dy, flat) * 180 / Math.PI));
5067
+ }
5068
+ }
5069
+ let sphere = null;
5070
+ if (view) {
5071
+ let image;
5072
+ try {
5073
+ image = await loadImage(view.url);
5074
+ } catch {
5075
+ return null;
5076
+ }
5077
+ try {
5078
+ sphere = createPanoSphere(deps.gl, image);
5079
+ } catch {
5080
+ return null;
5081
+ }
5082
+ sphere.mesh.renderOrder = 999;
5083
+ sphere.mesh.setParent(deps.scene);
5084
+ }
5085
+ const priorFov = deps.camera.fov;
5086
+ deps.camera.perspective({ fov: VFOV_DEG, aspect: deps.camera.aspect });
5087
+ const priorQuat = deps.camera.quaternion.slice();
5088
+ const aim = () => {
5089
+ const p = deps.camera.position;
5090
+ sphere?.mesh.position.set(p.x, p.y, p.z);
5091
+ const [dx, dy, dz] = bearingPitchToDirection(bearing, pitch);
5092
+ deps.camera.lookAt([p.x + dx, p.y + dy, p.z + dz]);
5093
+ deps.requestRender();
5094
+ };
5095
+ aim();
5096
+ const root = document.createElement("div");
5097
+ root.setAttribute("role", "dialog");
5098
+ root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
5099
+ Object.assign(root.style, {
5100
+ position: "absolute",
5101
+ inset: "0",
5102
+ zIndex: "10",
5103
+ cursor: "grab",
5104
+ touchAction: "none"
5105
+ });
5106
+ const closeBtn = document.createElement("button");
5107
+ closeBtn.type = "button";
5108
+ closeBtn.setAttribute("aria-label", "Close");
5109
+ closeBtn.textContent = "\u2715";
5110
+ Object.assign(closeBtn.style, {
5111
+ position: "absolute",
5112
+ top: "12px",
5113
+ right: "12px",
5114
+ zIndex: "2",
5115
+ width: "34px",
5116
+ height: "34px",
5117
+ borderRadius: "999px",
5118
+ cursor: "pointer",
5119
+ border: "1px solid rgba(255,255,255,0.25)",
5120
+ background: "rgba(8,12,18,0.6)",
5121
+ color: "#e6edf3",
5122
+ fontSize: "15px",
5123
+ lineHeight: "1"
5124
+ });
5125
+ root.appendChild(closeBtn);
5126
+ const hint = document.createElement("div");
5127
+ hint.textContent = "Drag to look around \xB7 Esc to close";
5128
+ Object.assign(hint.style, {
5129
+ position: "absolute",
5130
+ bottom: "12px",
5131
+ left: "0",
5132
+ right: "0",
5133
+ textAlign: "center",
5134
+ color: "rgba(230,237,243,0.7)",
5135
+ font: "12px ui-sans-serif, system-ui, sans-serif",
5136
+ pointerEvents: "none"
5137
+ });
5138
+ root.appendChild(hint);
5139
+ container.appendChild(root);
5140
+ let dragging = false;
5141
+ let lastX = 0;
5142
+ let lastY = 0;
5143
+ const onDown = (e) => {
5144
+ if (e.target === closeBtn) return;
5145
+ dragging = true;
5146
+ lastX = e.clientX;
5147
+ lastY = e.clientY;
5148
+ root.setPointerCapture?.(e.pointerId);
5149
+ root.style.cursor = "grabbing";
5150
+ };
5151
+ const onMove = (e) => {
5152
+ if (!dragging) return;
5153
+ bearing += (e.clientX - lastX) * DEG_PER_PX;
5154
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.clientY - lastY) * DEG_PER_PX));
5155
+ lastX = e.clientX;
5156
+ lastY = e.clientY;
5157
+ aim();
5158
+ };
5159
+ const onUp = () => {
5160
+ dragging = false;
5161
+ root.style.cursor = "grab";
5162
+ };
5163
+ root.addEventListener("pointerdown", onDown);
5164
+ root.addEventListener("pointermove", onMove);
5165
+ root.addEventListener("pointerup", onUp);
5166
+ root.addEventListener("pointercancel", onUp);
5167
+ const onKey = (e) => {
5168
+ if (e.key !== "Escape" || disposed) return;
5169
+ e.stopPropagation();
5170
+ handle.close();
5171
+ };
5172
+ window.addEventListener("keydown", onKey);
5173
+ let raf = 0;
5174
+ const fadeTo = (target, done) => {
5175
+ if (!sphere) {
5176
+ done?.();
5177
+ return;
5178
+ }
5179
+ const from = target === 1 ? 0 : 1;
5180
+ const start = performance.now();
5181
+ const step = () => {
5182
+ if (disposed) return;
5183
+ const t = fadeMs <= 0 ? 1 : Math.min(1, (performance.now() - start) / fadeMs);
5184
+ sphere?.setOpacity(from + (target - from) * t);
5185
+ deps.requestRender();
5186
+ if (t < 1) raf = requestAnimationFrame(step);
5187
+ else done?.();
5188
+ };
5189
+ raf = requestAnimationFrame(step);
5190
+ };
5191
+ fadeTo(1);
5192
+ const teardown = () => {
5193
+ if (disposed) return;
5194
+ disposed = true;
5195
+ cancelAnimationFrame(raf);
5196
+ root.removeEventListener("pointerdown", onDown);
5197
+ root.removeEventListener("pointermove", onMove);
5198
+ root.removeEventListener("pointerup", onUp);
5199
+ root.removeEventListener("pointercancel", onUp);
5200
+ window.removeEventListener("keydown", onKey);
5201
+ root.remove();
5202
+ sphere?.dispose();
5203
+ deps.camera.perspective({ fov: priorFov, aspect: deps.camera.aspect });
5204
+ deps.camera.quaternion.set(priorQuat[0], priorQuat[1], priorQuat[2], priorQuat[3]);
5205
+ deps.requestRender();
5206
+ };
5207
+ const handle = {
5208
+ close() {
5209
+ if (disposed) return;
5210
+ const finish = () => {
5211
+ teardown();
5212
+ opts.onClose?.();
5213
+ };
5214
+ cancelAnimationFrame(raf);
5215
+ fadeTo(0, finish);
5216
+ },
5217
+ dispose: teardown
5218
+ };
5219
+ closeBtn.addEventListener("click", () => handle.close());
5220
+ return handle;
5221
+ }
5222
+
3964
5223
  // src/view3d/analytics.ts
3965
5224
  var now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
3966
5225
  var Analytics3D = class {
@@ -4046,6 +5305,28 @@ function mountVenue3D(container, input, opts = {}) {
4046
5305
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
4047
5306
  gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
4048
5307
  gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
5308
+ gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
5309
+ lastGatherX = Infinity;
5310
+ };
5311
+ const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
5312
+ const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
5313
+ let lastGatherX = Infinity;
5314
+ let lastGatherZ = Infinity;
5315
+ const updateNearField = () => {
5316
+ if (!gpu) return;
5317
+ const cam = orbit.camera.position;
5318
+ const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
5319
+ if (moved2 < CHAIR_REBUILD_M) return;
5320
+ lastGatherX = cam.x;
5321
+ lastGatherZ = cam.z;
5322
+ const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
5323
+ if (outside > CHAIR_GATHER_M) {
5324
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
5325
+ return;
5326
+ }
5327
+ const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
5328
+ if (n === 0 && gpu.nearSeatCount() === 0) return;
5329
+ gpu.setNearSeats(nearBuf, n);
4049
5330
  };
4050
5331
  const glctx = new GLContext(container, {
4051
5332
  onContextLost: () => {
@@ -4092,7 +5373,9 @@ function mountVenue3D(container, input, opts = {}) {
4092
5373
  const u = gpu.seatProgram.uniforms;
4093
5374
  u.uSeatScale.value = lod.scale;
4094
5375
  u.uSeatFade.value = lod.fade;
5376
+ u.uMinPixels.value = lod.minPixels;
4095
5377
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
5378
+ updateNearField();
4096
5379
  glctx.renderer.render({ scene: gpu.background, clear: true });
4097
5380
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
4098
5381
  labelOverlay.update(
@@ -4100,7 +5383,10 @@ function mountVenue3D(container, input, opts = {}) {
4100
5383
  glctx.canvas.clientWidth || 1,
4101
5384
  glctx.canvas.clientHeight || 1,
4102
5385
  orbit.currentDistance,
4103
- model.bounds.radius
5386
+ model.bounds.radius,
5387
+ // The dense label rungs rank by real distance from the eye, not by the
5388
+ // orbit radius — at the arrival pose those are wildly different numbers.
5389
+ [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
4104
5390
  );
4105
5391
  return moving;
4106
5392
  });
@@ -4145,7 +5431,7 @@ function mountVenue3D(container, input, opts = {}) {
4145
5431
  ];
4146
5432
  const placeCameraFinal = (finalPos, focal) => {
4147
5433
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
4148
- orbit.camera.lookAt(new import_ogl7.Vec3(focal[0], focal[1], focal[2]));
5434
+ orbit.camera.lookAt(new import_ogl8.Vec3(focal[0], focal[1], focal[2]));
4149
5435
  orbit.camera.fov = FOV_END;
4150
5436
  orbit.camera.updateProjectionMatrix();
4151
5437
  };
@@ -4208,7 +5494,7 @@ function mountVenue3D(container, input, opts = {}) {
4208
5494
  zIndex: "4"
4209
5495
  });
4210
5496
  overviewChip.addEventListener("click", () => {
4211
- if (disposed || frozen) return;
5497
+ if (disposed || frozen || panorama) return;
4212
5498
  cancelFlight();
4213
5499
  removeArriveChip();
4214
5500
  orbit.frameSoft(model.bounds, stageAzimuth);
@@ -4236,18 +5522,36 @@ function mountVenue3D(container, input, opts = {}) {
4236
5522
  }
4237
5523
  if (disposed || gen !== flightGen) return;
4238
5524
  removeArriveChip();
4239
- panorama = mountPanorama(container, view, {
4240
- fadeMs,
4241
- seatLabel: seatId,
4242
- onClose: () => {
4243
- panorama = null;
4244
- frozen = false;
4245
- analytics.panoramaClosed();
4246
- orbit.resumeAfterFlight(model.focalWorld);
4247
- loop.requestRender();
4248
- showArriveChip(seatId, flightGen);
4249
- }
4250
- });
5525
+ const onClose = () => {
5526
+ panorama = null;
5527
+ labelOverlay.setVisible(true);
5528
+ frozen = false;
5529
+ analytics.panoramaClosed();
5530
+ orbit.resumeAfterFlight(model.focalWorld);
5531
+ loop.requestRender();
5532
+ showArriveChip(seatId, flightGen);
5533
+ };
5534
+ const sceneMode = view.generated === true;
5535
+ const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5536
+ gl: glctx.gl,
5537
+ scene: gpu.main,
5538
+ camera: orbit.camera,
5539
+ requestRender: () => loop.requestRender(),
5540
+ focalWorld: model.focalWorld
5541
+ }, { fadeMs, seatLabel: seatId, onClose }) : null;
5542
+ if (disposed || gen !== flightGen) {
5543
+ spherical?.dispose();
5544
+ return;
5545
+ }
5546
+ if (spherical) {
5547
+ orbit.syncFromCamera();
5548
+ labelOverlay.setVisible(false);
5549
+ frozen = false;
5550
+ loop.requestRender();
5551
+ panorama = spherical;
5552
+ } else {
5553
+ panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
5554
+ }
4251
5555
  analytics.panoramaOpened();
4252
5556
  };
4253
5557
  const cancelFlight = () => {
@@ -4280,7 +5584,7 @@ function mountVenue3D(container, input, opts = {}) {
4280
5584
  showArriveChip(seatId, gen);
4281
5585
  return Promise.resolve();
4282
5586
  }
4283
- const startQuat = new import_ogl7.Quat().copy(orbit.camera.quaternion);
5587
+ const startQuat = new import_ogl8.Quat().copy(orbit.camera.quaternion);
4284
5588
  const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
4285
5589
  loop.requestRender();
4286
5590
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {
@@ -4373,6 +5677,7 @@ function mountVenue3D(container, input, opts = {}) {
4373
5677
  if (gpu) {
4374
5678
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
4375
5679
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
5680
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
4376
5681
  }
4377
5682
  focusedFloor = value;
4378
5683
  if (index !== null) {