@seatlayer/core 0.50.2 → 0.52.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.
Files changed (40) hide show
  1. package/dist/{chunk-GD267BFB.js → chunk-UID7KN44.js} +75 -2
  2. package/dist/chunk-UID7KN44.js.map +1 -0
  3. package/dist/core/seatConfidence.d.cts +1 -1
  4. package/dist/core/seatConfidence.d.ts +1 -1
  5. package/dist/de-YGODYJ3E.js +292 -0
  6. package/dist/de-YGODYJ3E.js.map +1 -0
  7. package/dist/es-VMBTGVFQ.js +292 -0
  8. package/dist/es-VMBTGVFQ.js.map +1 -0
  9. package/dist/fr-SLBE2ULB.js +292 -0
  10. package/dist/fr-SLBE2ULB.js.map +1 -0
  11. package/dist/index.cjs +1606 -64
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +138 -17
  14. package/dist/index.d.ts +138 -17
  15. package/dist/index.js +829 -65
  16. package/dist/index.js.map +1 -1
  17. package/dist/picker/minimapGeometry.cjs +80 -0
  18. package/dist/picker/minimapGeometry.cjs.map +1 -0
  19. package/dist/picker/minimapGeometry.d.cts +37 -0
  20. package/dist/picker/minimapGeometry.d.ts +37 -0
  21. package/dist/picker/minimapGeometry.js +52 -0
  22. package/dist/picker/minimapGeometry.js.map +1 -0
  23. package/dist/{types-DR54nqKL.d.cts → types-tLOunI-B.d.cts} +131 -1
  24. package/dist/{types-DR54nqKL.d.ts → types-tLOunI-B.d.ts} +131 -1
  25. package/dist/view3d/crossfade/panorama.d.cts +1 -1
  26. package/dist/view3d/crossfade/panorama.d.ts +1 -1
  27. package/dist/view3d/index.cjs +1158 -123
  28. package/dist/view3d/index.cjs.map +1 -1
  29. package/dist/view3d/index.d.cts +20 -12
  30. package/dist/view3d/index.d.ts +20 -12
  31. package/dist/view3d/index.js +1158 -122
  32. package/dist/view3d/index.js.map +1 -1
  33. package/package.json +14 -1
  34. package/dist/chunk-GD267BFB.js.map +0 -1
  35. package/dist/de-ST4H423D.js +0 -56
  36. package/dist/de-ST4H423D.js.map +0 -1
  37. package/dist/es-WEC5NHRT.js +0 -56
  38. package/dist/es-WEC5NHRT.js.map +0 -1
  39. package/dist/fr-SGBFNAQV.js +0 -56
  40. package/dist/fr-SGBFNAQV.js.map +0 -1
@@ -4,10 +4,11 @@ import {
4
4
  SEATED_EYE_HEIGHT_M,
5
5
  accessibilityRingColor,
6
6
  distanceToPolyline,
7
+ fitCircle,
7
8
  pointInPolygonWithHoles,
8
9
  resolveSection,
9
10
  sectionGeometry
10
- } from "../chunk-GD267BFB.js";
11
+ } from "../chunk-UID7KN44.js";
11
12
  import {
12
13
  MAX_PITCH_DEG,
13
14
  VFOV_DEG,
@@ -455,7 +456,7 @@ var OrbitCamera = class {
455
456
  * then the damped `update()` eases it up into the 3/4 architectural angle and
456
457
  * dollies in — the venue "stands up" instead of teleporting (~600ms).
457
458
  */
458
- frame(bounds, intro = false, stageAzimuth, portraitCrop = false) {
459
+ frame(bounds, intro = false, stageAzimuth, portraitCrop = false, framing) {
459
460
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
460
461
  this.targetT.copy(this.target);
461
462
  this.panAnchor.copy(this.target);
@@ -465,9 +466,9 @@ var OrbitCamera = class {
465
466
  const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
466
467
  const halfH = Math.atan(Math.tan(halfV) * aspect);
467
468
  this.azT = stageAzimuth ?? -30 * DEG;
468
- this.polT = 55 * DEG;
469
+ this.polT = Math.max(15, Math.min(80, framing?.polarDeg ?? 55)) * DEG;
469
470
  const fit = this.fitDistance(bounds, halfV, halfH, this.azT, this.polT);
470
- this.distT = fit * FRAME_MARGIN;
471
+ this.distT = fit * Math.max(1.05, Math.min(2, framing?.margin ?? FRAME_MARGIN));
471
472
  this.minDist = Math.max(2, r * 0.12);
472
473
  this.maxDist = fit * 4;
473
474
  if (intro) {
@@ -489,7 +490,7 @@ var OrbitCamera = class {
489
490
  * seat, behind the shell, anywhere): re-pivot on the venue centre without
490
491
  * moving the camera, then glide targets back to the 3/4 architectural pose.
491
492
  */
492
- frameSoft(bounds, stageAzimuth, portraitCrop = false) {
493
+ frameSoft(bounds, stageAzimuth, portraitCrop = false, framing) {
493
494
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
494
495
  this.targetT.copy(this.target);
495
496
  this.panAnchor.copy(this.target);
@@ -500,9 +501,9 @@ var OrbitCamera = class {
500
501
  const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
501
502
  const halfH = Math.atan(Math.tan(halfV) * aspect);
502
503
  this.azT = stageAzimuth ?? this.azimuth;
503
- this.polT = 55 * DEG;
504
+ this.polT = Math.max(15, Math.min(80, framing?.polarDeg ?? 55)) * DEG;
504
505
  const fit = this.fitDistance(bounds, halfV, halfH, this.azT, this.polT);
505
- this.distT = fit * FRAME_MARGIN;
506
+ this.distT = fit * Math.max(1.05, Math.min(2, framing?.margin ?? FRAME_MARGIN));
506
507
  }
507
508
  /**
508
509
  * Distance from the bounds centre at which the venue fits the frustum at
@@ -518,14 +519,14 @@ var OrbitCamera = class {
518
519
  * every corner does. Clamped to the sphere fit (which always contains the
519
520
  * box) and floored so a degenerate box cannot glue the camera to the floor.
520
521
  */
521
- fitDistance(bounds, halfV, halfH, azimuth, polar) {
522
+ fitDistance(bounds, halfV, halfH, azimuth, polar2) {
522
523
  const r = Math.max(1, bounds.radius);
523
524
  const sphereFit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
524
525
  const box = bounds.box;
525
526
  if (!box) return sphereFit;
526
- const sp = Math.sin(polar);
527
+ const sp = Math.sin(polar2);
527
528
  const fx = -sp * Math.sin(azimuth);
528
- const fy = -Math.cos(polar);
529
+ const fy = -Math.cos(polar2);
529
530
  const fz = -sp * Math.cos(azimuth);
530
531
  if (Math.abs(fy) > 0.999) return sphereFit;
531
532
  let rx = -fz, rz = fx;
@@ -602,9 +603,9 @@ var OrbitCamera = class {
602
603
  const dy = this.camera.position.y - this.target.y;
603
604
  const dz = this.camera.position.z - this.target.z;
604
605
  const dist = Math.hypot(dx, dy, dz) || 1;
605
- const polar = Math.max(POLAR_MIN, Math.min(POLAR_MAX, Math.acos(Math.max(-1, Math.min(1, dy / dist)))));
606
+ const polar2 = Math.max(POLAR_MIN, Math.min(POLAR_MAX, Math.acos(Math.max(-1, Math.min(1, dy / dist)))));
606
607
  this.distance = this.distT = dist;
607
- this.polar = this.polT = polar;
608
+ this.polar = this.polT = polar2;
608
609
  this.azimuth = this.azT = Math.atan2(dx, dz);
609
610
  }
610
611
  /** Point the orbit pivot at a new world target without moving the camera. */
@@ -710,6 +711,20 @@ function computeSeatLod(distance, radius) {
710
711
  minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
711
712
  };
712
713
  }
714
+ function floorOverviewSeatDetailVisible(seatCount, viewportWidth, viewportHeight) {
715
+ if (!(seatCount > 0) || !(viewportWidth > 0) || !(viewportHeight > 0)) return false;
716
+ const pixelsPerSeat = viewportWidth * viewportHeight / seatCount;
717
+ return pixelsPerSeat >= 64;
718
+ }
719
+ function projectedSeatDetailVisible(seatCount, venueRadius, cameraDistance, fovYDeg, viewportHeightPx) {
720
+ if (!(seatCount > 0) || !(venueRadius > 0) || !(cameraDistance > 0)) return false;
721
+ if (!(fovYDeg > 0) || !(viewportHeightPx > 0)) return false;
722
+ const halfFov = Math.tan(fovYDeg / 2 * Math.PI / 180);
723
+ if (!(halfFov > 0)) return false;
724
+ const rPx = venueRadius / (cameraDistance * halfFov) * (viewportHeightPx / 2);
725
+ const projectedArea = Math.PI * rPx * rPx;
726
+ return projectedArea / seatCount >= 64;
727
+ }
713
728
 
714
729
  // src/view3d/scene/nearField.ts
715
730
  var SEATS_PER_CELL = 4;
@@ -1661,17 +1676,45 @@ function themeSeatColorLUT(theme, order) {
1661
1676
  }
1662
1677
 
1663
1678
  // src/view3d/labels.ts
1679
+ function labelsForFloorView(labels, floorCount, focusedFloor) {
1680
+ if (focusedFloor < 0) {
1681
+ return floorCount > 1 ? labels.filter((label) => label.kind === "floor") : [...labels];
1682
+ }
1683
+ return labels.filter((label) => label.floorIndex === void 0 || label.floorIndex === focusedFloor);
1684
+ }
1685
+ function spreadFloorLabels(items, viewportWidth) {
1686
+ const ordered = [...items].sort((left, right) => left.screen.y - right.screen.y);
1687
+ const clusters = [];
1688
+ for (const item of ordered) {
1689
+ const cluster = clusters.find((candidate) => candidate.some((member) => Math.abs(member.screen.x - item.screen.x) < 104 && Math.abs(member.screen.y - item.screen.y) < 34));
1690
+ if (cluster) cluster.push(item);
1691
+ else clusters.push([item]);
1692
+ }
1693
+ return clusters.flatMap((cluster) => {
1694
+ if (cluster.length < 2) return cluster;
1695
+ return cluster.map((item, index) => ({
1696
+ ...item,
1697
+ screen: {
1698
+ ...item.screen,
1699
+ x: Math.max(54, Math.min(viewportWidth - 54, item.screen.x + (index - (cluster.length - 1) / 2) * 104))
1700
+ }
1701
+ }));
1702
+ });
1703
+ }
1664
1704
  var ZONE_MIN_DISTANCE = 1.15;
1665
- var SECTION_MAX_DISTANCE = 2.2;
1705
+ var SECTION_MAX_DISTANCE = 3.25;
1706
+ var SECTION_MAX_DISTANCE_NO_ZONES = 4.5;
1666
1707
  var NEAR_MAX_DISTANCE = 0.85;
1667
1708
  var ROW_MAX_DISTANCE = 0.5;
1668
1709
  var SEAT_MAX_DISTANCE = 0.26;
1669
- function visibleLabelKinds(distance, venueRadius) {
1710
+ function visibleLabelKinds(distance, venueRadius, opts) {
1670
1711
  const r = Math.max(1e-6, venueRadius);
1671
1712
  const d = distance / r;
1713
+ const hasZones = opts?.hasZoneLabels ?? true;
1672
1714
  const out = /* @__PURE__ */ new Set();
1715
+ if (d >= 0.75) out.add("floor");
1673
1716
  if (d >= ZONE_MIN_DISTANCE) out.add("zone");
1674
- if (d <= SECTION_MAX_DISTANCE) {
1717
+ if (d <= (hasZones ? SECTION_MAX_DISTANCE : SECTION_MAX_DISTANCE_NO_ZONES)) {
1675
1718
  out.add("section");
1676
1719
  out.add("ga");
1677
1720
  }
@@ -2455,7 +2498,7 @@ function pointInRing2(ring, x, y) {
2455
2498
  return inside;
2456
2499
  }
2457
2500
  var MAX_TIER_RISE_M = 25;
2458
- function buildVenueSurfaces(units, seats) {
2501
+ function buildVenueSurfaces(units, seats, seatFloorIndices) {
2459
2502
  const bySection = /* @__PURE__ */ new Map();
2460
2503
  const seatOwner = new Array(seats.length).fill(null);
2461
2504
  const seatDeck = new Float64Array(seats.length);
@@ -2464,18 +2507,20 @@ function buildVenueSurfaces(units, seats) {
2464
2507
  const acc = /* @__PURE__ */ new Map();
2465
2508
  const boxes = [];
2466
2509
  const tableRowIds = /* @__PURE__ */ new Set();
2467
- for (const unit of units) {
2510
+ for (let floorIndex = 0; floorIndex < units.length; floorIndex += 1) {
2511
+ const unit = units[floorIndex];
2468
2512
  for (const o of unit.objects) {
2469
2513
  if (o.type === "table") tableRowIds.add(o.id);
2470
2514
  if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2471
2515
  const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2472
2516
  acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2473
- boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2517
+ boxes.push({ id: o.id, floorIndex, box: bboxOf(owned), section: o, unit, outline: owned });
2474
2518
  }
2475
2519
  }
2476
2520
  for (let i = 0; i < seats.length; i++) {
2477
2521
  const s = seats[i];
2478
2522
  for (const b of boxes) {
2523
+ if (seatFloorIndices && b.floorIndex !== seatFloorIndices[i]) continue;
2479
2524
  if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2480
2525
  if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2481
2526
  seatOwner[i] = b.id;
@@ -2633,6 +2678,634 @@ function buildVenueSurfaces(units, seats) {
2633
2678
  };
2634
2679
  }
2635
2680
 
2681
+ // src/core/bowlProfile.ts
2682
+ var DEG2 = Math.PI / 180;
2683
+ var BIN_COUNT = 96;
2684
+ var GAP_FILL_BINS = 6;
2685
+ var MIN_ARC_DEG = 60;
2686
+ var MIN_RING_DEG = 150;
2687
+ var MIN_HOLE_RATIO = 0.2;
2688
+ var RING_OVERLAP_RATIO = 0.35;
2689
+ function bearingDeg(c, p) {
2690
+ const d = Math.atan2(p.x - c.x, p.y - c.y) / DEG2;
2691
+ return d < 0 ? d + 360 : d;
2692
+ }
2693
+ function median(values) {
2694
+ if (!values.length) return NaN;
2695
+ const s = [...values].sort((a, b) => a - b);
2696
+ return s[Math.floor(s.length / 2)];
2697
+ }
2698
+ function smoothRun(values, wrap, window2 = 2) {
2699
+ const n = values.length;
2700
+ const out = new Float64Array(n);
2701
+ for (let i = 0; i < n; i++) {
2702
+ let sum = 0, cnt = 0;
2703
+ for (let k = -window2; k <= window2; k++) {
2704
+ let j = i + k;
2705
+ if (wrap) j = (j % n + n) % n;
2706
+ else if (j < 0 || j >= n) continue;
2707
+ sum += values[j];
2708
+ cnt++;
2709
+ }
2710
+ out[i] = sum / cnt;
2711
+ }
2712
+ return out;
2713
+ }
2714
+ function fitBowlProfile(sections, focal) {
2715
+ const usable = sections.filter((s) => s.rows.length >= 1);
2716
+ if (usable.length < 1) return null;
2717
+ const centres = [];
2718
+ let cx = 0, cy = 0, np = 0;
2719
+ for (const s of usable) {
2720
+ for (const row of s.rows) {
2721
+ for (const p of row.pts) {
2722
+ cx += p.x;
2723
+ cy += p.y;
2724
+ np++;
2725
+ }
2726
+ if (row.pts.length >= 4) {
2727
+ const c = fitCircle(row.pts);
2728
+ if (c && Number.isFinite(c.cx) && Number.isFinite(c.cy)) centres.push({ x: c.cx, y: c.cy });
2729
+ }
2730
+ }
2731
+ }
2732
+ if (!np) return null;
2733
+ const centroid2 = { x: cx / np, y: cy / np };
2734
+ let centre = centroid2;
2735
+ if (centres.length >= 8) {
2736
+ const mx = median(centres.map((c) => c.x));
2737
+ const my = median(centres.map((c) => c.y));
2738
+ let spread = 0;
2739
+ for (const s of usable) for (const row of s.rows) for (const p of row.pts) {
2740
+ const d = Math.hypot(p.x - centroid2.x, p.y - centroid2.y);
2741
+ if (d > spread) spread = d;
2742
+ }
2743
+ let near = 0;
2744
+ const nearR = spread * 0.2;
2745
+ for (const s of usable) for (const row of s.rows) for (const p of row.pts) {
2746
+ if (Math.hypot(p.x - mx, p.y - my) < nearR) near++;
2747
+ }
2748
+ if (Math.hypot(mx - centroid2.x, my - centroid2.y) <= spread && near / np < 0.01) centre = { x: mx, y: my };
2749
+ }
2750
+ const stats = [];
2751
+ for (const s of usable) {
2752
+ const st = { sectionId: s.sectionId, rMin: Infinity, rMax: -Infinity, samples: [] };
2753
+ for (const row of s.rows) {
2754
+ for (const p of row.pts) {
2755
+ const r = Math.hypot(p.x - centre.x, p.y - centre.y);
2756
+ const bin = Math.min(BIN_COUNT - 1, Math.floor(bearingDeg(centre, p) / (360 / BIN_COUNT)));
2757
+ st.samples.push({ r, bin, levelM: row.levelM });
2758
+ if (r < st.rMin) st.rMin = r;
2759
+ if (r > st.rMax) st.rMax = r;
2760
+ }
2761
+ }
2762
+ if (st.samples.length) stats.push(st);
2763
+ }
2764
+ if (!stats.length) return null;
2765
+ stats.sort((a, b) => a.rMin + a.rMax - (b.rMin + b.rMax));
2766
+ const ringAccs = [];
2767
+ for (const st of stats) {
2768
+ let joined = false;
2769
+ for (const ring of ringAccs) {
2770
+ const overlap = Math.min(st.rMax, ring.rMax) - Math.max(st.rMin, ring.rMin);
2771
+ const thinner = Math.min(st.rMax - st.rMin, ring.rMax - ring.rMin);
2772
+ if (thinner > 1e-9 && overlap >= RING_OVERLAP_RATIO * thinner) {
2773
+ ring.members.push(st);
2774
+ ring.rMin = Math.min(ring.rMin, st.rMin);
2775
+ ring.rMax = Math.max(ring.rMax, st.rMax);
2776
+ joined = true;
2777
+ break;
2778
+ }
2779
+ }
2780
+ if (!joined) ringAccs.push({ members: [st], rMin: st.rMin, rMax: st.rMax });
2781
+ }
2782
+ const rings = [];
2783
+ for (const ring of ringAccs) {
2784
+ const bins = new Array(BIN_COUNT).fill(null);
2785
+ for (const st of ring.members) {
2786
+ for (const s of st.samples) {
2787
+ let b = bins[s.bin];
2788
+ if (!b) {
2789
+ b = { samples: [] };
2790
+ bins[s.bin] = b;
2791
+ }
2792
+ b.samples.push({ r: s.r, levelM: s.levelM });
2793
+ }
2794
+ }
2795
+ const covered = bins.map((b) => !!b);
2796
+ const coveredCount = covered.filter(Boolean).length;
2797
+ if (!coveredCount) continue;
2798
+ const innerSeed = [];
2799
+ const outerSeed = [];
2800
+ for (const b of bins) {
2801
+ if (!b) continue;
2802
+ let lo = Infinity, hi = -Infinity;
2803
+ for (const s of b.samples) {
2804
+ if (s.r < lo) lo = s.r;
2805
+ if (s.r > hi) hi = s.r;
2806
+ }
2807
+ innerSeed.push(lo);
2808
+ outerSeed.push(hi);
2809
+ }
2810
+ const innerRMedian = median(innerSeed);
2811
+ const outerRMedian = median(outerSeed);
2812
+ if (!(innerRMedian >= MIN_HOLE_RATIO * outerRMedian) || !(outerRMedian > 0)) continue;
2813
+ const sortedInner = [...innerSeed].sort((a, b) => a - b);
2814
+ const innerP10 = sortedInner[Math.floor(sortedInner.length * 0.1)];
2815
+ if (!(innerP10 >= MIN_HOLE_RATIO * outerRMedian)) continue;
2816
+ const vals = bins.map((b) => {
2817
+ if (!b) return null;
2818
+ const sorted = [...b.samples].sort((u, v) => u.r - v.r);
2819
+ const inner = sorted[0].r;
2820
+ const outer = sorted[sorted.length - 1].r;
2821
+ const front = sorted[0].levelM;
2822
+ let top = -Infinity;
2823
+ for (const s of sorted) if (s.levelM > top) top = s.levelM;
2824
+ const hull = [];
2825
+ for (const s of sorted) {
2826
+ const pt = { r: s.r, l: s.levelM };
2827
+ while (hull.length && Math.abs(hull[hull.length - 1].r - pt.r) < 1e-9) {
2828
+ if (hull[hull.length - 1].l <= pt.l) {
2829
+ pt.skip = true;
2830
+ break;
2831
+ }
2832
+ hull.pop();
2833
+ }
2834
+ if (pt.skip) continue;
2835
+ while (hull.length >= 2) {
2836
+ const a = hull[hull.length - 2], b2 = hull[hull.length - 1];
2837
+ if ((b2.r - a.r) * (pt.l - a.l) - (b2.l - a.l) * (pt.r - a.r) <= 0) hull.pop();
2838
+ else break;
2839
+ }
2840
+ hull.push(pt);
2841
+ }
2842
+ const rs = hull.map((p) => p.r);
2843
+ const ls = hull.map((p) => p.l);
2844
+ return { inner, outer, front, top, profile: { rs, ls } };
2845
+ });
2846
+ const filled = covered.slice();
2847
+ for (let i = 0; i < BIN_COUNT; i++) {
2848
+ if (filled[i]) continue;
2849
+ let before = -1, after = -1, gap = 0;
2850
+ for (let k = 1; k <= BIN_COUNT; k++) {
2851
+ const j = (i - k + BIN_COUNT * 2) % BIN_COUNT;
2852
+ if (filled[j]) {
2853
+ before = j;
2854
+ break;
2855
+ }
2856
+ }
2857
+ for (let k = 1; k <= BIN_COUNT; k++) {
2858
+ const j = (i + k) % BIN_COUNT;
2859
+ if (filled[j]) {
2860
+ after = j;
2861
+ gap = k;
2862
+ break;
2863
+ }
2864
+ }
2865
+ if (before < 0 || after < 0) continue;
2866
+ let back = 0;
2867
+ for (let k = 1; k <= BIN_COUNT; k++) {
2868
+ const j = (i - k + BIN_COUNT * 2) % BIN_COUNT;
2869
+ if (covered[j]) {
2870
+ back = k;
2871
+ break;
2872
+ }
2873
+ }
2874
+ const gapLen = back - 1 + gap;
2875
+ if (gapLen > GAP_FILL_BINS) continue;
2876
+ const a = vals[before], b = vals[after];
2877
+ const t = back / (back + gap);
2878
+ const inner = a.inner + (b.inner - a.inner) * t;
2879
+ const outer = a.outer + (b.outer - a.outer) * t;
2880
+ const front = a.front + (b.front - a.front) * t;
2881
+ const top = a.top + (b.top - a.top) * t;
2882
+ vals[i] = { inner, outer, front, top, profile: { rs: [inner, outer], ls: [front, top] } };
2883
+ }
2884
+ for (let i = 0; i < BIN_COUNT; i++) if (vals[i]) filled[i] = true;
2885
+ const stepDeg = 360 / BIN_COUNT;
2886
+ const arcs = [];
2887
+ const filledCount = filled.filter(Boolean).length;
2888
+ const runs = [];
2889
+ if (filledCount === BIN_COUNT) {
2890
+ runs.push({ start: 0, len: BIN_COUNT });
2891
+ } else {
2892
+ let scan = 0;
2893
+ while (filled[scan]) scan = (scan + 1) % BIN_COUNT;
2894
+ let runStart = -1;
2895
+ for (let k = 0; k <= BIN_COUNT; k++) {
2896
+ const j = (scan + k) % BIN_COUNT;
2897
+ const on = k < BIN_COUNT && filled[j];
2898
+ if (on && runStart < 0) runStart = j;
2899
+ else if (!on && runStart >= 0) {
2900
+ const len = (j - runStart + BIN_COUNT) % BIN_COUNT || BIN_COUNT;
2901
+ runs.push({ start: runStart, len });
2902
+ runStart = -1;
2903
+ }
2904
+ }
2905
+ }
2906
+ for (const run of runs) {
2907
+ if (run.len * stepDeg < MIN_ARC_DEG && run.len < BIN_COUNT) continue;
2908
+ const closed = run.len === BIN_COUNT;
2909
+ const innerR = new Float64Array(run.len);
2910
+ const outerR = new Float64Array(run.len);
2911
+ const frontL = new Float64Array(run.len);
2912
+ const topL = new Float64Array(run.len);
2913
+ const profiles = [];
2914
+ for (let k = 0; k < run.len; k++) {
2915
+ const v = vals[(run.start + k) % BIN_COUNT];
2916
+ innerR[k] = v.inner;
2917
+ outerR[k] = v.outer;
2918
+ frontL[k] = v.front;
2919
+ topL[k] = v.top;
2920
+ profiles.push(v.profile ?? { rs: [v.inner, v.outer], ls: [v.front, v.top] });
2921
+ }
2922
+ const sInner = smoothRun(innerR, closed);
2923
+ const sOuter = smoothRun(outerR, closed);
2924
+ const sFront = smoothRun(frontL, closed);
2925
+ const sTop = smoothRun(topL, closed);
2926
+ for (let k = 0; k < run.len; k++) {
2927
+ if (sInner[k] > innerR[k]) sInner[k] = innerR[k];
2928
+ if (sOuter[k] < outerR[k]) sOuter[k] = outerR[k];
2929
+ if (sInner[k] > sOuter[k] - 1e-6) sInner[k] = Math.max(0, sOuter[k] - 1e-6);
2930
+ if (sTop[k] < sFront[k]) sTop[k] = sFront[k];
2931
+ }
2932
+ const arc = { startBin: run.start, binCount: run.len, closed, innerR: sInner, outerR: sOuter, frontLevelM: sFront, topLevelM: sTop, profiles };
2933
+ const clampProfile = (k, r, levelM) => {
2934
+ const prof = profiles[k];
2935
+ const rs = prof.rs, ls = prof.ls;
2936
+ if (r <= rs[0]) {
2937
+ if (ls[0] > levelM) ls[0] = levelM;
2938
+ return;
2939
+ }
2940
+ if (r >= rs[rs.length - 1]) {
2941
+ if (ls[ls.length - 1] > levelM) ls[ls.length - 1] = levelM;
2942
+ return;
2943
+ }
2944
+ for (let j = 0; j + 1 < rs.length; j++) {
2945
+ if (r <= rs[j + 1]) {
2946
+ if (ls[j] > levelM) ls[j] = levelM;
2947
+ if (ls[j + 1] > levelM) ls[j + 1] = levelM;
2948
+ return;
2949
+ }
2950
+ }
2951
+ };
2952
+ for (let k = 0; k < run.len; k++) {
2953
+ const b = bins[(run.start + k) % BIN_COUNT];
2954
+ if (!b) continue;
2955
+ for (const s of b.samples) {
2956
+ clampProfile(k, s.r, s.levelM);
2957
+ const prev = closed ? (k - 1 + run.len) % run.len : k - 1;
2958
+ const next = closed ? (k + 1) % run.len : k + 1;
2959
+ if (prev >= 0) clampProfile(prev, s.r, s.levelM);
2960
+ if (next < run.len) clampProfile(next, s.r, s.levelM);
2961
+ }
2962
+ }
2963
+ arcs.push(arc);
2964
+ }
2965
+ const totalDeg = arcs.reduce((sum, a) => sum + a.binCount * stepDeg, 0);
2966
+ if (totalDeg < MIN_RING_DEG) continue;
2967
+ rings.push({
2968
+ arcs,
2969
+ sectionIds: ring.members.map((m) => m.sectionId),
2970
+ innerRMedian,
2971
+ outerRMedian
2972
+ });
2973
+ }
2974
+ if (!rings.length) return null;
2975
+ rings.sort((a, b) => a.innerRMedian - b.innerRMedian);
2976
+ if (focal) {
2977
+ const outermost = rings[rings.length - 1].outerRMedian;
2978
+ if (Math.hypot(focal.x - centre.x, focal.y - centre.y) > 0.9 * outermost) return null;
2979
+ }
2980
+ return { centre, binCount: BIN_COUNT, binStepDeg: 360 / BIN_COUNT, rings };
2981
+ }
2982
+ function arcLevelAt(fit, arc, bearing, r) {
2983
+ const step = fit.binStepDeg;
2984
+ let f = bearing / step - 0.5 - arc.startBin;
2985
+ f = (f % fit.binCount + fit.binCount) % fit.binCount;
2986
+ if (f > arc.binCount - 1) {
2987
+ if (arc.closed) f = f - arc.binCount < arc.binCount - f ? 0 : arc.binCount - 1;
2988
+ else f = f - (arc.binCount - 1) < fit.binCount - f ? arc.binCount - 1 : 0;
2989
+ }
2990
+ const i0 = Math.floor(f);
2991
+ const i1 = arc.closed ? (i0 + 1) % arc.binCount : Math.min(arc.binCount - 1, i0 + 1);
2992
+ const t = f - i0;
2993
+ const sample = (i) => {
2994
+ const prof = arc.profiles[i];
2995
+ const rs = prof.rs, ls = prof.ls;
2996
+ if (r <= rs[0]) return ls[0];
2997
+ if (r >= rs[rs.length - 1]) return ls[ls.length - 1];
2998
+ for (let k = 0; k + 1 < rs.length; k++) {
2999
+ if (r <= rs[k + 1]) {
3000
+ const span = rs[k + 1] - rs[k];
3001
+ const u = span > 1e-9 ? (r - rs[k]) / span : 0;
3002
+ return ls[k] + (ls[k + 1] - ls[k]) * u;
3003
+ }
3004
+ }
3005
+ return ls[ls.length - 1];
3006
+ };
3007
+ return sample(i0) * (1 - t) + sample(i1) * t;
3008
+ }
3009
+
3010
+ // src/view3d/scene/bowlShell.ts
3011
+ var BAND_RECESS_M = 0.25;
3012
+ var BAND_MIN_RELIEF_M = 1.2;
3013
+ var SHELL_GAP_M = 0.6;
3014
+ var SHELL_THICKNESS_M = 0.45;
3015
+ var SHELL_PARAPET_M = 2.3;
3016
+ var DEG3 = Math.PI / 180;
3017
+ function polar(c, r, thetaDeg) {
3018
+ const t = thetaDeg * DEG3;
3019
+ return { x: c.x + r * Math.sin(t), y: c.y + r * Math.cos(t) };
3020
+ }
3021
+ function hasAuthoredPerimeter(shapes, fit) {
3022
+ const outer = fit.rings[fit.rings.length - 1];
3023
+ if (!outer) return false;
3024
+ const threshold = outer.outerRMedian * 0.85;
3025
+ for (const s of shapes) {
3026
+ if (s.role !== "wall") continue;
3027
+ const pts = s.points && s.points.length >= 2 ? s.points : s.x !== void 0 && s.y !== void 0 && s.width !== void 0 && s.height !== void 0 ? [
3028
+ { x: s.x, y: s.y },
3029
+ { x: s.x + s.width, y: s.y },
3030
+ { x: s.x + s.width, y: s.y + s.height },
3031
+ { x: s.x, y: s.y + s.height }
3032
+ ] : [];
3033
+ if (pts.length < 2) continue;
3034
+ let beyond = 0;
3035
+ for (const p of pts) {
3036
+ if (Math.hypot(p.x - fit.centre.x, p.y - fit.centre.y) >= threshold) beyond++;
3037
+ }
3038
+ if (beyond >= pts.length / 2) return true;
3039
+ }
3040
+ return false;
3041
+ }
3042
+ var BAND_RADIAL_CELLS = 24;
3043
+ function emitBandGrid(builder, fit, arc, innerEdge, outerEdge, bottomY, colors, ao, seatGuards) {
3044
+ const K = arc.binCount;
3045
+ const M2 = BAND_RADIAL_CELLS;
3046
+ const U = METRES_PER_CHART_UNIT;
3047
+ const floorY = bottomY + 0.02;
3048
+ const h = [];
3049
+ for (let k = 0; k < K; k++) {
3050
+ const bearing = (arc.startBin + k + 0.5) * fit.binStepDeg;
3051
+ const row = new Float64Array(M2 + 1);
3052
+ for (let j = 0; j <= M2; j++) {
3053
+ const r = innerEdge[k] + (outerEdge[k] - innerEdge[k]) * j / M2;
3054
+ row[j] = Math.max(floorY, arcLevelAt(fit, arc, bearing, r) - BAND_RECESS_M);
3055
+ }
3056
+ h.push(row);
3057
+ }
3058
+ for (const g of seatGuards) {
3059
+ const bearing = bearingDeg(fit.centre, g);
3060
+ const r = Math.hypot(g.x - fit.centre.x, g.y - fit.centre.y);
3061
+ let f = bearing / fit.binStepDeg - 0.5 - arc.startBin;
3062
+ f = (f % fit.binCount + fit.binCount) % fit.binCount;
3063
+ if (f > K - 1 && !arc.closed) {
3064
+ if (f < K - 0.5 || f > fit.binCount - 0.5) {
3065
+ } else continue;
3066
+ }
3067
+ const cap = g.deckY - BAND_RECESS_M;
3068
+ const k0 = Math.min(K - 1, Math.max(0, Math.floor(f)));
3069
+ const kk = [k0, arc.closed ? (k0 + 1) % K : Math.min(K - 1, k0 + 1)];
3070
+ for (const k of kk) {
3071
+ const span = outerEdge[k] - innerEdge[k];
3072
+ if (!(span > 1e-9)) continue;
3073
+ const jf = (r - innerEdge[k]) / span * M2;
3074
+ if (jf < -0.75 || jf > M2 + 0.75) continue;
3075
+ const j0 = Math.min(M2, Math.max(0, Math.floor(jf)));
3076
+ const j1 = Math.min(M2, j0 + 1);
3077
+ if (h[k][j0] > cap) h[k][j0] = Math.max(floorY, cap);
3078
+ if (h[k][j1] > cap) h[k][j1] = Math.max(floorY, cap);
3079
+ }
3080
+ }
3081
+ const cTop = [colors.bandTop[0] * ao.top, colors.bandTop[1] * ao.top, colors.bandTop[2] * ao.top];
3082
+ const cBot = [colors.bandTop[0] * ao.bottomCap, colors.bandTop[1] * ao.bottomCap, colors.bandTop[2] * ao.bottomCap];
3083
+ const cWallTop = [colors.bandWall[0] * ao.top, colors.bandWall[1] * ao.top, colors.bandWall[2] * ao.top];
3084
+ const cWallBot = [colors.bandWall[0] * ao.wallBottom, colors.bandWall[1] * ao.wallBottom, colors.bandWall[2] * ao.wallBottom];
3085
+ const point = (k, j) => {
3086
+ const bearing = (arc.startBin + k + 0.5) * fit.binStepDeg;
3087
+ const r = innerEdge[k] + (outerEdge[k] - innerEdge[k]) * j / M2;
3088
+ const p = polar(fit.centre, r, bearing);
3089
+ return [p.x * U, h[k][j], p.y * U];
3090
+ };
3091
+ {
3092
+ const TAN_LIMIT = Math.tan(12 * Math.PI / 180);
3093
+ for (let pass = 0; pass < 6; pass++) {
3094
+ let changed = false;
3095
+ for (let k = 0; k < K; k++) {
3096
+ for (let j = M2 - 1; j >= 0; j--) {
3097
+ if (h[k][j] > h[k][j + 1]) {
3098
+ h[k][j] = h[k][j + 1];
3099
+ changed = true;
3100
+ }
3101
+ }
3102
+ }
3103
+ const lastPair = arc.closed ? K : K - 1;
3104
+ for (let k = 0; k < lastPair; k++) {
3105
+ const k1 = (k + 1) % K;
3106
+ for (let j = 0; j <= M2; j++) {
3107
+ const d = h[k1][j] - h[k][j];
3108
+ if (Math.abs(d) > 1) continue;
3109
+ const r0 = innerEdge[k] + (outerEdge[k] - innerEdge[k]) * j / M2;
3110
+ const r1 = innerEdge[k1] + (outerEdge[k1] - innerEdge[k1]) * j / M2;
3111
+ const b0 = polar(fit.centre, r0, (arc.startBin + k + 0.5) * fit.binStepDeg);
3112
+ const b1 = polar(fit.centre, r1, (arc.startBin + k1 + 0.5) * fit.binStepDeg);
3113
+ const lim = TAN_LIMIT * Math.hypot(b1.x - b0.x, b1.y - b0.y) * U;
3114
+ if (d > lim) {
3115
+ h[k1][j] = h[k][j] + lim;
3116
+ changed = true;
3117
+ } else if (-d > lim) {
3118
+ h[k][j] = h[k1][j] + lim;
3119
+ changed = true;
3120
+ }
3121
+ }
3122
+ }
3123
+ if (!changed) break;
3124
+ }
3125
+ }
3126
+ const JUMP_M = 1;
3127
+ const cliffBetween = (k, k1) => {
3128
+ for (let j = 0; j <= M2; j += 4) {
3129
+ if (Math.abs(h[k][j] - h[k1][j]) > JUMP_M) return true;
3130
+ }
3131
+ return Math.abs(h[k][M2] - h[k1][M2]) > JUMP_M;
3132
+ };
3133
+ const TAN_WALL = Math.tan(38 * Math.PI / 180);
3134
+ const radialCellM = (k) => (outerEdge[k] - innerEdge[k]) / M2 * U;
3135
+ const radialStep = (k, j) => h[k][j + 1] - h[k][j] > TAN_WALL * Math.max(1e-6, radialCellM(k));
3136
+ const vertexNormal = (k, j) => {
3137
+ let kPrev = arc.closed ? (k - 1 + K) % K : Math.max(0, k - 1);
3138
+ let kNext = arc.closed ? (k + 1) % K : Math.min(K - 1, k + 1);
3139
+ if (Math.abs(h[kPrev][j] - h[k][j]) > JUMP_M) kPrev = k;
3140
+ if (Math.abs(h[kNext][j] - h[k][j]) > JUMP_M) kNext = k;
3141
+ let jPrev = Math.max(0, j - 1);
3142
+ let jNext = Math.min(M2, j + 1);
3143
+ if (jPrev < j && radialStep(k, jPrev)) jPrev = j;
3144
+ if (jNext > j && radialStep(k, j)) jNext = j;
3145
+ const pk0 = point(kPrev, j), pk1 = point(kNext, j);
3146
+ const pj0 = point(k, jPrev), pj1 = point(k, jNext);
3147
+ const ux = pk1[0] - pk0[0], uy = pk1[1] - pk0[1], uz = pk1[2] - pk0[2];
3148
+ const vx = pj1[0] - pj0[0], vy = pj1[1] - pj0[1], vz = pj1[2] - pj0[2];
3149
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
3150
+ const len = Math.hypot(nx, ny, nz);
3151
+ if (!(len > 1e-12)) return [0, 1, 0];
3152
+ nx /= len;
3153
+ ny /= len;
3154
+ nz /= len;
3155
+ if (ny < 0) {
3156
+ nx = -nx;
3157
+ ny = -ny;
3158
+ nz = -nz;
3159
+ }
3160
+ return [nx, ny, nz];
3161
+ };
3162
+ const wallQuad = (a, b) => {
3163
+ const a0 = [a[0], bottomY, a[2]];
3164
+ const b0 = [b[0], bottomY, b[2]];
3165
+ const ex = b[0] - a[0], ez = b[2] - a[2];
3166
+ const len = Math.hypot(ex, ez);
3167
+ if (!(len > 1e-12)) return;
3168
+ const n = [ez / len, 0, -ex / len];
3169
+ builder.tri(a, b, b0, n, cWallTop, cWallTop, cWallBot);
3170
+ builder.tri(a, b0, a0, n, cWallTop, cWallBot, cWallBot);
3171
+ };
3172
+ const MIN_SPAN_U = 0.05 * CHART_UNITS_PER_METRE;
3173
+ const pinched = (k) => outerEdge[k] - innerEdge[k] < MIN_SPAN_U;
3174
+ const lastK = arc.closed ? K : K - 1;
3175
+ for (let k = 0; k < lastK; k++) {
3176
+ const k1 = (k + 1) % K;
3177
+ if (pinched(k) || pinched(k1)) continue;
3178
+ if (cliffBetween(k, k1)) {
3179
+ const bearingMid = (arc.startBin + k + 1) * fit.binStepDeg;
3180
+ const midAt = (side, j) => {
3181
+ const rIn = (innerEdge[k] + innerEdge[k1]) / 2;
3182
+ const rOut = (outerEdge[k] + outerEdge[k1]) / 2;
3183
+ const r = rIn + (rOut - rIn) * j / M2;
3184
+ const p = polar(fit.centre, r, bearingMid);
3185
+ return [p.x * U, h[side][j], p.y * U];
3186
+ };
3187
+ for (let j = 0; j < M2; j++) {
3188
+ const aL = point(k, j), dL = point(k, j + 1);
3189
+ const bL = midAt(k, j), cL = midAt(k, j + 1);
3190
+ const nL0 = vertexNormal(k, j), nL1 = vertexNormal(k, j + 1);
3191
+ builder.triN(aL, bL, cL, nL0, nL0, nL1, cTop);
3192
+ builder.triN(aL, cL, dL, nL0, nL1, nL1, cTop);
3193
+ const bR = point(k1, j), cR = point(k1, j + 1);
3194
+ const aR = midAt(k1, j), dR = midAt(k1, j + 1);
3195
+ const nR0 = vertexNormal(k1, j), nR1 = vertexNormal(k1, j + 1);
3196
+ builder.triN(aR, bR, cR, nR0, nR0, nR1, cTop);
3197
+ builder.triN(aR, cR, dR, nR0, nR1, nR1, cTop);
3198
+ const w0 = midAt(k, j), w1 = midAt(k, j + 1), w2 = midAt(k1, j + 1), w3 = midAt(k1, j);
3199
+ const ex = w1[0] - w0[0], ez = w1[2] - w0[2];
3200
+ const el = Math.hypot(ex, ez);
3201
+ if (el > 1e-12) {
3202
+ const n = [ez / el, 0, -ex / el];
3203
+ builder.tri(w0, w1, w2, n, cWallTop);
3204
+ builder.tri(w0, w2, w3, n, cWallTop);
3205
+ }
3206
+ }
3207
+ const ai2 = point(k, 0), bi2 = point(k1, 0), ao_2 = point(k1, M2), di2 = point(k, M2);
3208
+ const flat2 = (p) => [p[0], bottomY, p[2]];
3209
+ builder.tri(flat2(ai2), flat2(ao_2), flat2(bi2), [0, -1, 0], cBot);
3210
+ builder.tri(flat2(ai2), flat2(di2), flat2(ao_2), [0, -1, 0], cBot);
3211
+ wallQuad(ai2, bi2);
3212
+ wallQuad(di2, ao_2);
3213
+ continue;
3214
+ }
3215
+ for (let j = 0; j < M2; j++) {
3216
+ const na = vertexNormal(k, j), nb = vertexNormal(k1, j), nc = vertexNormal(k1, j + 1), nd = vertexNormal(k, j + 1);
3217
+ if (radialStep(k, j) || radialStep(k1, j)) {
3218
+ const midPt = (kk, hh) => {
3219
+ const bearing = (arc.startBin + kk + 0.5) * fit.binStepDeg;
3220
+ const r = innerEdge[kk] + (outerEdge[kk] - innerEdge[kk]) * (j + 0.5) / M2;
3221
+ const p = polar(fit.centre, r, bearing);
3222
+ return [p.x * U, hh, p.y * U];
3223
+ };
3224
+ const a2 = point(k, j), b2 = point(k1, j);
3225
+ const mLo0 = midPt(k, h[k][j]), mLo1 = midPt(k1, h[k1][j]);
3226
+ builder.triN(a2, b2, mLo1, na, nb, nb, cTop);
3227
+ builder.triN(a2, mLo1, mLo0, na, nb, na, cTop);
3228
+ const c2 = point(k1, j + 1), d2 = point(k, j + 1);
3229
+ const mHi0 = midPt(k, h[k][j + 1]), mHi1 = midPt(k1, h[k1][j + 1]);
3230
+ builder.triN(mHi0, mHi1, c2, nd, nc, nc, cTop);
3231
+ builder.triN(mHi0, c2, d2, nd, nc, nd, cTop);
3232
+ const ex = mLo1[0] - mLo0[0], ez = mLo1[2] - mLo0[2];
3233
+ const el = Math.hypot(ex, ez);
3234
+ if (el > 1e-12) {
3235
+ const n = [-ez / el, 0, ex / el];
3236
+ builder.tri(mLo0, mLo1, mHi1, n, cWallTop);
3237
+ builder.tri(mLo0, mHi1, mHi0, n, cWallTop);
3238
+ }
3239
+ continue;
3240
+ }
3241
+ const a = point(k, j), b = point(k1, j), c = point(k1, j + 1), d = point(k, j + 1);
3242
+ builder.triN(a, b, c, na, nb, nc, cTop);
3243
+ builder.triN(a, c, d, na, nc, nd, cTop);
3244
+ }
3245
+ const ai = point(k, 0), bi = point(k1, 0), ao_ = point(k1, M2), di = point(k, M2);
3246
+ const flat = (p) => [p[0], bottomY, p[2]];
3247
+ builder.tri(flat(ai), flat(ao_), flat(bi), [0, -1, 0], cBot);
3248
+ builder.tri(flat(ai), flat(di), flat(ao_), [0, -1, 0], cBot);
3249
+ wallQuad(ai, bi);
3250
+ wallQuad(di, ao_);
3251
+ }
3252
+ if (!arc.closed) {
3253
+ for (const k of [0, K - 1]) {
3254
+ for (let j = 0; j < M2; j++) wallQuad(point(k, j), point(k, j + 1));
3255
+ }
3256
+ }
3257
+ }
3258
+ function emitBowlGeometry(builder, fit, opts) {
3259
+ const { bottomY, colors, ao } = opts;
3260
+ const drawnOuter = new Float64Array(fit.binCount).fill(-Infinity);
3261
+ for (const ring of fit.rings) {
3262
+ for (const arc of ring.arcs) {
3263
+ const innerEdge = new Float64Array(arc.binCount);
3264
+ const outerEdge = new Float64Array(arc.binCount);
3265
+ for (let k = 0; k < arc.binCount; k++) {
3266
+ const bin = (arc.startBin + k) % fit.binCount;
3267
+ const inner = Math.max(0, arc.innerR[k] - SEAT_OWNERSHIP_PAD_U);
3268
+ innerEdge[k] = Math.max(inner, drawnOuter[bin] + 1e-3);
3269
+ outerEdge[k] = Math.max(innerEdge[k] + 1e-3, arc.outerR[k] + SEAT_OWNERSHIP_PAD_U);
3270
+ }
3271
+ let relief = 0;
3272
+ for (let k = 0; k < arc.binCount; k++) {
3273
+ const d = arc.topLevelM[k] - bottomY;
3274
+ if (d > relief) relief = d;
3275
+ }
3276
+ if (relief >= BAND_MIN_RELIEF_M) {
3277
+ emitBandGrid(builder, fit, arc, innerEdge, outerEdge, bottomY, colors, ao, opts.seatGuards);
3278
+ }
3279
+ for (let k = 0; k < arc.binCount; k++) {
3280
+ const bin = (arc.startBin + k) % fit.binCount;
3281
+ if (outerEdge[k] > drawnOuter[bin]) drawnOuter[bin] = outerEdge[k];
3282
+ }
3283
+ }
3284
+ }
3285
+ if (hasAuthoredPerimeter(opts.shapes, fit)) return;
3286
+ const outerRing = fit.rings[fit.rings.length - 1];
3287
+ const gapU = SHELL_GAP_M * CHART_UNITS_PER_METRE;
3288
+ const thickU = SHELL_THICKNESS_M * CHART_UNITS_PER_METRE;
3289
+ for (const arc of outerRing.arcs) {
3290
+ const inner = [];
3291
+ const outer = [];
3292
+ for (let k = 0; k < arc.binCount; k++) {
3293
+ const bearing = (arc.startBin + k + 0.5) * fit.binStepDeg;
3294
+ const r0 = arc.outerR[k] + SEAT_OWNERSHIP_PAD_U + gapU;
3295
+ inner.push(polar(fit.centre, r0, bearing));
3296
+ outer.push(polar(fit.centre, r0 + thickU, bearing));
3297
+ }
3298
+ let crest = -Infinity;
3299
+ for (let k = 0; k < arc.binCount; k++) if (arc.topLevelM[k] > crest) crest = arc.topLevelM[k];
3300
+ const topY = () => crest + SHELL_PARAPET_M;
3301
+ if (arc.closed) {
3302
+ extrudePrism(builder, outer, [inner.slice().reverse()], topY, bottomY, colors.shellTop, colors.shellWall, ao);
3303
+ } else {
3304
+ extrudePrism(builder, [...outer, ...inner.slice().reverse()], void 0, topY, bottomY, colors.shellTop, colors.shellWall, ao);
3305
+ }
3306
+ }
3307
+ }
3308
+
2636
3309
  // src/view3d/scene/sceneModel.ts
2637
3310
  function shapeVisibleInConfiguration(shape, activeConfigurationId) {
2638
3311
  const ids = shape.eventConfigurationIds;
@@ -2642,14 +3315,28 @@ function visibleObjects(objects, activeConfigurationId) {
2642
3315
  return objects.filter((object) => object.type !== "shape" || shapeVisibleInConfiguration(object, activeConfigurationId));
2643
3316
  }
2644
3317
  function floorUnits(doc, activeConfigurationId) {
3318
+ const docFocal = doc.focalPoint ?? { x: 0, y: 0 };
2645
3319
  if (doc.floors?.length) {
2646
- return doc.floors.map((f) => ({
3320
+ return doc.floors.map((f, index) => ({
2647
3321
  objects: visibleObjects(f.objects, activeConfigurationId),
2648
- focal: f.focalPoint ?? doc.focalPoint,
2649
- baseHeightM: f.baseHeightM ?? 0
3322
+ focal: {
3323
+ x: (f.focalPoint ?? docFocal).x,
3324
+ y: (f.focalPoint ?? docFocal).y
3325
+ },
3326
+ baseHeightM: f.baseHeightM ?? 0,
3327
+ index
3328
+ // 2D deliberately packs levels into a non-overlapping grid. In 3D the
3329
+ // physical datum already separates them, so retaining each floor's
3330
+ // authored x/z registration is what turns independent drawings into one
3331
+ // coherent building stack. The source scene graph is never rewritten.
2650
3332
  }));
2651
3333
  }
2652
- return [{ objects: visibleObjects(doc.objects, activeConfigurationId), focal: doc.focalPoint, baseHeightM: 0 }];
3334
+ return [{
3335
+ objects: visibleObjects(doc.objects, activeConfigurationId),
3336
+ focal: docFocal,
3337
+ baseHeightM: 0,
3338
+ index: 0
3339
+ }];
2653
3340
  }
2654
3341
  var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
2655
3342
  function tintTop(fill, neutral) {
@@ -2824,6 +3511,79 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2824
3511
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
2825
3512
  }
2826
3513
  }
3514
+ function collectBowlSamples(unit, seats, surfaces) {
3515
+ const unitSections = /* @__PURE__ */ new Set();
3516
+ for (const o of unit.objects) if (o.type === "section") unitSections.add(o.id);
3517
+ const flatRows = /* @__PURE__ */ new Map();
3518
+ for (let i = 0; i < seats.length; i++) {
3519
+ const owner = surfaces.seatOwner[i];
3520
+ if (!owner || !unitSections.has(owner)) continue;
3521
+ const surf = surfaces.bySection.get(owner);
3522
+ if (surf && surf.rowLevels.length >= 2) continue;
3523
+ const s = seats[i];
3524
+ let rows = flatRows.get(owner);
3525
+ if (!rows) {
3526
+ rows = /* @__PURE__ */ new Map();
3527
+ flatRows.set(owner, rows);
3528
+ }
3529
+ const key = s.rowId || `__seat-${i}`;
3530
+ let row = rows.get(key);
3531
+ if (!row) {
3532
+ row = { pts: [], levelM: 0, n: 0 };
3533
+ rows.set(key, row);
3534
+ }
3535
+ row.pts.push({ x: s.x, y: s.y });
3536
+ row.levelM += surfaces.seatDeckY(i);
3537
+ row.n++;
3538
+ }
3539
+ const samples = [];
3540
+ for (const o of unit.objects) {
3541
+ if (o.type !== "section") continue;
3542
+ const surf = surfaces.bySection.get(o.id);
3543
+ if (surf && surf.rowLevels.length >= 2) {
3544
+ const rows2 = surf.rowLevels.filter((r) => !r.patch).map((r) => ({ pts: r.pts, levelM: r.y }));
3545
+ if (rows2.length >= 2) samples.push({ sectionId: o.id, rows: rows2 });
3546
+ continue;
3547
+ }
3548
+ const rows = flatRows.get(o.id);
3549
+ if (!rows) continue;
3550
+ samples.push({
3551
+ sectionId: o.id,
3552
+ rows: [...rows.values()].map((r) => ({ pts: r.pts, levelM: r.levelM / r.n }))
3553
+ });
3554
+ }
3555
+ return samples;
3556
+ }
3557
+ function buildBowl(builder, unit, seats, surfaces, S) {
3558
+ const samples = collectBowlSamples(unit, seats, surfaces);
3559
+ if (!samples.length) return;
3560
+ const fit = fitBowlProfile(samples, unit.focal);
3561
+ if (!fit) return;
3562
+ const unitSections = /* @__PURE__ */ new Set();
3563
+ for (const o of unit.objects) if (o.type === "section") unitSections.add(o.id);
3564
+ const seatGuards = [];
3565
+ for (let i = 0; i < seats.length; i++) {
3566
+ const owner = surfaces.seatOwner[i];
3567
+ if (owner ? unitSections.has(owner) : true) {
3568
+ seatGuards.push({ x: seats[i].x, y: seats[i].y, deckY: surfaces.seatDeckY(i) });
3569
+ }
3570
+ }
3571
+ builder.setMaterial(0);
3572
+ emitBowlGeometry(builder, fit, {
3573
+ bottomY: unit.baseHeightM,
3574
+ colors: {
3575
+ // A shade below the tier caps, so stands read as decks resting ON the
3576
+ // bowl rather than the bowl swallowing them.
3577
+ bandTop: scaleRgb(S.tierTop, 0.8),
3578
+ bandWall: S.tierWall,
3579
+ shellTop: scaleRgb(S.tierWall, 1.15),
3580
+ shellWall: S.tierWall
3581
+ },
3582
+ ao: AO,
3583
+ shapes: unit.objects.filter((o) => o.type === "shape"),
3584
+ seatGuards
3585
+ });
3586
+ }
2827
3587
  function coplanarLevels(a, b) {
2828
3588
  if (a === void 0 || b === void 0) return true;
2829
3589
  return Math.abs(a - b) < 1e-3;
@@ -2989,7 +3749,11 @@ function architectureForShape(shape) {
2989
3749
  restroom: { kind: "solid", heightM: 2.4 },
2990
3750
  bar: { kind: "solid", heightM: 1.1 },
2991
3751
  concession: { kind: "solid", heightM: 1.1 },
2992
- coat: { kind: "solid", heightM: 1.1 }
3752
+ coat: { kind: "solid", heightM: 1.1 },
3753
+ // Sports-surface imports describe a goal as one thin rectangular portal.
3754
+ // It therefore becomes a real open goal frame in 3D without a bespoke
3755
+ // chart model or a copied bitmap.
3756
+ goal: { kind: "portal", heightM: 1.8 }
2993
3757
  };
2994
3758
  const resolved = defaults[role] ?? { kind: "plate", heightM: 0.25 };
2995
3759
  return { ...resolved, heightM: shape.heightM ?? resolved.heightM };
@@ -3188,20 +3952,46 @@ function chartFootprint(units, seats) {
3188
3952
  return { minX, minY, maxX, maxY };
3189
3953
  }
3190
3954
  function buildSceneModel(input) {
3191
- const { doc, seats } = input;
3955
+ const { doc } = input;
3192
3956
  const theme = resolveTheme3D(doc.theme);
3193
3957
  const S = theme.structure;
3194
3958
  const units = floorUnits(doc, input.eventConfigurationId ?? doc.eventConfigurationId);
3959
+ const seats = input.seats;
3195
3960
  const builder = new MeshBuilder();
3961
+ const floorByOwner = /* @__PURE__ */ new Map();
3962
+ for (const unit of units) {
3963
+ for (const object of unit.objects) {
3964
+ floorByOwner.set(object.id, unit.index);
3965
+ if (object.type === "section") floorByOwner.set(object.logicalSectionId ?? object.id, unit.index);
3966
+ }
3967
+ }
3968
+ const hintedSeatFloor = seats.map((seat) => floorByOwner.get(seat.sectionId ?? "") ?? floorByOwner.get(seat.rowId ?? "") ?? 0);
3196
3969
  const fp = chartFootprint(units, seats);
3197
- const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
3198
- const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
3199
- extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
3970
+ if (units.length === 1) {
3971
+ const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
3972
+ const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
3973
+ extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
3974
+ } else {
3975
+ const unit = units.reduce((lowest, candidate) => candidate.baseHeightM < lowest.baseHeightM ? candidate : lowest);
3976
+ builder.setFloor(unit.index);
3977
+ const unitSeats = seats.filter((_seat, index) => hintedSeatFloor[index] === unit.index);
3978
+ const floorFp = chartFootprint([unit], unitSeats);
3979
+ const span = Math.hypot(floorFp.maxX - floorFp.minX, floorFp.maxY - floorFp.minY);
3980
+ const padU = Math.max(28, Math.min(80, span * 0.04));
3981
+ const plinth = rectPolygon(
3982
+ floorFp.minX - padU,
3983
+ floorFp.minY - padU,
3984
+ floorFp.maxX - floorFp.minX + padU * 2,
3985
+ floorFp.maxY - floorFp.minY + padU * 2
3986
+ );
3987
+ const top = unit.baseHeightM - 0.03;
3988
+ extrudePrism(builder, plinth, void 0, () => top, top - 0.22, S.ground, S.ground, AO);
3989
+ }
3200
3990
  const sectionFills = resolveSectionFills(doc, seats);
3201
3991
  const catColor = /* @__PURE__ */ new Map();
3202
3992
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
3203
3993
  const stageBounds = [];
3204
- const surfaces = buildVenueSurfaces(units, seats);
3994
+ const surfaces = buildVenueSurfaces(units, seats, hintedSeatFloor);
3205
3995
  const seatFloor = new Float32Array(seats.length);
3206
3996
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
3207
3997
  const unit = units[unitIndex];
@@ -3228,8 +4018,12 @@ function buildSceneModel(input) {
3228
4018
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3229
4019
  else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
3230
4020
  }
4021
+ buildBowl(builder, unit, seats, surfaces, S);
3231
4022
  }
3232
- const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
4023
+ const focal = units.length > 1 ? {
4024
+ x: units.reduce((sum, unit) => sum + unit.focal.x, 0) / units.length,
4025
+ y: units.reduce((sum, unit) => sum + unit.focal.y, 0) / units.length
4026
+ } : doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
3233
4027
  const solids = mergeMeshData([builder.build()]);
3234
4028
  const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor, catColor);
3235
4029
  const zoneDefs = doc.zones ?? [];
@@ -3335,9 +4129,11 @@ function buildSceneModel(input) {
3335
4129
  if (!a || a.n === 0) continue;
3336
4130
  const e = ext.get(o.id);
3337
4131
  const centre = [a.x / a.n * M, a.deck / a.n, a.y / a.n * M];
4132
+ const sectionLabel = o.displayLabel || o.label || o.id;
3338
4133
  sections.push({
3339
4134
  id: o.id,
3340
- label: o.displayLabel || o.label || o.id,
4135
+ floorIndex: unit.index,
4136
+ label: sectionLabel,
3341
4137
  seatCount: a.n,
3342
4138
  center: centre,
3343
4139
  // Half-diagonal of the seat extent — the same fit the zones use.
@@ -3346,13 +4142,17 @@ function buildSceneModel(input) {
3346
4142
  // look along so the seats present their fronts.
3347
4143
  focalWorld: [unit.focal.x * M, unit.baseHeightM + 1.5, unit.focal.y * M]
3348
4144
  });
3349
- labels.push({
3350
- id: `section:${o.id}`,
3351
- kind: "section",
3352
- // The buyer-facing name wins over the technical one, as it does in 2D.
3353
- text: o.displayLabel || o.label || o.id,
3354
- anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]]
3355
- });
4145
+ const floorLabel = doc.floors?.[unit.index]?.name?.trim().toLocaleLowerCase();
4146
+ if (!(units.length > 1 && floorLabel === sectionLabel.trim().toLocaleLowerCase())) {
4147
+ labels.push({
4148
+ id: `section:${o.id}`,
4149
+ kind: "section",
4150
+ // The buyer-facing name wins over the technical one, as it does in 2D.
4151
+ text: sectionLabel,
4152
+ anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]],
4153
+ floorIndex: unit.index
4154
+ });
4155
+ }
3356
4156
  }
3357
4157
  }
3358
4158
  }
@@ -3367,7 +4167,8 @@ function buildSceneModel(input) {
3367
4167
  id: `ga:${o.id}`,
3368
4168
  kind: "ga",
3369
4169
  text: o.capacity > 0 ? `${name} \xB7 ${o.capacity.toLocaleString()}` : name,
3370
- anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M]
4170
+ anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M],
4171
+ floorIndex: unit.index
3371
4172
  });
3372
4173
  }
3373
4174
  }
@@ -3386,7 +4187,8 @@ function buildSceneModel(input) {
3386
4187
  id: `row:${rowId}`,
3387
4188
  kind: "row",
3388
4189
  text,
3389
- anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
4190
+ anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M],
4191
+ floorIndex: hintedSeatFloor[end.index]
3390
4192
  });
3391
4193
  }
3392
4194
  const rowAcc = /* @__PURE__ */ new Map();
@@ -3441,7 +4243,8 @@ function buildSceneModel(input) {
3441
4243
  id: `seat:${s.id}`,
3442
4244
  kind: "seat",
3443
4245
  text,
3444
- anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M]
4246
+ anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M],
4247
+ floorIndex: hintedSeatFloor[i]
3445
4248
  });
3446
4249
  }
3447
4250
  }
@@ -3457,7 +4260,8 @@ function buildSceneModel(input) {
3457
4260
  kind: "annotation",
3458
4261
  text: o.label,
3459
4262
  anchor: [centre.x * M, unit.baseHeightM + architectureForShape(o).heightM + ANNOTATION_LIFT_M, centre.y * M],
3460
- color: o.fill
4263
+ color: o.fill,
4264
+ floorIndex: unit.index
3461
4265
  });
3462
4266
  } else if (o.type === "text") {
3463
4267
  if (!o.text) continue;
@@ -3467,7 +4271,8 @@ function buildSceneModel(input) {
3467
4271
  text: o.text,
3468
4272
  anchor: [o.position.x * M, unit.baseHeightM + ANNOTATION_LIFT_M, o.position.y * M],
3469
4273
  color: o.color,
3470
- rotation: o.rotation
4274
+ rotation: o.rotation,
4275
+ floorIndex: unit.index
3471
4276
  });
3472
4277
  } else if (o.type === "booth") {
3473
4278
  const poly = boothPolygon(o);
@@ -3478,21 +4283,52 @@ function buildSceneModel(input) {
3478
4283
  id: `booth:${o.id}`,
3479
4284
  kind: "booth",
3480
4285
  text: o.displayLabel || o.label || o.id,
3481
- anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M]
4286
+ anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M],
4287
+ floorIndex: unit.index
3482
4288
  });
3483
4289
  }
3484
4290
  }
3485
4291
  }
3486
4292
  const floors = [];
3487
4293
  {
4294
+ const solidBounds = units.map(() => ({
4295
+ minX: Infinity,
4296
+ minY: Infinity,
4297
+ minZ: Infinity,
4298
+ maxX: -Infinity,
4299
+ maxY: -Infinity,
4300
+ maxZ: -Infinity
4301
+ }));
4302
+ for (let vertex = 0; vertex < solids.floor.length; vertex += 1) {
4303
+ const floorIndex = Math.max(0, Math.min(units.length - 1, Math.round(solids.floor[vertex])));
4304
+ const offset = vertex * 3;
4305
+ const bound = solidBounds[floorIndex];
4306
+ bound.minX = Math.min(bound.minX, solids.position[offset]);
4307
+ bound.minY = Math.min(bound.minY, solids.position[offset + 1]);
4308
+ bound.minZ = Math.min(bound.minZ, solids.position[offset + 2]);
4309
+ bound.maxX = Math.max(bound.maxX, solids.position[offset]);
4310
+ bound.maxY = Math.max(bound.maxY, solids.position[offset + 1]);
4311
+ bound.maxZ = Math.max(bound.maxZ, solids.position[offset + 2]);
4312
+ }
3488
4313
  const sectionFloor = /* @__PURE__ */ new Map();
3489
4314
  for (let ui = 0; ui < units.length; ui++) {
3490
4315
  for (const o of units[ui].objects) if (o.type === "section") sectionFloor.set(o.id, ui);
3491
4316
  }
3492
- const acc = units.map(() => ({ n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 }));
4317
+ const acc = units.map(() => ({
4318
+ n: 0,
4319
+ minX: Infinity,
4320
+ minY: Infinity,
4321
+ minDeck: Infinity,
4322
+ maxX: -Infinity,
4323
+ maxY: -Infinity,
4324
+ maxDeck: -Infinity,
4325
+ sumX: 0,
4326
+ sumDeck: 0,
4327
+ sumZ: 0
4328
+ }));
3493
4329
  for (let i = 0; i < seats.length; i++) {
3494
4330
  const owner = surfaces.seatOwner[i];
3495
- const ui = owner !== null ? sectionFloor.get(owner) : void 0;
4331
+ const ui = (owner !== null ? sectionFloor.get(owner) : void 0) ?? hintedSeatFloor[i];
3496
4332
  if (ui === void 0) continue;
3497
4333
  const a = acc[ui];
3498
4334
  const s = seats[i];
@@ -3501,19 +4337,78 @@ function buildSceneModel(input) {
3501
4337
  if (s.y < a.minY) a.minY = s.y;
3502
4338
  if (s.x > a.maxX) a.maxX = s.x;
3503
4339
  if (s.y > a.maxY) a.maxY = s.y;
3504
- a.sumY += surfaces.seatDeckY(i);
4340
+ const deck = surfaces.seatDeckY(i);
4341
+ a.sumX += s.x * M;
4342
+ a.sumDeck += deck;
4343
+ a.sumZ += s.y * M;
4344
+ a.minDeck = Math.min(a.minDeck, deck);
4345
+ a.maxDeck = Math.max(a.maxDeck, deck + 1.6);
3505
4346
  seatFloor[i] = ui;
3506
4347
  }
3507
4348
  for (let ui = 0; ui < units.length; ui++) {
3508
4349
  const a = acc[ui];
3509
4350
  const f = doc.floors?.[ui];
4351
+ const solid = solidBounds[ui];
4352
+ const minWorldX = Math.min(
4353
+ a.n ? a.minX * M : units[ui].focal.x * M - 1,
4354
+ Number.isFinite(solid.minX) ? solid.minX : Infinity
4355
+ );
4356
+ const maxWorldX = Math.max(
4357
+ a.n ? a.maxX * M : units[ui].focal.x * M + 1,
4358
+ Number.isFinite(solid.maxX) ? solid.maxX : -Infinity
4359
+ );
4360
+ const minWorldZ = Math.min(
4361
+ a.n ? a.minY * M : units[ui].focal.y * M - 1,
4362
+ Number.isFinite(solid.minZ) ? solid.minZ : Infinity
4363
+ );
4364
+ const maxWorldZ = Math.max(
4365
+ a.n ? a.maxY * M : units[ui].focal.y * M + 1,
4366
+ Number.isFinite(solid.maxZ) ? solid.maxZ : -Infinity
4367
+ );
4368
+ const minWorldY = Math.min(
4369
+ a.n ? Math.min(units[ui].baseHeightM, a.minDeck) : units[ui].baseHeightM,
4370
+ Number.isFinite(solid.minY) ? solid.minY : Infinity
4371
+ );
4372
+ const maxWorldY = Math.max(
4373
+ a.n ? Math.max(a.maxDeck, minWorldY + 0.5) : minWorldY + 0.5,
4374
+ Number.isFinite(solid.maxY) ? solid.maxY : -Infinity
4375
+ );
4376
+ const center = [
4377
+ // Aim the floor camera at its sellable topology rather than the centre
4378
+ // of a generous imported outline/plinth. The complete box is still
4379
+ // fitted below, so stages, annotations and deck edges cannot clip.
4380
+ a.n ? a.sumX / a.n : (minWorldX + maxWorldX) / 2,
4381
+ a.n ? a.sumDeck / a.n : (minWorldY + maxWorldY) / 2,
4382
+ a.n ? a.sumZ / a.n : (minWorldZ + maxWorldZ) / 2
4383
+ ];
4384
+ const radius2 = Math.max(1, 0.5 * Math.hypot(
4385
+ maxWorldX - minWorldX,
4386
+ maxWorldY - minWorldY,
4387
+ maxWorldZ - minWorldZ
4388
+ ));
3510
4389
  floors.push({
3511
4390
  index: ui,
3512
4391
  id: f?.id ?? `floor-${ui}`,
3513
4392
  label: f?.name ?? (units.length > 1 ? `Floor ${ui + 1}` : "Venue"),
3514
4393
  seatCount: a.n,
3515
- center: a.n ? [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M] : [0, 0, 0],
3516
- radius: a.n ? 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1 : 0
4394
+ center,
4395
+ radius: radius2,
4396
+ box: {
4397
+ min: [minWorldX, minWorldY, minWorldZ],
4398
+ max: [maxWorldX, maxWorldY, maxWorldZ]
4399
+ },
4400
+ focalWorld: [units[ui].focal.x * M, units[ui].baseHeightM + 1.5, units[ui].focal.y * M]
4401
+ });
4402
+ }
4403
+ }
4404
+ if (floors.length > 1) {
4405
+ for (const floor of floors) {
4406
+ labels.push({
4407
+ id: `floor:${floor.id}`,
4408
+ kind: "floor",
4409
+ text: floor.label,
4410
+ anchor: [floor.center[0], floor.box.max[1] + 1.2, floor.center[2]],
4411
+ floorIndex: floor.index
3517
4412
  });
3518
4413
  }
3519
4414
  }
@@ -3591,18 +4486,20 @@ function buildSceneModel(input) {
3591
4486
  boxMinZ = Math.max(fpMinZ, contentMinZ - pad);
3592
4487
  boxMaxZ = Math.min(fpMaxZ, contentMaxZ + pad);
3593
4488
  }
4489
+ const boxMinY = Math.min(0, renderMinY);
4490
+ const boxMaxY = Math.max(renderMaxY, radius * 0.05);
3594
4491
  return {
3595
4492
  solids,
3596
4493
  seats: seatData,
3597
4494
  bounds: {
3598
4495
  // Centred on the CONTENT box, so an off-centre table cluster arrives
3599
4496
  // centred on screen instead of wherever the floor's midpoint put it.
3600
- center: [(boxMinX + boxMaxX) / 2, radius * 0.08, (boxMinZ + boxMaxZ) / 2],
4497
+ center: [(boxMinX + boxMaxX) / 2, (boxMinY + boxMaxY) / 2, (boxMinZ + boxMaxZ) / 2],
3601
4498
  radius,
3602
4499
  groundY: 0,
3603
4500
  box: {
3604
- min: [boxMinX, Math.min(0, renderMinY), boxMinZ],
3605
- max: [boxMaxX, Math.max(renderMaxY, radius * 0.05), boxMaxZ]
4501
+ min: [boxMinX, boxMinY, boxMinZ],
4502
+ max: [boxMaxX, boxMaxY, boxMaxZ]
3606
4503
  }
3607
4504
  },
3608
4505
  renderBounds: { center: renderCenter, radius: renderRadius },
@@ -3624,6 +4521,7 @@ function buildSceneModel(input) {
3624
4521
  var SEPARATION_X_PX = 88;
3625
4522
  var SEPARATION_Y_PX = 20;
3626
4523
  var KIND_STYLE = {
4524
+ floor: { size: 14, weight: "700", opacity: 0.98 },
3627
4525
  zone: { size: 15, weight: "600", opacity: 0.95 },
3628
4526
  section: { size: 12, weight: "500", opacity: 0.88 },
3629
4527
  ga: { size: 12, weight: "500", opacity: 0.88 },
@@ -3644,6 +4542,27 @@ var DENSE_SEPARATION = {
3644
4542
  // seating instead of clustering into one stack.
3645
4543
  seat: { x: 46, y: 18 }
3646
4544
  };
4545
+ function hexRgb(value) {
4546
+ const match = /^#([0-9a-f]{6})$/i.exec(value?.trim() ?? "");
4547
+ if (!match) return null;
4548
+ return [0, 2, 4].map((offset) => Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255);
4549
+ }
4550
+ function luminance(rgb) {
4551
+ const linear = (channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
4552
+ return 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]);
4553
+ }
4554
+ function ratio(left, right) {
4555
+ const a = luminance(left);
4556
+ const b = luminance(right);
4557
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
4558
+ }
4559
+ function resolve3dLabelTreatment(preferredInk, backgroundRgb = [0.04, 0.06, 0.1]) {
4560
+ const darkScene = luminance(backgroundRgb) < 0.42;
4561
+ const fallbackInk = darkScene ? "#f4f7fb" : "#0b1220";
4562
+ const authored = hexRgb(preferredInk);
4563
+ const ink = authored && ratio(authored, backgroundRgb) >= 4.5 ? preferredInk : fallbackInk;
4564
+ return darkScene ? { ink, backing: "rgba(8,12,22,.78)", border: "rgba(210,222,245,.24)" } : { ink, backing: "rgba(255,255,255,.84)", border: "rgba(11,18,32,.22)" };
4565
+ }
3647
4566
  var LabelOverlay = class {
3648
4567
  constructor(container, opts = {}) {
3649
4568
  this.nodes = /* @__PURE__ */ new Map();
@@ -3702,7 +4621,9 @@ var LabelOverlay = class {
3702
4621
  */
3703
4622
  update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
3704
4623
  if (!this.labels.length) return;
3705
- const kinds = visibleLabelKinds(cameraDistance, venueRadius);
4624
+ const kinds = visibleLabelKinds(cameraDistance, venueRadius, {
4625
+ hasZoneLabels: this.labels.some((label) => label.kind === "zone")
4626
+ });
3706
4627
  if (this.forcedDense) {
3707
4628
  kinds.add("row");
3708
4629
  kinds.add("seat");
@@ -3724,7 +4645,14 @@ var LabelOverlay = class {
3724
4645
  ...this.selectedSeatLabelIds.has(label.id) ? { priority: 1 } : {}
3725
4646
  });
3726
4647
  }
3727
- const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
4648
+ const floorLabels = spreadFloorLabels(
4649
+ candidates.filter((candidate) => candidate.label.kind === "floor"),
4650
+ width
4651
+ );
4652
+ const structure = [
4653
+ ...candidates.filter((candidate) => !DENSE_KINDS.has(candidate.label.kind) && candidate.label.kind !== "floor"),
4654
+ ...floorLabels
4655
+ ];
3728
4656
  const kept = [
3729
4657
  ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
3730
4658
  // The dense rungs are BUDGETED, not merely deduplicated — see
@@ -3771,14 +4699,16 @@ var LabelOverlay = class {
3771
4699
  s.fontSize = `${style.size}px`;
3772
4700
  s.fontWeight = selected ? "800" : style.weight;
3773
4701
  s.opacity = selected ? "1" : String(style.opacity);
3774
- s.color = selected ? "#ecf8ff" : label.color ?? this.opts.ink ?? "#e8edf5";
3775
- s.background = selected ? "rgba(10,28,45,.9)" : "";
3776
- s.border = selected ? "1px solid rgba(89,199,255,.92)" : "";
3777
- s.borderRadius = selected ? "999px" : "";
3778
- s.padding = selected ? "3px 7px" : "";
3779
- s.boxShadow = selected ? "0 0 0 2px rgba(41,176,255,.18), 0 5px 14px rgba(0,0,0,.35)" : "";
3780
- s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
3781
- if (label.kind === "zone") s.textTransform = "uppercase";
4702
+ const treatment = resolve3dLabelTreatment(label.color ?? this.opts.ink, this.opts.backgroundRgb);
4703
+ const backed = !DENSE_KINDS.has(label.kind);
4704
+ s.color = selected ? "#ecf8ff" : treatment.ink;
4705
+ s.background = selected ? "rgba(10,28,45,.9)" : backed ? treatment.backing : "";
4706
+ s.border = selected ? "1px solid rgba(89,199,255,.92)" : backed ? `1px solid ${treatment.border}` : "";
4707
+ s.borderRadius = selected || backed ? "999px" : "";
4708
+ s.padding = selected ? "3px 7px" : backed ? "3px 7px" : "";
4709
+ s.boxShadow = selected ? "0 0 0 2px rgba(41,176,255,.18), 0 5px 14px rgba(0,0,0,.35)" : backed ? "0 3px 10px rgba(0,0,0,.24)" : "";
4710
+ s.letterSpacing = label.kind === "zone" || label.kind === "floor" ? "0.08em" : "0.02em";
4711
+ if (label.kind === "zone" || label.kind === "floor") s.textTransform = "uppercase";
3782
4712
  return node;
3783
4713
  }
3784
4714
  dispose() {
@@ -3832,8 +4762,7 @@ out float vDim;
3832
4762
  out float vMaterial;
3833
4763
  void main() {
3834
4764
  // Per-floor isolation without splitting the merged mesh into a draw call per
3835
- // floor: a floor that is not the focused one is dimmed, not hidden, so the
3836
- // buyer keeps the whole venue as context while looking at one level.
4765
+ // floor. The fragment pass removes non-focused floors for a clean level view.
3837
4766
  vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3838
4767
  vMaterial = materialIndex;
3839
4768
  vec4 world = modelMatrix * vec4(position, 1.0);
@@ -3864,13 +4793,19 @@ uniform vec3 uKeyDir; // WORLD space, unit, points at
3864
4793
  uniform float uStructureDetail;
3865
4794
  out vec4 fragColor;
3866
4795
  void main() {
4796
+ if (vDim > 0.5) discard;
3867
4797
  // Row treads/risers are honest and useful when the buyer is inside a stand.
3868
4798
  // At whole-venue scale their overlapping centimetre-scale surfaces quantise
3869
4799
  // and alias into camera-dependent radial combs. The clean structural block
3870
4800
  // cap remains, so overview keeps every stand without pretending to show row
3871
4801
  // detail below a useful pixel size.
3872
4802
  float rowDetail = step(1.5, vMaterial);
3873
- if (rowDetail > 0.5 && uStructureDetail < 0.5) discard;
4803
+ // The rung DISSOLVES rather than popping: uStructureDetail eases 0..1 over a
4804
+ // few frames (see the render loop) and each pixel holds a stable screen-door
4805
+ // threshold against it, so free-zooming across the handover reads as detail
4806
+ // developing instead of the venue changing in a single frame.
4807
+ float stipple = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
4808
+ if (rowDetail > 0.5 && stipple >= uStructureDetail) discard;
3874
4809
  vec3 N = normalize(vNormalWorld);
3875
4810
  vec3 V = normalize(-vPosView);
3876
4811
  float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
@@ -3906,8 +4841,6 @@ void main() {
3906
4841
  + vec3(0.62, 0.46, 0.20) * band * 0.28
3907
4842
  + vec3(0.20, 0.10, 0.34) * focalHalo;
3908
4843
  base = mix(base, display, poweredDisplay * verticalFace * 0.92);
3909
- // Unfocused floors fall back toward the background rather than vanishing.
3910
- base = mix(base, base * 0.45, vDim);
3911
4844
  fragColor = vec4(base, 1.0);
3912
4845
  }`
3913
4846
  );
@@ -3991,7 +4924,11 @@ uniform float uSeatDetail; // 0 for venue/area overview; 1 in section/row/sea
3991
4924
  uniform vec3 uFadeColor;
3992
4925
  out vec4 fragColor;
3993
4926
  void main() {
3994
- if (uSeatDetail < 0.5) discard;
4927
+ if (vDim > 0.5) discard;
4928
+ // Same screen-door dissolve as the structure rung (uStructureDetail): the
4929
+ // dot layer develops over ~a quarter second instead of appearing at once.
4930
+ float stipple = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
4931
+ if (stipple >= uSeatDetail) discard;
3995
4932
  // Empty wheelchair provision is a square bay, never a round chair marker.
3996
4933
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
3997
4934
  if (d > 1.0) discard;
@@ -4024,9 +4961,6 @@ void main() {
4024
4961
  // while zooming. uSeatFade already measures this exact LOD transition: let
4025
4962
  // the category-tinted tier carry the overview, then reveal individual live
4026
4963
  // availability as the buyer approaches a section.
4027
- // Seats on an unfocused floor recede with their structure.
4028
- c = mix(c, uFadeColor, vDim * 0.75);
4029
- alpha *= mix(1.0, 0.30, vDim);
4030
4964
  // Yield to the chair. The chair grows out of this exact point, so through the
4031
4965
  // band the dot is always at least as big as the chair inside it and the seat
4032
4966
  // never thins out to nothing in between.
@@ -4171,6 +5105,7 @@ uniform vec3 uKeyDir;
4171
5105
  uniform vec3 uFadeColor;
4172
5106
  out vec4 fragColor;
4173
5107
  void main() {
5108
+ if (vDim > 0.5) discard;
4174
5109
  vec3 N = normalize(vNormalWorld);
4175
5110
  vec3 V = normalize(-vPosView);
4176
5111
  // The solids' rig, unchanged, so the chairs sit in the venue's light.
@@ -4203,7 +5138,6 @@ void main() {
4203
5138
  // A brighter rim than the solids get: it picks out every chair's own edge,
4204
5139
  // which is what stops a block of them merging into one mass up close.
4205
5140
  base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
4206
- base = mix(base, uFadeColor, vDim * 0.75);
4207
5141
  fragColor = vec4(base, 1.0);
4208
5142
  }`
4209
5143
  );
@@ -4241,6 +5175,7 @@ in vec2 position;
4241
5175
  in vec3 iOffset;
4242
5176
  in float iMaxRadius;
4243
5177
  in float iPhysicalSeat;
5178
+ in float iFloor;
4244
5179
  uniform mat4 modelViewMatrix;
4245
5180
  uniform mat4 projectionMatrix;
4246
5181
  uniform float uSeatRadius;
@@ -4249,9 +5184,11 @@ uniform float uMinPixels;
4249
5184
  uniform float uPixelToWorld;
4250
5185
  uniform float uChairFull;
4251
5186
  uniform float uChairNone;
5187
+ uniform float uFocusFloor;
4252
5188
  out vec2 vUv;
4253
5189
  out float vPhysicalSeat;
4254
5190
  out float vDotWeight;
5191
+ out float vHidden;
4255
5192
  flat out vec3 vPick;
4256
5193
  ${CHAIR_WEIGHT_GLSL}
4257
5194
  void main() {
@@ -4267,6 +5204,7 @@ void main() {
4267
5204
  mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
4268
5205
  vUv = position;
4269
5206
  vPhysicalSeat = iPhysicalSeat;
5207
+ vHidden = (uFocusFloor > -0.5 && abs(iFloor - uFocusFloor) > 0.5) ? 1.0 : 0.0;
4270
5208
  gl_Position = projectionMatrix * mv;
4271
5209
  }`
4272
5210
  );
@@ -4277,9 +5215,11 @@ precision highp float;
4277
5215
  in vec2 vUv;
4278
5216
  in float vPhysicalSeat;
4279
5217
  in float vDotWeight;
5218
+ in float vHidden;
4280
5219
  flat in vec3 vPick;
4281
5220
  out vec4 fragColor;
4282
5221
  void main() {
5222
+ if (vHidden > 0.5) discard;
4283
5223
  if (vDotWeight <= 0.02) discard; // invisible dot cannot steal a chair tap
4284
5224
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
4285
5225
  if (d > 1.0) discard; // hit-mask matches chair/bay shape
@@ -4298,18 +5238,22 @@ in float iYaw;
4298
5238
  in float iSeed;
4299
5239
  in float iPhysicalSeat;
4300
5240
  in float iPickIndex;
5241
+ in float iFloor;
4301
5242
  uniform mat4 modelViewMatrix;
4302
5243
  uniform mat4 projectionMatrix;
4303
5244
  uniform float uChairFull;
4304
5245
  uniform float uChairNone;
4305
5246
  uniform float uBackRake;
4306
5247
  uniform float uBackBase;
5248
+ uniform float uFocusFloor;
4307
5249
  flat out vec3 vPick;
5250
+ out float vHidden;
4308
5251
  ${CHAIR_WEIGHT_GLSL}
4309
5252
  ${OCCUPANT_WEIGHT_GLSL}
4310
5253
  void main() {
4311
5254
  int id = int(iPickIndex + 0.5); // already global index + 1
4312
5255
  vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
5256
+ vHidden = (uFocusFloor > -0.5 && abs(iFloor - uFocusFloor) > 0.5) ? 1.0 : 0.0;
4313
5257
  vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
4314
5258
  float viewDepth = -anchor.z;
4315
5259
  float depth = max(viewDepth, 0.001);
@@ -4342,17 +5286,25 @@ var CHAIR_PICK_FRAG = (
4342
5286
  `#version 300 es
4343
5287
  precision highp float;
4344
5288
  flat in vec3 vPick;
5289
+ in float vHidden;
4345
5290
  out vec4 fragColor;
4346
- void main() { fragColor = vec4(vPick, 1.0); }`
5291
+ void main() {
5292
+ if (vHidden > 0.5) discard;
5293
+ fragColor = vec4(vPick, 1.0);
5294
+ }`
4347
5295
  );
4348
5296
  var PICK_DEPTH_VERT = (
4349
5297
  /* glsl */
4350
5298
  `#version 300 es
4351
5299
  precision highp float;
4352
5300
  in vec3 position;
5301
+ in float floorIndex;
4353
5302
  uniform mat4 modelViewMatrix;
4354
5303
  uniform mat4 projectionMatrix;
5304
+ uniform float uFocusFloor;
5305
+ out float vHidden;
4355
5306
  void main() {
5307
+ vHidden = (uFocusFloor > -0.5 && abs(floorIndex - uFocusFloor) > 0.5) ? 1.0 : 0.0;
4356
5308
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
4357
5309
  }`
4358
5310
  );
@@ -4360,8 +5312,12 @@ var PICK_DEPTH_FRAG = (
4360
5312
  /* glsl */
4361
5313
  `#version 300 es
4362
5314
  precision highp float;
5315
+ in float vHidden;
4363
5316
  out vec4 fragColor;
4364
- void main() { fragColor = vec4(0.0, 0.0, 0.0, 1.0); }`
5317
+ void main() {
5318
+ if (vHidden > 0.5) discard;
5319
+ fragColor = vec4(0.0, 0.0, 0.0, 1.0);
5320
+ }`
4365
5321
  );
4366
5322
  function createSeatPickProgram(gl) {
4367
5323
  return new Program(gl, {
@@ -4377,7 +5333,8 @@ function createSeatPickProgram(gl) {
4377
5333
  uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
4378
5334
  uPixelToWorld: { value: 2e-3 },
4379
5335
  uChairFull: { value: CHAIR_FULL_M },
4380
- uChairNone: { value: CHAIR_NONE_M }
5336
+ uChairNone: { value: CHAIR_NONE_M },
5337
+ uFocusFloor: { value: -1 }
4381
5338
  }
4382
5339
  });
4383
5340
  }
@@ -4393,7 +5350,8 @@ function createChairPickProgram(gl) {
4393
5350
  uChairFull: { value: CHAIR_FULL_M },
4394
5351
  uChairNone: { value: CHAIR_NONE_M },
4395
5352
  uBackRake: { value: BACK_RAKE_SLOPE },
4396
- uBackBase: { value: BACK_BASE_M }
5353
+ uBackBase: { value: BACK_BASE_M },
5354
+ uFocusFloor: { value: -1 }
4397
5355
  }
4398
5356
  });
4399
5357
  }
@@ -4404,7 +5362,10 @@ function createPickDepthProgram(gl) {
4404
5362
  transparent: false,
4405
5363
  depthTest: true,
4406
5364
  depthWrite: true,
4407
- cullFace: false
5365
+ cullFace: false,
5366
+ uniforms: {
5367
+ uFocusFloor: { value: -1 }
5368
+ }
4408
5369
  });
4409
5370
  }
4410
5371
  function createSolidProgram(gl) {
@@ -4738,6 +5699,10 @@ var PickPipeline = class {
4738
5699
  /** Match the display seat sizing so the pick mask lines up with the dots. */
4739
5700
  syncFromSeatProgram(seatProgram) {
4740
5701
  for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
5702
+ const focusFloor = seatProgram.uniforms.uFocusFloor.value;
5703
+ this.seatProg.uniforms.uFocusFloor.value = focusFloor;
5704
+ this.chairProg.uniforms.uFocusFloor.value = focusFloor;
5705
+ this.depthProg.uniforms.uFocusFloor.value = focusFloor;
4741
5706
  }
4742
5707
  ensureTarget() {
4743
5708
  const w = this.gl.drawingBufferWidth;
@@ -4995,9 +5960,9 @@ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEG
4995
5960
  const uv = new Float32Array(vertexCount * 2);
4996
5961
  for (let iLat = 0; iLat < latCount; iLat++) {
4997
5962
  const v = iLat / segmentsLat;
4998
- const polar = v * Math.PI;
4999
- const sinPolar = Math.sin(polar);
5000
- const cosPolar = Math.cos(polar);
5963
+ const polar2 = v * Math.PI;
5964
+ const sinPolar = Math.sin(polar2);
5965
+ const cosPolar = Math.cos(polar2);
5001
5966
  for (let iLon = 0; iLon < lonCount; iLon++) {
5002
5967
  const u = iLon / segmentsLon;
5003
5968
  const azimuth = (u - 0.5) * Math.PI * 2;
@@ -5025,8 +5990,8 @@ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEG
5025
5990
  }
5026
5991
  return { position, uv, index };
5027
5992
  }
5028
- function bearingPitchToDirection(bearingDeg, pitchDeg) {
5029
- const yaw = bearingDeg * Math.PI / 180;
5993
+ function bearingPitchToDirection(bearingDeg2, pitchDeg) {
5994
+ const yaw = bearingDeg2 * Math.PI / 180;
5030
5995
  const pitch = pitchDeg * Math.PI / 180;
5031
5996
  const cosPitch = Math.cos(pitch);
5032
5997
  return [cosPitch * Math.sin(yaw), Math.sin(pitch), -cosPitch * Math.cos(yaw)];
@@ -5530,7 +6495,7 @@ function prepareVenue3D(input) {
5530
6495
  }
5531
6496
 
5532
6497
  // src/view3d/index.ts
5533
- var DEG2 = Math.PI / 180;
6498
+ var DEG4 = Math.PI / 180;
5534
6499
  var TAP_SLOP = 6;
5535
6500
  var TAP_MS = 500;
5536
6501
  function mountVenue3D(container, input, opts = {}) {
@@ -5598,11 +6563,11 @@ function mountVenue3D(container, input, opts = {}) {
5598
6563
  const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
5599
6564
  let lastGatherX = Infinity;
5600
6565
  let lastGatherZ = Infinity;
5601
- let nearFieldDetailEnabled = false;
5602
6566
  let cameraDetailVisible = false;
6567
+ let seatDetailMix = 0;
6568
+ let structureDetailMix = 0;
5603
6569
  let panoramaSphereVisible = false;
5604
6570
  const setNearFieldDetailEnabled = (enabled) => {
5605
- nearFieldDetailEnabled = enabled;
5606
6571
  if (!enabled) cameraDetailVisible = false;
5607
6572
  lastGatherX = Infinity;
5608
6573
  lastGatherZ = Infinity;
@@ -5651,27 +6616,36 @@ function mountVenue3D(container, input, opts = {}) {
5651
6616
  // first real drag/wheel/pinch (not the intro ease)
5652
6617
  );
5653
6618
  orbit.setAspect(glctx.aspect);
6619
+ const inferredImportedGeometry = (input.doc.floors?.flatMap((floor) => floor.objects) ?? input.doc.objects).some((object) => object.type === "section" && object.physical3dProvenance?.surfaceKind?.status === "inferred");
5654
6620
  const stageAzimuth = (() => {
6621
+ if (inferredImportedGeometry && model.stages.length === 0) return void 0;
5655
6622
  const dx = model.focalWorld[0] - model.bounds.center[0];
5656
6623
  const dz = model.focalWorld[2] - model.bounds.center[2];
5657
6624
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5658
6625
  })();
6626
+ const overviewFraming = model.floors.length > 1 ? { polarDeg: 48, margin: 1.1 } : void 0;
5659
6627
  const reduceMotionOnMount = typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
5660
- orbit.frame(model.bounds, !reduceMotionOnMount, stageAzimuth, opts.portraitOverviewCrop === true);
6628
+ orbit.frame(model.bounds, !reduceMotionOnMount, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
5661
6629
  const cinematic = new Cinematic(orbit.camera);
5662
6630
  const restoreContainerPosition = establishPositioningContext(container);
5663
6631
  const labelOverlay = new LabelOverlay(container, {
5664
6632
  fontFamily: input.doc.theme?.fontFamily,
5665
- ink: input.doc.theme?.textColor
6633
+ ink: input.doc.theme?.textColor,
6634
+ backgroundRgb: model.theme.background.top
5666
6635
  });
5667
- labelOverlay.setLabels(model.labels);
5668
6636
  const baseLabels = model.labels;
6637
+ const labelsForFocusedFloor = () => labelsForFloorView(
6638
+ baseLabels,
6639
+ model.floors.length,
6640
+ focusedFloor
6641
+ );
6642
+ labelOverlay.setLabels(labelsForFocusedFloor());
5669
6643
  let focusedSectionId = null;
5670
6644
  const showSectionSeatLabels = (sectionId) => {
5671
6645
  focusedSectionId = sectionId;
5672
6646
  labelOverlay.setForcedDense(!!sectionId);
5673
6647
  if (!sectionId) {
5674
- labelOverlay.setLabels(baseLabels);
6648
+ labelOverlay.setLabels(labelsForFocusedFloor());
5675
6649
  return;
5676
6650
  }
5677
6651
  const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
@@ -5679,7 +6653,7 @@ function mountVenue3D(container, input, opts = {}) {
5679
6653
  const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
5680
6654
  const selectedIds = new Set(selection.keys());
5681
6655
  const selectedInSection = new Set(sectionSeats.filter((seat) => selectedIds.has(seat.id)).map((seat) => seat.id));
5682
- const focusedBase = baseLabels.filter((label) => label.kind !== "row" && label.kind !== "seat" || (label.kind === "row" ? rowIds.has(label.id.slice("row:".length)) : seatIds.has(label.id.slice("seat:".length)) && (selectedInSection.size === 0 || selectedInSection.has(label.id.slice("seat:".length)))));
6656
+ const focusedBase = labelsForFocusedFloor().filter((label) => label.kind !== "row" && label.kind !== "seat" || (label.kind === "row" ? rowIds.has(label.id.slice("row:".length)) : seatIds.has(label.id.slice("seat:".length)) && (selectedInSection.size === 0 || selectedInSection.has(label.id.slice("seat:".length)))));
5683
6657
  if (model.seatCount <= 6e3 && selectedInSection.size === 0) {
5684
6658
  labelOverlay.setLabels(focusedBase);
5685
6659
  return;
@@ -5710,29 +6684,58 @@ function mountVenue3D(container, input, opts = {}) {
5710
6684
  ]);
5711
6685
  };
5712
6686
  rebuildGpu();
5713
- const loop = new RenderLoop(() => {
6687
+ const loop = new RenderLoop((dt) => {
5714
6688
  if (contextLost || !gpu || frozen) return false;
5715
6689
  const flying = cinematic.active;
5716
6690
  const moving = flying ? cinematic.update(performance.now()) : orbit.update();
5717
- const lod = computeSeatLod(orbit.currentDistance, model.bounds.radius);
6691
+ const activeFloorBounds = focusedFloor >= 0 ? model.floors[focusedFloor] : null;
6692
+ const lodRadius = activeFloorBounds?.radius ?? model.bounds.radius;
6693
+ const lod = computeSeatLod(orbit.currentDistance, lodRadius);
5718
6694
  const clippingBounds = panoramaSphereVisible ? { center: [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z], radius: 520 } : model.renderBounds;
5719
6695
  orbit.updateClipping(clippingBounds);
5720
- const cameraDx = orbit.camera.position.x - model.renderBounds.center[0];
5721
- const cameraDy = orbit.camera.position.y - model.renderBounds.center[1];
5722
- const cameraDz = orbit.camera.position.z - model.renderBounds.center[2];
5723
- const cameraVenueDistance = Math.hypot(cameraDx, cameraDy, cameraDz);
5724
- const detailEnter = model.renderBounds.radius * 1.55;
5725
- const detailExit = model.renderBounds.radius * 1.8;
5726
- if (!nearFieldDetailEnabled) cameraDetailVisible = false;
5727
- else if (cameraDetailVisible) cameraDetailVisible = cameraVenueDistance <= detailExit;
5728
- else cameraDetailVisible = cameraVenueDistance <= detailEnter;
6696
+ const detailCenter = activeFloorBounds?.center ?? model.renderBounds.center;
6697
+ const detailRadius = activeFloorBounds?.radius ?? model.renderBounds.radius;
6698
+ const withinScope = (center, radius) => {
6699
+ const d = Math.hypot(
6700
+ orbit.camera.position.x - center[0],
6701
+ orbit.camera.position.y - center[1],
6702
+ orbit.camera.position.z - center[2]
6703
+ );
6704
+ return d <= radius * (cameraDetailVisible ? 1.8 : 1.55);
6705
+ };
6706
+ let withinDetail = withinScope(detailCenter, detailRadius);
6707
+ if (!withinDetail && focusedSectionId) {
6708
+ const sec = model.sections.find((candidate) => candidate.id === focusedSectionId);
6709
+ if (sec) withinDetail = withinScope(sec.center, Math.max(12, sec.radius * 2));
6710
+ }
6711
+ cameraDetailVisible = withinDetail;
5729
6712
  const u = gpu.seatProgram.uniforms;
5730
6713
  u.uSeatScale.value = lod.scale;
5731
6714
  u.uSeatFade.value = lod.fade;
5732
- u.uSeatDetail.value = cameraDetailVisible ? 1 : 0;
5733
- gpu.solidProgram.uniforms.uStructureDetail.value = cameraDetailVisible ? 1 : 0;
6715
+ const floorSeatDetail = !!activeFloorBounds && floorOverviewSeatDetailVisible(
6716
+ activeFloorBounds.seatCount,
6717
+ glctx.canvas.clientWidth || 1,
6718
+ glctx.canvas.clientHeight || 1
6719
+ );
6720
+ const venueSeatDetail = projectedSeatDetailVisible(
6721
+ model.seats.count,
6722
+ lodRadius,
6723
+ orbit.currentDistance,
6724
+ orbit.camera.fov,
6725
+ glctx.canvas.clientHeight || 1
6726
+ );
6727
+ const seatDetailTarget = cameraDetailVisible || floorSeatDetail || venueSeatDetail ? 1 : 0;
6728
+ const structureDetailTarget = cameraDetailVisible || focusedFloor >= 0 || model.floors.length > 1 ? 1 : 0;
6729
+ const easeIn = 1 - Math.exp(-dt * 9);
6730
+ const easeOut = 1 - Math.exp(-dt * 20);
6731
+ seatDetailMix += (seatDetailTarget - seatDetailMix) * (seatDetailTarget > seatDetailMix ? easeIn : easeOut);
6732
+ structureDetailMix += (structureDetailTarget - structureDetailMix) * (structureDetailTarget > structureDetailMix ? easeIn : easeOut);
6733
+ if (Math.abs(seatDetailTarget - seatDetailMix) < 0.01) seatDetailMix = seatDetailTarget;
6734
+ if (Math.abs(structureDetailTarget - structureDetailMix) < 0.01) structureDetailMix = structureDetailTarget;
6735
+ u.uSeatDetail.value = seatDetailMix;
6736
+ gpu.solidProgram.uniforms.uStructureDetail.value = structureDetailMix;
5734
6737
  u.uMinPixels.value = lod.minPixels;
5735
- u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
6738
+ u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG4 / 2) / Math.max(1, glctx.pixelHeight);
5736
6739
  updateNearField(cameraDetailVisible);
5737
6740
  glctx.renderer.render({ scene: gpu.background, clear: true });
5738
6741
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
@@ -5741,12 +6744,12 @@ function mountVenue3D(container, input, opts = {}) {
5741
6744
  glctx.canvas.clientWidth || 1,
5742
6745
  glctx.canvas.clientHeight || 1,
5743
6746
  orbit.currentDistance,
5744
- model.bounds.radius,
6747
+ lodRadius,
5745
6748
  // The dense label rungs rank by real distance from the eye, not by the
5746
6749
  // orbit radius — at the arrival pose those are wildly different numbers.
5747
6750
  [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
5748
6751
  );
5749
- return moving;
6752
+ return moving || seatDetailMix !== seatDetailTarget || structureDetailMix !== structureDetailTarget;
5750
6753
  });
5751
6754
  const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
5752
6755
  ro?.observe(container);
@@ -5790,14 +6793,20 @@ function mountVenue3D(container, input, opts = {}) {
5790
6793
  const resolvedSeatViewPose = (seatId, idx) => {
5791
6794
  const seat = expandedSeatById.get(seatId);
5792
6795
  const floorIndex = Math.max(0, Math.round(model.seats.iFloor[idx] ?? 0));
5793
- const floor = input.doc.floors?.[floorIndex];
5794
- const focalPoint = seat?.focalPoint ?? floor?.focalPoint ?? input.doc.focalPoint;
6796
+ const sourceFloor = input.doc.floors?.[floorIndex];
6797
+ const sceneFloor = model.floors[floorIndex];
6798
+ const sourceFocal = sourceFloor?.focalPoint ?? input.doc.focalPoint;
6799
+ const sceneFocal = sceneFloor ? { x: sceneFloor.focalWorld[0] / M, y: sceneFloor.focalWorld[2] / M } : sourceFocal;
6800
+ const focalPoint = seat?.focalPoint ? {
6801
+ x: seat.focalPoint.x + sceneFocal.x - sourceFocal.x,
6802
+ y: seat.focalPoint.y + sceneFocal.y - sourceFocal.y
6803
+ } : sceneFocal;
5795
6804
  const deck = [
5796
6805
  model.seats.iPosition[idx * 3],
5797
6806
  model.seats.iPosition[idx * 3 + 1],
5798
6807
  model.seats.iPosition[idx * 3 + 2]
5799
6808
  ];
5800
- return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0, seat?.eyeHeightM);
6809
+ return seatViewPose(deck, focalPoint, sourceFloor?.baseHeightM ?? 0, seat?.eyeHeightM);
5801
6810
  };
5802
6811
  const placeCameraFinal = (finalPos, focal, fov = FOV_END) => {
5803
6812
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
@@ -6027,8 +7036,8 @@ function mountVenue3D(container, input, opts = {}) {
6027
7036
  overviewChip.className = "sl-3d-overview-control sl-3d-icon-control";
6028
7037
  overviewChip.type = "button";
6029
7038
  overviewChip.textContent = "\u2302";
6030
- overviewChip.setAttribute("aria-label", "Fit the whole venue in view");
6031
- overviewChip.dataset.tooltip = "Fit venue";
7039
+ overviewChip.setAttribute("aria-label", "Fit the active level or whole venue in view");
7040
+ overviewChip.dataset.tooltip = "Fit view";
6032
7041
  Object.assign(overviewChip.style, {
6033
7042
  width: "44px",
6034
7043
  minWidth: "44px",
@@ -6047,6 +7056,15 @@ function mountVenue3D(container, input, opts = {}) {
6047
7056
  overviewChip.textContent = "\u2302";
6048
7057
  };
6049
7058
  layoutOverviewChip();
7059
+ const applyFloorFocus = (value) => {
7060
+ focusedFloor = value;
7061
+ if (gpu) {
7062
+ gpu.seatProgram.uniforms.uFocusFloor.value = value;
7063
+ gpu.solidProgram.uniforms.uFocusFloor.value = value;
7064
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
7065
+ }
7066
+ showSectionSeatLabels(null);
7067
+ };
6050
7068
  const focusOverview = () => {
6051
7069
  if (disposed) return;
6052
7070
  opts.onViewTargetChange?.(null);
@@ -6063,13 +7081,25 @@ function mountVenue3D(container, input, opts = {}) {
6063
7081
  overviewChip.style.display = "";
6064
7082
  labelOverlay.setVisible(true);
6065
7083
  frozen = false;
6066
- setNearFieldDetailEnabled(false);
7084
+ const activeFloor = focusedFloor >= 0 ? model.floors[focusedFloor] : null;
7085
+ setNearFieldDetailEnabled(!!activeFloor);
6067
7086
  setSeatViewLocked(false);
6068
7087
  cancelFlight();
6069
7088
  removeArriveChip();
6070
7089
  setNavigationMode("orbit");
6071
- if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
6072
- else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
7090
+ if (activeFloor) {
7091
+ const dx = activeFloor.focalWorld[0] - activeFloor.center[0];
7092
+ const dz = activeFloor.focalWorld[2] - activeFloor.center[2];
7093
+ const azimuth = Math.hypot(dx, dz) > activeFloor.radius * 0.12 ? Math.atan2(dx, dz) : stageAzimuth;
7094
+ const floorBounds = { center: activeFloor.center, radius: activeFloor.radius, box: activeFloor.box };
7095
+ const floorFraming = { polarDeg: 58, margin: 1.12 };
7096
+ if (reducedMotion()) orbit.frame(floorBounds, false, azimuth, false, floorFraming);
7097
+ else orbit.frameSoft(floorBounds, azimuth, false, floorFraming);
7098
+ } else if (reducedMotion()) {
7099
+ orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
7100
+ } else {
7101
+ orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
7102
+ }
6073
7103
  showSectionSeatLabels(null);
6074
7104
  loop.requestRender();
6075
7105
  };
@@ -6256,6 +7286,7 @@ function mountVenue3D(container, input, opts = {}) {
6256
7286
  setSeatViewLocked(false);
6257
7287
  cinematic.cancel();
6258
7288
  removeArriveChip();
7289
+ if (model.floors.length > 1 && focusedFloor !== sec.floorIndex) applyFloorFocus(sec.floorIndex);
6259
7290
  setNavigationMode("pan");
6260
7291
  showSectionSeatLabels(sectionId);
6261
7292
  const dx = sec.focalWorld[0] - sec.center[0];
@@ -6438,20 +7469,25 @@ function mountVenue3D(container, input, opts = {}) {
6438
7469
  focusFloor(index) {
6439
7470
  if (index !== null && !model.floors.some((f) => f.index === index)) return false;
6440
7471
  const value = index ?? -1;
6441
- setNearFieldDetailEnabled(false);
7472
+ setNearFieldDetailEnabled(index !== null);
6442
7473
  setSeatViewLocked(false);
6443
- if (gpu) {
6444
- gpu.seatProgram.uniforms.uFocusFloor.value = value;
6445
- gpu.solidProgram.uniforms.uFocusFloor.value = value;
6446
- gpu.chairProgram.uniforms.uFocusFloor.value = value;
6447
- }
6448
- focusedFloor = value;
7474
+ applyFloorFocus(value);
6449
7475
  setNavigationMode("orbit");
6450
- if (index !== null) {
7476
+ if (index === null) {
7477
+ cinematic.cancel();
7478
+ if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
7479
+ else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
7480
+ } else {
6451
7481
  const f = model.floors[index];
6452
7482
  if (f.seatCount > 0) {
6453
7483
  cinematic.cancel();
6454
- orbit.frame({ center: f.center, radius: f.radius * 1.25 });
7484
+ const dx = f.focalWorld[0] - f.center[0];
7485
+ const dz = f.focalWorld[2] - f.center[2];
7486
+ const azimuth = Math.hypot(dx, dz) > f.radius * 0.12 ? Math.atan2(dx, dz) : stageAzimuth;
7487
+ orbit.frame({ center: f.center, radius: f.radius, box: f.box }, false, azimuth, false, {
7488
+ polarDeg: 58,
7489
+ margin: 1.12
7490
+ });
6455
7491
  }
6456
7492
  }
6457
7493
  loop.requestRender();