@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.
@@ -52,15 +52,6 @@ var SEAT_STATE_COLORS = {
52
52
  selected: [0.24, 0.74, 1],
53
53
  dimmed: [0.28, 0.32, 0.37]
54
54
  };
55
- function seatStateColorLUT() {
56
- const out = [];
57
- for (const s of SEAT_STATES) out.push(...SEAT_STATE_COLORS[s]);
58
- return out;
59
- }
60
- function seatStateColorByIndex(index) {
61
- const state = SEAT_STATES[index] ?? "available";
62
- return SEAT_STATE_COLORS[state];
63
- }
64
55
  var STRUCTURE = {
65
56
  ground: [0.07, 0.085, 0.11],
66
57
  tierTop: [0.24, 0.28, 0.34],
@@ -71,7 +62,13 @@ var STRUCTURE = {
71
62
  decorTop: [0.22, 0.25, 0.29],
72
63
  decorWall: [0.15, 0.17, 0.2],
73
64
  gaTop: [0.24, 0.28, 0.33],
74
- gaWall: [0.16, 0.19, 0.23]
65
+ gaWall: [0.16, 0.19, 0.23],
66
+ /** Exhibition / trade-show booth stand. */
67
+ boothTop: [0.3, 0.33, 0.38],
68
+ boothWall: [0.2, 0.23, 0.27],
69
+ /** Banquet table top — warmer, so a laid table reads apart from structure. */
70
+ tableTop: [0.38, 0.34, 0.29],
71
+ tableWall: [0.24, 0.21, 0.18]
75
72
  };
76
73
  var BACKGROUND = {
77
74
  top: [0.05, 0.06, 0.08],
@@ -106,6 +103,8 @@ function computeDpr() {
106
103
  }
107
104
  var GLContext = class {
108
105
  constructor(container, opts) {
106
+ /** Current clear colour, so a context restore repaints the themed background. */
107
+ this.clearRgb = [0, 0, 0];
109
108
  this.container = container;
110
109
  this.canvas = document.createElement("canvas");
111
110
  this.canvas.style.display = "block";
@@ -123,7 +122,8 @@ var GLContext = class {
123
122
  webgl: 2
124
123
  });
125
124
  this.gl = this.renderer.gl;
126
- this.gl.clearColor(BACKGROUND.top[0], BACKGROUND.top[1], BACKGROUND.top[2], 1);
125
+ this.clearRgb = [BACKGROUND.top[0], BACKGROUND.top[1], BACKGROUND.top[2]];
126
+ this.gl.clearColor(this.clearRgb[0], this.clearRgb[1], this.clearRgb[2], 1);
127
127
  container.appendChild(this.canvas);
128
128
  this.lostHandler = (e) => {
129
129
  e.preventDefault();
@@ -134,6 +134,21 @@ var GLContext = class {
134
134
  this.canvas.addEventListener("webglcontextrestored", this.restoredHandler, false);
135
135
  this.resize();
136
136
  }
137
+ /**
138
+ * Repaint the clear colour from the chart's theme.
139
+ *
140
+ * The clear shows for one frame before the background triangle draws, and on
141
+ * any frame the scene does not cover — so leaving it at the library default
142
+ * flashes SeatLayer grey into a white-labelled venue.
143
+ */
144
+ setClearColor(rgb) {
145
+ this.clearRgb = [rgb[0], rgb[1], rgb[2]];
146
+ this.gl.clearColor(rgb[0], rgb[1], rgb[2], 1);
147
+ }
148
+ /** The clear colour currently set (the pick pass restores through this). */
149
+ get clearColor() {
150
+ return this.clearRgb;
151
+ }
137
152
  /** Match the drawing buffer to the container's CSS box. */
138
153
  resize() {
139
154
  const w = Math.max(1, this.container.clientWidth || this.canvas.clientWidth || 1);
@@ -282,14 +297,14 @@ var OrbitCamera = class {
282
297
  * then the damped `update()` eases it up into the 3/4 architectural angle and
283
298
  * dollies in — the venue "stands up" instead of teleporting (~600ms).
284
299
  */
285
- frame(bounds, intro = false) {
300
+ frame(bounds, intro = false, stageAzimuth) {
286
301
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
287
302
  const r = Math.max(1, bounds.radius);
288
303
  const halfV = this.fovY * DEG / 2;
289
304
  const aspect = this.camera.aspect || 1;
290
305
  const halfH = Math.atan(Math.tan(halfV) * aspect);
291
306
  const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
292
- this.azT = -30 * DEG;
307
+ this.azT = stageAzimuth ?? -30 * DEG;
293
308
  this.polT = 55 * DEG;
294
309
  this.distT = fit * FRAME_MARGIN;
295
310
  this.minDist = Math.max(2, r * 0.12);
@@ -308,6 +323,24 @@ var OrbitCamera = class {
308
323
  setAspect(aspect) {
309
324
  this.camera.perspective({ aspect });
310
325
  }
326
+ /**
327
+ * Damped return to the framed overview from wherever the camera is now (a
328
+ * seat, behind the shell, anywhere): re-pivot on the venue centre without
329
+ * moving the camera, then glide targets back to the 3/4 architectural pose.
330
+ */
331
+ frameSoft(bounds, stageAzimuth) {
332
+ this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
333
+ this.syncFromCamera();
334
+ this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
335
+ const r = Math.max(1, bounds.radius);
336
+ const halfV = this.fovY * DEG / 2;
337
+ const aspect = this.camera.aspect || 1;
338
+ const halfH = Math.atan(Math.tan(halfV) * aspect);
339
+ const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
340
+ this.azT = stageAzimuth ?? this.azimuth;
341
+ this.polT = 55 * DEG;
342
+ this.distT = fit * FRAME_MARGIN;
343
+ }
311
344
  /** Damp toward targets; returns true while still moving. */
312
345
  update() {
313
346
  const da = this.azT - this.azimuth;
@@ -424,6 +457,46 @@ function computeSeatLod(distance, radius) {
424
457
  };
425
458
  }
426
459
 
460
+ // src/core/types.ts
461
+ var ACCESSIBILITY_TYPES = [
462
+ { key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
463
+ { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
464
+ { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
465
+ { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
466
+ { key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
467
+ { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
468
+ { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
469
+ { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
470
+ ];
471
+ var ACCESSIBILITY_LABEL = new Map(ACCESSIBILITY_TYPES.map((a) => [a.key, a]));
472
+ var ACCESSIBILITY_RING_COLOR = {
473
+ wheelchair: "#3b82f6",
474
+ companion: "#8b5cf6",
475
+ "semi-ambulatory": "#0ea5e9",
476
+ hearing: "#14b8a6",
477
+ cart: "#7c3aed",
478
+ "sign-language": "#f59e0b",
479
+ "plus-size": "#ec4899",
480
+ "lift-armrest": "#22c55e"
481
+ };
482
+ function accessibilityRingColor(types) {
483
+ const primary = types?.[0];
484
+ return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
485
+ }
486
+ var SURROUNDINGS_SHAPE_ROLES = [
487
+ "reference-focal",
488
+ "bar",
489
+ "entrance",
490
+ "exit",
491
+ "restroom",
492
+ "screen",
493
+ "sound",
494
+ "concession",
495
+ "coat",
496
+ "wall"
497
+ ];
498
+ var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
499
+
427
500
  // src/core/units.ts
428
501
  var METRES_PER_CHART_UNIT = 0.55 / 24;
429
502
  var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
@@ -462,18 +535,47 @@ function sectionGeometry(section, context = {}) {
462
535
 
463
536
  // src/view3d/scene/geometry.ts
464
537
  var import_earcut = __toESM(require("earcut"), 1);
538
+ var import_polygon_clipping = __toESM(require("polygon-clipping"), 1);
465
539
  var M = METRES_PER_CHART_UNIT;
540
+ var MIN_TRI_ALTITUDE_M = 2e-3;
541
+ function isDegenerate(p0, p1, p2) {
542
+ const e0 = Math.hypot(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
543
+ const e1 = Math.hypot(p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]);
544
+ const e2 = Math.hypot(p0[0] - p2[0], p0[1] - p2[1], p0[2] - p2[2]);
545
+ const longest = Math.max(e0, e1, e2);
546
+ if (longest < 1e-9) return true;
547
+ const s = (e0 + e1 + e2) / 2;
548
+ const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
549
+ return 2 * area / longest < MIN_TRI_ALTITUDE_M;
550
+ }
466
551
  var MeshBuilder = class {
467
552
  constructor() {
468
553
  this.pos = [];
469
554
  this.nor = [];
470
555
  this.col = [];
556
+ this.flr = [];
557
+ /** Floor index stamped onto every triangle emitted from now on. */
558
+ this.currentFloor = 0;
559
+ }
560
+ /** Stamp subsequent triangles as belonging to `index`. */
561
+ setFloor(index) {
562
+ this.currentFloor = index;
471
563
  }
472
564
  /** One triangle with a shared (flat) normal and per-vertex colours. */
473
565
  tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
566
+ if (isDegenerate(p0, p1, p2)) return;
474
567
  this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
475
568
  this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
476
569
  this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
570
+ this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
571
+ }
572
+ /** One triangle with independent per-vertex normals (smooth shading). */
573
+ triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
574
+ if (isDegenerate(p0, p1, p2)) return;
575
+ this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
576
+ this.nor.push(n0[0], n0[1], n0[2], n1[0], n1[1], n1[2], n2[0], n2[1], n2[2]);
577
+ this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
578
+ this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
477
579
  }
478
580
  get vertexCount() {
479
581
  return this.pos.length / 3;
@@ -483,6 +585,7 @@ var MeshBuilder = class {
483
585
  position: new Float32Array(this.pos),
484
586
  normal: new Float32Array(this.nor),
485
587
  color: new Float32Array(this.col),
588
+ floor: new Float32Array(this.flr),
486
589
  count: this.pos.length / 3
487
590
  };
488
591
  }
@@ -537,33 +640,158 @@ function signedArea(pts) {
537
640
  function toCCW(pts) {
538
641
  return signedArea(pts) < 0 ? [...pts].reverse() : pts;
539
642
  }
540
- function extrudePrism(builder, outlineIn, holesIn, topY, bottomY, colTop, colWall, ao) {
643
+ var MAX_CAP_SPLIT_DEPTH = 3;
644
+ function capDeviation(a, b, c, topY) {
645
+ const ya = topY(a), yb = topY(b), yc = topY(c);
646
+ const edge = (p, q, yp, yq) => Math.abs(topY({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }) - (yp + yq) / 2);
647
+ const cx3 = (a.x + b.x + c.x) / 3, cy3 = (a.y + b.y + c.y) / 3;
648
+ const yCen = (ya + yb + yc) / 3;
649
+ let worst = Math.max(
650
+ edge(a, b, ya, yb),
651
+ edge(b, c, yb, yc),
652
+ edge(c, a, yc, ya),
653
+ Math.abs(topY({ x: cx3, y: cy3 }) - yCen)
654
+ );
655
+ const probe = (px, py, yp) => {
656
+ const d = Math.abs(topY({ x: (px + cx3) / 2, y: (py + cy3) / 2 }) - (yp + yCen) / 2);
657
+ if (d > worst) worst = d;
658
+ };
659
+ probe((a.x + b.x) / 2, (a.y + b.y) / 2, (ya + yb) / 2);
660
+ probe((b.x + c.x) / 2, (b.y + c.y) / 2, (yb + yc) / 2);
661
+ probe((c.x + a.x) / 2, (c.y + a.y) / 2, (yc + ya) / 2);
662
+ return worst;
663
+ }
664
+ function maxDevAtDepth(a, b, c, topY, depth) {
665
+ if (depth <= 0) return capDeviation(a, b, c, topY);
666
+ const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
667
+ const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
668
+ const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
669
+ return Math.max(
670
+ maxDevAtDepth(a, ab, ca, topY, depth - 1),
671
+ maxDevAtDepth(ab, b, bc, topY, depth - 1),
672
+ maxDevAtDepth(ca, bc, c, topY, depth - 1),
673
+ maxDevAtDepth(ab, bc, ca, topY, depth - 1)
674
+ );
675
+ }
676
+ function emitCapUniform(builder, a, b, c, topY, colTop, depth, topN) {
677
+ if (depth > 0) {
678
+ const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
679
+ const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
680
+ const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
681
+ emitCapUniform(builder, a, ab, ca, topY, colTop, depth - 1, topN);
682
+ emitCapUniform(builder, ab, b, bc, topY, colTop, depth - 1, topN);
683
+ emitCapUniform(builder, ca, bc, c, topY, colTop, depth - 1, topN);
684
+ emitCapUniform(builder, ab, bc, ca, topY, colTop, depth - 1, topN);
685
+ return;
686
+ }
687
+ const at = [a.x * M, topY(a), a.y * M];
688
+ const bt = [b.x * M, topY(b), b.y * M];
689
+ const ct = [c.x * M, topY(c), c.y * M];
690
+ if (topN) {
691
+ builder.triN(at, bt, ct, topN(a), topN(b), topN(c), colTop);
692
+ return;
693
+ }
694
+ let n = faceNormal(at, bt, ct);
695
+ if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
696
+ builder.tri(at, bt, ct, n, colTop);
697
+ }
698
+ var MAX_RING_SPLIT_DEPTH = 8;
699
+ function densifyRing(ring, topY, maxError) {
700
+ if (!Number.isFinite(maxError)) return ring;
701
+ const out = [];
702
+ const emit = (p, q, yp, yq, depth) => {
703
+ if (depth > 0) {
704
+ const mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
705
+ const ym = topY(mid);
706
+ if (Math.abs(ym - (yp + yq) / 2) > maxError) {
707
+ emit(p, mid, yp, ym, depth - 1);
708
+ emit(mid, q, ym, yq, depth - 1);
709
+ return;
710
+ }
711
+ }
712
+ out.push(p);
713
+ };
714
+ for (let i = 0; i < ring.length; i++) {
715
+ const p = ring[i], q = ring[(i + 1) % ring.length];
716
+ emit(p, q, topY(p), topY(q), MAX_RING_SPLIT_DEPTH);
717
+ }
718
+ return out;
719
+ }
720
+ function dedupeRing(ring, eps) {
721
+ if (ring.length < 3) return ring;
722
+ const out = [];
723
+ for (const p of ring) {
724
+ const last = out[out.length - 1];
725
+ if (last && Math.hypot(p.x - last.x, p.y - last.y) < eps) continue;
726
+ out.push(p);
727
+ }
728
+ while (out.length > 2 && Math.hypot(out[0].x - out[out.length - 1].x, out[0].y - out[out.length - 1].y) < eps) {
729
+ out.pop();
730
+ }
731
+ if (out.length < 3) return ring;
732
+ const keep = [];
733
+ for (let i = 0; i < out.length; i++) {
734
+ const prev = keep.length ? keep[keep.length - 1] : out[(i - 1 + out.length) % out.length];
735
+ const p = out[i];
736
+ const next = out[(i + 1) % out.length];
737
+ const ax = next.x - prev.x, ay = next.y - prev.y;
738
+ const len = Math.hypot(ax, ay);
739
+ if (len > eps) {
740
+ const cross = Math.abs((p.x - prev.x) * ay - (p.y - prev.y) * ax) / len;
741
+ if (cross < eps) continue;
742
+ }
743
+ keep.push(p);
744
+ }
745
+ return keep.length >= 3 ? keep : out;
746
+ }
747
+ var RING_EPS_U = 1e-3 * CHART_UNITS_PER_METRE * 0.1;
748
+ function extrudePrism(builder, outlineIn, holesIn, topY, bottomY, colTop, colWall, ao, maxCapError = Infinity, topNormal) {
541
749
  if (!outlineIn || outlineIn.length < 3) return;
750
+ if (maxCapError === void 0) maxCapError = Infinity;
542
751
  if (Math.abs(signedArea(outlineIn)) < 1e-4) return;
543
- const outline = toCCW(outlineIn);
544
- const holes = holesIn?.map((h) => toCCW(h)).filter((h) => h.length >= 3 && Math.abs(signedArea(h)) >= 1e-4);
752
+ const outline = dedupeRing(densifyRing(dedupeRing(toCCW(outlineIn), RING_EPS_U), topY, maxCapError), RING_EPS_U);
753
+ 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);
545
754
  const { pts, tris } = triangulate(outline, holes);
546
755
  const cTop = [colTop[0] * ao.top, colTop[1] * ao.top, colTop[2] * ao.top];
547
756
  const cBot = [colTop[0] * ao.bottomCap, colTop[1] * ao.bottomCap, colTop[2] * ao.bottomCap];
548
757
  const cWallTop = [colWall[0] * ao.top, colWall[1] * ao.top, colWall[2] * ao.top];
549
758
  const cWallBot = [colWall[0] * ao.wallBottom, colWall[1] * ao.wallBottom, colWall[2] * ao.wallBottom];
759
+ let capDepth = 0;
760
+ if (Number.isFinite(maxCapError)) {
761
+ for (; capDepth < MAX_CAP_SPLIT_DEPTH; capDepth++) {
762
+ let worst = 0;
763
+ for (let i = 0; i < tris.length; i += 3) {
764
+ const d = maxDevAtDepth(pts[tris[i]], pts[tris[i + 1]], pts[tris[i + 2]], topY, capDepth);
765
+ if (d > worst) worst = d;
766
+ }
767
+ if (worst <= maxCapError) break;
768
+ }
769
+ }
550
770
  for (let i = 0; i < tris.length; i += 3) {
551
771
  const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
552
- const at = [a.x * M, topY(a), a.y * M];
553
- const bt = [b.x * M, topY(b), b.y * M];
554
- const ct = [c.x * M, topY(c), c.y * M];
555
- let n = faceNormal(at, bt, ct);
556
- if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
557
- builder.tri(at, bt, ct, n, cTop);
772
+ emitCapUniform(builder, a, b, c, topY, cTop, capDepth, topNormal);
558
773
  const ab = [a.x * M, bottomY, a.y * M];
559
774
  const bb = [b.x * M, bottomY, b.y * M];
560
775
  const cb = [c.x * M, bottomY, c.y * M];
561
776
  builder.tri(ab, cb, bb, [0, -1, 0], cBot);
562
777
  }
778
+ const splitRing = (ring) => {
779
+ if (capDepth <= 0) return ring;
780
+ const n = 1 << capDepth;
781
+ const out = [];
782
+ for (let i = 0; i < ring.length; i++) {
783
+ const a = ring[i], b = ring[(i + 1) % ring.length];
784
+ for (let k = 0; k < n; k++) {
785
+ const t = k / n;
786
+ out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
787
+ }
788
+ }
789
+ return out;
790
+ };
563
791
  const oc = centroid(outline);
564
- const rings = [{ ring: outline, flip: false }];
792
+ const rings = [{ ring: splitRing(outline), flip: false }];
565
793
  if (holes) {
566
- for (const h of holes) if (h.length >= 3) rings.push({ ring: h, flip: true });
794
+ for (const h of holes) if (h.length >= 3) rings.push({ ring: splitRing(h), flip: true });
567
795
  }
568
796
  for (const { ring, flip } of rings) {
569
797
  for (let i = 0; i < ring.length; i++) {
@@ -599,14 +827,74 @@ function mergeMeshData(parts) {
599
827
  const position = new Float32Array(total * 3);
600
828
  const normal = new Float32Array(total * 3);
601
829
  const color = new Float32Array(total * 3);
830
+ const floor = new Float32Array(total);
602
831
  let off = 0;
603
832
  for (const p of parts) {
604
833
  position.set(p.position, off * 3);
605
834
  normal.set(p.normal, off * 3);
606
835
  color.set(p.color, off * 3);
836
+ floor.set(p.floor, off);
607
837
  off += p.count;
608
838
  }
609
- return { position, normal, color, count: total };
839
+ return { position, normal, color, floor, count: total };
840
+ }
841
+ function outsetRing(ring, d, miterLimit = 2.5) {
842
+ if (d <= 0 || ring.length < 3) return ring;
843
+ const r = toCCW(ring);
844
+ const n = r.length;
845
+ const nrm = [];
846
+ for (let i = 0; i < n; i++) {
847
+ const a = r[i], b = r[(i + 1) % n];
848
+ const dx = b.x - a.x, dy = b.y - a.y;
849
+ const len = Math.hypot(dx, dy) || 1;
850
+ nrm.push({ x: dy / len, y: -dx / len });
851
+ }
852
+ const out = [];
853
+ for (let i = 0; i < n; i++) {
854
+ const p = r[i];
855
+ const nPrev = nrm[(i - 1 + n) % n];
856
+ const nCur = nrm[i];
857
+ let mx = nPrev.x + nCur.x, my = nPrev.y + nCur.y;
858
+ const ml = Math.hypot(mx, my);
859
+ const bevel = () => {
860
+ out.push([p.x + nPrev.x * d, p.y + nPrev.y * d]);
861
+ out.push([p.x + nCur.x * d, p.y + nCur.y * d]);
862
+ };
863
+ if (ml < 1e-9) {
864
+ bevel();
865
+ continue;
866
+ }
867
+ mx /= ml;
868
+ my /= ml;
869
+ const cosHalf = mx * nPrev.x + my * nPrev.y;
870
+ const scale2 = 1 / Math.max(cosHalf, 1e-6);
871
+ if (!Number.isFinite(scale2) || scale2 > miterLimit) {
872
+ bevel();
873
+ continue;
874
+ }
875
+ out.push([p.x + mx * d * scale2, p.y + my * d * scale2]);
876
+ }
877
+ if (out.length < 3) return ring;
878
+ try {
879
+ const merged = import_polygon_clipping.default.union([[...out, out[0]]]);
880
+ let best = null, bestArea = -Infinity;
881
+ for (const poly of merged) {
882
+ const area = Math.abs(signedArea(poly[0].map(([x, y]) => ({ x, y }))));
883
+ if (area > bestArea) {
884
+ bestArea = area;
885
+ best = poly[0];
886
+ }
887
+ }
888
+ if (!best) return ring;
889
+ const pts = best.map(([x, y]) => ({ x, y }));
890
+ if (pts.length > 1) {
891
+ const f = pts[0], l = pts[pts.length - 1];
892
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
893
+ }
894
+ return pts.length >= 3 ? pts : ring;
895
+ } catch {
896
+ return ring;
897
+ }
610
898
  }
611
899
  function ellipsePolygon(cx, cy, rx, ry, seg = 28) {
612
900
  const out = [];
@@ -626,29 +914,107 @@ function rectPolygon(x, y, w, h) {
626
914
  }
627
915
 
628
916
  // src/view3d/scene/seatInstances.ts
629
- var SEAT_SURFACE_LIFT_M = 0.18;
917
+ var SEAT_DOT_RADIUS_M = 0.22;
918
+ var SEAT_PITCH_FRACTION = 0.42;
919
+ function nearestNeighbourSpacing(seats) {
920
+ const n = seats.length;
921
+ const out = new Float64Array(n);
922
+ if (n < 2) {
923
+ out.fill(Infinity);
924
+ return out;
925
+ }
926
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
927
+ for (const s of seats) {
928
+ if (s.x < minX) minX = s.x;
929
+ if (s.y < minY) minY = s.y;
930
+ if (s.x > maxX) maxX = s.x;
931
+ if (s.y > maxY) maxY = s.y;
932
+ }
933
+ const w = Math.max(maxX - minX, 1e-6), h = Math.max(maxY - minY, 1e-6);
934
+ const cell = Math.max(Math.sqrt(w * h / n), 1e-6);
935
+ const cols = Math.max(1, Math.ceil(w / cell) + 1);
936
+ const rows = Math.max(1, Math.ceil(h / cell) + 1);
937
+ const buckets = /* @__PURE__ */ new Map();
938
+ const cellOf = (x, y) => {
939
+ const cx = Math.min(cols - 1, Math.max(0, Math.floor((x - minX) / cell)));
940
+ const cy = Math.min(rows - 1, Math.max(0, Math.floor((y - minY) / cell)));
941
+ return cy * cols + cx;
942
+ };
943
+ for (let i = 0; i < n; i++) {
944
+ const k = cellOf(seats[i].x, seats[i].y);
945
+ const b = buckets.get(k);
946
+ if (b) b.push(i);
947
+ else buckets.set(k, [i]);
948
+ }
949
+ for (let i = 0; i < n; i++) {
950
+ const s = seats[i];
951
+ const cx = Math.min(cols - 1, Math.max(0, Math.floor((s.x - minX) / cell)));
952
+ const cy = Math.min(rows - 1, Math.max(0, Math.floor((s.y - minY) / cell)));
953
+ let best = Infinity;
954
+ for (let r = 1; r <= 4; r++) {
955
+ for (let gy = cy - r; gy <= cy + r; gy++) {
956
+ if (gy < 0 || gy >= rows) continue;
957
+ for (let gx = cx - r; gx <= cx + r; gx++) {
958
+ if (gx < 0 || gx >= cols) continue;
959
+ if (r > 1 && Math.abs(gy - cy) < r && Math.abs(gx - cx) < r) continue;
960
+ const b = buckets.get(gy * cols + gx);
961
+ if (!b) continue;
962
+ for (const j of b) {
963
+ if (j === i) continue;
964
+ const d = Math.hypot(seats[j].x - s.x, seats[j].y - s.y);
965
+ if (d < best) best = d;
966
+ }
967
+ }
968
+ }
969
+ if (Number.isFinite(best) && best <= r * cell) break;
970
+ }
971
+ out[i] = best;
972
+ }
973
+ return out;
974
+ }
630
975
  function seatSurfaceY(seat) {
631
976
  const eye = seat.eyeHeightM;
632
- if (Number.isFinite(eye)) {
633
- return Math.max(0, eye - SEATED_EYE_HEIGHT_M) + SEAT_SURFACE_LIFT_M;
634
- }
635
- return SEAT_SURFACE_LIFT_M;
977
+ if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
978
+ return 0;
636
979
  }
637
- function buildSeatInstances(seats, initial) {
980
+ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
638
981
  const count = seats.length;
639
982
  const iPosition = new Float32Array(count * 3);
640
983
  const iState = new Float32Array(count);
984
+ const iMaxRadius = new Float32Array(count);
985
+ const iRing = new Float32Array(count * 3);
641
986
  const idToIndex = /* @__PURE__ */ new Map();
987
+ const spacing = nearestNeighbourSpacing(seats);
642
988
  for (let i = 0; i < count; i++) {
643
989
  const seat = seats[i];
990
+ const resolved = surfaces?.seatPitchU(i);
991
+ const pitchM = (resolved ?? spacing[i]) * M;
992
+ iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
644
993
  iPosition[i * 3] = seat.x * M;
645
- iPosition[i * 3 + 1] = seatSurfaceY(seat);
994
+ iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
646
995
  iPosition[i * 3 + 2] = seat.y * M;
647
996
  iState[i] = seatStateIndex(initial ? initial(seat) : "available");
997
+ if (seat.accessibility?.length) {
998
+ const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
999
+ if (rgb) {
1000
+ iRing[i * 3] = rgb[0];
1001
+ iRing[i * 3 + 1] = rgb[1];
1002
+ iRing[i * 3 + 2] = rgb[2];
1003
+ }
1004
+ }
648
1005
  idToIndex.set(seat.id, i);
649
1006
  }
650
- return { count, iPosition, iState, idToIndex };
1007
+ return {
1008
+ count,
1009
+ iPosition,
1010
+ iState,
1011
+ iMaxRadius,
1012
+ iRing,
1013
+ idToIndex,
1014
+ iFloor: seatFloor ?? new Float32Array(count)
1015
+ };
651
1016
  }
1017
+ var RUN_MERGE_GAP = 64;
652
1018
  function applySeatStates(data, updates) {
653
1019
  const changed = [];
654
1020
  for (const u of updates) {
@@ -668,7 +1034,7 @@ function applySeatStates(data, updates) {
668
1034
  for (let i = 1; i < changed.length; i++) {
669
1035
  const idx = changed[i];
670
1036
  if (idx === prev) continue;
671
- if (idx === prev + 1) {
1037
+ if (idx <= prev + RUN_MERGE_GAP) {
672
1038
  prev = idx;
673
1039
  continue;
674
1040
  }
@@ -680,6 +1046,1155 @@ function applySeatStates(data, updates) {
680
1046
  return runs;
681
1047
  }
682
1048
 
1049
+ // src/view3d/theme.ts
1050
+ var BACKGROUND_INFLUENCE = 0.15;
1051
+ var BG_TOP_SCALE = 0.9;
1052
+ var BG_BOTTOM_SCALE = 1.76;
1053
+ var SEAT_SCALE_MIN = 0.7;
1054
+ var SEAT_SCALE_MAX = 1.6;
1055
+ function defaultTheme3D() {
1056
+ return {
1057
+ background: { top: [...BACKGROUND.top], bottom: [...BACKGROUND.bottom] },
1058
+ structure: STRUCTURE,
1059
+ seatStates: { ...SEAT_STATE_COLORS },
1060
+ seatScale: 1
1061
+ };
1062
+ }
1063
+ function resolveTheme3D(theme) {
1064
+ const base = defaultTheme3D();
1065
+ if (!theme) return base;
1066
+ const bg = hexToRgb(theme.background);
1067
+ if (bg) {
1068
+ base.background = {
1069
+ top: scaleRgb(bg, BG_TOP_SCALE),
1070
+ bottom: scaleRgb(bg, BG_BOTTOM_SCALE)
1071
+ };
1072
+ const rebased = {};
1073
+ for (const [key, value] of Object.entries(STRUCTURE)) {
1074
+ rebased[key] = mix(value, bg, BACKGROUND_INFLUENCE);
1075
+ }
1076
+ base.structure = rebased;
1077
+ }
1078
+ const selection = hexToRgb(theme.selectionColor) ?? hexToRgb(theme.accent);
1079
+ if (selection) base.seatStates = { ...base.seatStates, selected: selection };
1080
+ const scale2 = theme.seatScale;
1081
+ if (typeof scale2 === "number" && Number.isFinite(scale2)) {
1082
+ base.seatScale = Math.min(SEAT_SCALE_MAX, Math.max(SEAT_SCALE_MIN, scale2));
1083
+ }
1084
+ return base;
1085
+ }
1086
+ function themeSeatColorLUT(theme, order) {
1087
+ const out = [];
1088
+ for (const s of order) out.push(...theme.seatStates[s]);
1089
+ return out;
1090
+ }
1091
+
1092
+ // src/view3d/labels.ts
1093
+ var ZONE_MIN_DISTANCE = 1.15;
1094
+ var SECTION_MAX_DISTANCE = 2.2;
1095
+ var NEAR_MAX_DISTANCE = 0.85;
1096
+ function visibleLabelKinds(distance, venueRadius) {
1097
+ const r = Math.max(1e-6, venueRadius);
1098
+ const d = distance / r;
1099
+ const out = /* @__PURE__ */ new Set();
1100
+ if (d >= ZONE_MIN_DISTANCE) out.add("zone");
1101
+ if (d <= SECTION_MAX_DISTANCE) out.add("section");
1102
+ if (d <= NEAR_MAX_DISTANCE) {
1103
+ out.add("annotation");
1104
+ out.add("booth");
1105
+ }
1106
+ return out;
1107
+ }
1108
+ function projectToScreen(viewProjection, p, width, height) {
1109
+ const m = viewProjection;
1110
+ const x = p[0], y = p[1], z = p[2];
1111
+ const cx = m[0] * x + m[4] * y + m[8] * z + m[12];
1112
+ const cy = m[1] * x + m[5] * y + m[9] * z + m[13];
1113
+ const cz = m[2] * x + m[6] * y + m[10] * z + m[14];
1114
+ const cw = m[3] * x + m[7] * y + m[11] * z + m[15];
1115
+ if (!(cw > 1e-6)) return { x: 0, y: 0, depth: Infinity, visible: false };
1116
+ const ndcX = cx / cw, ndcY = cy / cw, ndcZ = cz / cw;
1117
+ const inside = ndcX >= -1.05 && ndcX <= 1.05 && ndcY >= -1.05 && ndcY <= 1.05 && ndcZ <= 1;
1118
+ return {
1119
+ x: (ndcX * 0.5 + 0.5) * width,
1120
+ y: (1 - (ndcY * 0.5 + 0.5)) * height,
1121
+ depth: ndcZ,
1122
+ visible: inside
1123
+ };
1124
+ }
1125
+ function cullOverlapping(items, separationX, separationY = separationX) {
1126
+ const kept = [];
1127
+ const ordered = [...items].sort((a, b) => a.screen.depth - b.screen.depth);
1128
+ for (const item of ordered) {
1129
+ let clash = false;
1130
+ for (const k of kept) {
1131
+ const dx = Math.abs(item.screen.x - k.screen.x);
1132
+ const dy = Math.abs(item.screen.y - k.screen.y);
1133
+ if (dx < separationX && dy < separationY) {
1134
+ clash = true;
1135
+ break;
1136
+ }
1137
+ }
1138
+ if (!clash) kept.push(item);
1139
+ }
1140
+ return kept;
1141
+ }
1142
+ function centroidOf(points) {
1143
+ if (!points.length) return null;
1144
+ let x = 0, y = 0;
1145
+ for (const p of points) {
1146
+ x += p.x;
1147
+ y += p.y;
1148
+ }
1149
+ return { x: x / points.length, y: y / points.length };
1150
+ }
1151
+
1152
+ // src/view3d/scene/sceneModel.ts
1153
+ var import_polygon_clipping3 = __toESM(require("polygon-clipping"), 1);
1154
+
1155
+ // src/core/venueStructure.ts
1156
+ var BLOCK_LINK_RATIO = 1.8;
1157
+ function probesOf(pts) {
1158
+ const n = pts.length;
1159
+ if (n <= 3) return [...pts];
1160
+ return [pts[Math.floor(n * 0.25)], pts[Math.floor(n * 0.5)], pts[Math.floor(n * 0.75)]];
1161
+ }
1162
+ function distanceToPolyline(pts, x, y) {
1163
+ if (!pts.length) return Infinity;
1164
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1165
+ let best = Infinity;
1166
+ for (let i = 0; i + 1 < pts.length; i++) {
1167
+ const a = pts[i], b = pts[i + 1];
1168
+ const vx = b.x - a.x, vy = b.y - a.y;
1169
+ const len2 = vx * vx + vy * vy;
1170
+ let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
1171
+ if (t < 0) t = 0;
1172
+ else if (t > 1) t = 1;
1173
+ const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
1174
+ if (d < best) best = d;
1175
+ }
1176
+ return best;
1177
+ }
1178
+ function boundOf(pts) {
1179
+ let cx = 0, cy = 0;
1180
+ for (const p of pts) {
1181
+ cx += p.x;
1182
+ cy += p.y;
1183
+ }
1184
+ const n = pts.length || 1;
1185
+ cx /= n;
1186
+ cy /= n;
1187
+ let r = 0;
1188
+ for (const p of pts) {
1189
+ const d = Math.hypot(p.x - cx, p.y - cy);
1190
+ if (d > r) r = d;
1191
+ }
1192
+ return { cx, cy, r };
1193
+ }
1194
+ function boundGap(a, b) {
1195
+ return Math.hypot(a.cx - b.cx, a.cy - b.cy) - a.r - b.r;
1196
+ }
1197
+ function rowGap(a, b) {
1198
+ let best = Infinity;
1199
+ for (const p of probesOf(a)) {
1200
+ const d = distanceToPolyline(b, p.x, p.y);
1201
+ if (d < best) best = d;
1202
+ }
1203
+ for (const p of probesOf(b)) {
1204
+ const d = distanceToPolyline(a, p.x, p.y);
1205
+ if (d < best) best = d;
1206
+ }
1207
+ return best;
1208
+ }
1209
+ function orderAlongRow(pts, indices) {
1210
+ if (pts.length < 3) return { pts, seatIndices: indices };
1211
+ let cx = 0, cy = 0;
1212
+ for (const p of pts) {
1213
+ cx += p.x;
1214
+ cy += p.y;
1215
+ }
1216
+ cx /= pts.length;
1217
+ cy /= pts.length;
1218
+ let far = -1, ax = pts[0];
1219
+ for (const p of pts) {
1220
+ const d = Math.hypot(p.x - cx, p.y - cy);
1221
+ if (d > far) {
1222
+ far = d;
1223
+ ax = p;
1224
+ }
1225
+ }
1226
+ let dx = ax.x - cx, dy = ax.y - cy;
1227
+ const len = Math.hypot(dx, dy);
1228
+ if (len > 1e-9) {
1229
+ dx /= len;
1230
+ dy /= len;
1231
+ } else {
1232
+ dx = 1;
1233
+ dy = 0;
1234
+ }
1235
+ const order = pts.map((p, i) => ({ p, i, t: (p.x - cx) * dx + (p.y - cy) * dy })).sort((u, v) => u.t - v.t);
1236
+ return { pts: order.map((o) => o.p), seatIndices: order.map((o) => indices[o.i]) };
1237
+ }
1238
+ function fitCircle(pts) {
1239
+ const n = pts.length;
1240
+ if (n < 3) return null;
1241
+ let mx = 0, my = 0;
1242
+ for (const p of pts) {
1243
+ mx += p.x;
1244
+ my += p.y;
1245
+ }
1246
+ mx /= n;
1247
+ my /= n;
1248
+ let suu = 0, suv = 0, svv = 0, suuu = 0, svvv = 0, suvv = 0, svuu = 0;
1249
+ for (const p of pts) {
1250
+ const u = p.x - mx, v = p.y - my;
1251
+ suu += u * u;
1252
+ svv += v * v;
1253
+ suv += u * v;
1254
+ suuu += u * u * u;
1255
+ svvv += v * v * v;
1256
+ suvv += u * v * v;
1257
+ svuu += v * u * u;
1258
+ }
1259
+ const det = suu * svv - suv * suv;
1260
+ if (Math.abs(det) < 1e-9) return null;
1261
+ const b1 = (suuu + suvv) / 2;
1262
+ const b2 = (svvv + svuu) / 2;
1263
+ const uc = (b1 * svv - b2 * suv) / det;
1264
+ const vc = (b2 * suu - b1 * suv) / det;
1265
+ const cx = uc + mx, cy = vc + my;
1266
+ if (!Number.isFinite(cx) || !Number.isFinite(cy)) return null;
1267
+ return { cx, cy };
1268
+ }
1269
+ var CIRCLE_ROW_SPREAD_LIMIT = 0.15;
1270
+ function fitBlockAxis(group) {
1271
+ if (group.length < 2) return null;
1272
+ const cents = group.map((r) => {
1273
+ let x = 0, y = 0;
1274
+ for (const p of r.pts) {
1275
+ x += p.x;
1276
+ y += p.y;
1277
+ }
1278
+ return { x: x / r.pts.length, y: y / r.pts.length };
1279
+ });
1280
+ let ox = 0, oy = 0;
1281
+ for (const c of cents) {
1282
+ ox += c.x;
1283
+ oy += c.y;
1284
+ }
1285
+ ox /= cents.length;
1286
+ oy /= cents.length;
1287
+ let sxx = 0, sxy = 0, syy = 0;
1288
+ for (const c of cents) {
1289
+ const dx = c.x - ox, dy = c.y - oy;
1290
+ sxx += dx * dx;
1291
+ sxy += dx * dy;
1292
+ syy += dy * dy;
1293
+ }
1294
+ const tr = sxx + syy;
1295
+ const det = sxx * syy - sxy * sxy;
1296
+ const lambda = tr / 2 + Math.sqrt(Math.max(0, tr * tr / 4 - det));
1297
+ let ax, ay;
1298
+ if (Math.abs(sxy) > 1e-12) {
1299
+ ax = lambda - syy;
1300
+ ay = sxy;
1301
+ } else if (sxx >= syy) {
1302
+ ax = 1;
1303
+ ay = 0;
1304
+ } else {
1305
+ ax = 0;
1306
+ ay = 1;
1307
+ }
1308
+ const len = Math.hypot(ax, ay);
1309
+ if (!(len > 1e-12)) return null;
1310
+ ax /= len;
1311
+ ay /= len;
1312
+ let lo = Infinity, hi = -Infinity;
1313
+ const widths = [];
1314
+ for (const r of group) {
1315
+ let rlo = Infinity, rhi = -Infinity;
1316
+ for (const p of r.pts) {
1317
+ const t = p.x * ax + p.y * ay;
1318
+ if (t < rlo) rlo = t;
1319
+ if (t > rhi) rhi = t;
1320
+ if (t < lo) lo = t;
1321
+ if (t > hi) hi = t;
1322
+ }
1323
+ widths.push(rhi - rlo);
1324
+ }
1325
+ const range = hi - lo;
1326
+ if (!(range > 1e-9)) return null;
1327
+ widths.sort((a, b) => a - b);
1328
+ const p90 = widths[Math.min(widths.length - 1, Math.floor(widths.length * 0.9))];
1329
+ return p90 / range <= AXIS_ROW_WIDTH_LIMIT ? { x: ax, y: ay } : null;
1330
+ }
1331
+ var AXIS_ROW_WIDTH_LIMIT = 0.35;
1332
+ function resolveSection(sectionId, seats, seatIndices, focal) {
1333
+ const groups = /* @__PURE__ */ new Map();
1334
+ for (const i of seatIndices) {
1335
+ const s = seats[i];
1336
+ const key = s.rowId || `__seat-${i}`;
1337
+ let g = groups.get(key);
1338
+ if (!g) {
1339
+ g = { pts: [], idx: [] };
1340
+ groups.set(key, g);
1341
+ }
1342
+ g.pts.push({ x: s.x, y: s.y });
1343
+ g.idx.push(i);
1344
+ }
1345
+ if (!groups.size) return { sectionId, rows: [], blockCount: 0 };
1346
+ const rows = [];
1347
+ for (const [id, g] of groups) {
1348
+ const ordered = orderAlongRow(g.pts, g.idx);
1349
+ let sum = 0;
1350
+ for (const p of ordered.pts) sum += Math.hypot(p.x - focal.x, p.y - focal.y);
1351
+ rows.push({
1352
+ id,
1353
+ pts: ordered.pts,
1354
+ seatIndices: ordered.seatIndices,
1355
+ blockId: -1,
1356
+ ordinal: 0,
1357
+ blockDepth: 0,
1358
+ focalDistance: sum / ordered.pts.length
1359
+ });
1360
+ }
1361
+ const bounds = rows.map((r) => boundOf(r.pts));
1362
+ const nearest = [];
1363
+ for (let i = 0; i < rows.length; i++) {
1364
+ let best = Infinity;
1365
+ for (let j = 0; j < rows.length; j++) {
1366
+ if (i === j) continue;
1367
+ if (boundGap(bounds[i], bounds[j]) >= best) continue;
1368
+ const d = rowGap(rows[i].pts, rows[j].pts);
1369
+ if (d < best) best = d;
1370
+ }
1371
+ if (Number.isFinite(best)) nearest.push(best);
1372
+ }
1373
+ nearest.sort((a, b) => a - b);
1374
+ const typicalGap = nearest.length ? nearest[Math.floor(nearest.length / 2)] : 1;
1375
+ const linkDistance = Math.max(typicalGap * BLOCK_LINK_RATIO, 1e-6);
1376
+ const adjacency = rows.map(() => []);
1377
+ for (let i = 0; i < rows.length; i++) {
1378
+ for (let j = i + 1; j < rows.length; j++) {
1379
+ if (boundGap(bounds[i], bounds[j]) > linkDistance) continue;
1380
+ if (rowGap(rows[i].pts, rows[j].pts) <= linkDistance) {
1381
+ adjacency[i].push(j);
1382
+ adjacency[j].push(i);
1383
+ }
1384
+ }
1385
+ }
1386
+ let blockCount = 0;
1387
+ for (let i = 0; i < rows.length; i++) {
1388
+ if (rows[i].blockId !== -1) continue;
1389
+ const id = blockCount++;
1390
+ const stack = [i];
1391
+ rows[i].blockId = id;
1392
+ while (stack.length) {
1393
+ const k = stack.pop();
1394
+ for (const n of adjacency[k]) {
1395
+ if (rows[n].blockId !== -1) continue;
1396
+ rows[n].blockId = id;
1397
+ stack.push(n);
1398
+ }
1399
+ }
1400
+ }
1401
+ const byBlock = /* @__PURE__ */ new Map();
1402
+ for (const r of rows) {
1403
+ const a = byBlock.get(r.blockId) ?? [];
1404
+ a.push(r);
1405
+ byBlock.set(r.blockId, a);
1406
+ }
1407
+ const centres = [];
1408
+ for (const r of rows) {
1409
+ if (r.pts.length < 4) continue;
1410
+ const c = fitCircle(r.pts);
1411
+ if (!c) continue;
1412
+ if (!Number.isFinite(c.cx) || !Number.isFinite(c.cy)) continue;
1413
+ centres.push({ x: c.cx, y: c.cy });
1414
+ }
1415
+ const centre = centres.length >= 2 ? (() => {
1416
+ const xs = centres.map((c) => c.x).sort((a, b) => a - b);
1417
+ const ys = centres.map((c) => c.y).sort((a, b) => a - b);
1418
+ const mid = Math.floor(centres.length / 2);
1419
+ return { cx: xs[mid], cy: ys[mid] };
1420
+ })() : null;
1421
+ if (centre) {
1422
+ const radiusOf = (p) => Math.hypot(p.x - centre.cx, p.y - centre.cy);
1423
+ let lo = Infinity, hi = -Infinity;
1424
+ const spreads = [];
1425
+ for (const r of rows) {
1426
+ let rlo = Infinity, rhi = -Infinity;
1427
+ for (const p of r.pts) {
1428
+ const d = radiusOf(p);
1429
+ if (d < rlo) rlo = d;
1430
+ if (d > rhi) rhi = d;
1431
+ }
1432
+ spreads.push(rhi - rlo);
1433
+ if (rlo < lo) lo = rlo;
1434
+ if (rhi > hi) hi = rhi;
1435
+ }
1436
+ const range = hi - lo;
1437
+ spreads.sort((a, b) => a - b);
1438
+ const p90 = spreads[Math.min(spreads.length - 1, Math.floor(spreads.length * 0.9))];
1439
+ if (range > 1e-9 && p90 / range <= CIRCLE_ROW_SPREAD_LIMIT) {
1440
+ const withRadius = rows.map((r) => {
1441
+ let sum = 0;
1442
+ for (const p of r.pts) sum += radiusOf(p);
1443
+ return { row: r, radius: sum / r.pts.length };
1444
+ });
1445
+ let minR = Infinity;
1446
+ for (const w of withRadius) if (w.radius < minR) minR = w.radius;
1447
+ withRadius.sort((a, b) => a.radius - b.radius);
1448
+ let ordinal = -1, lastRadius = -Infinity;
1449
+ const tolerance = range / Math.max(1, withRadius.length) * 0.5;
1450
+ const ordered = [];
1451
+ for (const w of withRadius) {
1452
+ if (w.radius - lastRadius > tolerance) {
1453
+ ordinal++;
1454
+ lastRadius = w.radius;
1455
+ }
1456
+ w.row.ordinal = ordinal;
1457
+ w.row.blockDepth = w.radius - minR;
1458
+ ordered.push(w.row);
1459
+ }
1460
+ return { sectionId, rows: ordered, blockCount };
1461
+ }
1462
+ }
1463
+ const out = [];
1464
+ for (const [, group] of byBlock) {
1465
+ const axis = fitBlockAxis(group);
1466
+ if (axis) {
1467
+ const key = (r) => {
1468
+ let sum = 0;
1469
+ for (const p of r.pts) sum += p.x * axis.x + p.y * axis.y;
1470
+ return sum / r.pts.length;
1471
+ };
1472
+ group.sort((a, b) => key(a) - key(b));
1473
+ let depth = 0;
1474
+ for (let i = 0; i < group.length; i++) {
1475
+ if (i > 0) depth += rowGap(group[i - 1].pts, group[i].pts);
1476
+ group[i].ordinal = i;
1477
+ group[i].blockDepth = depth;
1478
+ out.push(group[i]);
1479
+ }
1480
+ } else {
1481
+ let front = Infinity;
1482
+ for (const r of group) if (r.focalDistance < front) front = r.focalDistance;
1483
+ group.sort((a, b) => a.focalDistance - b.focalDistance);
1484
+ for (let i = 0; i < group.length; i++) {
1485
+ group[i].ordinal = i;
1486
+ group[i].blockDepth = Math.max(0, group[i].focalDistance - front);
1487
+ out.push(group[i]);
1488
+ }
1489
+ }
1490
+ }
1491
+ return { sectionId, rows: out, blockCount };
1492
+ }
1493
+
1494
+ // src/core/layout.ts
1495
+ var DEG2 = Math.PI / 180;
1496
+ function pointInPolygon(p, poly) {
1497
+ let inside = false;
1498
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
1499
+ const xi = poly[i].x;
1500
+ const yi = poly[i].y;
1501
+ const xj = poly[j].x;
1502
+ const yj = poly[j].y;
1503
+ const hit = yi > p.y !== yj > p.y && p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi;
1504
+ if (hit) inside = !inside;
1505
+ }
1506
+ return inside;
1507
+ }
1508
+ function pointOnPolygonBoundary(p, poly) {
1509
+ return poly.some((start, index) => {
1510
+ const end = poly[(index + 1) % poly.length];
1511
+ const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
1512
+ if (Math.abs(cross) > 1e-7) return false;
1513
+ const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
1514
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
1515
+ return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
1516
+ });
1517
+ }
1518
+ function pointInPolygonWithHoles(p, outer, holes) {
1519
+ return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
1520
+ }
1521
+
1522
+ // src/core/rake.ts
1523
+ var BLEND_ROWS = 3;
1524
+ var BLEND_DISTANCE_RATIO = 2.2;
1525
+ var GRAD_STEP = 0.5;
1526
+ function radialRake(focal) {
1527
+ return {
1528
+ kind: "radial",
1529
+ rowCount: 0,
1530
+ rows: [],
1531
+ nearestRow: () => -1,
1532
+ depthAt: (x, y) => Math.hypot(x - focal.x, y - focal.y),
1533
+ gradientAt: (x, y) => {
1534
+ const dx = x - focal.x, dy = y - focal.y;
1535
+ const d = Math.hypot(dx, dy);
1536
+ return d < 1e-9 ? [0, 0] : [dx / d, dy / d];
1537
+ }
1538
+ };
1539
+ }
1540
+ function fitRow(points, focal) {
1541
+ if (!points.length) return null;
1542
+ let cx = 0, cy = 0, depth = 0;
1543
+ for (const p of points) {
1544
+ cx += p.x;
1545
+ cy += p.y;
1546
+ depth += Math.hypot(p.x - focal.x, p.y - focal.y);
1547
+ }
1548
+ const n = points.length;
1549
+ cx /= n;
1550
+ cy /= n;
1551
+ depth /= n;
1552
+ let ax = points[0], far = -1;
1553
+ for (const p of points) {
1554
+ const d = Math.hypot(p.x - cx, p.y - cy);
1555
+ if (d > far) {
1556
+ far = d;
1557
+ ax = p;
1558
+ }
1559
+ }
1560
+ let dx = ax.x - cx, dy = ax.y - cy;
1561
+ const len = Math.hypot(dx, dy);
1562
+ if (len > 1e-9) {
1563
+ dx /= len;
1564
+ dy /= len;
1565
+ } else {
1566
+ dx = 1;
1567
+ dy = 0;
1568
+ }
1569
+ const pts = [...points].sort((p, q) => (p.x - cx) * dx + (p.y - cy) * dy - ((q.x - cx) * dx + (q.y - cy) * dy));
1570
+ let radius = 0;
1571
+ for (const p of pts) {
1572
+ const d = Math.hypot(p.x - cx, p.y - cy);
1573
+ if (d > radius) radius = d;
1574
+ }
1575
+ return { pts, cx, cy, radius, depth };
1576
+ }
1577
+ function distToRow(r, x, y) {
1578
+ const pts = r.pts;
1579
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1580
+ let best = Infinity;
1581
+ for (let i = 0; i + 1 < pts.length; i++) {
1582
+ const a = pts[i], b = pts[i + 1];
1583
+ const vx = b.x - a.x, vy = b.y - a.y;
1584
+ const len2 = vx * vx + vy * vy;
1585
+ let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
1586
+ if (t < 0) t = 0;
1587
+ else if (t > 1) t = 1;
1588
+ const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
1589
+ if (d < best) best = d;
1590
+ }
1591
+ return best;
1592
+ }
1593
+ var CANDIDATE_ROWS = 8;
1594
+ function sampleRows(rows, x, y) {
1595
+ const candD = new Array(CANDIDATE_ROWS).fill(Infinity);
1596
+ const candI = new Array(CANDIDATE_ROWS).fill(-1);
1597
+ for (let i = 0; i < rows.length; i++) {
1598
+ const r = rows[i];
1599
+ const lower = Math.hypot(x - r.cx, y - r.cy) - r.radius;
1600
+ for (let k = 0; k < CANDIDATE_ROWS; k++) {
1601
+ if (lower < candD[k]) {
1602
+ for (let j = CANDIDATE_ROWS - 1; j > k; j--) {
1603
+ candD[j] = candD[j - 1];
1604
+ candI[j] = candI[j - 1];
1605
+ }
1606
+ candD[k] = lower;
1607
+ candI[k] = i;
1608
+ break;
1609
+ }
1610
+ }
1611
+ }
1612
+ const bestD = new Array(BLEND_ROWS).fill(Infinity);
1613
+ const bestI = new Array(BLEND_ROWS).fill(-1);
1614
+ for (const i of candI) {
1615
+ if (i < 0) continue;
1616
+ const d = distToRow(rows[i], x, y);
1617
+ for (let k = 0; k < BLEND_ROWS; k++) {
1618
+ if (d < bestD[k]) {
1619
+ for (let j = BLEND_ROWS - 1; j > k; j--) {
1620
+ bestD[j] = bestD[j - 1];
1621
+ bestI[j] = bestI[j - 1];
1622
+ }
1623
+ bestD[k] = d;
1624
+ bestI[k] = i;
1625
+ break;
1626
+ }
1627
+ }
1628
+ }
1629
+ if (bestI[0] < 0) return 0;
1630
+ if (bestD[0] < 1e-6) return rows[bestI[0]].depth;
1631
+ const cutoff = bestD[0] * BLEND_DISTANCE_RATIO;
1632
+ let num = 0, den = 0;
1633
+ for (let k = 0; k < BLEND_ROWS; k++) {
1634
+ const i = bestI[k];
1635
+ if (i < 0 || bestD[k] > cutoff) continue;
1636
+ const w = 1 / (bestD[k] * bestD[k]);
1637
+ num += w * rows[i].depth;
1638
+ den += w;
1639
+ }
1640
+ return den > 0 ? num / den : rows[bestI[0]].depth;
1641
+ }
1642
+ function rowsRake(rows) {
1643
+ const depthAt = (x, y) => sampleRows(rows, x, y);
1644
+ const nearestRow = (x, y) => {
1645
+ let best = Infinity, bestI = -1;
1646
+ for (let i = 0; i < rows.length; i++) {
1647
+ const r = rows[i];
1648
+ if (Math.hypot(x - r.cx, y - r.cy) - r.radius >= best) continue;
1649
+ const d = distToRow(r, x, y);
1650
+ if (d < best) {
1651
+ best = d;
1652
+ bestI = i;
1653
+ }
1654
+ }
1655
+ return bestI;
1656
+ };
1657
+ return {
1658
+ kind: "rows",
1659
+ rowCount: rows.length,
1660
+ rows,
1661
+ nearestRow,
1662
+ depthAt,
1663
+ gradientAt: (x, y) => {
1664
+ const gx = (depthAt(x + GRAD_STEP, y) - depthAt(x - GRAD_STEP, y)) / (2 * GRAD_STEP);
1665
+ const gy = (depthAt(x, y + GRAD_STEP) - depthAt(x, y - GRAD_STEP)) / (2 * GRAD_STEP);
1666
+ const len = Math.hypot(gx, gy);
1667
+ return len < 1e-9 ? [0, 0] : [gx / len, gy / len];
1668
+ }
1669
+ };
1670
+ }
1671
+ function buildSectionRake(rows, focal) {
1672
+ const fits = [];
1673
+ for (const r of rows) {
1674
+ const f = fitRow(r.points, focal);
1675
+ if (f) fits.push(f);
1676
+ }
1677
+ if (fits.length < 2) return radialRake(focal);
1678
+ fits.sort((a, b) => a.depth - b.depth);
1679
+ return rowsRake(fits);
1680
+ }
1681
+
1682
+ // src/view3d/scene/surface.ts
1683
+ var FLAT_SLAB_TOP_M = 0.05;
1684
+ var CAP_MAX_ERROR_M = 0.05;
1685
+ var SEAT_CLEARANCE_M = 0.15;
1686
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1687
+ function bboxOf(pts) {
1688
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1689
+ for (const p of pts) {
1690
+ if (p.x < minX) minX = p.x;
1691
+ if (p.y < minY) minY = p.y;
1692
+ if (p.x > maxX) maxX = p.x;
1693
+ if (p.y > maxY) maxY = p.y;
1694
+ }
1695
+ return { minX, minY, maxX, maxY };
1696
+ }
1697
+ var MAX_TIER_RISE_M = 25;
1698
+ function buildVenueSurfaces(units, seats) {
1699
+ const bySection = /* @__PURE__ */ new Map();
1700
+ const seatOwner = new Array(seats.length).fill(null);
1701
+ const seatDeck = new Float64Array(seats.length);
1702
+ const seatRowLevel = new Array(seats.length).fill(void 0);
1703
+ const seatPitch = new Array(seats.length).fill(void 0);
1704
+ const acc = /* @__PURE__ */ new Map();
1705
+ const boxes = [];
1706
+ for (const unit of units) {
1707
+ for (const o of unit.objects) {
1708
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1709
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
1710
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
1711
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
1712
+ }
1713
+ }
1714
+ for (let i = 0; i < seats.length; i++) {
1715
+ const s = seats[i];
1716
+ for (const b of boxes) {
1717
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
1718
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
1719
+ seatOwner[i] = b.id;
1720
+ const a = acc.get(b.id);
1721
+ a.hasSeats = true;
1722
+ const f = s.focalPoint ?? b.unit.focal;
1723
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
1724
+ if (d < a.frontU) a.frontU = d;
1725
+ a.seatIndices.push(i);
1726
+ const rowKey = s.rowId || `__seat-${i}`;
1727
+ const arr = a.rows.get(rowKey);
1728
+ if (arr) arr.push({ x: s.x, y: s.y });
1729
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
1730
+ break;
1731
+ }
1732
+ }
1733
+ for (const [id, a] of acc) {
1734
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
1735
+ const bottomY = a.unit.baseHeightM;
1736
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
1737
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
1738
+ const kind = a.section.surfaceKind;
1739
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
1740
+ const rake = buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal);
1741
+ let frontU = Infinity;
1742
+ if (a.hasSeats) {
1743
+ for (const pts of a.rows.values()) {
1744
+ for (const p of pts) {
1745
+ const d = rake.depthAt(p.x, p.y);
1746
+ if (d < frontU) frontU = d;
1747
+ }
1748
+ }
1749
+ }
1750
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
1751
+ frontU = Infinity;
1752
+ for (const p of a.section.outline) {
1753
+ const d = rake.depthAt(p.x, p.y);
1754
+ if (d < frontU) frontU = d;
1755
+ }
1756
+ }
1757
+ const flatTop = bottomY + FLAT_SLAB_TOP_M;
1758
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
1759
+ const levelFor = (depthU) => {
1760
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
1761
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1762
+ return Math.max(baseFloor, geo.height + rise);
1763
+ };
1764
+ const levelForBlockDepth = (blockDepthU) => {
1765
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
1766
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1767
+ return Math.max(baseFloor, geo.height + rise);
1768
+ };
1769
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
1770
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
1771
+ pts: r.pts,
1772
+ y: levelForBlockDepth(r.blockDepth),
1773
+ depth: r.blockDepth,
1774
+ blockId: r.blockId
1775
+ }));
1776
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
1777
+ const rowBounds = rowLevels.map((r) => {
1778
+ let cx = 0, cy = 0;
1779
+ for (const p of r.pts) {
1780
+ cx += p.x;
1781
+ cy += p.y;
1782
+ }
1783
+ const n = r.pts.length || 1;
1784
+ cx /= n;
1785
+ cy /= n;
1786
+ let rad = 0;
1787
+ for (const p of r.pts) {
1788
+ const d = Math.hypot(p.x - cx, p.y - cy);
1789
+ if (d > rad) rad = d;
1790
+ }
1791
+ return { cx, cy, rad };
1792
+ });
1793
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
1794
+ let best = Infinity, bestY = landingY;
1795
+ for (let i = 0; i < rowLevels.length; i++) {
1796
+ const b = rowBounds[i];
1797
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
1798
+ const d = distanceToPolyline(rowLevels[i].pts, x, y);
1799
+ if (d < best) {
1800
+ best = d;
1801
+ bestY = rowLevels[i].y;
1802
+ }
1803
+ }
1804
+ return bestY;
1805
+ } : (x, y) => levelFor(rake.depthAt(x, y));
1806
+ const UP = [0, 1, 0];
1807
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
1808
+ const d = rake.depthAt(x, y);
1809
+ if (d <= frontU) return UP;
1810
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
1811
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
1812
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
1813
+ const [gx, gy] = rake.gradientAt(x, y);
1814
+ if (gx === 0 && gy === 0) return UP;
1815
+ const inv = 1 / Math.hypot(rakeTan, 1);
1816
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
1817
+ };
1818
+ if (!flat) {
1819
+ for (const r of structure.rows) {
1820
+ const y = levelForBlockDepth(r.blockDepth);
1821
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
1822
+ }
1823
+ }
1824
+ for (const r of structure.rows) {
1825
+ const gaps = [];
1826
+ for (let k = 1; k < r.pts.length; k++) {
1827
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
1828
+ if (d > 1e-6) gaps.push(d);
1829
+ }
1830
+ gaps.sort((x, y) => x - y);
1831
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
1832
+ let across = Infinity;
1833
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
1834
+ if (probe) {
1835
+ for (const other of structure.rows) {
1836
+ if (other === r || other.blockId !== r.blockId) continue;
1837
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
1838
+ if (d > 1e-6 && d < across) across = d;
1839
+ }
1840
+ }
1841
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
1842
+ if (Number.isFinite(pitch) && pitch > 0) {
1843
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
1844
+ }
1845
+ }
1846
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1847
+ }
1848
+ for (let i = 0; i < seats.length; i++) {
1849
+ const ownerId = seatOwner[i];
1850
+ const s = seats[i];
1851
+ if (ownerId) {
1852
+ const own = seatRowLevel[i];
1853
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
1854
+ continue;
1855
+ }
1856
+ const eye = s.eyeHeightM;
1857
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1858
+ }
1859
+ return {
1860
+ bySection,
1861
+ seatOwner,
1862
+ seatDeckY: (i) => seatDeck[i],
1863
+ seatPitchU: (i) => seatPitch[i]
1864
+ };
1865
+ }
1866
+
1867
+ // src/view3d/scene/deckBands.ts
1868
+ var import_polygon_clipping2 = __toESM(require("polygon-clipping"), 1);
1869
+ var import_earcut2 = __toESM(require("earcut"), 1);
1870
+ var FRONT_REACH = 0.58;
1871
+ var MIN_RISER_M = 0.01;
1872
+ var MIN_REACH_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1873
+ var BACK_REACH = 0.5;
1874
+ var END_REACH = 0.6;
1875
+ function rowNormals(pts, focal) {
1876
+ const n = pts.length;
1877
+ const raw = [];
1878
+ for (let i = 0; i < n; i++) {
1879
+ const a = pts[Math.max(0, i - 1)];
1880
+ const b = pts[Math.min(n - 1, i + 1)];
1881
+ let dx = b.x - a.x, dy = b.y - a.y;
1882
+ const len = Math.hypot(dx, dy);
1883
+ if (len < 1e-9) {
1884
+ raw.push([0, 0]);
1885
+ continue;
1886
+ }
1887
+ dx /= len;
1888
+ dy /= len;
1889
+ raw.push([-dy, dx]);
1890
+ }
1891
+ let vote = 0;
1892
+ for (let i = 0; i < n; i++) {
1893
+ vote += (pts[i].x - focal.x) * raw[i][0] + (pts[i].y - focal.y) * raw[i][1];
1894
+ }
1895
+ const flip = vote < 0;
1896
+ return flip ? raw.map(([x, y]) => [-x, -y]) : raw;
1897
+ }
1898
+ function extendEnds(pts, by) {
1899
+ if (pts.length < 2 || by <= 0) return [...pts];
1900
+ const out = [...pts];
1901
+ const dir = (p, q) => {
1902
+ const dx = q.x - p.x, dy = q.y - p.y;
1903
+ const len = Math.hypot(dx, dy) || 1;
1904
+ return { x: dx / len, y: dy / len };
1905
+ };
1906
+ const head = dir(out[1], out[0]);
1907
+ const tail = dir(out[out.length - 2], out[out.length - 1]);
1908
+ out.unshift({ x: out[0].x + head.x * by, y: out[0].y + head.y * by });
1909
+ out.push({
1910
+ x: out[out.length - 1].x + tail.x * by,
1911
+ y: out[out.length - 1].y + tail.y * by
1912
+ });
1913
+ return out;
1914
+ }
1915
+ function distToPolyline(pts, x, y) {
1916
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1917
+ let best = Infinity;
1918
+ for (let i = 0; i + 1 < pts.length; i++) {
1919
+ const a = pts[i], b = pts[i + 1];
1920
+ const vx = b.x - a.x, vy = b.y - a.y;
1921
+ const len2 = vx * vx + vy * vy;
1922
+ let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
1923
+ if (t < 0) t = 0;
1924
+ else if (t > 1) t = 1;
1925
+ const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
1926
+ if (d < best) best = d;
1927
+ }
1928
+ return best;
1929
+ }
1930
+ function neighbourhoods(rows) {
1931
+ const probesOf2 = (pts) => {
1932
+ const n = pts.length;
1933
+ if (n <= 2) return [...pts];
1934
+ return [pts[Math.floor(n * 0.25)], pts[Math.floor(n * 0.5)], pts[Math.floor(n * 0.75)]];
1935
+ };
1936
+ const out = [];
1937
+ for (let i = 0; i < rows.length; i++) {
1938
+ const probes = probesOf2(rows[i].pts);
1939
+ const pitches = [];
1940
+ let bestFrontD = Infinity, belowY = null;
1941
+ for (const c of probes) {
1942
+ let nearest = Infinity;
1943
+ for (let j = 0; j < rows.length; j++) {
1944
+ if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
1945
+ const d = distToPolyline(rows[j].pts, c.x, c.y);
1946
+ if (d < nearest) nearest = d;
1947
+ if (rows[j].depth < rows[i].depth && d < bestFrontD) {
1948
+ bestFrontD = d;
1949
+ belowY = rows[j].y;
1950
+ }
1951
+ }
1952
+ if (Number.isFinite(nearest) && nearest > 0) pitches.push(nearest);
1953
+ }
1954
+ pitches.sort((a, b) => a - b);
1955
+ const pitch = pitches.length ? pitches[Math.floor(pitches.length / 2)] : 1;
1956
+ out.push({ pitch, belowY });
1957
+ }
1958
+ return out;
1959
+ }
1960
+ function ribbonOf(rows, i, nbrs, focal) {
1961
+ const row = rows[i];
1962
+ const rowPts = row.pts.length >= 2 ? [...row.pts] : row.pts.length === 1 ? (() => {
1963
+ const p = row.pts[0];
1964
+ let dx = p.x - focal.x, dy = p.y - focal.y;
1965
+ const len = Math.hypot(dx, dy) || 1;
1966
+ dx /= len;
1967
+ dy /= len;
1968
+ const h = Math.max(nbrs[i].pitch, 1e-3) * 0.5;
1969
+ return [{ x: p.x + dy * h, y: p.y - dx * h }, { x: p.x - dy * h, y: p.y + dx * h }];
1970
+ })() : [];
1971
+ if (rowPts.length < 2) return null;
1972
+ let seatSpan = 0, spanN = 0;
1973
+ for (let k = 1; k < rowPts.length; k++) {
1974
+ const d = Math.hypot(rowPts[k].x - rowPts[k - 1].x, rowPts[k].y - rowPts[k - 1].y);
1975
+ if (d > 1e-6) {
1976
+ seatSpan += d;
1977
+ spanN++;
1978
+ }
1979
+ }
1980
+ const spacing = spanN > 0 ? seatSpan / spanN : 0;
1981
+ const pts = extendEnds(rowPts, Math.max(spacing * END_REACH, MIN_REACH_U));
1982
+ return {
1983
+ pts,
1984
+ nrm: rowNormals(pts, focal),
1985
+ front: Math.max(nbrs[i].pitch * FRONT_REACH, MIN_REACH_U),
1986
+ back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
1987
+ };
1988
+ }
1989
+ function deckFootprints(rows, focal) {
1990
+ if (rows.length < 2) return [];
1991
+ const nbrs = neighbourhoods(rows);
1992
+ const rings = [];
1993
+ const ribbons = [];
1994
+ for (let i = 0; i < rows.length; i++) {
1995
+ const r = ribbonOf(rows, i, nbrs, focal);
1996
+ ribbons.push(r);
1997
+ if (!r) continue;
1998
+ const f = [];
1999
+ const b = [];
2000
+ const rf = r.front + FOOTPRINT_MARGIN_U;
2001
+ const rb = r.back + FOOTPRINT_MARGIN_U;
2002
+ for (let k = 0; k < r.pts.length; k++) {
2003
+ const p = r.pts[k], n = r.nrm[k];
2004
+ f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
2005
+ b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
2006
+ }
2007
+ const ring = [...f, ...b.reverse()];
2008
+ if (ring.length < 3) continue;
2009
+ ring.push(ring[0]);
2010
+ rings.push([ring]);
2011
+ }
2012
+ if (!rings.length) return [];
2013
+ let merged;
2014
+ try {
2015
+ merged = import_polygon_clipping2.default.union(rings[0], ...rings.slice(1));
2016
+ } catch {
2017
+ return [];
2018
+ }
2019
+ const out = [];
2020
+ for (const poly of merged) {
2021
+ if (!poly.length || poly[0].length < 4) continue;
2022
+ const toPts = (ring) => {
2023
+ const pts = ring.map(([x, y]) => ({ x, y }));
2024
+ const first = pts[0], last = pts[pts.length - 1];
2025
+ if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
2026
+ return pts;
2027
+ };
2028
+ const outline = toPts(poly[0]);
2029
+ if (outline.length < 3) continue;
2030
+ let topY = Infinity;
2031
+ for (let i = 0; i < rows.length; i++) {
2032
+ const r = ribbons[i];
2033
+ if (!r) continue;
2034
+ const mid = r.pts[Math.floor(r.pts.length / 2)];
2035
+ if (pointInRing(outline, mid.x, mid.y) && rows[i].y < topY) topY = rows[i].y;
2036
+ }
2037
+ if (!Number.isFinite(topY)) continue;
2038
+ out.push({
2039
+ outline: simplifyRing(outline, FOOTPRINT_TOLERANCE_U),
2040
+ holes: poly.slice(1).map(toPts).map((h) => simplifyRing(h, FOOTPRINT_TOLERANCE_U)).filter((h) => h.length >= 3),
2041
+ topY
2042
+ });
2043
+ }
2044
+ return out;
2045
+ }
2046
+ var FOOTPRINT_TOLERANCE_U = 0.3;
2047
+ var FOOTPRINT_MARGIN_U = 1.5;
2048
+ function simplifyRun(pts, tol) {
2049
+ if (pts.length < 3) return pts;
2050
+ const a = pts[0], b = pts[pts.length - 1];
2051
+ const dx = b.x - a.x, dy = b.y - a.y;
2052
+ const len = Math.hypot(dx, dy);
2053
+ let worst = -1, worstI = -1;
2054
+ for (let i = 1; i < pts.length - 1; i++) {
2055
+ const p = pts[i];
2056
+ 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);
2057
+ if (d > worst) {
2058
+ worst = d;
2059
+ worstI = i;
2060
+ }
2061
+ }
2062
+ if (worst <= tol || worstI < 0) return [a, b];
2063
+ const left = simplifyRun(pts.slice(0, worstI + 1), tol);
2064
+ const right = simplifyRun(pts.slice(worstI), tol);
2065
+ return [...left.slice(0, -1), ...right];
2066
+ }
2067
+ function simplifyRing(ring, tol) {
2068
+ if (ring.length < 4) return ring;
2069
+ const out = simplifyRun([...ring, ring[0]], tol);
2070
+ out.pop();
2071
+ return out.length >= 3 ? out : ring;
2072
+ }
2073
+ function pointInRing(ring, x, y) {
2074
+ let inside = false;
2075
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2076
+ const a = ring[i], b = ring[j];
2077
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
2078
+ }
2079
+ return inside;
2080
+ }
2081
+ var ClipTest = class {
2082
+ constructor(rings) {
2083
+ this.minX = Infinity;
2084
+ this.minY = Infinity;
2085
+ this.maxX = -Infinity;
2086
+ this.maxY = -Infinity;
2087
+ this.rings = rings;
2088
+ for (const r of rings) {
2089
+ for (const p of r) {
2090
+ if (p.x < this.minX) this.minX = p.x;
2091
+ if (p.y < this.minY) this.minY = p.y;
2092
+ if (p.x > this.maxX) this.maxX = p.x;
2093
+ if (p.y > this.maxY) this.maxY = p.y;
2094
+ }
2095
+ }
2096
+ }
2097
+ /** True when every point lies strictly inside ONE ring of the clip region. */
2098
+ containsAll(pts) {
2099
+ for (const p of pts) {
2100
+ if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
2101
+ }
2102
+ for (const ring of this.rings) {
2103
+ let all = true;
2104
+ for (const p of pts) {
2105
+ if (!pointInRing(ring, p.x, p.y)) {
2106
+ all = false;
2107
+ break;
2108
+ }
2109
+ }
2110
+ if (all) return true;
2111
+ }
2112
+ return false;
2113
+ }
2114
+ };
2115
+ function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
2116
+ if (clipTest.containsAll(quad)) {
2117
+ const UPF = [0, 1, 0];
2118
+ const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
2119
+ 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);
2120
+ 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);
2121
+ return;
2122
+ }
2123
+ const ring = quad.map((p) => [p.x, p.y]);
2124
+ ring.push(ring[0]);
2125
+ let pieces;
2126
+ try {
2127
+ pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
2128
+ } catch {
2129
+ return;
2130
+ }
2131
+ const UP = [0, 1, 0];
2132
+ for (const poly of pieces) {
2133
+ if (!poly.length || poly[0].length < 4) continue;
2134
+ const outer = poly[0];
2135
+ const flat = [];
2136
+ const pts = [];
2137
+ for (let i = 0; i < outer.length - 1; i++) {
2138
+ flat.push(outer[i][0], outer[i][1]);
2139
+ pts.push([outer[i][0], outer[i][1]]);
2140
+ }
2141
+ if (pts.length < 3) continue;
2142
+ const tris = (0, import_earcut2.default)(flat, void 0, 2);
2143
+ for (let i = 0; i < tris.length; i += 3) {
2144
+ const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
2145
+ builder.tri(
2146
+ [a[0] * M, y, a[1] * M],
2147
+ [b[0] * M, y, b[1] * M],
2148
+ [c[0] * M, y, c[1] * M],
2149
+ UP,
2150
+ color
2151
+ );
2152
+ }
2153
+ }
2154
+ }
2155
+ function emitDeckBands(builder, rows, focal, landingY, colors, clip) {
2156
+ if (rows.length < 2) return;
2157
+ const nbrs = neighbourhoods(rows);
2158
+ const UP = [0, 1, 0];
2159
+ const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2160
+ const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2161
+ for (let i = 0; i < rows.length; i++) {
2162
+ const row = rows[i];
2163
+ const rib = ribbonOf(rows, i, nbrs, focal);
2164
+ if (!rib) continue;
2165
+ const { pts, nrm, front, back } = rib;
2166
+ const belowY = nbrs[i].belowY ?? landingY;
2167
+ for (let k = 0; k + 1 < pts.length; k++) {
2168
+ const p = pts[k], q = pts[k + 1];
2169
+ const np = nrm[k], nq = nrm[k + 1];
2170
+ if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
2171
+ if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
2172
+ const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
2173
+ const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2174
+ const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2175
+ const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2176
+ if (clipRing && clipTest) {
2177
+ emitClippedQuad(builder, clipRing, clipTest, [
2178
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
2179
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2180
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2181
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
2182
+ ], row.y, colors.tread);
2183
+ } else {
2184
+ builder.tri(pF, qF, qB, UP, colors.tread);
2185
+ builder.tri(pF, qB, pB, UP, colors.tread);
2186
+ }
2187
+ if (row.y > belowY + MIN_RISER_M) {
2188
+ const pFd = [pF[0], belowY, pF[2]];
2189
+ const qFd = [qF[0], belowY, qF[2]];
2190
+ const rn = [-np[0], 0, -np[1]];
2191
+ builder.tri(pF, qF, qFd, rn, colors.riser);
2192
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
2193
+ }
2194
+ }
2195
+ }
2196
+ }
2197
+
683
2198
  // src/view3d/scene/sceneModel.ts
684
2199
  function floorUnits(doc) {
685
2200
  if (doc.floors?.length) {
@@ -692,8 +2207,6 @@ function floorUnits(doc) {
692
2207
  return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
693
2208
  }
694
2209
  var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
695
- var DECK_DROP_M = 0.28;
696
- var MAX_TIER_RISE_M = 25;
697
2210
  function tintTop(fill, neutral) {
698
2211
  if (!fill) return neutral;
699
2212
  const muted = scaleRgb(desaturate(fill, 0.4), 0.62);
@@ -730,31 +2243,229 @@ function resolveSectionFills(doc, seats) {
730
2243
  function sectionFill(section, byLogical) {
731
2244
  return hexToRgb(section.color) ?? byLogical.get(section.logicalSectionId ?? section.id) ?? null;
732
2245
  }
733
- function buildTier(builder, section, unit, fill) {
2246
+ function bboxOfRing(ring) {
2247
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2248
+ for (const p of ring) {
2249
+ if (p.x < minX) minX = p.x;
2250
+ if (p.y < minY) minY = p.y;
2251
+ if (p.x > maxX) maxX = p.x;
2252
+ if (p.y > maxY) maxY = p.y;
2253
+ }
2254
+ return { minX, minY, maxX, maxY };
2255
+ }
2256
+ function boxesOverlap(a, b) {
2257
+ return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
2258
+ }
2259
+ function paddedFootprint(section, siblings) {
2260
+ const __tp = performance.now();
2261
+ const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
2262
+ __t("outsetRing", __tp);
2263
+ const box = bboxOfRing(padded);
2264
+ const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
2265
+ if (!others.length) return [padded];
2266
+ const toRing = (pts) => {
2267
+ const r = pts.map((p) => [p.x, p.y]);
2268
+ r.push(r[0]);
2269
+ return r;
2270
+ };
2271
+ try {
2272
+ const diff = import_polygon_clipping3.default.difference([toRing(padded)], ...others.map((o) => [toRing(o.outline)]));
2273
+ const out = [];
2274
+ for (const poly of diff) {
2275
+ if (!poly.length) continue;
2276
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
2277
+ if (pts.length > 1) {
2278
+ const f = pts[0], l = pts[pts.length - 1];
2279
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
2280
+ }
2281
+ if (pts.length >= 3) out.push(pts);
2282
+ }
2283
+ return out.length ? out : [padded];
2284
+ } catch {
2285
+ return [padded];
2286
+ }
2287
+ }
2288
+ function clipFootprints(blocks, allowed) {
2289
+ if (!allowed.length) return blocks;
2290
+ const toRing = (pts) => {
2291
+ const r = pts.map((p) => [p.x, p.y]);
2292
+ r.push(r[0]);
2293
+ return r;
2294
+ };
2295
+ const allowedPolys = allowed.map((a) => [toRing(a)]);
2296
+ const out = [];
2297
+ for (const b of blocks) {
2298
+ let pieces;
2299
+ try {
2300
+ pieces = import_polygon_clipping3.default.intersection([toRing(b.outline)], ...[allowedPolys.flat()]);
2301
+ } catch {
2302
+ out.push(b);
2303
+ continue;
2304
+ }
2305
+ for (const poly of pieces) {
2306
+ if (!poly.length) continue;
2307
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
2308
+ if (pts.length > 1) {
2309
+ const f = pts[0], l = pts[pts.length - 1];
2310
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
2311
+ }
2312
+ if (pts.length >= 3) out.push({ outline: pts, holes: [], topY: b.topY });
2313
+ }
2314
+ }
2315
+ return out.length ? out : blocks;
2316
+ }
2317
+ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S) {
734
2318
  if (!section.outline || section.outline.length < 3) return;
735
- const geo = sectionGeometry(section, { floorBaseHeightM: unit.baseHeightM });
736
2319
  const bottomY = unit.baseHeightM;
737
- const rakeRad = geo.rake * Math.PI / 180;
738
- const flat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
739
- const colTop = tintTop(fill, STRUCTURE.tierTop);
740
- if (flat) {
741
- const topY2 = bottomY + 0.05;
742
- extrudePrism(builder, section.outline, section.holes, () => topY2, bottomY, colTop, STRUCTURE.tierWall, AO);
2320
+ const colTop = tintTop(fill, S.tierTop);
2321
+ if (!surface) return;
2322
+ const topY = (p) => surface.deckAt(p.x, p.y);
2323
+ const footprint = paddedFootprint(section, siblings);
2324
+ const outline = footprint[0] ?? section.outline;
2325
+ if (surface.rowLevels.length >= 2) {
2326
+ const __tf = performance.now();
2327
+ const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
2328
+ __t("deckFootprints", __tf);
2329
+ const capUp = () => [0, 1, 0];
2330
+ for (const b of blocks) {
2331
+ extrudePrism(
2332
+ builder,
2333
+ b.outline,
2334
+ b.holes,
2335
+ () => b.topY,
2336
+ bottomY,
2337
+ colTop,
2338
+ S.tierWall,
2339
+ AO,
2340
+ void 0,
2341
+ capUp
2342
+ );
2343
+ }
2344
+ if (!blocks.length) {
2345
+ extrudePrism(
2346
+ builder,
2347
+ outline,
2348
+ section.holes,
2349
+ () => surface.landingY,
2350
+ bottomY,
2351
+ colTop,
2352
+ S.tierWall,
2353
+ AO
2354
+ );
2355
+ }
2356
+ const __tb = performance.now();
2357
+ emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
2358
+ tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
2359
+ // Risers read as the structure they are, a shade below their tread, which
2360
+ // is what makes the stepping legible from a low angle.
2361
+ riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
2362
+ }, footprint.length === 1 ? outline : footprint.flat());
2363
+ __t("emitDeckBands", __tb);
743
2364
  return;
744
2365
  }
745
- let frontDist = Infinity;
746
- for (const p of section.outline) {
747
- const d = Math.hypot(p.x - unit.focal.x, p.y - unit.focal.y);
748
- if (d < frontDist) frontDist = d;
749
- }
750
- const tan = Math.tan(rakeRad);
751
- const topY = (p) => {
752
- const d = Math.hypot(p.x - unit.focal.x, p.y - unit.focal.y);
753
- const depthM = Math.max(0, d - frontDist) * M;
754
- const rise = Math.min(depthM * tan, MAX_TIER_RISE_M);
755
- return Math.max(bottomY + 0.05, geo.height + rise - DECK_DROP_M);
756
- };
757
- extrudePrism(builder, section.outline, section.holes, topY, bottomY, colTop, STRUCTURE.tierWall, AO);
2366
+ const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
2367
+ const topN = (p) => surface.normalAt(p.x, p.y);
2368
+ for (const ring of claimed.subtract(outline)) {
2369
+ extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
2370
+ }
2371
+ }
2372
+ var ClaimedArea = class {
2373
+ constructor() {
2374
+ this.rings = [];
2375
+ this.boxes = [];
2376
+ }
2377
+ /** `ring` minus everything claimed so far; then claim what is returned. */
2378
+ subtract(ring) {
2379
+ const closed = ring.map((p) => [p.x, p.y]);
2380
+ if (closed.length < 3) return [];
2381
+ closed.push(closed[0]);
2382
+ const box = bboxOfRing(ring);
2383
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
2384
+ let pieces = [[closed]];
2385
+ if (overlapping.length) {
2386
+ try {
2387
+ const diff = import_polygon_clipping3.default.difference([closed], ...overlapping);
2388
+ pieces = diff.map((poly) => poly.map((r) => r.map(([x, y]) => [x, y])));
2389
+ } catch {
2390
+ pieces = [[closed]];
2391
+ }
2392
+ }
2393
+ this.rings.push([closed]);
2394
+ this.boxes.push(box);
2395
+ const out = [];
2396
+ for (const poly of pieces) {
2397
+ if (!poly.length) continue;
2398
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
2399
+ if (pts.length > 1) {
2400
+ const f = pts[0], l = pts[pts.length - 1];
2401
+ if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
2402
+ }
2403
+ if (pts.length >= 3) out.push(pts);
2404
+ }
2405
+ return out;
2406
+ }
2407
+ };
2408
+ var BOOTH_HEIGHT_M = 2.4;
2409
+ var ZONE_LABEL_LIFT_M = 6;
2410
+ var SECTION_LABEL_LIFT_M = 2.2;
2411
+ var ANNOTATION_LIFT_M = 0.1;
2412
+ var BOOTH_LABEL_LIFT_M = 0.3;
2413
+ var TABLE_HEIGHT_M = 0.75;
2414
+ function boothPolygon(booth) {
2415
+ if (booth.points && booth.points.length >= 3) return booth.points;
2416
+ const { center, width, height, rotation } = booth;
2417
+ if (!width || !height) return null;
2418
+ const a = (rotation ?? 0) * Math.PI / 180;
2419
+ const cos = Math.cos(a), sin = Math.sin(a);
2420
+ const hw = width / 2, hh = height / 2;
2421
+ return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
2422
+ x: center.x + lx * cos - ly * sin,
2423
+ y: center.y + lx * sin + ly * cos
2424
+ }));
2425
+ }
2426
+ function buildBooth(builder, booth, base, fill, S) {
2427
+ const poly = boothPolygon(booth);
2428
+ if (!poly) return;
2429
+ extrudePrism(
2430
+ builder,
2431
+ poly,
2432
+ void 0,
2433
+ () => base + BOOTH_HEIGHT_M,
2434
+ base,
2435
+ tintTop(fill, S.boothTop),
2436
+ S.boothWall,
2437
+ AO
2438
+ );
2439
+ }
2440
+ function tablePolygon(table) {
2441
+ if (table.shape === "round") {
2442
+ const r = table.radius;
2443
+ if (!r) return null;
2444
+ return ellipsePolygon(table.center.x, table.center.y, r, r, 24);
2445
+ }
2446
+ const { width, height, rotation, center } = table;
2447
+ if (!width || !height) return null;
2448
+ const a = (rotation ?? 0) * Math.PI / 180;
2449
+ const cos = Math.cos(a), sin = Math.sin(a);
2450
+ const hw = width / 2, hh = height / 2;
2451
+ return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
2452
+ x: center.x + lx * cos - ly * sin,
2453
+ y: center.y + lx * sin + ly * cos
2454
+ }));
2455
+ }
2456
+ function buildTable(builder, table, base, fill, S) {
2457
+ const poly = tablePolygon(table);
2458
+ if (!poly) return;
2459
+ extrudePrism(
2460
+ builder,
2461
+ poly,
2462
+ void 0,
2463
+ () => base + TABLE_HEIGHT_M,
2464
+ base,
2465
+ tintTop(fill, S.tableTop),
2466
+ S.tableWall,
2467
+ AO
2468
+ );
758
2469
  }
759
2470
  function shapePolygon(shape) {
760
2471
  if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
@@ -768,19 +2479,19 @@ function shapePolygon(shape) {
768
2479
  }
769
2480
  return null;
770
2481
  }
771
- function buildShape(builder, shape, base) {
2482
+ function buildShape(builder, shape, base, S) {
772
2483
  const poly = shapePolygon(shape);
773
2484
  if (!poly) return;
774
2485
  const isStage = shape.role === "stage";
775
2486
  const height = isStage ? base + 1 : base + 0.25;
776
- const colTop = isStage ? STRUCTURE.stageTop : STRUCTURE.decorTop;
777
- const colWall = isStage ? STRUCTURE.stageWall : STRUCTURE.decorWall;
2487
+ const colTop = isStage ? S.stageTop : S.decorTop;
2488
+ const colWall = isStage ? S.stageWall : S.decorWall;
778
2489
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
779
2490
  }
780
- function buildGa(builder, ga, base, fill) {
2491
+ function buildGa(builder, ga, base, fill, S) {
781
2492
  if (!ga.points || ga.points.length < 3) return;
782
- const colTop = tintTop(fill, STRUCTURE.gaTop);
783
- extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, STRUCTURE.gaWall, AO);
2493
+ const colTop = tintTop(fill, S.gaTop);
2494
+ extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, S.gaWall, AO);
784
2495
  }
785
2496
  function chartFootprint(units, seats) {
786
2497
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -796,6 +2507,13 @@ function chartFootprint(units, seats) {
796
2507
  if (o.type === "section") for (const p of o.outline) acc(p.x, p.y);
797
2508
  else if (o.type === "shape" && o.points) for (const p of o.points) acc(p.x, p.y);
798
2509
  else if (o.type === "gaArea") for (const p of o.points) acc(p.x, p.y);
2510
+ else if (o.type === "booth") {
2511
+ const b = boothPolygon(o);
2512
+ if (b) for (const p of b) acc(p.x, p.y);
2513
+ } else if (o.type === "table") {
2514
+ const t = tablePolygon(o);
2515
+ if (t) for (const p of t) acc(p.x, p.y);
2516
+ }
799
2517
  }
800
2518
  }
801
2519
  if (!Number.isFinite(minX)) {
@@ -806,41 +2524,318 @@ function chartFootprint(units, seats) {
806
2524
  }
807
2525
  return { minX, minY, maxX, maxY };
808
2526
  }
2527
+ var __PROF = {};
2528
+ var __t = (k, t0) => {
2529
+ __PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
2530
+ };
809
2531
  function buildSceneModel(input) {
2532
+ for (const k of Object.keys(__PROF)) delete __PROF[k];
810
2533
  const { doc, seats } = input;
2534
+ const theme = resolveTheme3D(doc.theme);
2535
+ const S = theme.structure;
811
2536
  const units = floorUnits(doc);
812
2537
  const builder = new MeshBuilder();
813
2538
  const fp = chartFootprint(units, seats);
814
2539
  const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
815
2540
  const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
816
- extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, STRUCTURE.ground, STRUCTURE.ground, AO);
2541
+ extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
817
2542
  const sectionFills = resolveSectionFills(doc, seats);
818
2543
  const catColor = /* @__PURE__ */ new Map();
819
2544
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
820
- for (const unit of units) {
2545
+ const __t0 = performance.now();
2546
+ const surfaces = buildVenueSurfaces(units, seats);
2547
+ __t("surfaces", __t0);
2548
+ const seatFloor = new Float32Array(seats.length);
2549
+ for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
2550
+ const unit = units[unitIndex];
2551
+ builder.setFloor(unitIndex);
2552
+ const claimed = new ClaimedArea();
2553
+ const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
821
2554
  for (const o of unit.objects) {
822
- if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills));
823
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM);
824
- else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)));
2555
+ if (o.type === "section") {
2556
+ const t = performance.now();
2557
+ buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2558
+ __t("buildTier", t);
2559
+ } else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
2560
+ else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2561
+ else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2562
+ else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
825
2563
  }
826
2564
  }
2565
+ const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
2566
+ const __tm = performance.now();
827
2567
  const solids = mergeMeshData([builder.build()]);
828
- const seatData = buildSeatInstances(seats, input.initialState);
2568
+ __t("meshBuild+merge", __tm);
2569
+ const __ts = performance.now();
2570
+ const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
2571
+ __t("seatInstances", __ts);
2572
+ const zoneDefs = doc.zones ?? [];
2573
+ const zones = [];
2574
+ if (zoneDefs.length) {
2575
+ const sectionZone = /* @__PURE__ */ new Map();
2576
+ for (const unit of units) {
2577
+ for (const o of unit.objects) {
2578
+ if (o.type === "section" && o.zone) sectionZone.set(o.id, o.zone);
2579
+ }
2580
+ }
2581
+ const acc = /* @__PURE__ */ new Map();
2582
+ for (let i = 0; i < seats.length; i++) {
2583
+ const s = seats[i];
2584
+ const owner = surfaces.seatOwner[i];
2585
+ const zid = s.zoneId ?? (owner ? sectionZone.get(owner) : void 0);
2586
+ if (!zid) continue;
2587
+ let a = acc.get(zid);
2588
+ if (!a) {
2589
+ a = { n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 };
2590
+ acc.set(zid, a);
2591
+ }
2592
+ a.n++;
2593
+ if (s.x < a.minX) a.minX = s.x;
2594
+ if (s.y < a.minY) a.minY = s.y;
2595
+ if (s.x > a.maxX) a.maxX = s.x;
2596
+ if (s.y > a.maxY) a.maxY = s.y;
2597
+ a.sumY += surfaces.seatDeckY(i);
2598
+ }
2599
+ for (const z of zoneDefs) {
2600
+ const a = acc.get(z.id);
2601
+ const sectionIds = [];
2602
+ for (const [secId, zid] of sectionZone) if (zid === z.id) sectionIds.push(secId);
2603
+ if (!a || a.n === 0) {
2604
+ zones.push({
2605
+ id: z.id,
2606
+ label: z.label,
2607
+ color: hexToRgb(z.color),
2608
+ sectionIds,
2609
+ seatCount: 0,
2610
+ center: [0, 0, 0],
2611
+ radius: 0,
2612
+ focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
2613
+ });
2614
+ continue;
2615
+ }
2616
+ zones.push({
2617
+ id: z.id,
2618
+ label: z.label,
2619
+ color: hexToRgb(z.color),
2620
+ sectionIds,
2621
+ seatCount: a.n,
2622
+ center: [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M],
2623
+ radius: 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1,
2624
+ // A zone may face its own point (`ZoneDef.focalPoint`); the documented
2625
+ // fallback is the floor/chart focal.
2626
+ focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
2627
+ });
2628
+ }
2629
+ }
2630
+ const labels = [];
2631
+ for (const z of zones) {
2632
+ if (z.seatCount === 0) continue;
2633
+ labels.push({
2634
+ id: `zone:${z.id}`,
2635
+ kind: "zone",
2636
+ text: z.label,
2637
+ anchor: [z.center[0], z.center[1] + ZONE_LABEL_LIFT_M, z.center[2]],
2638
+ color: doc.zones?.find((d) => d.id === z.id)?.color
2639
+ });
2640
+ }
2641
+ {
2642
+ const acc = /* @__PURE__ */ new Map();
2643
+ for (let i = 0; i < seats.length; i++) {
2644
+ const owner = surfaces.seatOwner[i];
2645
+ if (!owner) continue;
2646
+ let a = acc.get(owner);
2647
+ if (!a) {
2648
+ a = { n: 0, x: 0, y: 0, deck: 0 };
2649
+ acc.set(owner, a);
2650
+ }
2651
+ a.n++;
2652
+ a.x += seats[i].x;
2653
+ a.y += seats[i].y;
2654
+ a.deck += surfaces.seatDeckY(i);
2655
+ }
2656
+ for (const unit of units) {
2657
+ for (const o of unit.objects) {
2658
+ if (o.type !== "section") continue;
2659
+ const a = acc.get(o.id);
2660
+ if (!a || a.n === 0) continue;
2661
+ labels.push({
2662
+ id: `section:${o.id}`,
2663
+ kind: "section",
2664
+ // The buyer-facing name wins over the technical one, as it does in 2D.
2665
+ text: o.displayLabel || o.label || o.id,
2666
+ anchor: [a.x / a.n * M, a.deck / a.n + SECTION_LABEL_LIFT_M, a.y / a.n * M]
2667
+ });
2668
+ }
2669
+ }
2670
+ }
2671
+ for (const unit of units) {
2672
+ for (const o of unit.objects) {
2673
+ if (o.type === "text") {
2674
+ if (!o.text) continue;
2675
+ labels.push({
2676
+ id: `text:${o.id}`,
2677
+ kind: "annotation",
2678
+ text: o.text,
2679
+ anchor: [o.position.x * M, unit.baseHeightM + ANNOTATION_LIFT_M, o.position.y * M],
2680
+ color: o.color,
2681
+ rotation: o.rotation
2682
+ });
2683
+ } else if (o.type === "booth") {
2684
+ const poly = boothPolygon(o);
2685
+ if (!poly) continue;
2686
+ const c = centroidOf(poly);
2687
+ if (!c) continue;
2688
+ labels.push({
2689
+ id: `booth:${o.id}`,
2690
+ kind: "booth",
2691
+ text: o.displayLabel || o.label || o.id,
2692
+ anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M]
2693
+ });
2694
+ }
2695
+ }
2696
+ }
2697
+ const floors = [];
2698
+ {
2699
+ const sectionFloor = /* @__PURE__ */ new Map();
2700
+ for (let ui = 0; ui < units.length; ui++) {
2701
+ for (const o of units[ui].objects) if (o.type === "section") sectionFloor.set(o.id, ui);
2702
+ }
2703
+ const acc = units.map(() => ({ n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 }));
2704
+ for (let i = 0; i < seats.length; i++) {
2705
+ const owner = surfaces.seatOwner[i];
2706
+ const ui = owner !== null ? sectionFloor.get(owner) : void 0;
2707
+ if (ui === void 0) continue;
2708
+ const a = acc[ui];
2709
+ const s = seats[i];
2710
+ a.n++;
2711
+ if (s.x < a.minX) a.minX = s.x;
2712
+ if (s.y < a.minY) a.minY = s.y;
2713
+ if (s.x > a.maxX) a.maxX = s.x;
2714
+ if (s.y > a.maxY) a.maxY = s.y;
2715
+ a.sumY += surfaces.seatDeckY(i);
2716
+ seatFloor[i] = ui;
2717
+ }
2718
+ for (let ui = 0; ui < units.length; ui++) {
2719
+ const a = acc[ui];
2720
+ const f = doc.floors?.[ui];
2721
+ floors.push({
2722
+ index: ui,
2723
+ id: f?.id ?? `floor-${ui}`,
2724
+ label: f?.name ?? (units.length > 1 ? `Floor ${ui + 1}` : "Venue"),
2725
+ seatCount: a.n,
2726
+ center: a.n ? [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M] : [0, 0, 0],
2727
+ radius: a.n ? 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1 : 0
2728
+ });
2729
+ }
2730
+ }
829
2731
  const cx = (fp.minX + fp.maxX) / 2 * M;
830
2732
  const cz = (fp.minY + fp.maxY) / 2 * M;
831
2733
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
832
- const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
833
2734
  return {
834
2735
  solids,
835
2736
  seats: seatData,
836
2737
  bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
837
- stateColorLUT: seatStateColorLUT(),
2738
+ stateColorLUT: themeSeatColorLUT(theme, SEAT_STATES),
2739
+ theme,
838
2740
  seatCount: seats.length,
839
2741
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
840
- focalWorld: [focal.x * M, 1.5, focal.y * M]
2742
+ focalWorld: [focal.x * M, 1.5, focal.y * M],
2743
+ zones,
2744
+ labels,
2745
+ floors
841
2746
  };
842
2747
  }
843
2748
 
2749
+ // src/view3d/labelOverlay.ts
2750
+ var SEPARATION_X_PX = 88;
2751
+ var SEPARATION_Y_PX = 20;
2752
+ var KIND_STYLE = {
2753
+ zone: { size: 15, weight: "600", opacity: 0.95 },
2754
+ section: { size: 12, weight: "500", opacity: 0.88 },
2755
+ booth: { size: 11, weight: "500", opacity: 0.85 },
2756
+ annotation: { size: 11, weight: "400", opacity: 0.75 }
2757
+ };
2758
+ var LabelOverlay = class {
2759
+ constructor(container, opts = {}) {
2760
+ this.nodes = /* @__PURE__ */ new Map();
2761
+ this.labels = [];
2762
+ this.opts = opts;
2763
+ this.root = document.createElement("div");
2764
+ this.root.setAttribute("data-view3d-labels", "");
2765
+ const s = this.root.style;
2766
+ s.position = "absolute";
2767
+ s.inset = "0";
2768
+ s.pointerEvents = "none";
2769
+ s.overflow = "hidden";
2770
+ if (opts.fontFamily) s.fontFamily = opts.fontFamily;
2771
+ container.appendChild(this.root);
2772
+ }
2773
+ setLabels(labels) {
2774
+ this.labels = labels;
2775
+ for (const [id, node] of this.nodes) {
2776
+ if (!labels.some((l) => l.id === id)) {
2777
+ node.remove();
2778
+ this.nodes.delete(id);
2779
+ }
2780
+ }
2781
+ }
2782
+ /**
2783
+ * Reposition every label for the current camera.
2784
+ *
2785
+ * `viewProjection` is column-major, as OGL supplies it.
2786
+ */
2787
+ update(viewProjection, width, height, cameraDistance, venueRadius) {
2788
+ if (!this.labels.length) return;
2789
+ const kinds = visibleLabelKinds(cameraDistance, venueRadius);
2790
+ const candidates = [];
2791
+ for (const label of this.labels) {
2792
+ if (!kinds.has(label.kind)) continue;
2793
+ const screen = projectToScreen(viewProjection, label.anchor, width, height);
2794
+ if (!screen.visible) continue;
2795
+ candidates.push({ label, screen });
2796
+ }
2797
+ const kept = cullOverlapping(candidates, SEPARATION_X_PX, SEPARATION_Y_PX);
2798
+ const keptIds = new Set(kept.map((k) => k.label.id));
2799
+ for (const { label, screen } of kept) {
2800
+ const node = this.nodeFor(label);
2801
+ const st = node.style;
2802
+ st.display = "";
2803
+ st.transform = `translate(-50%, -50%) translate(${screen.x.toFixed(1)}px, ${screen.y.toFixed(1)}px)`;
2804
+ }
2805
+ for (const [id, node] of this.nodes) {
2806
+ if (!keptIds.has(id)) node.style.display = "none";
2807
+ }
2808
+ }
2809
+ nodeFor(label) {
2810
+ let node = this.nodes.get(label.id);
2811
+ if (node) return node;
2812
+ node = document.createElement("div");
2813
+ node.textContent = label.text;
2814
+ node.setAttribute("data-label-kind", label.kind);
2815
+ const style = KIND_STYLE[label.kind];
2816
+ const s = node.style;
2817
+ s.position = "absolute";
2818
+ s.left = "0";
2819
+ s.top = "0";
2820
+ s.whiteSpace = "nowrap";
2821
+ s.fontSize = `${style.size}px`;
2822
+ s.fontWeight = style.weight;
2823
+ s.opacity = String(style.opacity);
2824
+ s.color = label.color ?? this.opts.ink ?? "#e8edf5";
2825
+ s.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
2826
+ s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
2827
+ if (label.kind === "zone") s.textTransform = "uppercase";
2828
+ s.display = "none";
2829
+ this.root.appendChild(node);
2830
+ this.nodes.set(label.id, node);
2831
+ return node;
2832
+ }
2833
+ dispose() {
2834
+ this.root.remove();
2835
+ this.nodes.clear();
2836
+ }
2837
+ };
2838
+
844
2839
  // src/view3d/scene/build.ts
845
2840
  var import_ogl4 = require("ogl");
846
2841
 
@@ -853,16 +2848,29 @@ precision highp float;
853
2848
  in vec3 position;
854
2849
  in vec3 normal;
855
2850
  in vec3 color;
2851
+ in float floorIndex;
2852
+ uniform mat4 modelMatrix;
2853
+ uniform float uFocusFloor; // -1 = show every floor
856
2854
  uniform mat4 modelViewMatrix;
857
2855
  uniform mat4 projectionMatrix;
858
2856
  uniform mat3 normalMatrix;
859
2857
  out vec3 vColor;
2858
+ out vec3 vNormalWorld;
860
2859
  out vec3 vNormalView;
861
2860
  out vec3 vPosView;
2861
+ out float vDim;
862
2862
  void main() {
2863
+ // Per-floor isolation without splitting the merged mesh into a draw call per
2864
+ // floor: a floor that is not the focused one is dimmed, not hidden, so the
2865
+ // buyer keeps the whole venue as context while looking at one level.
2866
+ vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
863
2867
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
864
2868
  vPosView = mv.xyz;
865
2869
  vNormalView = normalize(normalMatrix * normal);
2870
+ // World normal drives the key + hemisphere so the lighting stays welded to the
2871
+ // venue as the camera orbits (the scene has no non-uniform scale, so mat3 of
2872
+ // the model matrix is the correct normal transform).
2873
+ vNormalWorld = normalize(mat3(modelMatrix) * normal);
866
2874
  vColor = color;
867
2875
  gl_Position = projectionMatrix * mv;
868
2876
  }`
@@ -872,18 +2880,26 @@ var SOLID_FRAG = (
872
2880
  `#version 300 es
873
2881
  precision highp float;
874
2882
  in vec3 vColor;
2883
+ in vec3 vNormalWorld;
875
2884
  in vec3 vNormalView;
876
2885
  in vec3 vPosView;
2886
+ in float vDim;
2887
+ uniform vec3 uKeyDir; // WORLD space, unit, points at the light
877
2888
  out vec4 fragColor;
878
2889
  void main() {
879
- vec3 N = normalize(vNormalView);
2890
+ vec3 N = normalize(vNormalWorld);
880
2891
  vec3 V = normalize(-vPosView);
881
- float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient
882
- vec3 L = normalize(vec3(0.4, 0.85, 0.55)); // warm key, view space
883
- float key = max(dot(N, L), 0.0);
884
- vec3 base = vColor * (0.60 + 0.32 * hemi) + vColor * key * 0.32;
885
- float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
886
- base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained
2892
+ float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
2893
+ float key = max(dot(N, uKeyDir), 0.0); // fixed key \u2014 does not orbit with you
2894
+ // Low opposite fill so faces turned away from the key keep their form instead
2895
+ // of crushing to a single flat value.
2896
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
2897
+ float fill = max(dot(N, fillDir), 0.0);
2898
+ vec3 base = vColor * (0.52 + 0.34 * hemi) + vColor * key * 0.34 + vColor * fill * 0.10;
2899
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
2900
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained (view-dependent by design)
2901
+ // Unfocused floors fall back toward the background rather than vanishing.
2902
+ base = mix(base, base * 0.45, vDim);
887
2903
  fragColor = vec4(base, 1.0);
888
2904
  }`
889
2905
  );
@@ -894,22 +2910,45 @@ precision highp float;
894
2910
  in vec2 position; // quad corner in [-1,1]
895
2911
  in vec3 iOffset; // per-instance world position
896
2912
  in vec3 iColor; // per-instance state colour (resolved CPU-side)
2913
+ in float iMaxRadius; // per-instance world-radius ceiling (seat pitch derived)
2914
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
2915
+ in float iFloor; // owning floor index
897
2916
  uniform mat4 modelViewMatrix;
898
2917
  uniform mat4 projectionMatrix;
899
2918
  uniform float uSeatRadius;
900
2919
  uniform float uSeatScale;
901
2920
  uniform float uMinPixels;
902
2921
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
2922
+ uniform float uFocusFloor; // -1 = show every floor
903
2923
  out vec2 vUv;
904
2924
  out vec3 vColor;
2925
+ out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
2926
+ out vec3 vRing;
2927
+ out float vDim;
905
2928
  void main() {
906
2929
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
907
2930
  float depth = max(-mv.z, 0.001);
908
2931
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
909
- float r = max(uSeatRadius * uSeatScale, minR);
2932
+ // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
2933
+ // unbounded growth is what merges neighbouring rows into one mass at range.
2934
+ float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
2935
+ // How much of the requested pixel floor the dot could actually afford. Below 1
2936
+ // it is losing legibility to distance, and the fragment stage dissolves it
2937
+ // toward the tier top rather than letting a sub-pixel dot alias and shimmer.
2938
+ vBudget = minR > 0.0 ? clamp(r / minR, 0.0, 1.0) : 1.0;
910
2939
  mv.xy += position * r; // camera-facing billboard
2940
+ // Seat the dot ON the deck instead of centring it in the deck. iOffset is the
2941
+ // exact surface point, so half the billboard would otherwise sit below the cap
2942
+ // and be clipped by it \u2014 and because uMinPixels grows r with distance, no
2943
+ // constant world-space lift can prevent that at every range. Offsetting by r
2944
+ // along the screen projection of WORLD up is self-correcting: it is full at a
2945
+ // grazing view (where slicing happens) and vanishes looking straight down
2946
+ // (where the dot must stay centred on its seat).
2947
+ mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
911
2948
  vUv = position;
912
2949
  vColor = iColor;
2950
+ vRing = iRing;
2951
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
913
2952
  gl_Position = projectionMatrix * mv;
914
2953
  }`
915
2954
  );
@@ -919,6 +2958,9 @@ var SEAT_FRAG = (
919
2958
  precision highp float;
920
2959
  in vec2 vUv;
921
2960
  in vec3 vColor;
2961
+ in float vBudget;
2962
+ in vec3 vRing;
2963
+ in float vDim;
922
2964
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
923
2965
  uniform vec3 uFadeColor;
924
2966
  out vec4 fragColor;
@@ -929,6 +2971,20 @@ void main() {
929
2971
  float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
930
2972
  vec3 c = vColor * shade;
931
2973
  c = mix(c, uFadeColor, uSeatFade);
2974
+ // Accommodation ring \u2014 the 3D echo of the coloured ring 2D draws around every
2975
+ // accessible seat. Painted INSIDE the dot's own radius rather than outside it,
2976
+ // so an accessible seat still respects the row-pitch ceiling and cannot grow
2977
+ // into its neighbour just for carrying a ring.
2978
+ float ringMask = step(0.001, dot(vRing, vRing));
2979
+ float ring = smoothstep(0.58, 0.70, d) * (1.0 - smoothstep(0.90, 1.0, d));
2980
+ c = mix(c, vRing, ring * ringMask * 0.95);
2981
+ // A dot that can no longer afford its pixel floor dissolves instead of
2982
+ // aliasing; the tier cap underneath already carries the section's category
2983
+ // tint, so the block reads as coloured seating rather than empty concrete.
2984
+ alpha *= smoothstep(0.35, 1.0, vBudget);
2985
+ // Seats on an unfocused floor recede with their structure.
2986
+ c = mix(c, uFadeColor, vDim * 0.75);
2987
+ alpha *= mix(1.0, 0.30, vDim);
932
2988
  fragColor = vec4(c, alpha);
933
2989
  }`
934
2990
  );
@@ -964,6 +3020,7 @@ var SEAT_PICK_VERT = (
964
3020
  precision highp float;
965
3021
  in vec2 position;
966
3022
  in vec3 iOffset;
3023
+ in float iMaxRadius;
967
3024
  uniform mat4 modelViewMatrix;
968
3025
  uniform mat4 projectionMatrix;
969
3026
  uniform float uSeatRadius;
@@ -978,8 +3035,10 @@ void main() {
978
3035
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
979
3036
  float depth = max(-mv.z, 0.001);
980
3037
  float minR = uMinPixels * depth * uPixelToWorld;
981
- float r = max(uSeatRadius * uSeatScale, minR);
3038
+ float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
982
3039
  mv.xy += position * r;
3040
+ // Must match SEAT_VERT exactly, or the hit mask drifts off the drawn dot.
3041
+ mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
983
3042
  vUv = position;
984
3043
  gl_Position = projectionMatrix * mv;
985
3044
  }`
@@ -1023,7 +3082,7 @@ function createSeatPickProgram(gl) {
1023
3082
  depthWrite: true,
1024
3083
  cullFace: false,
1025
3084
  uniforms: {
1026
- uSeatRadius: { value: 0.22 },
3085
+ uSeatRadius: { value: SEAT_DOT_RADIUS_M },
1027
3086
  uSeatScale: { value: 1 },
1028
3087
  uMinPixels: { value: 2.5 },
1029
3088
  uPixelToWorld: { value: 2e-3 }
@@ -1050,7 +3109,13 @@ function createSolidProgram(gl) {
1050
3109
  fragment: SOLID_FRAG,
1051
3110
  cullFace: false,
1052
3111
  depthTest: true,
1053
- depthWrite: true
3112
+ depthWrite: true,
3113
+ uniforms: {
3114
+ // High and off-axis, in world space: reads as a house rig rather than a
3115
+ // headlamp welded to the camera.
3116
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
3117
+ uFocusFloor: { value: -1 }
3118
+ }
1054
3119
  });
1055
3120
  }
1056
3121
  function createSeatProgram(gl) {
@@ -1062,11 +3127,12 @@ function createSeatProgram(gl) {
1062
3127
  depthWrite: false,
1063
3128
  cullFace: false,
1064
3129
  uniforms: {
1065
- uSeatRadius: { value: 0.22 },
3130
+ uSeatRadius: { value: SEAT_DOT_RADIUS_M },
1066
3131
  uSeatScale: { value: 1 },
1067
3132
  uMinPixels: { value: 2.5 },
1068
3133
  uPixelToWorld: { value: 2e-3 },
1069
3134
  uSeatFade: { value: 0 },
3135
+ uFocusFloor: { value: -1 },
1070
3136
  uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
1071
3137
  }
1072
3138
  });
@@ -1086,9 +3152,9 @@ function createBackgroundProgram(gl, top, bottom) {
1086
3152
  }
1087
3153
 
1088
3154
  // src/view3d/scene/build.ts
1089
- function writeSeatColors(iColor, iState, start, count) {
3155
+ function writeSeatColors(iColor, iState, start, count, states) {
1090
3156
  for (let i = start; i < start + count; i++) {
1091
- const c = seatStateColorByIndex(iState[i]);
3157
+ const c = states[iState[i]] ?? states[0];
1092
3158
  iColor[i * 3] = c[0];
1093
3159
  iColor[i * 3 + 1] = c[1];
1094
3160
  iColor[i * 3 + 2] = c[2];
@@ -1100,14 +3166,15 @@ function buildGpuScene(gl, model) {
1100
3166
  const main = new import_ogl4.Transform();
1101
3167
  const background = new import_ogl4.Transform();
1102
3168
  const bgGeo = new import_ogl4.Geometry(gl, { position: { size: 2, data: BG_TRI } });
1103
- const bgProg = createBackgroundProgram(gl, BACKGROUND.top, BACKGROUND.bottom);
3169
+ const bgProg = createBackgroundProgram(gl, model.theme.background.top, model.theme.background.bottom);
1104
3170
  const bgMesh = new import_ogl4.Mesh(gl, { geometry: bgGeo, program: bgProg });
1105
3171
  bgMesh.frustumCulled = false;
1106
3172
  bgMesh.setParent(background);
1107
3173
  const solidGeo = new import_ogl4.Geometry(gl, {
1108
3174
  position: { size: 3, data: model.solids.position },
1109
3175
  normal: { size: 3, data: model.solids.normal },
1110
- color: { size: 3, data: model.solids.color }
3176
+ color: { size: 3, data: model.solids.color },
3177
+ floorIndex: { size: 1, data: model.solids.floor }
1111
3178
  });
1112
3179
  const solidProg = createSolidProgram(gl);
1113
3180
  const solidMesh = new import_ogl4.Mesh(gl, { geometry: solidGeo, program: solidProg });
@@ -1115,11 +3182,18 @@ function buildGpuScene(gl, model) {
1115
3182
  solidMesh.setParent(main);
1116
3183
  const seatProg = createSeatProgram(gl);
1117
3184
  const iColor = new Float32Array(model.seats.count * 3);
1118
- writeSeatColors(iColor, model.seats.iState, 0, model.seats.count);
3185
+ const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
3186
+ writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
1119
3187
  const seatGeo = new import_ogl4.Geometry(gl, {
1120
3188
  position: { size: 2, data: SEAT_QUAD },
1121
3189
  iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
1122
- iColor: { size: 3, data: iColor, instanced: 1 }
3190
+ iColor: { size: 3, data: iColor, instanced: 1 },
3191
+ // Per-seat world-radius ceiling: what stops distant rows merging into one
3192
+ // mass when the shader grows a dot to hold its minimum pixel size.
3193
+ iMaxRadius: { size: 1, data: model.seats.iMaxRadius, instanced: 1 },
3194
+ // Accommodation ring colour; (0,0,0) means the seat carries no access type.
3195
+ iRing: { size: 3, data: model.seats.iRing, instanced: 1 },
3196
+ iFloor: { size: 1, data: model.seats.iFloor, instanced: 1 }
1123
3197
  });
1124
3198
  const seatMesh = new import_ogl4.Mesh(gl, { geometry: seatGeo, program: seatProg });
1125
3199
  seatMesh.frustumCulled = false;
@@ -1129,12 +3203,13 @@ function buildGpuScene(gl, model) {
1129
3203
  main,
1130
3204
  background,
1131
3205
  seatProgram: seatProg,
3206
+ solidProgram: solidProg,
1132
3207
  seatGeometry: seatGeo,
1133
3208
  solidGeometry: solidGeo,
1134
3209
  drawCalls: 3,
1135
3210
  uploadSeatStateRuns(runs) {
1136
3211
  if (!runs.length) return;
1137
- for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length);
3212
+ for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
1138
3213
  const buffer = colorAttr.buffer;
1139
3214
  if (!buffer) {
1140
3215
  colorAttr.needsUpdate = true;
@@ -1202,6 +3277,8 @@ var PickPipeline = class {
1202
3277
  this.seatScene = new import_ogl5.Transform();
1203
3278
  this.solidScene = new import_ogl5.Transform();
1204
3279
  this.target = null;
3280
+ /** Display clear colour to restore after the pick pass (theme-dependent). */
3281
+ this.restoreClear = [0, 0, 0];
1205
3282
  this.renderer = renderer;
1206
3283
  this.gl = renderer.gl;
1207
3284
  this.maxIndex = seatCount;
@@ -1214,6 +3291,10 @@ var PickPipeline = class {
1214
3291
  solidMesh.frustumCulled = false;
1215
3292
  solidMesh.setParent(this.solidScene);
1216
3293
  }
3294
+ /** Follow the theme's background when the scene is (re)built. */
3295
+ setRestoreClear(rgb) {
3296
+ this.restoreClear = [rgb[0], rgb[1], rgb[2]];
3297
+ }
1217
3298
  /** Match the display seat sizing so the pick mask lines up with the dots. */
1218
3299
  syncFromSeatProgram(seatProgram) {
1219
3300
  for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
@@ -1254,7 +3335,7 @@ var PickPipeline = class {
1254
3335
  const boxH = Math.max(1, Math.min(bh, py + radius + 1) - y0);
1255
3336
  gl.enable(gl.SCISSOR_TEST);
1256
3337
  gl.scissor(x0, y0, boxW, boxH);
1257
- const [br, bg, bb] = BACKGROUND.top;
3338
+ const [br, bg, bb] = this.restoreClear;
1258
3339
  gl.clearColor(0, 0, 0, 1);
1259
3340
  this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
1260
3341
  this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
@@ -1682,7 +3763,7 @@ var Analytics3D = class {
1682
3763
 
1683
3764
  // src/view3d/index.ts
1684
3765
  var SEAT_EYE_ABOVE_DECK = 1.02;
1685
- var DEG2 = Math.PI / 180;
3766
+ var DEG3 = Math.PI / 180;
1686
3767
  var TAP_SLOP = 6;
1687
3768
  var TAP_MS = 500;
1688
3769
  function mountVenue3D(container, input, opts = {}) {
@@ -1707,9 +3788,15 @@ function mountVenue3D(container, input, opts = {}) {
1707
3788
  const prefetch = /* @__PURE__ */ new Map();
1708
3789
  let flightGen = 0;
1709
3790
  let reducedForced = null;
3791
+ let focusedFloor = -1;
1710
3792
  const rebuildGpu = () => {
1711
3793
  gpu = buildGpuScene(glctx.gl, model);
1712
3794
  pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
3795
+ glctx.setClearColor(model.theme.background.top);
3796
+ pick.setRestoreClear(model.theme.background.top);
3797
+ gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
3798
+ gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
3799
+ gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
1713
3800
  };
1714
3801
  const glctx = new GLContext(container, {
1715
3802
  onContextLost: () => {
@@ -1732,8 +3819,21 @@ function mountVenue3D(container, input, opts = {}) {
1732
3819
  // first real drag/wheel/pinch (not the intro ease)
1733
3820
  );
1734
3821
  orbit.setAspect(glctx.aspect);
1735
- orbit.frame(model.bounds, true);
3822
+ const stageAzimuth = (() => {
3823
+ const dx = model.focalWorld[0] - model.bounds.center[0];
3824
+ const dz = model.focalWorld[2] - model.bounds.center[2];
3825
+ return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
3826
+ })();
3827
+ orbit.frame(model.bounds, true, stageAzimuth);
1736
3828
  const cinematic = new Cinematic(orbit.camera);
3829
+ if (!container.style.position || container.style.position === "static") {
3830
+ container.style.position = "relative";
3831
+ }
3832
+ const labelOverlay = new LabelOverlay(container, {
3833
+ fontFamily: input.doc.theme?.fontFamily,
3834
+ ink: input.doc.theme?.textColor
3835
+ });
3836
+ labelOverlay.setLabels(model.labels);
1737
3837
  rebuildGpu();
1738
3838
  const loop = new RenderLoop(() => {
1739
3839
  if (contextLost || !gpu || frozen) return false;
@@ -1743,9 +3843,16 @@ function mountVenue3D(container, input, opts = {}) {
1743
3843
  const u = gpu.seatProgram.uniforms;
1744
3844
  u.uSeatScale.value = lod.scale;
1745
3845
  u.uSeatFade.value = lod.fade;
1746
- u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
3846
+ u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
1747
3847
  glctx.renderer.render({ scene: gpu.background, clear: true });
1748
3848
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
3849
+ labelOverlay.update(
3850
+ orbit.camera.projectionViewMatrix,
3851
+ glctx.canvas.clientWidth || 1,
3852
+ glctx.canvas.clientHeight || 1,
3853
+ orbit.currentDistance,
3854
+ model.bounds.radius
3855
+ );
1749
3856
  return moving;
1750
3857
  });
1751
3858
  const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
@@ -1793,6 +3900,72 @@ function mountVenue3D(container, input, opts = {}) {
1793
3900
  orbit.camera.fov = FOV_END;
1794
3901
  orbit.camera.updateProjectionMatrix();
1795
3902
  };
3903
+ let arriveChip = null;
3904
+ const removeArriveChip = () => {
3905
+ arriveChip?.remove();
3906
+ arriveChip = null;
3907
+ };
3908
+ const showArriveChip = (seatId, gen) => {
3909
+ removeArriveChip();
3910
+ if (disposed || !opts.getSeatView) return;
3911
+ const chip = document.createElement("button");
3912
+ chip.type = "button";
3913
+ chip.textContent = "\u25C9 View in 360\xB0";
3914
+ chip.setAttribute("aria-label", `Open the 360\xB0 view from seat ${seatId}`);
3915
+ Object.assign(chip.style, {
3916
+ position: "absolute",
3917
+ left: "50%",
3918
+ bottom: "18px",
3919
+ transform: "translateX(-50%)",
3920
+ minHeight: "44px",
3921
+ padding: "10px 18px",
3922
+ borderRadius: "999px",
3923
+ background: "rgba(12,18,32,0.78)",
3924
+ color: "#eef1f8",
3925
+ border: "1px solid rgba(150,165,205,0.4)",
3926
+ backdropFilter: "blur(6px)",
3927
+ font: "600 13px/1 inherit",
3928
+ cursor: "pointer",
3929
+ zIndex: "4"
3930
+ });
3931
+ chip.addEventListener("click", () => {
3932
+ if (disposed || gen !== flightGen) {
3933
+ removeArriveChip();
3934
+ return;
3935
+ }
3936
+ removeArriveChip();
3937
+ void openPanorama(seatId, 400, gen);
3938
+ });
3939
+ container.appendChild(chip);
3940
+ arriveChip = chip;
3941
+ };
3942
+ const overviewChip = document.createElement("button");
3943
+ overviewChip.type = "button";
3944
+ overviewChip.textContent = "\u2302 Overview";
3945
+ overviewChip.setAttribute("aria-label", "Return to the venue overview");
3946
+ Object.assign(overviewChip.style, {
3947
+ position: "absolute",
3948
+ right: "14px",
3949
+ bottom: "18px",
3950
+ minHeight: "40px",
3951
+ padding: "8px 14px",
3952
+ borderRadius: "999px",
3953
+ background: "rgba(12,18,32,0.72)",
3954
+ color: "#c9d4ea",
3955
+ border: "1px solid rgba(150,165,205,0.35)",
3956
+ backdropFilter: "blur(6px)",
3957
+ font: "600 12.5px/1 inherit",
3958
+ cursor: "pointer",
3959
+ zIndex: "4"
3960
+ });
3961
+ overviewChip.addEventListener("click", () => {
3962
+ if (disposed || frozen) return;
3963
+ cancelFlight();
3964
+ removeArriveChip();
3965
+ orbit.frameSoft(model.bounds, stageAzimuth);
3966
+ loop.requestRender();
3967
+ });
3968
+ container.appendChild(overviewChip);
1796
3969
  const openPanorama = async (seatId, fadeMs, gen) => {
1797
3970
  const viewPromise = ensureSeatView(seatId);
1798
3971
  if (!viewPromise) {
@@ -1813,6 +3986,7 @@ function mountVenue3D(container, input, opts = {}) {
1813
3986
  return;
1814
3987
  }
1815
3988
  if (disposed || gen !== flightGen) return;
3989
+ removeArriveChip();
1816
3990
  panorama = mountPanorama(container, view, {
1817
3991
  fadeMs,
1818
3992
  seatLabel: seatId,
@@ -1822,6 +3996,7 @@ function mountVenue3D(container, input, opts = {}) {
1822
3996
  analytics.panoramaClosed();
1823
3997
  orbit.resumeAfterFlight(model.focalWorld);
1824
3998
  loop.requestRender();
3999
+ showArriveChip(seatId, flightGen);
1825
4000
  }
1826
4001
  });
1827
4002
  analytics.panoramaOpened();
@@ -1843,6 +4018,7 @@ function mountVenue3D(container, input, opts = {}) {
1843
4018
  }
1844
4019
  frozen = false;
1845
4020
  const gen = ++flightGen;
4021
+ removeArriveChip();
1846
4022
  const seatEye = seatEyeWorld(idx);
1847
4023
  const focal = model.focalWorld;
1848
4024
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
@@ -1851,9 +4027,9 @@ function mountVenue3D(container, input, opts = {}) {
1851
4027
  placeCameraFinal(finalPos, focal);
1852
4028
  loop.requestRender();
1853
4029
  analytics.cinematicSkipped();
1854
- return openPanorama(seatId, 300, gen).then(() => {
1855
- if (!disposed) orbit.syncFromCamera();
1856
- });
4030
+ if (!disposed) orbit.syncFromCamera();
4031
+ showArriveChip(seatId, gen);
4032
+ return Promise.resolve();
1857
4033
  }
1858
4034
  const startQuat = new import_ogl7.Quat().copy(orbit.camera.quaternion);
1859
4035
  const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
@@ -1861,7 +4037,9 @@ function mountVenue3D(container, input, opts = {}) {
1861
4037
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {
1862
4038
  if (disposed || gen !== flightGen) return;
1863
4039
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
1864
- return openPanorama(seatId, 400, gen);
4040
+ orbit.resumeAfterFlight(model.focalWorld);
4041
+ loop.requestRender();
4042
+ showArriveChip(seatId, gen);
1865
4043
  });
1866
4044
  };
1867
4045
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
@@ -1937,13 +4115,51 @@ function mountVenue3D(container, input, opts = {}) {
1937
4115
  loseContextForTest() {
1938
4116
  glctx.simulateContextLossCycle();
1939
4117
  },
4118
+ floors() {
4119
+ return model.floors;
4120
+ },
4121
+ focusFloor(index) {
4122
+ if (index !== null && !model.floors.some((f) => f.index === index)) return false;
4123
+ const value = index ?? -1;
4124
+ if (gpu) {
4125
+ gpu.seatProgram.uniforms.uFocusFloor.value = value;
4126
+ gpu.solidProgram.uniforms.uFocusFloor.value = value;
4127
+ }
4128
+ focusedFloor = value;
4129
+ if (index !== null) {
4130
+ const f = model.floors[index];
4131
+ if (f.seatCount > 0) {
4132
+ cinematic.cancel();
4133
+ orbit.frame({ center: f.center, radius: f.radius * 1.25 });
4134
+ }
4135
+ }
4136
+ loop.requestRender();
4137
+ return true;
4138
+ },
4139
+ zones() {
4140
+ return model.zones;
4141
+ },
4142
+ focusZone(zoneId) {
4143
+ const zone = model.zones.find((z) => z.id === zoneId);
4144
+ if (!zone || zone.seatCount === 0) return false;
4145
+ cinematic.cancel();
4146
+ const dx = zone.focalWorld[0] - zone.center[0];
4147
+ const dz = zone.focalWorld[2] - zone.center[2];
4148
+ const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
4149
+ orbit.frame({ center: zone.center, radius: zone.radius * 1.25 }, false, azimuth);
4150
+ loop.requestRender();
4151
+ return true;
4152
+ },
1940
4153
  setReducedMotionForTest(value) {
1941
4154
  reducedForced = value;
1942
4155
  },
1943
4156
  dispose() {
1944
4157
  disposed = true;
4158
+ removeArriveChip();
4159
+ overviewChip.remove();
1945
4160
  cancelFlight();
1946
4161
  loop.stop();
4162
+ labelOverlay.dispose();
1947
4163
  ro?.disconnect();
1948
4164
  if (panorama) {
1949
4165
  panorama.dispose();