@seatlayer/core 0.29.0 → 0.30.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.
@@ -1,8 +1,13 @@
1
1
  import {
2
+ CHART_UNITS_PER_METRE,
2
3
  METRES_PER_CHART_UNIT,
3
4
  SEATED_EYE_HEIGHT_M,
5
+ accessibilityRingColor,
6
+ distanceToPolyline,
7
+ pointInPolygonWithHoles,
8
+ resolveSection,
4
9
  sectionGeometry
5
- } from "../chunk-5MLADB2N.js";
10
+ } from "../chunk-FGS67GT3.js";
6
11
 
7
12
  // src/view3d/index.ts
8
13
  import { Quat as Quat2, Vec3 as Vec33 } from "ogl";
@@ -23,15 +28,6 @@ var SEAT_STATE_COLORS = {
23
28
  selected: [0.24, 0.74, 1],
24
29
  dimmed: [0.28, 0.32, 0.37]
25
30
  };
26
- function seatStateColorLUT() {
27
- const out = [];
28
- for (const s of SEAT_STATES) out.push(...SEAT_STATE_COLORS[s]);
29
- return out;
30
- }
31
- function seatStateColorByIndex(index) {
32
- const state = SEAT_STATES[index] ?? "available";
33
- return SEAT_STATE_COLORS[state];
34
- }
35
31
  var STRUCTURE = {
36
32
  ground: [0.07, 0.085, 0.11],
37
33
  tierTop: [0.24, 0.28, 0.34],
@@ -42,7 +38,13 @@ var STRUCTURE = {
42
38
  decorTop: [0.22, 0.25, 0.29],
43
39
  decorWall: [0.15, 0.17, 0.2],
44
40
  gaTop: [0.24, 0.28, 0.33],
45
- gaWall: [0.16, 0.19, 0.23]
41
+ gaWall: [0.16, 0.19, 0.23],
42
+ /** Exhibition / trade-show booth stand. */
43
+ boothTop: [0.3, 0.33, 0.38],
44
+ boothWall: [0.2, 0.23, 0.27],
45
+ /** Banquet table top — warmer, so a laid table reads apart from structure. */
46
+ tableTop: [0.38, 0.34, 0.29],
47
+ tableWall: [0.24, 0.21, 0.18]
46
48
  };
47
49
  var BACKGROUND = {
48
50
  top: [0.05, 0.06, 0.08],
@@ -77,6 +79,8 @@ function computeDpr() {
77
79
  }
78
80
  var GLContext = class {
79
81
  constructor(container, opts) {
82
+ /** Current clear colour, so a context restore repaints the themed background. */
83
+ this.clearRgb = [0, 0, 0];
80
84
  this.container = container;
81
85
  this.canvas = document.createElement("canvas");
82
86
  this.canvas.style.display = "block";
@@ -94,7 +98,8 @@ var GLContext = class {
94
98
  webgl: 2
95
99
  });
96
100
  this.gl = this.renderer.gl;
97
- this.gl.clearColor(BACKGROUND.top[0], BACKGROUND.top[1], BACKGROUND.top[2], 1);
101
+ this.clearRgb = [BACKGROUND.top[0], BACKGROUND.top[1], BACKGROUND.top[2]];
102
+ this.gl.clearColor(this.clearRgb[0], this.clearRgb[1], this.clearRgb[2], 1);
98
103
  container.appendChild(this.canvas);
99
104
  this.lostHandler = (e) => {
100
105
  e.preventDefault();
@@ -105,6 +110,21 @@ var GLContext = class {
105
110
  this.canvas.addEventListener("webglcontextrestored", this.restoredHandler, false);
106
111
  this.resize();
107
112
  }
113
+ /**
114
+ * Repaint the clear colour from the chart's theme.
115
+ *
116
+ * The clear shows for one frame before the background triangle draws, and on
117
+ * any frame the scene does not cover — so leaving it at the library default
118
+ * flashes SeatLayer grey into a white-labelled venue.
119
+ */
120
+ setClearColor(rgb) {
121
+ this.clearRgb = [rgb[0], rgb[1], rgb[2]];
122
+ this.gl.clearColor(rgb[0], rgb[1], rgb[2], 1);
123
+ }
124
+ /** The clear colour currently set (the pick pass restores through this). */
125
+ get clearColor() {
126
+ return this.clearRgb;
127
+ }
108
128
  /** Match the drawing buffer to the container's CSS box. */
109
129
  resize() {
110
130
  const w = Math.max(1, this.container.clientWidth || this.canvas.clientWidth || 1);
@@ -253,14 +273,14 @@ var OrbitCamera = class {
253
273
  * then the damped `update()` eases it up into the 3/4 architectural angle and
254
274
  * dollies in — the venue "stands up" instead of teleporting (~600ms).
255
275
  */
256
- frame(bounds, intro = false) {
276
+ frame(bounds, intro = false, stageAzimuth) {
257
277
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
258
278
  const r = Math.max(1, bounds.radius);
259
279
  const halfV = this.fovY * DEG / 2;
260
280
  const aspect = this.camera.aspect || 1;
261
281
  const halfH = Math.atan(Math.tan(halfV) * aspect);
262
282
  const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
263
- this.azT = -30 * DEG;
283
+ this.azT = stageAzimuth ?? -30 * DEG;
264
284
  this.polT = 55 * DEG;
265
285
  this.distT = fit * FRAME_MARGIN;
266
286
  this.minDist = Math.max(2, r * 0.12);
@@ -279,6 +299,24 @@ var OrbitCamera = class {
279
299
  setAspect(aspect) {
280
300
  this.camera.perspective({ aspect });
281
301
  }
302
+ /**
303
+ * Damped return to the framed overview from wherever the camera is now (a
304
+ * seat, behind the shell, anywhere): re-pivot on the venue centre without
305
+ * moving the camera, then glide targets back to the 3/4 architectural pose.
306
+ */
307
+ frameSoft(bounds, stageAzimuth) {
308
+ this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
309
+ this.syncFromCamera();
310
+ this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
311
+ const r = Math.max(1, bounds.radius);
312
+ const halfV = this.fovY * DEG / 2;
313
+ const aspect = this.camera.aspect || 1;
314
+ const halfH = Math.atan(Math.tan(halfV) * aspect);
315
+ const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
316
+ this.azT = stageAzimuth ?? this.azimuth;
317
+ this.polT = 55 * DEG;
318
+ this.distT = fit * FRAME_MARGIN;
319
+ }
282
320
  /** Damp toward targets; returns true while still moving. */
283
321
  update() {
284
322
  const da = this.azT - this.azimuth;
@@ -397,18 +435,47 @@ function computeSeatLod(distance, radius) {
397
435
 
398
436
  // src/view3d/scene/geometry.ts
399
437
  import earcut from "earcut";
438
+ import polygonClipping from "polygon-clipping";
400
439
  var M = METRES_PER_CHART_UNIT;
440
+ var MIN_TRI_ALTITUDE_M = 2e-3;
441
+ function isDegenerate(p0, p1, p2) {
442
+ const e0 = Math.hypot(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
443
+ const e1 = Math.hypot(p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]);
444
+ const e2 = Math.hypot(p0[0] - p2[0], p0[1] - p2[1], p0[2] - p2[2]);
445
+ const longest = Math.max(e0, e1, e2);
446
+ if (longest < 1e-9) return true;
447
+ const s = (e0 + e1 + e2) / 2;
448
+ const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
449
+ return 2 * area / longest < MIN_TRI_ALTITUDE_M;
450
+ }
401
451
  var MeshBuilder = class {
402
452
  constructor() {
403
453
  this.pos = [];
404
454
  this.nor = [];
405
455
  this.col = [];
456
+ this.flr = [];
457
+ /** Floor index stamped onto every triangle emitted from now on. */
458
+ this.currentFloor = 0;
459
+ }
460
+ /** Stamp subsequent triangles as belonging to `index`. */
461
+ setFloor(index) {
462
+ this.currentFloor = index;
406
463
  }
407
464
  /** One triangle with a shared (flat) normal and per-vertex colours. */
408
465
  tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
466
+ if (isDegenerate(p0, p1, p2)) return;
409
467
  this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
410
468
  this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
411
469
  this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
470
+ this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
471
+ }
472
+ /** One triangle with independent per-vertex normals (smooth shading). */
473
+ triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
474
+ if (isDegenerate(p0, p1, p2)) return;
475
+ this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
476
+ this.nor.push(n0[0], n0[1], n0[2], n1[0], n1[1], n1[2], n2[0], n2[1], n2[2]);
477
+ this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
478
+ this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
412
479
  }
413
480
  get vertexCount() {
414
481
  return this.pos.length / 3;
@@ -418,6 +485,7 @@ var MeshBuilder = class {
418
485
  position: new Float32Array(this.pos),
419
486
  normal: new Float32Array(this.nor),
420
487
  color: new Float32Array(this.col),
488
+ floor: new Float32Array(this.flr),
421
489
  count: this.pos.length / 3
422
490
  };
423
491
  }
@@ -472,33 +540,158 @@ function signedArea(pts) {
472
540
  function toCCW(pts) {
473
541
  return signedArea(pts) < 0 ? [...pts].reverse() : pts;
474
542
  }
475
- function extrudePrism(builder, outlineIn, holesIn, topY, bottomY, colTop, colWall, ao) {
543
+ var MAX_CAP_SPLIT_DEPTH = 3;
544
+ function capDeviation(a, b, c, topY) {
545
+ const ya = topY(a), yb = topY(b), yc = topY(c);
546
+ const edge = (p, q, yp, yq) => Math.abs(topY({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }) - (yp + yq) / 2);
547
+ const cx3 = (a.x + b.x + c.x) / 3, cy3 = (a.y + b.y + c.y) / 3;
548
+ const yCen = (ya + yb + yc) / 3;
549
+ let worst = Math.max(
550
+ edge(a, b, ya, yb),
551
+ edge(b, c, yb, yc),
552
+ edge(c, a, yc, ya),
553
+ Math.abs(topY({ x: cx3, y: cy3 }) - yCen)
554
+ );
555
+ const probe = (px, py, yp) => {
556
+ const d = Math.abs(topY({ x: (px + cx3) / 2, y: (py + cy3) / 2 }) - (yp + yCen) / 2);
557
+ if (d > worst) worst = d;
558
+ };
559
+ probe((a.x + b.x) / 2, (a.y + b.y) / 2, (ya + yb) / 2);
560
+ probe((b.x + c.x) / 2, (b.y + c.y) / 2, (yb + yc) / 2);
561
+ probe((c.x + a.x) / 2, (c.y + a.y) / 2, (yc + ya) / 2);
562
+ return worst;
563
+ }
564
+ function maxDevAtDepth(a, b, c, topY, depth) {
565
+ if (depth <= 0) return capDeviation(a, b, c, topY);
566
+ const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
567
+ const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
568
+ const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
569
+ return Math.max(
570
+ maxDevAtDepth(a, ab, ca, topY, depth - 1),
571
+ maxDevAtDepth(ab, b, bc, topY, depth - 1),
572
+ maxDevAtDepth(ca, bc, c, topY, depth - 1),
573
+ maxDevAtDepth(ab, bc, ca, topY, depth - 1)
574
+ );
575
+ }
576
+ function emitCapUniform(builder, a, b, c, topY, colTop, depth, topN) {
577
+ if (depth > 0) {
578
+ const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
579
+ const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
580
+ const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
581
+ emitCapUniform(builder, a, ab, ca, topY, colTop, depth - 1, topN);
582
+ emitCapUniform(builder, ab, b, bc, topY, colTop, depth - 1, topN);
583
+ emitCapUniform(builder, ca, bc, c, topY, colTop, depth - 1, topN);
584
+ emitCapUniform(builder, ab, bc, ca, topY, colTop, depth - 1, topN);
585
+ return;
586
+ }
587
+ const at = [a.x * M, topY(a), a.y * M];
588
+ const bt = [b.x * M, topY(b), b.y * M];
589
+ const ct = [c.x * M, topY(c), c.y * M];
590
+ if (topN) {
591
+ builder.triN(at, bt, ct, topN(a), topN(b), topN(c), colTop);
592
+ return;
593
+ }
594
+ let n = faceNormal(at, bt, ct);
595
+ if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
596
+ builder.tri(at, bt, ct, n, colTop);
597
+ }
598
+ var MAX_RING_SPLIT_DEPTH = 8;
599
+ function densifyRing(ring, topY, maxError) {
600
+ if (!Number.isFinite(maxError)) return ring;
601
+ const out = [];
602
+ const emit = (p, q, yp, yq, depth) => {
603
+ if (depth > 0) {
604
+ const mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
605
+ const ym = topY(mid);
606
+ if (Math.abs(ym - (yp + yq) / 2) > maxError) {
607
+ emit(p, mid, yp, ym, depth - 1);
608
+ emit(mid, q, ym, yq, depth - 1);
609
+ return;
610
+ }
611
+ }
612
+ out.push(p);
613
+ };
614
+ for (let i = 0; i < ring.length; i++) {
615
+ const p = ring[i], q = ring[(i + 1) % ring.length];
616
+ emit(p, q, topY(p), topY(q), MAX_RING_SPLIT_DEPTH);
617
+ }
618
+ return out;
619
+ }
620
+ function dedupeRing(ring, eps) {
621
+ if (ring.length < 3) return ring;
622
+ const out = [];
623
+ for (const p of ring) {
624
+ const last = out[out.length - 1];
625
+ if (last && Math.hypot(p.x - last.x, p.y - last.y) < eps) continue;
626
+ out.push(p);
627
+ }
628
+ while (out.length > 2 && Math.hypot(out[0].x - out[out.length - 1].x, out[0].y - out[out.length - 1].y) < eps) {
629
+ out.pop();
630
+ }
631
+ if (out.length < 3) return ring;
632
+ const keep = [];
633
+ for (let i = 0; i < out.length; i++) {
634
+ const prev = keep.length ? keep[keep.length - 1] : out[(i - 1 + out.length) % out.length];
635
+ const p = out[i];
636
+ const next = out[(i + 1) % out.length];
637
+ const ax = next.x - prev.x, ay = next.y - prev.y;
638
+ const len = Math.hypot(ax, ay);
639
+ if (len > eps) {
640
+ const cross = Math.abs((p.x - prev.x) * ay - (p.y - prev.y) * ax) / len;
641
+ if (cross < eps) continue;
642
+ }
643
+ keep.push(p);
644
+ }
645
+ return keep.length >= 3 ? keep : out;
646
+ }
647
+ var RING_EPS_U = 1e-3 * CHART_UNITS_PER_METRE * 0.1;
648
+ function extrudePrism(builder, outlineIn, holesIn, topY, bottomY, colTop, colWall, ao, maxCapError = Infinity, topNormal) {
476
649
  if (!outlineIn || outlineIn.length < 3) return;
650
+ if (maxCapError === void 0) maxCapError = Infinity;
477
651
  if (Math.abs(signedArea(outlineIn)) < 1e-4) return;
478
- const outline = toCCW(outlineIn);
479
- const holes = holesIn?.map((h) => toCCW(h)).filter((h) => h.length >= 3 && Math.abs(signedArea(h)) >= 1e-4);
652
+ const outline = dedupeRing(densifyRing(dedupeRing(toCCW(outlineIn), RING_EPS_U), topY, maxCapError), RING_EPS_U);
653
+ const holes = holesIn?.map((h) => dedupeRing(densifyRing(dedupeRing(toCCW(h), RING_EPS_U), topY, maxCapError), RING_EPS_U)).filter((h) => h.length >= 3 && Math.abs(signedArea(h)) >= 1e-4);
480
654
  const { pts, tris } = triangulate(outline, holes);
481
655
  const cTop = [colTop[0] * ao.top, colTop[1] * ao.top, colTop[2] * ao.top];
482
656
  const cBot = [colTop[0] * ao.bottomCap, colTop[1] * ao.bottomCap, colTop[2] * ao.bottomCap];
483
657
  const cWallTop = [colWall[0] * ao.top, colWall[1] * ao.top, colWall[2] * ao.top];
484
658
  const cWallBot = [colWall[0] * ao.wallBottom, colWall[1] * ao.wallBottom, colWall[2] * ao.wallBottom];
659
+ let capDepth = 0;
660
+ if (Number.isFinite(maxCapError)) {
661
+ for (; capDepth < MAX_CAP_SPLIT_DEPTH; capDepth++) {
662
+ let worst = 0;
663
+ for (let i = 0; i < tris.length; i += 3) {
664
+ const d = maxDevAtDepth(pts[tris[i]], pts[tris[i + 1]], pts[tris[i + 2]], topY, capDepth);
665
+ if (d > worst) worst = d;
666
+ }
667
+ if (worst <= maxCapError) break;
668
+ }
669
+ }
485
670
  for (let i = 0; i < tris.length; i += 3) {
486
671
  const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
487
- const at = [a.x * M, topY(a), a.y * M];
488
- const bt = [b.x * M, topY(b), b.y * M];
489
- const ct = [c.x * M, topY(c), c.y * M];
490
- let n = faceNormal(at, bt, ct);
491
- if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
492
- builder.tri(at, bt, ct, n, cTop);
672
+ emitCapUniform(builder, a, b, c, topY, cTop, capDepth, topNormal);
493
673
  const ab = [a.x * M, bottomY, a.y * M];
494
674
  const bb = [b.x * M, bottomY, b.y * M];
495
675
  const cb = [c.x * M, bottomY, c.y * M];
496
676
  builder.tri(ab, cb, bb, [0, -1, 0], cBot);
497
677
  }
678
+ const splitRing = (ring) => {
679
+ if (capDepth <= 0) return ring;
680
+ const n = 1 << capDepth;
681
+ const out = [];
682
+ for (let i = 0; i < ring.length; i++) {
683
+ const a = ring[i], b = ring[(i + 1) % ring.length];
684
+ for (let k = 0; k < n; k++) {
685
+ const t = k / n;
686
+ out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
687
+ }
688
+ }
689
+ return out;
690
+ };
498
691
  const oc = centroid(outline);
499
- const rings = [{ ring: outline, flip: false }];
692
+ const rings = [{ ring: splitRing(outline), flip: false }];
500
693
  if (holes) {
501
- for (const h of holes) if (h.length >= 3) rings.push({ ring: h, flip: true });
694
+ for (const h of holes) if (h.length >= 3) rings.push({ ring: splitRing(h), flip: true });
502
695
  }
503
696
  for (const { ring, flip } of rings) {
504
697
  for (let i = 0; i < ring.length; i++) {
@@ -534,14 +727,74 @@ function mergeMeshData(parts) {
534
727
  const position = new Float32Array(total * 3);
535
728
  const normal = new Float32Array(total * 3);
536
729
  const color = new Float32Array(total * 3);
730
+ const floor = new Float32Array(total);
537
731
  let off = 0;
538
732
  for (const p of parts) {
539
733
  position.set(p.position, off * 3);
540
734
  normal.set(p.normal, off * 3);
541
735
  color.set(p.color, off * 3);
736
+ floor.set(p.floor, off);
542
737
  off += p.count;
543
738
  }
544
- return { position, normal, color, count: total };
739
+ return { position, normal, color, floor, count: total };
740
+ }
741
+ function outsetRing(ring, d, miterLimit = 2.5) {
742
+ if (d <= 0 || ring.length < 3) return ring;
743
+ const r = toCCW(ring);
744
+ const n = r.length;
745
+ const nrm = [];
746
+ for (let i = 0; i < n; i++) {
747
+ const a = r[i], b = r[(i + 1) % n];
748
+ const dx = b.x - a.x, dy = b.y - a.y;
749
+ const len = Math.hypot(dx, dy) || 1;
750
+ nrm.push({ x: dy / len, y: -dx / len });
751
+ }
752
+ const out = [];
753
+ for (let i = 0; i < n; i++) {
754
+ const p = r[i];
755
+ const nPrev = nrm[(i - 1 + n) % n];
756
+ const nCur = nrm[i];
757
+ let mx = nPrev.x + nCur.x, my = nPrev.y + nCur.y;
758
+ const ml = Math.hypot(mx, my);
759
+ const bevel = () => {
760
+ out.push([p.x + nPrev.x * d, p.y + nPrev.y * d]);
761
+ out.push([p.x + nCur.x * d, p.y + nCur.y * d]);
762
+ };
763
+ if (ml < 1e-9) {
764
+ bevel();
765
+ continue;
766
+ }
767
+ mx /= ml;
768
+ my /= ml;
769
+ const cosHalf = mx * nPrev.x + my * nPrev.y;
770
+ const scale2 = 1 / Math.max(cosHalf, 1e-6);
771
+ if (!Number.isFinite(scale2) || scale2 > miterLimit) {
772
+ bevel();
773
+ continue;
774
+ }
775
+ out.push([p.x + mx * d * scale2, p.y + my * d * scale2]);
776
+ }
777
+ if (out.length < 3) return ring;
778
+ try {
779
+ const merged = polygonClipping.union([[...out, out[0]]]);
780
+ let best = null, bestArea = -Infinity;
781
+ for (const poly of merged) {
782
+ const area = Math.abs(signedArea(poly[0].map(([x, y]) => ({ x, y }))));
783
+ if (area > bestArea) {
784
+ bestArea = area;
785
+ best = poly[0];
786
+ }
787
+ }
788
+ if (!best) return ring;
789
+ const pts = best.map(([x, y]) => ({ x, y }));
790
+ if (pts.length > 1) {
791
+ const f = pts[0], l = pts[pts.length - 1];
792
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
793
+ }
794
+ return pts.length >= 3 ? pts : ring;
795
+ } catch {
796
+ return ring;
797
+ }
545
798
  }
546
799
  function ellipsePolygon(cx, cy, rx, ry, seg = 28) {
547
800
  const out = [];
@@ -561,29 +814,107 @@ function rectPolygon(x, y, w, h) {
561
814
  }
562
815
 
563
816
  // src/view3d/scene/seatInstances.ts
564
- var SEAT_SURFACE_LIFT_M = 0.18;
817
+ var SEAT_DOT_RADIUS_M = 0.22;
818
+ var SEAT_PITCH_FRACTION = 0.42;
819
+ function nearestNeighbourSpacing(seats) {
820
+ const n = seats.length;
821
+ const out = new Float64Array(n);
822
+ if (n < 2) {
823
+ out.fill(Infinity);
824
+ return out;
825
+ }
826
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
827
+ for (const s of seats) {
828
+ if (s.x < minX) minX = s.x;
829
+ if (s.y < minY) minY = s.y;
830
+ if (s.x > maxX) maxX = s.x;
831
+ if (s.y > maxY) maxY = s.y;
832
+ }
833
+ const w = Math.max(maxX - minX, 1e-6), h = Math.max(maxY - minY, 1e-6);
834
+ const cell = Math.max(Math.sqrt(w * h / n), 1e-6);
835
+ const cols = Math.max(1, Math.ceil(w / cell) + 1);
836
+ const rows = Math.max(1, Math.ceil(h / cell) + 1);
837
+ const buckets = /* @__PURE__ */ new Map();
838
+ const cellOf = (x, y) => {
839
+ const cx = Math.min(cols - 1, Math.max(0, Math.floor((x - minX) / cell)));
840
+ const cy = Math.min(rows - 1, Math.max(0, Math.floor((y - minY) / cell)));
841
+ return cy * cols + cx;
842
+ };
843
+ for (let i = 0; i < n; i++) {
844
+ const k = cellOf(seats[i].x, seats[i].y);
845
+ const b = buckets.get(k);
846
+ if (b) b.push(i);
847
+ else buckets.set(k, [i]);
848
+ }
849
+ for (let i = 0; i < n; i++) {
850
+ const s = seats[i];
851
+ const cx = Math.min(cols - 1, Math.max(0, Math.floor((s.x - minX) / cell)));
852
+ const cy = Math.min(rows - 1, Math.max(0, Math.floor((s.y - minY) / cell)));
853
+ let best = Infinity;
854
+ for (let r = 1; r <= 4; r++) {
855
+ for (let gy = cy - r; gy <= cy + r; gy++) {
856
+ if (gy < 0 || gy >= rows) continue;
857
+ for (let gx = cx - r; gx <= cx + r; gx++) {
858
+ if (gx < 0 || gx >= cols) continue;
859
+ if (r > 1 && Math.abs(gy - cy) < r && Math.abs(gx - cx) < r) continue;
860
+ const b = buckets.get(gy * cols + gx);
861
+ if (!b) continue;
862
+ for (const j of b) {
863
+ if (j === i) continue;
864
+ const d = Math.hypot(seats[j].x - s.x, seats[j].y - s.y);
865
+ if (d < best) best = d;
866
+ }
867
+ }
868
+ }
869
+ if (Number.isFinite(best) && best <= r * cell) break;
870
+ }
871
+ out[i] = best;
872
+ }
873
+ return out;
874
+ }
565
875
  function seatSurfaceY(seat) {
566
876
  const eye = seat.eyeHeightM;
567
- if (Number.isFinite(eye)) {
568
- return Math.max(0, eye - SEATED_EYE_HEIGHT_M) + SEAT_SURFACE_LIFT_M;
569
- }
570
- return SEAT_SURFACE_LIFT_M;
877
+ if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
878
+ return 0;
571
879
  }
572
- function buildSeatInstances(seats, initial) {
880
+ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
573
881
  const count = seats.length;
574
882
  const iPosition = new Float32Array(count * 3);
575
883
  const iState = new Float32Array(count);
884
+ const iMaxRadius = new Float32Array(count);
885
+ const iRing = new Float32Array(count * 3);
576
886
  const idToIndex = /* @__PURE__ */ new Map();
887
+ const spacing = nearestNeighbourSpacing(seats);
577
888
  for (let i = 0; i < count; i++) {
578
889
  const seat = seats[i];
890
+ const resolved = surfaces?.seatPitchU(i);
891
+ const pitchM = (resolved ?? spacing[i]) * M;
892
+ iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
579
893
  iPosition[i * 3] = seat.x * M;
580
- iPosition[i * 3 + 1] = seatSurfaceY(seat);
894
+ iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
581
895
  iPosition[i * 3 + 2] = seat.y * M;
582
896
  iState[i] = seatStateIndex(initial ? initial(seat) : "available");
897
+ if (seat.accessibility?.length) {
898
+ const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
899
+ if (rgb) {
900
+ iRing[i * 3] = rgb[0];
901
+ iRing[i * 3 + 1] = rgb[1];
902
+ iRing[i * 3 + 2] = rgb[2];
903
+ }
904
+ }
583
905
  idToIndex.set(seat.id, i);
584
906
  }
585
- return { count, iPosition, iState, idToIndex };
907
+ return {
908
+ count,
909
+ iPosition,
910
+ iState,
911
+ iMaxRadius,
912
+ iRing,
913
+ idToIndex,
914
+ iFloor: seatFloor ?? new Float32Array(count)
915
+ };
586
916
  }
917
+ var RUN_MERGE_GAP = 64;
587
918
  function applySeatStates(data, updates) {
588
919
  const changed = [];
589
920
  for (const u of updates) {
@@ -603,7 +934,7 @@ function applySeatStates(data, updates) {
603
934
  for (let i = 1; i < changed.length; i++) {
604
935
  const idx = changed[i];
605
936
  if (idx === prev) continue;
606
- if (idx === prev + 1) {
937
+ if (idx <= prev + RUN_MERGE_GAP) {
607
938
  prev = idx;
608
939
  continue;
609
940
  }
@@ -615,6 +946,788 @@ function applySeatStates(data, updates) {
615
946
  return runs;
616
947
  }
617
948
 
949
+ // src/view3d/theme.ts
950
+ var BACKGROUND_INFLUENCE = 0.15;
951
+ var BG_TOP_SCALE = 0.9;
952
+ var BG_BOTTOM_SCALE = 1.76;
953
+ var SEAT_SCALE_MIN = 0.7;
954
+ var SEAT_SCALE_MAX = 1.6;
955
+ function defaultTheme3D() {
956
+ return {
957
+ background: { top: [...BACKGROUND.top], bottom: [...BACKGROUND.bottom] },
958
+ structure: STRUCTURE,
959
+ seatStates: { ...SEAT_STATE_COLORS },
960
+ seatScale: 1
961
+ };
962
+ }
963
+ function resolveTheme3D(theme) {
964
+ const base = defaultTheme3D();
965
+ if (!theme) return base;
966
+ const bg = hexToRgb(theme.background);
967
+ if (bg) {
968
+ base.background = {
969
+ top: scaleRgb(bg, BG_TOP_SCALE),
970
+ bottom: scaleRgb(bg, BG_BOTTOM_SCALE)
971
+ };
972
+ const rebased = {};
973
+ for (const [key, value] of Object.entries(STRUCTURE)) {
974
+ rebased[key] = mix(value, bg, BACKGROUND_INFLUENCE);
975
+ }
976
+ base.structure = rebased;
977
+ }
978
+ const selection = hexToRgb(theme.selectionColor) ?? hexToRgb(theme.accent);
979
+ if (selection) base.seatStates = { ...base.seatStates, selected: selection };
980
+ const scale2 = theme.seatScale;
981
+ if (typeof scale2 === "number" && Number.isFinite(scale2)) {
982
+ base.seatScale = Math.min(SEAT_SCALE_MAX, Math.max(SEAT_SCALE_MIN, scale2));
983
+ }
984
+ return base;
985
+ }
986
+ function themeSeatColorLUT(theme, order) {
987
+ const out = [];
988
+ for (const s of order) out.push(...theme.seatStates[s]);
989
+ return out;
990
+ }
991
+
992
+ // src/view3d/labels.ts
993
+ var ZONE_MIN_DISTANCE = 1.15;
994
+ var SECTION_MAX_DISTANCE = 2.2;
995
+ var NEAR_MAX_DISTANCE = 0.85;
996
+ function visibleLabelKinds(distance, venueRadius) {
997
+ const r = Math.max(1e-6, venueRadius);
998
+ const d = distance / r;
999
+ const out = /* @__PURE__ */ new Set();
1000
+ if (d >= ZONE_MIN_DISTANCE) out.add("zone");
1001
+ if (d <= SECTION_MAX_DISTANCE) out.add("section");
1002
+ if (d <= NEAR_MAX_DISTANCE) {
1003
+ out.add("annotation");
1004
+ out.add("booth");
1005
+ }
1006
+ return out;
1007
+ }
1008
+ function projectToScreen(viewProjection, p, width, height) {
1009
+ const m = viewProjection;
1010
+ const x = p[0], y = p[1], z = p[2];
1011
+ const cx = m[0] * x + m[4] * y + m[8] * z + m[12];
1012
+ const cy = m[1] * x + m[5] * y + m[9] * z + m[13];
1013
+ const cz = m[2] * x + m[6] * y + m[10] * z + m[14];
1014
+ const cw = m[3] * x + m[7] * y + m[11] * z + m[15];
1015
+ if (!(cw > 1e-6)) return { x: 0, y: 0, depth: Infinity, visible: false };
1016
+ const ndcX = cx / cw, ndcY = cy / cw, ndcZ = cz / cw;
1017
+ const inside = ndcX >= -1.05 && ndcX <= 1.05 && ndcY >= -1.05 && ndcY <= 1.05 && ndcZ <= 1;
1018
+ return {
1019
+ x: (ndcX * 0.5 + 0.5) * width,
1020
+ y: (1 - (ndcY * 0.5 + 0.5)) * height,
1021
+ depth: ndcZ,
1022
+ visible: inside
1023
+ };
1024
+ }
1025
+ function cullOverlapping(items, separationX, separationY = separationX) {
1026
+ const kept = [];
1027
+ const ordered = [...items].sort((a, b) => a.screen.depth - b.screen.depth);
1028
+ for (const item of ordered) {
1029
+ let clash = false;
1030
+ for (const k of kept) {
1031
+ const dx = Math.abs(item.screen.x - k.screen.x);
1032
+ const dy = Math.abs(item.screen.y - k.screen.y);
1033
+ if (dx < separationX && dy < separationY) {
1034
+ clash = true;
1035
+ break;
1036
+ }
1037
+ }
1038
+ if (!clash) kept.push(item);
1039
+ }
1040
+ return kept;
1041
+ }
1042
+ function centroidOf(points) {
1043
+ if (!points.length) return null;
1044
+ let x = 0, y = 0;
1045
+ for (const p of points) {
1046
+ x += p.x;
1047
+ y += p.y;
1048
+ }
1049
+ return { x: x / points.length, y: y / points.length };
1050
+ }
1051
+
1052
+ // src/view3d/scene/sceneModel.ts
1053
+ import polygonClipping3 from "polygon-clipping";
1054
+
1055
+ // src/core/rake.ts
1056
+ var BLEND_ROWS = 3;
1057
+ var BLEND_DISTANCE_RATIO = 2.2;
1058
+ var GRAD_STEP = 0.5;
1059
+ function radialRake(focal) {
1060
+ return {
1061
+ kind: "radial",
1062
+ rowCount: 0,
1063
+ rows: [],
1064
+ nearestRow: () => -1,
1065
+ depthAt: (x, y) => Math.hypot(x - focal.x, y - focal.y),
1066
+ gradientAt: (x, y) => {
1067
+ const dx = x - focal.x, dy = y - focal.y;
1068
+ const d = Math.hypot(dx, dy);
1069
+ return d < 1e-9 ? [0, 0] : [dx / d, dy / d];
1070
+ }
1071
+ };
1072
+ }
1073
+ function fitRow(points, focal) {
1074
+ if (!points.length) return null;
1075
+ let cx = 0, cy = 0, depth = 0;
1076
+ for (const p of points) {
1077
+ cx += p.x;
1078
+ cy += p.y;
1079
+ depth += Math.hypot(p.x - focal.x, p.y - focal.y);
1080
+ }
1081
+ const n = points.length;
1082
+ cx /= n;
1083
+ cy /= n;
1084
+ depth /= n;
1085
+ let ax = points[0], far = -1;
1086
+ for (const p of points) {
1087
+ const d = Math.hypot(p.x - cx, p.y - cy);
1088
+ if (d > far) {
1089
+ far = d;
1090
+ ax = p;
1091
+ }
1092
+ }
1093
+ let dx = ax.x - cx, dy = ax.y - cy;
1094
+ const len = Math.hypot(dx, dy);
1095
+ if (len > 1e-9) {
1096
+ dx /= len;
1097
+ dy /= len;
1098
+ } else {
1099
+ dx = 1;
1100
+ dy = 0;
1101
+ }
1102
+ const pts = [...points].sort((p, q) => (p.x - cx) * dx + (p.y - cy) * dy - ((q.x - cx) * dx + (q.y - cy) * dy));
1103
+ let radius = 0;
1104
+ for (const p of pts) {
1105
+ const d = Math.hypot(p.x - cx, p.y - cy);
1106
+ if (d > radius) radius = d;
1107
+ }
1108
+ return { pts, cx, cy, radius, depth };
1109
+ }
1110
+ function distToRow(r, x, y) {
1111
+ const pts = r.pts;
1112
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1113
+ let best = Infinity;
1114
+ for (let i = 0; i + 1 < pts.length; i++) {
1115
+ const a = pts[i], b = pts[i + 1];
1116
+ const vx = b.x - a.x, vy = b.y - a.y;
1117
+ const len2 = vx * vx + vy * vy;
1118
+ let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
1119
+ if (t < 0) t = 0;
1120
+ else if (t > 1) t = 1;
1121
+ const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
1122
+ if (d < best) best = d;
1123
+ }
1124
+ return best;
1125
+ }
1126
+ var CANDIDATE_ROWS = 8;
1127
+ function sampleRows(rows, x, y) {
1128
+ const candD = new Array(CANDIDATE_ROWS).fill(Infinity);
1129
+ const candI = new Array(CANDIDATE_ROWS).fill(-1);
1130
+ for (let i = 0; i < rows.length; i++) {
1131
+ const r = rows[i];
1132
+ const lower = Math.hypot(x - r.cx, y - r.cy) - r.radius;
1133
+ for (let k = 0; k < CANDIDATE_ROWS; k++) {
1134
+ if (lower < candD[k]) {
1135
+ for (let j = CANDIDATE_ROWS - 1; j > k; j--) {
1136
+ candD[j] = candD[j - 1];
1137
+ candI[j] = candI[j - 1];
1138
+ }
1139
+ candD[k] = lower;
1140
+ candI[k] = i;
1141
+ break;
1142
+ }
1143
+ }
1144
+ }
1145
+ const bestD = new Array(BLEND_ROWS).fill(Infinity);
1146
+ const bestI = new Array(BLEND_ROWS).fill(-1);
1147
+ for (const i of candI) {
1148
+ if (i < 0) continue;
1149
+ const d = distToRow(rows[i], x, y);
1150
+ for (let k = 0; k < BLEND_ROWS; k++) {
1151
+ if (d < bestD[k]) {
1152
+ for (let j = BLEND_ROWS - 1; j > k; j--) {
1153
+ bestD[j] = bestD[j - 1];
1154
+ bestI[j] = bestI[j - 1];
1155
+ }
1156
+ bestD[k] = d;
1157
+ bestI[k] = i;
1158
+ break;
1159
+ }
1160
+ }
1161
+ }
1162
+ if (bestI[0] < 0) return 0;
1163
+ if (bestD[0] < 1e-6) return rows[bestI[0]].depth;
1164
+ const cutoff = bestD[0] * BLEND_DISTANCE_RATIO;
1165
+ let num = 0, den = 0;
1166
+ for (let k = 0; k < BLEND_ROWS; k++) {
1167
+ const i = bestI[k];
1168
+ if (i < 0 || bestD[k] > cutoff) continue;
1169
+ const w = 1 / (bestD[k] * bestD[k]);
1170
+ num += w * rows[i].depth;
1171
+ den += w;
1172
+ }
1173
+ return den > 0 ? num / den : rows[bestI[0]].depth;
1174
+ }
1175
+ function rowsRake(rows) {
1176
+ const depthAt = (x, y) => sampleRows(rows, x, y);
1177
+ const nearestRow = (x, y) => {
1178
+ let best = Infinity, bestI = -1;
1179
+ for (let i = 0; i < rows.length; i++) {
1180
+ const r = rows[i];
1181
+ if (Math.hypot(x - r.cx, y - r.cy) - r.radius >= best) continue;
1182
+ const d = distToRow(r, x, y);
1183
+ if (d < best) {
1184
+ best = d;
1185
+ bestI = i;
1186
+ }
1187
+ }
1188
+ return bestI;
1189
+ };
1190
+ return {
1191
+ kind: "rows",
1192
+ rowCount: rows.length,
1193
+ rows,
1194
+ nearestRow,
1195
+ depthAt,
1196
+ gradientAt: (x, y) => {
1197
+ const gx = (depthAt(x + GRAD_STEP, y) - depthAt(x - GRAD_STEP, y)) / (2 * GRAD_STEP);
1198
+ const gy = (depthAt(x, y + GRAD_STEP) - depthAt(x, y - GRAD_STEP)) / (2 * GRAD_STEP);
1199
+ const len = Math.hypot(gx, gy);
1200
+ return len < 1e-9 ? [0, 0] : [gx / len, gy / len];
1201
+ }
1202
+ };
1203
+ }
1204
+ function buildSectionRake(rows, focal) {
1205
+ const fits = [];
1206
+ for (const r of rows) {
1207
+ const f = fitRow(r.points, focal);
1208
+ if (f) fits.push(f);
1209
+ }
1210
+ if (fits.length < 2) return radialRake(focal);
1211
+ fits.sort((a, b) => a.depth - b.depth);
1212
+ return rowsRake(fits);
1213
+ }
1214
+
1215
+ // src/view3d/scene/surface.ts
1216
+ var FLAT_SLAB_TOP_M = 0.05;
1217
+ var CAP_MAX_ERROR_M = 0.05;
1218
+ var SEAT_CLEARANCE_M = 0.15;
1219
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1220
+ function bboxOf(pts) {
1221
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1222
+ for (const p of pts) {
1223
+ if (p.x < minX) minX = p.x;
1224
+ if (p.y < minY) minY = p.y;
1225
+ if (p.x > maxX) maxX = p.x;
1226
+ if (p.y > maxY) maxY = p.y;
1227
+ }
1228
+ return { minX, minY, maxX, maxY };
1229
+ }
1230
+ var MAX_TIER_RISE_M = 25;
1231
+ function buildVenueSurfaces(units, seats) {
1232
+ const bySection = /* @__PURE__ */ new Map();
1233
+ const seatOwner = new Array(seats.length).fill(null);
1234
+ const seatDeck = new Float64Array(seats.length);
1235
+ const seatRowLevel = new Array(seats.length).fill(void 0);
1236
+ const seatPitch = new Array(seats.length).fill(void 0);
1237
+ const acc = /* @__PURE__ */ new Map();
1238
+ const boxes = [];
1239
+ for (const unit of units) {
1240
+ for (const o of unit.objects) {
1241
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1242
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
1243
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
1244
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
1245
+ }
1246
+ }
1247
+ for (let i = 0; i < seats.length; i++) {
1248
+ const s = seats[i];
1249
+ for (const b of boxes) {
1250
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
1251
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
1252
+ seatOwner[i] = b.id;
1253
+ const a = acc.get(b.id);
1254
+ a.hasSeats = true;
1255
+ const f = s.focalPoint ?? b.unit.focal;
1256
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
1257
+ if (d < a.frontU) a.frontU = d;
1258
+ a.seatIndices.push(i);
1259
+ const rowKey = s.rowId || `__seat-${i}`;
1260
+ const arr = a.rows.get(rowKey);
1261
+ if (arr) arr.push({ x: s.x, y: s.y });
1262
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
1263
+ break;
1264
+ }
1265
+ }
1266
+ for (const [id, a] of acc) {
1267
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
1268
+ const bottomY = a.unit.baseHeightM;
1269
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
1270
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
1271
+ const kind = a.section.surfaceKind;
1272
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
1273
+ const rake = buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal);
1274
+ let frontU = Infinity;
1275
+ if (a.hasSeats) {
1276
+ for (const pts of a.rows.values()) {
1277
+ for (const p of pts) {
1278
+ const d = rake.depthAt(p.x, p.y);
1279
+ if (d < frontU) frontU = d;
1280
+ }
1281
+ }
1282
+ }
1283
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
1284
+ frontU = Infinity;
1285
+ for (const p of a.section.outline) {
1286
+ const d = rake.depthAt(p.x, p.y);
1287
+ if (d < frontU) frontU = d;
1288
+ }
1289
+ }
1290
+ const flatTop = bottomY + FLAT_SLAB_TOP_M;
1291
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
1292
+ const levelFor = (depthU) => {
1293
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
1294
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1295
+ return Math.max(baseFloor, geo.height + rise);
1296
+ };
1297
+ const levelForBlockDepth = (blockDepthU) => {
1298
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
1299
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1300
+ return Math.max(baseFloor, geo.height + rise);
1301
+ };
1302
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
1303
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
1304
+ pts: r.pts,
1305
+ y: levelForBlockDepth(r.blockDepth),
1306
+ depth: r.blockDepth,
1307
+ blockId: r.blockId
1308
+ }));
1309
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
1310
+ const rowBounds = rowLevels.map((r) => {
1311
+ let cx = 0, cy = 0;
1312
+ for (const p of r.pts) {
1313
+ cx += p.x;
1314
+ cy += p.y;
1315
+ }
1316
+ const n = r.pts.length || 1;
1317
+ cx /= n;
1318
+ cy /= n;
1319
+ let rad = 0;
1320
+ for (const p of r.pts) {
1321
+ const d = Math.hypot(p.x - cx, p.y - cy);
1322
+ if (d > rad) rad = d;
1323
+ }
1324
+ return { cx, cy, rad };
1325
+ });
1326
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
1327
+ let best = Infinity, bestY = landingY;
1328
+ for (let i = 0; i < rowLevels.length; i++) {
1329
+ const b = rowBounds[i];
1330
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
1331
+ const d = distanceToPolyline(rowLevels[i].pts, x, y);
1332
+ if (d < best) {
1333
+ best = d;
1334
+ bestY = rowLevels[i].y;
1335
+ }
1336
+ }
1337
+ return bestY;
1338
+ } : (x, y) => levelFor(rake.depthAt(x, y));
1339
+ const UP = [0, 1, 0];
1340
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
1341
+ const d = rake.depthAt(x, y);
1342
+ if (d <= frontU) return UP;
1343
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
1344
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
1345
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
1346
+ const [gx, gy] = rake.gradientAt(x, y);
1347
+ if (gx === 0 && gy === 0) return UP;
1348
+ const inv = 1 / Math.hypot(rakeTan, 1);
1349
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
1350
+ };
1351
+ if (!flat) {
1352
+ for (const r of structure.rows) {
1353
+ const y = levelForBlockDepth(r.blockDepth);
1354
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
1355
+ }
1356
+ }
1357
+ for (const r of structure.rows) {
1358
+ const gaps = [];
1359
+ for (let k = 1; k < r.pts.length; k++) {
1360
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
1361
+ if (d > 1e-6) gaps.push(d);
1362
+ }
1363
+ gaps.sort((x, y) => x - y);
1364
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
1365
+ let across = Infinity;
1366
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
1367
+ if (probe) {
1368
+ for (const other of structure.rows) {
1369
+ if (other === r || other.blockId !== r.blockId) continue;
1370
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
1371
+ if (d > 1e-6 && d < across) across = d;
1372
+ }
1373
+ }
1374
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
1375
+ if (Number.isFinite(pitch) && pitch > 0) {
1376
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
1377
+ }
1378
+ }
1379
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1380
+ }
1381
+ for (let i = 0; i < seats.length; i++) {
1382
+ const ownerId = seatOwner[i];
1383
+ const s = seats[i];
1384
+ if (ownerId) {
1385
+ const own = seatRowLevel[i];
1386
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
1387
+ continue;
1388
+ }
1389
+ const eye = s.eyeHeightM;
1390
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1391
+ }
1392
+ return {
1393
+ bySection,
1394
+ seatOwner,
1395
+ seatDeckY: (i) => seatDeck[i],
1396
+ seatPitchU: (i) => seatPitch[i]
1397
+ };
1398
+ }
1399
+
1400
+ // src/view3d/scene/deckBands.ts
1401
+ import polygonClipping2 from "polygon-clipping";
1402
+ import earcut2 from "earcut";
1403
+ var FRONT_REACH = 0.58;
1404
+ var MIN_RISER_M = 0.01;
1405
+ var MIN_REACH_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1406
+ var BACK_REACH = 0.5;
1407
+ var END_REACH = 0.6;
1408
+ function rowNormals(pts, focal) {
1409
+ const n = pts.length;
1410
+ const raw = [];
1411
+ for (let i = 0; i < n; i++) {
1412
+ const a = pts[Math.max(0, i - 1)];
1413
+ const b = pts[Math.min(n - 1, i + 1)];
1414
+ let dx = b.x - a.x, dy = b.y - a.y;
1415
+ const len = Math.hypot(dx, dy);
1416
+ if (len < 1e-9) {
1417
+ raw.push([0, 0]);
1418
+ continue;
1419
+ }
1420
+ dx /= len;
1421
+ dy /= len;
1422
+ raw.push([-dy, dx]);
1423
+ }
1424
+ let vote = 0;
1425
+ for (let i = 0; i < n; i++) {
1426
+ vote += (pts[i].x - focal.x) * raw[i][0] + (pts[i].y - focal.y) * raw[i][1];
1427
+ }
1428
+ const flip = vote < 0;
1429
+ return flip ? raw.map(([x, y]) => [-x, -y]) : raw;
1430
+ }
1431
+ function extendEnds(pts, by) {
1432
+ if (pts.length < 2 || by <= 0) return [...pts];
1433
+ const out = [...pts];
1434
+ const dir = (p, q) => {
1435
+ const dx = q.x - p.x, dy = q.y - p.y;
1436
+ const len = Math.hypot(dx, dy) || 1;
1437
+ return { x: dx / len, y: dy / len };
1438
+ };
1439
+ const head = dir(out[1], out[0]);
1440
+ const tail = dir(out[out.length - 2], out[out.length - 1]);
1441
+ out.unshift({ x: out[0].x + head.x * by, y: out[0].y + head.y * by });
1442
+ out.push({
1443
+ x: out[out.length - 1].x + tail.x * by,
1444
+ y: out[out.length - 1].y + tail.y * by
1445
+ });
1446
+ return out;
1447
+ }
1448
+ function distToPolyline(pts, x, y) {
1449
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1450
+ let best = Infinity;
1451
+ for (let i = 0; i + 1 < pts.length; i++) {
1452
+ const a = pts[i], b = pts[i + 1];
1453
+ const vx = b.x - a.x, vy = b.y - a.y;
1454
+ const len2 = vx * vx + vy * vy;
1455
+ let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
1456
+ if (t < 0) t = 0;
1457
+ else if (t > 1) t = 1;
1458
+ const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
1459
+ if (d < best) best = d;
1460
+ }
1461
+ return best;
1462
+ }
1463
+ function neighbourhoods(rows) {
1464
+ const probesOf = (pts) => {
1465
+ const n = pts.length;
1466
+ if (n <= 2) return [...pts];
1467
+ return [pts[Math.floor(n * 0.25)], pts[Math.floor(n * 0.5)], pts[Math.floor(n * 0.75)]];
1468
+ };
1469
+ const out = [];
1470
+ for (let i = 0; i < rows.length; i++) {
1471
+ const probes = probesOf(rows[i].pts);
1472
+ const pitches = [];
1473
+ let bestFrontD = Infinity, belowY = null;
1474
+ for (const c of probes) {
1475
+ let nearest = Infinity;
1476
+ for (let j = 0; j < rows.length; j++) {
1477
+ if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
1478
+ const d = distToPolyline(rows[j].pts, c.x, c.y);
1479
+ if (d < nearest) nearest = d;
1480
+ if (rows[j].depth < rows[i].depth && d < bestFrontD) {
1481
+ bestFrontD = d;
1482
+ belowY = rows[j].y;
1483
+ }
1484
+ }
1485
+ if (Number.isFinite(nearest) && nearest > 0) pitches.push(nearest);
1486
+ }
1487
+ pitches.sort((a, b) => a - b);
1488
+ const pitch = pitches.length ? pitches[Math.floor(pitches.length / 2)] : 1;
1489
+ out.push({ pitch, belowY });
1490
+ }
1491
+ return out;
1492
+ }
1493
+ function ribbonOf(rows, i, nbrs, focal) {
1494
+ const row = rows[i];
1495
+ const rowPts = row.pts.length >= 2 ? [...row.pts] : row.pts.length === 1 ? (() => {
1496
+ const p = row.pts[0];
1497
+ let dx = p.x - focal.x, dy = p.y - focal.y;
1498
+ const len = Math.hypot(dx, dy) || 1;
1499
+ dx /= len;
1500
+ dy /= len;
1501
+ const h = Math.max(nbrs[i].pitch, 1e-3) * 0.5;
1502
+ return [{ x: p.x + dy * h, y: p.y - dx * h }, { x: p.x - dy * h, y: p.y + dx * h }];
1503
+ })() : [];
1504
+ if (rowPts.length < 2) return null;
1505
+ let seatSpan = 0, spanN = 0;
1506
+ for (let k = 1; k < rowPts.length; k++) {
1507
+ const d = Math.hypot(rowPts[k].x - rowPts[k - 1].x, rowPts[k].y - rowPts[k - 1].y);
1508
+ if (d > 1e-6) {
1509
+ seatSpan += d;
1510
+ spanN++;
1511
+ }
1512
+ }
1513
+ const spacing = spanN > 0 ? seatSpan / spanN : 0;
1514
+ const pts = extendEnds(rowPts, Math.max(spacing * END_REACH, MIN_REACH_U));
1515
+ return {
1516
+ pts,
1517
+ nrm: rowNormals(pts, focal),
1518
+ front: Math.max(nbrs[i].pitch * FRONT_REACH, MIN_REACH_U),
1519
+ back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
1520
+ };
1521
+ }
1522
+ function deckFootprints(rows, focal) {
1523
+ if (rows.length < 2) return [];
1524
+ const nbrs = neighbourhoods(rows);
1525
+ const rings = [];
1526
+ const ribbons = [];
1527
+ for (let i = 0; i < rows.length; i++) {
1528
+ const r = ribbonOf(rows, i, nbrs, focal);
1529
+ ribbons.push(r);
1530
+ if (!r) continue;
1531
+ const f = [];
1532
+ const b = [];
1533
+ const rf = r.front + FOOTPRINT_MARGIN_U;
1534
+ const rb = r.back + FOOTPRINT_MARGIN_U;
1535
+ for (let k = 0; k < r.pts.length; k++) {
1536
+ const p = r.pts[k], n = r.nrm[k];
1537
+ f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
1538
+ b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
1539
+ }
1540
+ const ring = [...f, ...b.reverse()];
1541
+ if (ring.length < 3) continue;
1542
+ ring.push(ring[0]);
1543
+ rings.push([ring]);
1544
+ }
1545
+ if (!rings.length) return [];
1546
+ let merged;
1547
+ try {
1548
+ merged = polygonClipping2.union(rings[0], ...rings.slice(1));
1549
+ } catch {
1550
+ return [];
1551
+ }
1552
+ const out = [];
1553
+ for (const poly of merged) {
1554
+ if (!poly.length || poly[0].length < 4) continue;
1555
+ const toPts = (ring) => {
1556
+ const pts = ring.map(([x, y]) => ({ x, y }));
1557
+ const first = pts[0], last = pts[pts.length - 1];
1558
+ if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
1559
+ return pts;
1560
+ };
1561
+ const outline = toPts(poly[0]);
1562
+ if (outline.length < 3) continue;
1563
+ let topY = Infinity;
1564
+ for (let i = 0; i < rows.length; i++) {
1565
+ const r = ribbons[i];
1566
+ if (!r) continue;
1567
+ const mid = r.pts[Math.floor(r.pts.length / 2)];
1568
+ if (pointInRing(outline, mid.x, mid.y) && rows[i].y < topY) topY = rows[i].y;
1569
+ }
1570
+ if (!Number.isFinite(topY)) continue;
1571
+ out.push({
1572
+ outline: simplifyRing(outline, FOOTPRINT_TOLERANCE_U),
1573
+ holes: poly.slice(1).map(toPts).map((h) => simplifyRing(h, FOOTPRINT_TOLERANCE_U)).filter((h) => h.length >= 3),
1574
+ topY
1575
+ });
1576
+ }
1577
+ return out;
1578
+ }
1579
+ var FOOTPRINT_TOLERANCE_U = 0.3;
1580
+ var FOOTPRINT_MARGIN_U = 1.5;
1581
+ function simplifyRun(pts, tol) {
1582
+ if (pts.length < 3) return pts;
1583
+ const a = pts[0], b = pts[pts.length - 1];
1584
+ const dx = b.x - a.x, dy = b.y - a.y;
1585
+ const len = Math.hypot(dx, dy);
1586
+ let worst = -1, worstI = -1;
1587
+ for (let i = 1; i < pts.length - 1; i++) {
1588
+ const p = pts[i];
1589
+ const d = len > 1e-12 ? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / len : Math.hypot(p.x - a.x, p.y - a.y);
1590
+ if (d > worst) {
1591
+ worst = d;
1592
+ worstI = i;
1593
+ }
1594
+ }
1595
+ if (worst <= tol || worstI < 0) return [a, b];
1596
+ const left = simplifyRun(pts.slice(0, worstI + 1), tol);
1597
+ const right = simplifyRun(pts.slice(worstI), tol);
1598
+ return [...left.slice(0, -1), ...right];
1599
+ }
1600
+ function simplifyRing(ring, tol) {
1601
+ if (ring.length < 4) return ring;
1602
+ const out = simplifyRun([...ring, ring[0]], tol);
1603
+ out.pop();
1604
+ return out.length >= 3 ? out : ring;
1605
+ }
1606
+ function pointInRing(ring, x, y) {
1607
+ let inside = false;
1608
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
1609
+ const a = ring[i], b = ring[j];
1610
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
1611
+ }
1612
+ return inside;
1613
+ }
1614
+ var ClipTest = class {
1615
+ constructor(rings) {
1616
+ this.minX = Infinity;
1617
+ this.minY = Infinity;
1618
+ this.maxX = -Infinity;
1619
+ this.maxY = -Infinity;
1620
+ this.rings = rings;
1621
+ for (const r of rings) {
1622
+ for (const p of r) {
1623
+ if (p.x < this.minX) this.minX = p.x;
1624
+ if (p.y < this.minY) this.minY = p.y;
1625
+ if (p.x > this.maxX) this.maxX = p.x;
1626
+ if (p.y > this.maxY) this.maxY = p.y;
1627
+ }
1628
+ }
1629
+ }
1630
+ /** True when every point lies strictly inside ONE ring of the clip region. */
1631
+ containsAll(pts) {
1632
+ for (const p of pts) {
1633
+ if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
1634
+ }
1635
+ for (const ring of this.rings) {
1636
+ let all = true;
1637
+ for (const p of pts) {
1638
+ if (!pointInRing(ring, p.x, p.y)) {
1639
+ all = false;
1640
+ break;
1641
+ }
1642
+ }
1643
+ if (all) return true;
1644
+ }
1645
+ return false;
1646
+ }
1647
+ };
1648
+ function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
1649
+ if (clipTest.containsAll(quad)) {
1650
+ const UPF = [0, 1, 0];
1651
+ const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
1652
+ 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);
1653
+ 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);
1654
+ return;
1655
+ }
1656
+ const ring = quad.map((p) => [p.x, p.y]);
1657
+ ring.push(ring[0]);
1658
+ let pieces;
1659
+ try {
1660
+ pieces = polygonClipping2.intersection([ring], clipRing);
1661
+ } catch {
1662
+ return;
1663
+ }
1664
+ const UP = [0, 1, 0];
1665
+ for (const poly of pieces) {
1666
+ if (!poly.length || poly[0].length < 4) continue;
1667
+ const outer = poly[0];
1668
+ const flat = [];
1669
+ const pts = [];
1670
+ for (let i = 0; i < outer.length - 1; i++) {
1671
+ flat.push(outer[i][0], outer[i][1]);
1672
+ pts.push([outer[i][0], outer[i][1]]);
1673
+ }
1674
+ if (pts.length < 3) continue;
1675
+ const tris = earcut2(flat, void 0, 2);
1676
+ for (let i = 0; i < tris.length; i += 3) {
1677
+ const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
1678
+ builder.tri(
1679
+ [a[0] * M, y, a[1] * M],
1680
+ [b[0] * M, y, b[1] * M],
1681
+ [c[0] * M, y, c[1] * M],
1682
+ UP,
1683
+ color
1684
+ );
1685
+ }
1686
+ }
1687
+ }
1688
+ function emitDeckBands(builder, rows, focal, landingY, colors, clip) {
1689
+ if (rows.length < 2) return;
1690
+ const nbrs = neighbourhoods(rows);
1691
+ const UP = [0, 1, 0];
1692
+ const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
1693
+ const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
1694
+ for (let i = 0; i < rows.length; i++) {
1695
+ const row = rows[i];
1696
+ const rib = ribbonOf(rows, i, nbrs, focal);
1697
+ if (!rib) continue;
1698
+ const { pts, nrm, front, back } = rib;
1699
+ const belowY = nbrs[i].belowY ?? landingY;
1700
+ for (let k = 0; k + 1 < pts.length; k++) {
1701
+ const p = pts[k], q = pts[k + 1];
1702
+ const np = nrm[k], nq = nrm[k + 1];
1703
+ if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
1704
+ if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
1705
+ const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
1706
+ const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
1707
+ const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
1708
+ const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
1709
+ if (clipRing && clipTest) {
1710
+ emitClippedQuad(builder, clipRing, clipTest, [
1711
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
1712
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
1713
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
1714
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
1715
+ ], row.y, colors.tread);
1716
+ } else {
1717
+ builder.tri(pF, qF, qB, UP, colors.tread);
1718
+ builder.tri(pF, qB, pB, UP, colors.tread);
1719
+ }
1720
+ if (row.y > belowY + MIN_RISER_M) {
1721
+ const pFd = [pF[0], belowY, pF[2]];
1722
+ const qFd = [qF[0], belowY, qF[2]];
1723
+ const rn = [-np[0], 0, -np[1]];
1724
+ builder.tri(pF, qF, qFd, rn, colors.riser);
1725
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
1726
+ }
1727
+ }
1728
+ }
1729
+ }
1730
+
618
1731
  // src/view3d/scene/sceneModel.ts
619
1732
  function floorUnits(doc) {
620
1733
  if (doc.floors?.length) {
@@ -627,8 +1740,6 @@ function floorUnits(doc) {
627
1740
  return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
628
1741
  }
629
1742
  var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
630
- var DECK_DROP_M = 0.28;
631
- var MAX_TIER_RISE_M = 25;
632
1743
  function tintTop(fill, neutral) {
633
1744
  if (!fill) return neutral;
634
1745
  const muted = scaleRgb(desaturate(fill, 0.4), 0.62);
@@ -665,31 +1776,229 @@ function resolveSectionFills(doc, seats) {
665
1776
  function sectionFill(section, byLogical) {
666
1777
  return hexToRgb(section.color) ?? byLogical.get(section.logicalSectionId ?? section.id) ?? null;
667
1778
  }
668
- function buildTier(builder, section, unit, fill) {
1779
+ function bboxOfRing(ring) {
1780
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1781
+ for (const p of ring) {
1782
+ if (p.x < minX) minX = p.x;
1783
+ if (p.y < minY) minY = p.y;
1784
+ if (p.x > maxX) maxX = p.x;
1785
+ if (p.y > maxY) maxY = p.y;
1786
+ }
1787
+ return { minX, minY, maxX, maxY };
1788
+ }
1789
+ function boxesOverlap(a, b) {
1790
+ return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
1791
+ }
1792
+ function paddedFootprint(section, siblings) {
1793
+ const __tp = performance.now();
1794
+ const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
1795
+ __t("outsetRing", __tp);
1796
+ const box = bboxOfRing(padded);
1797
+ const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
1798
+ if (!others.length) return [padded];
1799
+ const toRing = (pts) => {
1800
+ const r = pts.map((p) => [p.x, p.y]);
1801
+ r.push(r[0]);
1802
+ return r;
1803
+ };
1804
+ try {
1805
+ const diff = polygonClipping3.difference([toRing(padded)], ...others.map((o) => [toRing(o.outline)]));
1806
+ const out = [];
1807
+ for (const poly of diff) {
1808
+ if (!poly.length) continue;
1809
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
1810
+ if (pts.length > 1) {
1811
+ const f = pts[0], l = pts[pts.length - 1];
1812
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
1813
+ }
1814
+ if (pts.length >= 3) out.push(pts);
1815
+ }
1816
+ return out.length ? out : [padded];
1817
+ } catch {
1818
+ return [padded];
1819
+ }
1820
+ }
1821
+ function clipFootprints(blocks, allowed) {
1822
+ if (!allowed.length) return blocks;
1823
+ const toRing = (pts) => {
1824
+ const r = pts.map((p) => [p.x, p.y]);
1825
+ r.push(r[0]);
1826
+ return r;
1827
+ };
1828
+ const allowedPolys = allowed.map((a) => [toRing(a)]);
1829
+ const out = [];
1830
+ for (const b of blocks) {
1831
+ let pieces;
1832
+ try {
1833
+ pieces = polygonClipping3.intersection([toRing(b.outline)], ...[allowedPolys.flat()]);
1834
+ } catch {
1835
+ out.push(b);
1836
+ continue;
1837
+ }
1838
+ for (const poly of pieces) {
1839
+ if (!poly.length) continue;
1840
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
1841
+ if (pts.length > 1) {
1842
+ const f = pts[0], l = pts[pts.length - 1];
1843
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
1844
+ }
1845
+ if (pts.length >= 3) out.push({ outline: pts, holes: [], topY: b.topY });
1846
+ }
1847
+ }
1848
+ return out.length ? out : blocks;
1849
+ }
1850
+ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S) {
669
1851
  if (!section.outline || section.outline.length < 3) return;
670
- const geo = sectionGeometry(section, { floorBaseHeightM: unit.baseHeightM });
671
1852
  const bottomY = unit.baseHeightM;
672
- const rakeRad = geo.rake * Math.PI / 180;
673
- const flat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
674
- const colTop = tintTop(fill, STRUCTURE.tierTop);
675
- if (flat) {
676
- const topY2 = bottomY + 0.05;
677
- extrudePrism(builder, section.outline, section.holes, () => topY2, bottomY, colTop, STRUCTURE.tierWall, AO);
1853
+ const colTop = tintTop(fill, S.tierTop);
1854
+ if (!surface) return;
1855
+ const topY = (p) => surface.deckAt(p.x, p.y);
1856
+ const footprint = paddedFootprint(section, siblings);
1857
+ const outline = footprint[0] ?? section.outline;
1858
+ if (surface.rowLevels.length >= 2) {
1859
+ const __tf = performance.now();
1860
+ const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
1861
+ __t("deckFootprints", __tf);
1862
+ const capUp = () => [0, 1, 0];
1863
+ for (const b of blocks) {
1864
+ extrudePrism(
1865
+ builder,
1866
+ b.outline,
1867
+ b.holes,
1868
+ () => b.topY,
1869
+ bottomY,
1870
+ colTop,
1871
+ S.tierWall,
1872
+ AO,
1873
+ void 0,
1874
+ capUp
1875
+ );
1876
+ }
1877
+ if (!blocks.length) {
1878
+ extrudePrism(
1879
+ builder,
1880
+ outline,
1881
+ section.holes,
1882
+ () => surface.landingY,
1883
+ bottomY,
1884
+ colTop,
1885
+ S.tierWall,
1886
+ AO
1887
+ );
1888
+ }
1889
+ const __tb = performance.now();
1890
+ emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
1891
+ tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
1892
+ // Risers read as the structure they are, a shade below their tread, which
1893
+ // is what makes the stepping legible from a low angle.
1894
+ riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
1895
+ }, footprint.length === 1 ? outline : footprint.flat());
1896
+ __t("emitDeckBands", __tb);
678
1897
  return;
679
1898
  }
680
- let frontDist = Infinity;
681
- for (const p of section.outline) {
682
- const d = Math.hypot(p.x - unit.focal.x, p.y - unit.focal.y);
683
- if (d < frontDist) frontDist = d;
684
- }
685
- const tan = Math.tan(rakeRad);
686
- const topY = (p) => {
687
- const d = Math.hypot(p.x - unit.focal.x, p.y - unit.focal.y);
688
- const depthM = Math.max(0, d - frontDist) * M;
689
- const rise = Math.min(depthM * tan, MAX_TIER_RISE_M);
690
- return Math.max(bottomY + 0.05, geo.height + rise - DECK_DROP_M);
691
- };
692
- extrudePrism(builder, section.outline, section.holes, topY, bottomY, colTop, STRUCTURE.tierWall, AO);
1899
+ const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
1900
+ const topN = (p) => surface.normalAt(p.x, p.y);
1901
+ for (const ring of claimed.subtract(outline)) {
1902
+ extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
1903
+ }
1904
+ }
1905
+ var ClaimedArea = class {
1906
+ constructor() {
1907
+ this.rings = [];
1908
+ this.boxes = [];
1909
+ }
1910
+ /** `ring` minus everything claimed so far; then claim what is returned. */
1911
+ subtract(ring) {
1912
+ const closed = ring.map((p) => [p.x, p.y]);
1913
+ if (closed.length < 3) return [];
1914
+ closed.push(closed[0]);
1915
+ const box = bboxOfRing(ring);
1916
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
1917
+ let pieces = [[closed]];
1918
+ if (overlapping.length) {
1919
+ try {
1920
+ const diff = polygonClipping3.difference([closed], ...overlapping);
1921
+ pieces = diff.map((poly) => poly.map((r) => r.map(([x, y]) => [x, y])));
1922
+ } catch {
1923
+ pieces = [[closed]];
1924
+ }
1925
+ }
1926
+ this.rings.push([closed]);
1927
+ this.boxes.push(box);
1928
+ const out = [];
1929
+ for (const poly of pieces) {
1930
+ if (!poly.length) continue;
1931
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
1932
+ if (pts.length > 1) {
1933
+ const f = pts[0], l = pts[pts.length - 1];
1934
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
1935
+ }
1936
+ if (pts.length >= 3) out.push(pts);
1937
+ }
1938
+ return out;
1939
+ }
1940
+ };
1941
+ var BOOTH_HEIGHT_M = 2.4;
1942
+ var ZONE_LABEL_LIFT_M = 6;
1943
+ var SECTION_LABEL_LIFT_M = 2.2;
1944
+ var ANNOTATION_LIFT_M = 0.1;
1945
+ var BOOTH_LABEL_LIFT_M = 0.3;
1946
+ var TABLE_HEIGHT_M = 0.75;
1947
+ function boothPolygon(booth) {
1948
+ if (booth.points && booth.points.length >= 3) return booth.points;
1949
+ const { center, width, height, rotation } = booth;
1950
+ if (!width || !height) return null;
1951
+ const a = (rotation ?? 0) * Math.PI / 180;
1952
+ const cos = Math.cos(a), sin = Math.sin(a);
1953
+ const hw = width / 2, hh = height / 2;
1954
+ return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
1955
+ x: center.x + lx * cos - ly * sin,
1956
+ y: center.y + lx * sin + ly * cos
1957
+ }));
1958
+ }
1959
+ function buildBooth(builder, booth, base, fill, S) {
1960
+ const poly = boothPolygon(booth);
1961
+ if (!poly) return;
1962
+ extrudePrism(
1963
+ builder,
1964
+ poly,
1965
+ void 0,
1966
+ () => base + BOOTH_HEIGHT_M,
1967
+ base,
1968
+ tintTop(fill, S.boothTop),
1969
+ S.boothWall,
1970
+ AO
1971
+ );
1972
+ }
1973
+ function tablePolygon(table) {
1974
+ if (table.shape === "round") {
1975
+ const r = table.radius;
1976
+ if (!r) return null;
1977
+ return ellipsePolygon(table.center.x, table.center.y, r, r, 24);
1978
+ }
1979
+ const { width, height, rotation, center } = table;
1980
+ if (!width || !height) return null;
1981
+ const a = (rotation ?? 0) * Math.PI / 180;
1982
+ const cos = Math.cos(a), sin = Math.sin(a);
1983
+ const hw = width / 2, hh = height / 2;
1984
+ return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
1985
+ x: center.x + lx * cos - ly * sin,
1986
+ y: center.y + lx * sin + ly * cos
1987
+ }));
1988
+ }
1989
+ function buildTable(builder, table, base, fill, S) {
1990
+ const poly = tablePolygon(table);
1991
+ if (!poly) return;
1992
+ extrudePrism(
1993
+ builder,
1994
+ poly,
1995
+ void 0,
1996
+ () => base + TABLE_HEIGHT_M,
1997
+ base,
1998
+ tintTop(fill, S.tableTop),
1999
+ S.tableWall,
2000
+ AO
2001
+ );
693
2002
  }
694
2003
  function shapePolygon(shape) {
695
2004
  if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
@@ -703,19 +2012,19 @@ function shapePolygon(shape) {
703
2012
  }
704
2013
  return null;
705
2014
  }
706
- function buildShape(builder, shape, base) {
2015
+ function buildShape(builder, shape, base, S) {
707
2016
  const poly = shapePolygon(shape);
708
2017
  if (!poly) return;
709
2018
  const isStage = shape.role === "stage";
710
2019
  const height = isStage ? base + 1 : base + 0.25;
711
- const colTop = isStage ? STRUCTURE.stageTop : STRUCTURE.decorTop;
712
- const colWall = isStage ? STRUCTURE.stageWall : STRUCTURE.decorWall;
2020
+ const colTop = isStage ? S.stageTop : S.decorTop;
2021
+ const colWall = isStage ? S.stageWall : S.decorWall;
713
2022
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
714
2023
  }
715
- function buildGa(builder, ga, base, fill) {
2024
+ function buildGa(builder, ga, base, fill, S) {
716
2025
  if (!ga.points || ga.points.length < 3) return;
717
- const colTop = tintTop(fill, STRUCTURE.gaTop);
718
- extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, STRUCTURE.gaWall, AO);
2026
+ const colTop = tintTop(fill, S.gaTop);
2027
+ extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, S.gaWall, AO);
719
2028
  }
720
2029
  function chartFootprint(units, seats) {
721
2030
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -731,6 +2040,13 @@ function chartFootprint(units, seats) {
731
2040
  if (o.type === "section") for (const p of o.outline) acc(p.x, p.y);
732
2041
  else if (o.type === "shape" && o.points) for (const p of o.points) acc(p.x, p.y);
733
2042
  else if (o.type === "gaArea") for (const p of o.points) acc(p.x, p.y);
2043
+ else if (o.type === "booth") {
2044
+ const b = boothPolygon(o);
2045
+ if (b) for (const p of b) acc(p.x, p.y);
2046
+ } else if (o.type === "table") {
2047
+ const t = tablePolygon(o);
2048
+ if (t) for (const p of t) acc(p.x, p.y);
2049
+ }
734
2050
  }
735
2051
  }
736
2052
  if (!Number.isFinite(minX)) {
@@ -741,41 +2057,318 @@ function chartFootprint(units, seats) {
741
2057
  }
742
2058
  return { minX, minY, maxX, maxY };
743
2059
  }
2060
+ var __PROF = {};
2061
+ var __t = (k, t0) => {
2062
+ __PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
2063
+ };
744
2064
  function buildSceneModel(input) {
2065
+ for (const k of Object.keys(__PROF)) delete __PROF[k];
745
2066
  const { doc, seats } = input;
2067
+ const theme = resolveTheme3D(doc.theme);
2068
+ const S = theme.structure;
746
2069
  const units = floorUnits(doc);
747
2070
  const builder = new MeshBuilder();
748
2071
  const fp = chartFootprint(units, seats);
749
2072
  const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
750
2073
  const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
751
- extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, STRUCTURE.ground, STRUCTURE.ground, AO);
2074
+ extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
752
2075
  const sectionFills = resolveSectionFills(doc, seats);
753
2076
  const catColor = /* @__PURE__ */ new Map();
754
2077
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
755
- for (const unit of units) {
2078
+ const __t0 = performance.now();
2079
+ const surfaces = buildVenueSurfaces(units, seats);
2080
+ __t("surfaces", __t0);
2081
+ const seatFloor = new Float32Array(seats.length);
2082
+ for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
2083
+ const unit = units[unitIndex];
2084
+ builder.setFloor(unitIndex);
2085
+ const claimed = new ClaimedArea();
2086
+ const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
756
2087
  for (const o of unit.objects) {
757
- if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills));
758
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM);
759
- else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)));
2088
+ if (o.type === "section") {
2089
+ const t = performance.now();
2090
+ buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2091
+ __t("buildTier", t);
2092
+ } else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
2093
+ else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2094
+ else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2095
+ else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
760
2096
  }
761
2097
  }
2098
+ const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
2099
+ const __tm = performance.now();
762
2100
  const solids = mergeMeshData([builder.build()]);
763
- const seatData = buildSeatInstances(seats, input.initialState);
2101
+ __t("meshBuild+merge", __tm);
2102
+ const __ts = performance.now();
2103
+ const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
2104
+ __t("seatInstances", __ts);
2105
+ const zoneDefs = doc.zones ?? [];
2106
+ const zones = [];
2107
+ if (zoneDefs.length) {
2108
+ const sectionZone = /* @__PURE__ */ new Map();
2109
+ for (const unit of units) {
2110
+ for (const o of unit.objects) {
2111
+ if (o.type === "section" && o.zone) sectionZone.set(o.id, o.zone);
2112
+ }
2113
+ }
2114
+ const acc = /* @__PURE__ */ new Map();
2115
+ for (let i = 0; i < seats.length; i++) {
2116
+ const s = seats[i];
2117
+ const owner = surfaces.seatOwner[i];
2118
+ const zid = s.zoneId ?? (owner ? sectionZone.get(owner) : void 0);
2119
+ if (!zid) continue;
2120
+ let a = acc.get(zid);
2121
+ if (!a) {
2122
+ a = { n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 };
2123
+ acc.set(zid, a);
2124
+ }
2125
+ a.n++;
2126
+ if (s.x < a.minX) a.minX = s.x;
2127
+ if (s.y < a.minY) a.minY = s.y;
2128
+ if (s.x > a.maxX) a.maxX = s.x;
2129
+ if (s.y > a.maxY) a.maxY = s.y;
2130
+ a.sumY += surfaces.seatDeckY(i);
2131
+ }
2132
+ for (const z of zoneDefs) {
2133
+ const a = acc.get(z.id);
2134
+ const sectionIds = [];
2135
+ for (const [secId, zid] of sectionZone) if (zid === z.id) sectionIds.push(secId);
2136
+ if (!a || a.n === 0) {
2137
+ zones.push({
2138
+ id: z.id,
2139
+ label: z.label,
2140
+ color: hexToRgb(z.color),
2141
+ sectionIds,
2142
+ seatCount: 0,
2143
+ center: [0, 0, 0],
2144
+ radius: 0,
2145
+ focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
2146
+ });
2147
+ continue;
2148
+ }
2149
+ zones.push({
2150
+ id: z.id,
2151
+ label: z.label,
2152
+ color: hexToRgb(z.color),
2153
+ sectionIds,
2154
+ seatCount: a.n,
2155
+ center: [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M],
2156
+ radius: 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1,
2157
+ // A zone may face its own point (`ZoneDef.focalPoint`); the documented
2158
+ // fallback is the floor/chart focal.
2159
+ focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
2160
+ });
2161
+ }
2162
+ }
2163
+ const labels = [];
2164
+ for (const z of zones) {
2165
+ if (z.seatCount === 0) continue;
2166
+ labels.push({
2167
+ id: `zone:${z.id}`,
2168
+ kind: "zone",
2169
+ text: z.label,
2170
+ anchor: [z.center[0], z.center[1] + ZONE_LABEL_LIFT_M, z.center[2]],
2171
+ color: doc.zones?.find((d) => d.id === z.id)?.color
2172
+ });
2173
+ }
2174
+ {
2175
+ const acc = /* @__PURE__ */ new Map();
2176
+ for (let i = 0; i < seats.length; i++) {
2177
+ const owner = surfaces.seatOwner[i];
2178
+ if (!owner) continue;
2179
+ let a = acc.get(owner);
2180
+ if (!a) {
2181
+ a = { n: 0, x: 0, y: 0, deck: 0 };
2182
+ acc.set(owner, a);
2183
+ }
2184
+ a.n++;
2185
+ a.x += seats[i].x;
2186
+ a.y += seats[i].y;
2187
+ a.deck += surfaces.seatDeckY(i);
2188
+ }
2189
+ for (const unit of units) {
2190
+ for (const o of unit.objects) {
2191
+ if (o.type !== "section") continue;
2192
+ const a = acc.get(o.id);
2193
+ if (!a || a.n === 0) continue;
2194
+ labels.push({
2195
+ id: `section:${o.id}`,
2196
+ kind: "section",
2197
+ // The buyer-facing name wins over the technical one, as it does in 2D.
2198
+ text: o.displayLabel || o.label || o.id,
2199
+ anchor: [a.x / a.n * M, a.deck / a.n + SECTION_LABEL_LIFT_M, a.y / a.n * M]
2200
+ });
2201
+ }
2202
+ }
2203
+ }
2204
+ for (const unit of units) {
2205
+ for (const o of unit.objects) {
2206
+ if (o.type === "text") {
2207
+ if (!o.text) continue;
2208
+ labels.push({
2209
+ id: `text:${o.id}`,
2210
+ kind: "annotation",
2211
+ text: o.text,
2212
+ anchor: [o.position.x * M, unit.baseHeightM + ANNOTATION_LIFT_M, o.position.y * M],
2213
+ color: o.color,
2214
+ rotation: o.rotation
2215
+ });
2216
+ } else if (o.type === "booth") {
2217
+ const poly = boothPolygon(o);
2218
+ if (!poly) continue;
2219
+ const c = centroidOf(poly);
2220
+ if (!c) continue;
2221
+ labels.push({
2222
+ id: `booth:${o.id}`,
2223
+ kind: "booth",
2224
+ text: o.displayLabel || o.label || o.id,
2225
+ anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M]
2226
+ });
2227
+ }
2228
+ }
2229
+ }
2230
+ const floors = [];
2231
+ {
2232
+ const sectionFloor = /* @__PURE__ */ new Map();
2233
+ for (let ui = 0; ui < units.length; ui++) {
2234
+ for (const o of units[ui].objects) if (o.type === "section") sectionFloor.set(o.id, ui);
2235
+ }
2236
+ const acc = units.map(() => ({ n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 }));
2237
+ for (let i = 0; i < seats.length; i++) {
2238
+ const owner = surfaces.seatOwner[i];
2239
+ const ui = owner !== null ? sectionFloor.get(owner) : void 0;
2240
+ if (ui === void 0) continue;
2241
+ const a = acc[ui];
2242
+ const s = seats[i];
2243
+ a.n++;
2244
+ if (s.x < a.minX) a.minX = s.x;
2245
+ if (s.y < a.minY) a.minY = s.y;
2246
+ if (s.x > a.maxX) a.maxX = s.x;
2247
+ if (s.y > a.maxY) a.maxY = s.y;
2248
+ a.sumY += surfaces.seatDeckY(i);
2249
+ seatFloor[i] = ui;
2250
+ }
2251
+ for (let ui = 0; ui < units.length; ui++) {
2252
+ const a = acc[ui];
2253
+ const f = doc.floors?.[ui];
2254
+ floors.push({
2255
+ index: ui,
2256
+ id: f?.id ?? `floor-${ui}`,
2257
+ label: f?.name ?? (units.length > 1 ? `Floor ${ui + 1}` : "Venue"),
2258
+ seatCount: a.n,
2259
+ center: a.n ? [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M] : [0, 0, 0],
2260
+ radius: a.n ? 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1 : 0
2261
+ });
2262
+ }
2263
+ }
764
2264
  const cx = (fp.minX + fp.maxX) / 2 * M;
765
2265
  const cz = (fp.minY + fp.maxY) / 2 * M;
766
2266
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
767
- const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
768
2267
  return {
769
2268
  solids,
770
2269
  seats: seatData,
771
2270
  bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
772
- stateColorLUT: seatStateColorLUT(),
2271
+ stateColorLUT: themeSeatColorLUT(theme, SEAT_STATES),
2272
+ theme,
773
2273
  seatCount: seats.length,
774
2274
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
775
- focalWorld: [focal.x * M, 1.5, focal.y * M]
2275
+ focalWorld: [focal.x * M, 1.5, focal.y * M],
2276
+ zones,
2277
+ labels,
2278
+ floors
776
2279
  };
777
2280
  }
778
2281
 
2282
+ // src/view3d/labelOverlay.ts
2283
+ var SEPARATION_X_PX = 88;
2284
+ var SEPARATION_Y_PX = 20;
2285
+ var KIND_STYLE = {
2286
+ zone: { size: 15, weight: "600", opacity: 0.95 },
2287
+ section: { size: 12, weight: "500", opacity: 0.88 },
2288
+ booth: { size: 11, weight: "500", opacity: 0.85 },
2289
+ annotation: { size: 11, weight: "400", opacity: 0.75 }
2290
+ };
2291
+ var LabelOverlay = class {
2292
+ constructor(container, opts = {}) {
2293
+ this.nodes = /* @__PURE__ */ new Map();
2294
+ this.labels = [];
2295
+ this.opts = opts;
2296
+ this.root = document.createElement("div");
2297
+ this.root.setAttribute("data-view3d-labels", "");
2298
+ const s = this.root.style;
2299
+ s.position = "absolute";
2300
+ s.inset = "0";
2301
+ s.pointerEvents = "none";
2302
+ s.overflow = "hidden";
2303
+ if (opts.fontFamily) s.fontFamily = opts.fontFamily;
2304
+ container.appendChild(this.root);
2305
+ }
2306
+ setLabels(labels) {
2307
+ this.labels = labels;
2308
+ for (const [id, node] of this.nodes) {
2309
+ if (!labels.some((l) => l.id === id)) {
2310
+ node.remove();
2311
+ this.nodes.delete(id);
2312
+ }
2313
+ }
2314
+ }
2315
+ /**
2316
+ * Reposition every label for the current camera.
2317
+ *
2318
+ * `viewProjection` is column-major, as OGL supplies it.
2319
+ */
2320
+ update(viewProjection, width, height, cameraDistance, venueRadius) {
2321
+ if (!this.labels.length) return;
2322
+ const kinds = visibleLabelKinds(cameraDistance, venueRadius);
2323
+ const candidates = [];
2324
+ for (const label of this.labels) {
2325
+ if (!kinds.has(label.kind)) continue;
2326
+ const screen = projectToScreen(viewProjection, label.anchor, width, height);
2327
+ if (!screen.visible) continue;
2328
+ candidates.push({ label, screen });
2329
+ }
2330
+ const kept = cullOverlapping(candidates, SEPARATION_X_PX, SEPARATION_Y_PX);
2331
+ const keptIds = new Set(kept.map((k) => k.label.id));
2332
+ for (const { label, screen } of kept) {
2333
+ const node = this.nodeFor(label);
2334
+ const st = node.style;
2335
+ st.display = "";
2336
+ st.transform = `translate(-50%, -50%) translate(${screen.x.toFixed(1)}px, ${screen.y.toFixed(1)}px)`;
2337
+ }
2338
+ for (const [id, node] of this.nodes) {
2339
+ if (!keptIds.has(id)) node.style.display = "none";
2340
+ }
2341
+ }
2342
+ nodeFor(label) {
2343
+ let node = this.nodes.get(label.id);
2344
+ if (node) return node;
2345
+ node = document.createElement("div");
2346
+ node.textContent = label.text;
2347
+ node.setAttribute("data-label-kind", label.kind);
2348
+ const style = KIND_STYLE[label.kind];
2349
+ const s = node.style;
2350
+ s.position = "absolute";
2351
+ s.left = "0";
2352
+ s.top = "0";
2353
+ s.whiteSpace = "nowrap";
2354
+ s.fontSize = `${style.size}px`;
2355
+ s.fontWeight = style.weight;
2356
+ s.opacity = String(style.opacity);
2357
+ s.color = label.color ?? this.opts.ink ?? "#e8edf5";
2358
+ s.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
2359
+ s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
2360
+ if (label.kind === "zone") s.textTransform = "uppercase";
2361
+ s.display = "none";
2362
+ this.root.appendChild(node);
2363
+ this.nodes.set(label.id, node);
2364
+ return node;
2365
+ }
2366
+ dispose() {
2367
+ this.root.remove();
2368
+ this.nodes.clear();
2369
+ }
2370
+ };
2371
+
779
2372
  // src/view3d/scene/build.ts
780
2373
  import { Geometry, Mesh, Transform } from "ogl";
781
2374
 
@@ -788,16 +2381,29 @@ precision highp float;
788
2381
  in vec3 position;
789
2382
  in vec3 normal;
790
2383
  in vec3 color;
2384
+ in float floorIndex;
2385
+ uniform mat4 modelMatrix;
2386
+ uniform float uFocusFloor; // -1 = show every floor
791
2387
  uniform mat4 modelViewMatrix;
792
2388
  uniform mat4 projectionMatrix;
793
2389
  uniform mat3 normalMatrix;
794
2390
  out vec3 vColor;
2391
+ out vec3 vNormalWorld;
795
2392
  out vec3 vNormalView;
796
2393
  out vec3 vPosView;
2394
+ out float vDim;
797
2395
  void main() {
2396
+ // Per-floor isolation without splitting the merged mesh into a draw call per
2397
+ // floor: a floor that is not the focused one is dimmed, not hidden, so the
2398
+ // buyer keeps the whole venue as context while looking at one level.
2399
+ vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
798
2400
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
799
2401
  vPosView = mv.xyz;
800
2402
  vNormalView = normalize(normalMatrix * normal);
2403
+ // World normal drives the key + hemisphere so the lighting stays welded to the
2404
+ // venue as the camera orbits (the scene has no non-uniform scale, so mat3 of
2405
+ // the model matrix is the correct normal transform).
2406
+ vNormalWorld = normalize(mat3(modelMatrix) * normal);
801
2407
  vColor = color;
802
2408
  gl_Position = projectionMatrix * mv;
803
2409
  }`
@@ -807,18 +2413,26 @@ var SOLID_FRAG = (
807
2413
  `#version 300 es
808
2414
  precision highp float;
809
2415
  in vec3 vColor;
2416
+ in vec3 vNormalWorld;
810
2417
  in vec3 vNormalView;
811
2418
  in vec3 vPosView;
2419
+ in float vDim;
2420
+ uniform vec3 uKeyDir; // WORLD space, unit, points at the light
812
2421
  out vec4 fragColor;
813
2422
  void main() {
814
- vec3 N = normalize(vNormalView);
2423
+ vec3 N = normalize(vNormalWorld);
815
2424
  vec3 V = normalize(-vPosView);
816
- float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient
817
- vec3 L = normalize(vec3(0.4, 0.85, 0.55)); // warm key, view space
818
- float key = max(dot(N, L), 0.0);
819
- vec3 base = vColor * (0.60 + 0.32 * hemi) + vColor * key * 0.32;
820
- float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
821
- base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained
2425
+ float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
2426
+ float key = max(dot(N, uKeyDir), 0.0); // fixed key \u2014 does not orbit with you
2427
+ // Low opposite fill so faces turned away from the key keep their form instead
2428
+ // of crushing to a single flat value.
2429
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
2430
+ float fill = max(dot(N, fillDir), 0.0);
2431
+ vec3 base = vColor * (0.52 + 0.34 * hemi) + vColor * key * 0.34 + vColor * fill * 0.10;
2432
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
2433
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained (view-dependent by design)
2434
+ // Unfocused floors fall back toward the background rather than vanishing.
2435
+ base = mix(base, base * 0.45, vDim);
822
2436
  fragColor = vec4(base, 1.0);
823
2437
  }`
824
2438
  );
@@ -829,22 +2443,45 @@ precision highp float;
829
2443
  in vec2 position; // quad corner in [-1,1]
830
2444
  in vec3 iOffset; // per-instance world position
831
2445
  in vec3 iColor; // per-instance state colour (resolved CPU-side)
2446
+ in float iMaxRadius; // per-instance world-radius ceiling (seat pitch derived)
2447
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
2448
+ in float iFloor; // owning floor index
832
2449
  uniform mat4 modelViewMatrix;
833
2450
  uniform mat4 projectionMatrix;
834
2451
  uniform float uSeatRadius;
835
2452
  uniform float uSeatScale;
836
2453
  uniform float uMinPixels;
837
2454
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
2455
+ uniform float uFocusFloor; // -1 = show every floor
838
2456
  out vec2 vUv;
839
2457
  out vec3 vColor;
2458
+ out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
2459
+ out vec3 vRing;
2460
+ out float vDim;
840
2461
  void main() {
841
2462
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
842
2463
  float depth = max(-mv.z, 0.001);
843
2464
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
844
- float r = max(uSeatRadius * uSeatScale, minR);
2465
+ // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
2466
+ // unbounded growth is what merges neighbouring rows into one mass at range.
2467
+ float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
2468
+ // How much of the requested pixel floor the dot could actually afford. Below 1
2469
+ // it is losing legibility to distance, and the fragment stage dissolves it
2470
+ // toward the tier top rather than letting a sub-pixel dot alias and shimmer.
2471
+ vBudget = minR > 0.0 ? clamp(r / minR, 0.0, 1.0) : 1.0;
845
2472
  mv.xy += position * r; // camera-facing billboard
2473
+ // Seat the dot ON the deck instead of centring it in the deck. iOffset is the
2474
+ // exact surface point, so half the billboard would otherwise sit below the cap
2475
+ // and be clipped by it \u2014 and because uMinPixels grows r with distance, no
2476
+ // constant world-space lift can prevent that at every range. Offsetting by r
2477
+ // along the screen projection of WORLD up is self-correcting: it is full at a
2478
+ // grazing view (where slicing happens) and vanishes looking straight down
2479
+ // (where the dot must stay centred on its seat).
2480
+ mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
846
2481
  vUv = position;
847
2482
  vColor = iColor;
2483
+ vRing = iRing;
2484
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
848
2485
  gl_Position = projectionMatrix * mv;
849
2486
  }`
850
2487
  );
@@ -854,6 +2491,9 @@ var SEAT_FRAG = (
854
2491
  precision highp float;
855
2492
  in vec2 vUv;
856
2493
  in vec3 vColor;
2494
+ in float vBudget;
2495
+ in vec3 vRing;
2496
+ in float vDim;
857
2497
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
858
2498
  uniform vec3 uFadeColor;
859
2499
  out vec4 fragColor;
@@ -864,6 +2504,20 @@ void main() {
864
2504
  float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
865
2505
  vec3 c = vColor * shade;
866
2506
  c = mix(c, uFadeColor, uSeatFade);
2507
+ // Accommodation ring \u2014 the 3D echo of the coloured ring 2D draws around every
2508
+ // accessible seat. Painted INSIDE the dot's own radius rather than outside it,
2509
+ // so an accessible seat still respects the row-pitch ceiling and cannot grow
2510
+ // into its neighbour just for carrying a ring.
2511
+ float ringMask = step(0.001, dot(vRing, vRing));
2512
+ float ring = smoothstep(0.58, 0.70, d) * (1.0 - smoothstep(0.90, 1.0, d));
2513
+ c = mix(c, vRing, ring * ringMask * 0.95);
2514
+ // A dot that can no longer afford its pixel floor dissolves instead of
2515
+ // aliasing; the tier cap underneath already carries the section's category
2516
+ // tint, so the block reads as coloured seating rather than empty concrete.
2517
+ alpha *= smoothstep(0.35, 1.0, vBudget);
2518
+ // Seats on an unfocused floor recede with their structure.
2519
+ c = mix(c, uFadeColor, vDim * 0.75);
2520
+ alpha *= mix(1.0, 0.30, vDim);
867
2521
  fragColor = vec4(c, alpha);
868
2522
  }`
869
2523
  );
@@ -899,6 +2553,7 @@ var SEAT_PICK_VERT = (
899
2553
  precision highp float;
900
2554
  in vec2 position;
901
2555
  in vec3 iOffset;
2556
+ in float iMaxRadius;
902
2557
  uniform mat4 modelViewMatrix;
903
2558
  uniform mat4 projectionMatrix;
904
2559
  uniform float uSeatRadius;
@@ -913,8 +2568,10 @@ void main() {
913
2568
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
914
2569
  float depth = max(-mv.z, 0.001);
915
2570
  float minR = uMinPixels * depth * uPixelToWorld;
916
- float r = max(uSeatRadius * uSeatScale, minR);
2571
+ float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
917
2572
  mv.xy += position * r;
2573
+ // Must match SEAT_VERT exactly, or the hit mask drifts off the drawn dot.
2574
+ mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
918
2575
  vUv = position;
919
2576
  gl_Position = projectionMatrix * mv;
920
2577
  }`
@@ -958,7 +2615,7 @@ function createSeatPickProgram(gl) {
958
2615
  depthWrite: true,
959
2616
  cullFace: false,
960
2617
  uniforms: {
961
- uSeatRadius: { value: 0.22 },
2618
+ uSeatRadius: { value: SEAT_DOT_RADIUS_M },
962
2619
  uSeatScale: { value: 1 },
963
2620
  uMinPixels: { value: 2.5 },
964
2621
  uPixelToWorld: { value: 2e-3 }
@@ -985,7 +2642,13 @@ function createSolidProgram(gl) {
985
2642
  fragment: SOLID_FRAG,
986
2643
  cullFace: false,
987
2644
  depthTest: true,
988
- depthWrite: true
2645
+ depthWrite: true,
2646
+ uniforms: {
2647
+ // High and off-axis, in world space: reads as a house rig rather than a
2648
+ // headlamp welded to the camera.
2649
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
2650
+ uFocusFloor: { value: -1 }
2651
+ }
989
2652
  });
990
2653
  }
991
2654
  function createSeatProgram(gl) {
@@ -997,11 +2660,12 @@ function createSeatProgram(gl) {
997
2660
  depthWrite: false,
998
2661
  cullFace: false,
999
2662
  uniforms: {
1000
- uSeatRadius: { value: 0.22 },
2663
+ uSeatRadius: { value: SEAT_DOT_RADIUS_M },
1001
2664
  uSeatScale: { value: 1 },
1002
2665
  uMinPixels: { value: 2.5 },
1003
2666
  uPixelToWorld: { value: 2e-3 },
1004
2667
  uSeatFade: { value: 0 },
2668
+ uFocusFloor: { value: -1 },
1005
2669
  uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
1006
2670
  }
1007
2671
  });
@@ -1021,9 +2685,9 @@ function createBackgroundProgram(gl, top, bottom) {
1021
2685
  }
1022
2686
 
1023
2687
  // src/view3d/scene/build.ts
1024
- function writeSeatColors(iColor, iState, start, count) {
2688
+ function writeSeatColors(iColor, iState, start, count, states) {
1025
2689
  for (let i = start; i < start + count; i++) {
1026
- const c = seatStateColorByIndex(iState[i]);
2690
+ const c = states[iState[i]] ?? states[0];
1027
2691
  iColor[i * 3] = c[0];
1028
2692
  iColor[i * 3 + 1] = c[1];
1029
2693
  iColor[i * 3 + 2] = c[2];
@@ -1035,14 +2699,15 @@ function buildGpuScene(gl, model) {
1035
2699
  const main = new Transform();
1036
2700
  const background = new Transform();
1037
2701
  const bgGeo = new Geometry(gl, { position: { size: 2, data: BG_TRI } });
1038
- const bgProg = createBackgroundProgram(gl, BACKGROUND.top, BACKGROUND.bottom);
2702
+ const bgProg = createBackgroundProgram(gl, model.theme.background.top, model.theme.background.bottom);
1039
2703
  const bgMesh = new Mesh(gl, { geometry: bgGeo, program: bgProg });
1040
2704
  bgMesh.frustumCulled = false;
1041
2705
  bgMesh.setParent(background);
1042
2706
  const solidGeo = new Geometry(gl, {
1043
2707
  position: { size: 3, data: model.solids.position },
1044
2708
  normal: { size: 3, data: model.solids.normal },
1045
- color: { size: 3, data: model.solids.color }
2709
+ color: { size: 3, data: model.solids.color },
2710
+ floorIndex: { size: 1, data: model.solids.floor }
1046
2711
  });
1047
2712
  const solidProg = createSolidProgram(gl);
1048
2713
  const solidMesh = new Mesh(gl, { geometry: solidGeo, program: solidProg });
@@ -1050,11 +2715,18 @@ function buildGpuScene(gl, model) {
1050
2715
  solidMesh.setParent(main);
1051
2716
  const seatProg = createSeatProgram(gl);
1052
2717
  const iColor = new Float32Array(model.seats.count * 3);
1053
- writeSeatColors(iColor, model.seats.iState, 0, model.seats.count);
2718
+ const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
2719
+ writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
1054
2720
  const seatGeo = new Geometry(gl, {
1055
2721
  position: { size: 2, data: SEAT_QUAD },
1056
2722
  iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
1057
- iColor: { size: 3, data: iColor, instanced: 1 }
2723
+ iColor: { size: 3, data: iColor, instanced: 1 },
2724
+ // Per-seat world-radius ceiling: what stops distant rows merging into one
2725
+ // mass when the shader grows a dot to hold its minimum pixel size.
2726
+ iMaxRadius: { size: 1, data: model.seats.iMaxRadius, instanced: 1 },
2727
+ // Accommodation ring colour; (0,0,0) means the seat carries no access type.
2728
+ iRing: { size: 3, data: model.seats.iRing, instanced: 1 },
2729
+ iFloor: { size: 1, data: model.seats.iFloor, instanced: 1 }
1058
2730
  });
1059
2731
  const seatMesh = new Mesh(gl, { geometry: seatGeo, program: seatProg });
1060
2732
  seatMesh.frustumCulled = false;
@@ -1064,12 +2736,13 @@ function buildGpuScene(gl, model) {
1064
2736
  main,
1065
2737
  background,
1066
2738
  seatProgram: seatProg,
2739
+ solidProgram: solidProg,
1067
2740
  seatGeometry: seatGeo,
1068
2741
  solidGeometry: solidGeo,
1069
2742
  drawCalls: 3,
1070
2743
  uploadSeatStateRuns(runs) {
1071
2744
  if (!runs.length) return;
1072
- for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length);
2745
+ for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
1073
2746
  const buffer = colorAttr.buffer;
1074
2747
  if (!buffer) {
1075
2748
  colorAttr.needsUpdate = true;
@@ -1137,6 +2810,8 @@ var PickPipeline = class {
1137
2810
  this.seatScene = new Transform2();
1138
2811
  this.solidScene = new Transform2();
1139
2812
  this.target = null;
2813
+ /** Display clear colour to restore after the pick pass (theme-dependent). */
2814
+ this.restoreClear = [0, 0, 0];
1140
2815
  this.renderer = renderer;
1141
2816
  this.gl = renderer.gl;
1142
2817
  this.maxIndex = seatCount;
@@ -1149,6 +2824,10 @@ var PickPipeline = class {
1149
2824
  solidMesh.frustumCulled = false;
1150
2825
  solidMesh.setParent(this.solidScene);
1151
2826
  }
2827
+ /** Follow the theme's background when the scene is (re)built. */
2828
+ setRestoreClear(rgb) {
2829
+ this.restoreClear = [rgb[0], rgb[1], rgb[2]];
2830
+ }
1152
2831
  /** Match the display seat sizing so the pick mask lines up with the dots. */
1153
2832
  syncFromSeatProgram(seatProgram) {
1154
2833
  for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
@@ -1189,7 +2868,7 @@ var PickPipeline = class {
1189
2868
  const boxH = Math.max(1, Math.min(bh, py + radius + 1) - y0);
1190
2869
  gl.enable(gl.SCISSOR_TEST);
1191
2870
  gl.scissor(x0, y0, boxW, boxH);
1192
- const [br, bg, bb] = BACKGROUND.top;
2871
+ const [br, bg, bb] = this.restoreClear;
1193
2872
  gl.clearColor(0, 0, 0, 1);
1194
2873
  this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
1195
2874
  this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
@@ -1642,9 +3321,15 @@ function mountVenue3D(container, input, opts = {}) {
1642
3321
  const prefetch = /* @__PURE__ */ new Map();
1643
3322
  let flightGen = 0;
1644
3323
  let reducedForced = null;
3324
+ let focusedFloor = -1;
1645
3325
  const rebuildGpu = () => {
1646
3326
  gpu = buildGpuScene(glctx.gl, model);
1647
3327
  pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
3328
+ glctx.setClearColor(model.theme.background.top);
3329
+ pick.setRestoreClear(model.theme.background.top);
3330
+ gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
3331
+ gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
3332
+ gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
1648
3333
  };
1649
3334
  const glctx = new GLContext(container, {
1650
3335
  onContextLost: () => {
@@ -1667,8 +3352,21 @@ function mountVenue3D(container, input, opts = {}) {
1667
3352
  // first real drag/wheel/pinch (not the intro ease)
1668
3353
  );
1669
3354
  orbit.setAspect(glctx.aspect);
1670
- orbit.frame(model.bounds, true);
3355
+ const stageAzimuth = (() => {
3356
+ const dx = model.focalWorld[0] - model.bounds.center[0];
3357
+ const dz = model.focalWorld[2] - model.bounds.center[2];
3358
+ return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
3359
+ })();
3360
+ orbit.frame(model.bounds, true, stageAzimuth);
1671
3361
  const cinematic = new Cinematic(orbit.camera);
3362
+ if (!container.style.position || container.style.position === "static") {
3363
+ container.style.position = "relative";
3364
+ }
3365
+ const labelOverlay = new LabelOverlay(container, {
3366
+ fontFamily: input.doc.theme?.fontFamily,
3367
+ ink: input.doc.theme?.textColor
3368
+ });
3369
+ labelOverlay.setLabels(model.labels);
1672
3370
  rebuildGpu();
1673
3371
  const loop = new RenderLoop(() => {
1674
3372
  if (contextLost || !gpu || frozen) return false;
@@ -1681,6 +3379,13 @@ function mountVenue3D(container, input, opts = {}) {
1681
3379
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
1682
3380
  glctx.renderer.render({ scene: gpu.background, clear: true });
1683
3381
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
3382
+ labelOverlay.update(
3383
+ orbit.camera.projectionViewMatrix,
3384
+ glctx.canvas.clientWidth || 1,
3385
+ glctx.canvas.clientHeight || 1,
3386
+ orbit.currentDistance,
3387
+ model.bounds.radius
3388
+ );
1684
3389
  return moving;
1685
3390
  });
1686
3391
  const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
@@ -1728,6 +3433,72 @@ function mountVenue3D(container, input, opts = {}) {
1728
3433
  orbit.camera.fov = FOV_END;
1729
3434
  orbit.camera.updateProjectionMatrix();
1730
3435
  };
3436
+ let arriveChip = null;
3437
+ const removeArriveChip = () => {
3438
+ arriveChip?.remove();
3439
+ arriveChip = null;
3440
+ };
3441
+ const showArriveChip = (seatId, gen) => {
3442
+ removeArriveChip();
3443
+ if (disposed || !opts.getSeatView) return;
3444
+ const chip = document.createElement("button");
3445
+ chip.type = "button";
3446
+ chip.textContent = "\u25C9 View in 360\xB0";
3447
+ chip.setAttribute("aria-label", `Open the 360\xB0 view from seat ${seatId}`);
3448
+ Object.assign(chip.style, {
3449
+ position: "absolute",
3450
+ left: "50%",
3451
+ bottom: "18px",
3452
+ transform: "translateX(-50%)",
3453
+ minHeight: "44px",
3454
+ padding: "10px 18px",
3455
+ borderRadius: "999px",
3456
+ background: "rgba(12,18,32,0.78)",
3457
+ color: "#eef1f8",
3458
+ border: "1px solid rgba(150,165,205,0.4)",
3459
+ backdropFilter: "blur(6px)",
3460
+ font: "600 13px/1 inherit",
3461
+ cursor: "pointer",
3462
+ zIndex: "4"
3463
+ });
3464
+ chip.addEventListener("click", () => {
3465
+ if (disposed || gen !== flightGen) {
3466
+ removeArriveChip();
3467
+ return;
3468
+ }
3469
+ removeArriveChip();
3470
+ void openPanorama(seatId, 400, gen);
3471
+ });
3472
+ container.appendChild(chip);
3473
+ arriveChip = chip;
3474
+ };
3475
+ const overviewChip = document.createElement("button");
3476
+ overviewChip.type = "button";
3477
+ overviewChip.textContent = "\u2302 Overview";
3478
+ overviewChip.setAttribute("aria-label", "Return to the venue overview");
3479
+ Object.assign(overviewChip.style, {
3480
+ position: "absolute",
3481
+ right: "14px",
3482
+ bottom: "18px",
3483
+ minHeight: "40px",
3484
+ padding: "8px 14px",
3485
+ borderRadius: "999px",
3486
+ background: "rgba(12,18,32,0.72)",
3487
+ color: "#c9d4ea",
3488
+ border: "1px solid rgba(150,165,205,0.35)",
3489
+ backdropFilter: "blur(6px)",
3490
+ font: "600 12.5px/1 inherit",
3491
+ cursor: "pointer",
3492
+ zIndex: "4"
3493
+ });
3494
+ overviewChip.addEventListener("click", () => {
3495
+ if (disposed || frozen) return;
3496
+ cancelFlight();
3497
+ removeArriveChip();
3498
+ orbit.frameSoft(model.bounds, stageAzimuth);
3499
+ loop.requestRender();
3500
+ });
3501
+ container.appendChild(overviewChip);
1731
3502
  const openPanorama = async (seatId, fadeMs, gen) => {
1732
3503
  const viewPromise = ensureSeatView(seatId);
1733
3504
  if (!viewPromise) {
@@ -1748,6 +3519,7 @@ function mountVenue3D(container, input, opts = {}) {
1748
3519
  return;
1749
3520
  }
1750
3521
  if (disposed || gen !== flightGen) return;
3522
+ removeArriveChip();
1751
3523
  panorama = mountPanorama(container, view, {
1752
3524
  fadeMs,
1753
3525
  seatLabel: seatId,
@@ -1757,6 +3529,7 @@ function mountVenue3D(container, input, opts = {}) {
1757
3529
  analytics.panoramaClosed();
1758
3530
  orbit.resumeAfterFlight(model.focalWorld);
1759
3531
  loop.requestRender();
3532
+ showArriveChip(seatId, flightGen);
1760
3533
  }
1761
3534
  });
1762
3535
  analytics.panoramaOpened();
@@ -1778,6 +3551,7 @@ function mountVenue3D(container, input, opts = {}) {
1778
3551
  }
1779
3552
  frozen = false;
1780
3553
  const gen = ++flightGen;
3554
+ removeArriveChip();
1781
3555
  const seatEye = seatEyeWorld(idx);
1782
3556
  const focal = model.focalWorld;
1783
3557
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
@@ -1786,9 +3560,9 @@ function mountVenue3D(container, input, opts = {}) {
1786
3560
  placeCameraFinal(finalPos, focal);
1787
3561
  loop.requestRender();
1788
3562
  analytics.cinematicSkipped();
1789
- return openPanorama(seatId, 300, gen).then(() => {
1790
- if (!disposed) orbit.syncFromCamera();
1791
- });
3563
+ if (!disposed) orbit.syncFromCamera();
3564
+ showArriveChip(seatId, gen);
3565
+ return Promise.resolve();
1792
3566
  }
1793
3567
  const startQuat = new Quat2().copy(orbit.camera.quaternion);
1794
3568
  const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
@@ -1796,7 +3570,9 @@ function mountVenue3D(container, input, opts = {}) {
1796
3570
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {
1797
3571
  if (disposed || gen !== flightGen) return;
1798
3572
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
1799
- return openPanorama(seatId, 400, gen);
3573
+ orbit.resumeAfterFlight(model.focalWorld);
3574
+ loop.requestRender();
3575
+ showArriveChip(seatId, gen);
1800
3576
  });
1801
3577
  };
1802
3578
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
@@ -1872,13 +3648,51 @@ function mountVenue3D(container, input, opts = {}) {
1872
3648
  loseContextForTest() {
1873
3649
  glctx.simulateContextLossCycle();
1874
3650
  },
3651
+ floors() {
3652
+ return model.floors;
3653
+ },
3654
+ focusFloor(index) {
3655
+ if (index !== null && !model.floors.some((f) => f.index === index)) return false;
3656
+ const value = index ?? -1;
3657
+ if (gpu) {
3658
+ gpu.seatProgram.uniforms.uFocusFloor.value = value;
3659
+ gpu.solidProgram.uniforms.uFocusFloor.value = value;
3660
+ }
3661
+ focusedFloor = value;
3662
+ if (index !== null) {
3663
+ const f = model.floors[index];
3664
+ if (f.seatCount > 0) {
3665
+ cinematic.cancel();
3666
+ orbit.frame({ center: f.center, radius: f.radius * 1.25 });
3667
+ }
3668
+ }
3669
+ loop.requestRender();
3670
+ return true;
3671
+ },
3672
+ zones() {
3673
+ return model.zones;
3674
+ },
3675
+ focusZone(zoneId) {
3676
+ const zone = model.zones.find((z) => z.id === zoneId);
3677
+ if (!zone || zone.seatCount === 0) return false;
3678
+ cinematic.cancel();
3679
+ const dx = zone.focalWorld[0] - zone.center[0];
3680
+ const dz = zone.focalWorld[2] - zone.center[2];
3681
+ const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
3682
+ orbit.frame({ center: zone.center, radius: zone.radius * 1.25 }, false, azimuth);
3683
+ loop.requestRender();
3684
+ return true;
3685
+ },
1875
3686
  setReducedMotionForTest(value) {
1876
3687
  reducedForced = value;
1877
3688
  },
1878
3689
  dispose() {
1879
3690
  disposed = true;
3691
+ removeArriveChip();
3692
+ overviewChip.remove();
1880
3693
  cancelFlight();
1881
3694
  loop.stop();
3695
+ labelOverlay.dispose();
1882
3696
  ro?.disconnect();
1883
3697
  if (panorama) {
1884
3698
  panorama.dispose();