@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
@@ -467,7 +467,7 @@ var OrbitCamera = class {
467
467
  * then the damped `update()` eases it up into the 3/4 architectural angle and
468
468
  * dollies in — the venue "stands up" instead of teleporting (~600ms).
469
469
  */
470
- frame(bounds, intro = false, stageAzimuth, portraitCrop = false) {
470
+ frame(bounds, intro = false, stageAzimuth, portraitCrop = false, framing) {
471
471
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
472
472
  this.targetT.copy(this.target);
473
473
  this.panAnchor.copy(this.target);
@@ -477,9 +477,9 @@ var OrbitCamera = class {
477
477
  const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
478
478
  const halfH = Math.atan(Math.tan(halfV) * aspect);
479
479
  this.azT = stageAzimuth ?? -30 * DEG;
480
- this.polT = 55 * DEG;
480
+ this.polT = Math.max(15, Math.min(80, framing?.polarDeg ?? 55)) * DEG;
481
481
  const fit = this.fitDistance(bounds, halfV, halfH, this.azT, this.polT);
482
- this.distT = fit * FRAME_MARGIN;
482
+ this.distT = fit * Math.max(1.05, Math.min(2, framing?.margin ?? FRAME_MARGIN));
483
483
  this.minDist = Math.max(2, r * 0.12);
484
484
  this.maxDist = fit * 4;
485
485
  if (intro) {
@@ -501,7 +501,7 @@ var OrbitCamera = class {
501
501
  * seat, behind the shell, anywhere): re-pivot on the venue centre without
502
502
  * moving the camera, then glide targets back to the 3/4 architectural pose.
503
503
  */
504
- frameSoft(bounds, stageAzimuth, portraitCrop = false) {
504
+ frameSoft(bounds, stageAzimuth, portraitCrop = false, framing) {
505
505
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
506
506
  this.targetT.copy(this.target);
507
507
  this.panAnchor.copy(this.target);
@@ -512,9 +512,9 @@ var OrbitCamera = class {
512
512
  const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
513
513
  const halfH = Math.atan(Math.tan(halfV) * aspect);
514
514
  this.azT = stageAzimuth ?? this.azimuth;
515
- this.polT = 55 * DEG;
515
+ this.polT = Math.max(15, Math.min(80, framing?.polarDeg ?? 55)) * DEG;
516
516
  const fit = this.fitDistance(bounds, halfV, halfH, this.azT, this.polT);
517
- this.distT = fit * FRAME_MARGIN;
517
+ this.distT = fit * Math.max(1.05, Math.min(2, framing?.margin ?? FRAME_MARGIN));
518
518
  }
519
519
  /**
520
520
  * Distance from the bounds centre at which the venue fits the frustum at
@@ -530,14 +530,14 @@ var OrbitCamera = class {
530
530
  * every corner does. Clamped to the sphere fit (which always contains the
531
531
  * box) and floored so a degenerate box cannot glue the camera to the floor.
532
532
  */
533
- fitDistance(bounds, halfV, halfH, azimuth, polar) {
533
+ fitDistance(bounds, halfV, halfH, azimuth, polar2) {
534
534
  const r = Math.max(1, bounds.radius);
535
535
  const sphereFit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
536
536
  const box = bounds.box;
537
537
  if (!box) return sphereFit;
538
- const sp = Math.sin(polar);
538
+ const sp = Math.sin(polar2);
539
539
  const fx = -sp * Math.sin(azimuth);
540
- const fy = -Math.cos(polar);
540
+ const fy = -Math.cos(polar2);
541
541
  const fz = -sp * Math.cos(azimuth);
542
542
  if (Math.abs(fy) > 0.999) return sphereFit;
543
543
  let rx = -fz, rz = fx;
@@ -614,9 +614,9 @@ var OrbitCamera = class {
614
614
  const dy = this.camera.position.y - this.target.y;
615
615
  const dz = this.camera.position.z - this.target.z;
616
616
  const dist = Math.hypot(dx, dy, dz) || 1;
617
- const polar = Math.max(POLAR_MIN, Math.min(POLAR_MAX, Math.acos(Math.max(-1, Math.min(1, dy / dist)))));
617
+ const polar2 = Math.max(POLAR_MIN, Math.min(POLAR_MAX, Math.acos(Math.max(-1, Math.min(1, dy / dist)))));
618
618
  this.distance = this.distT = dist;
619
- this.polar = this.polT = polar;
619
+ this.polar = this.polT = polar2;
620
620
  this.azimuth = this.azT = Math.atan2(dx, dz);
621
621
  }
622
622
  /** Point the orbit pivot at a new world target without moving the camera. */
@@ -722,6 +722,20 @@ function computeSeatLod(distance, radius) {
722
722
  minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
723
723
  };
724
724
  }
725
+ function floorOverviewSeatDetailVisible(seatCount, viewportWidth, viewportHeight) {
726
+ if (!(seatCount > 0) || !(viewportWidth > 0) || !(viewportHeight > 0)) return false;
727
+ const pixelsPerSeat = viewportWidth * viewportHeight / seatCount;
728
+ return pixelsPerSeat >= 64;
729
+ }
730
+ function projectedSeatDetailVisible(seatCount, venueRadius, cameraDistance, fovYDeg, viewportHeightPx) {
731
+ if (!(seatCount > 0) || !(venueRadius > 0) || !(cameraDistance > 0)) return false;
732
+ if (!(fovYDeg > 0) || !(viewportHeightPx > 0)) return false;
733
+ const halfFov = Math.tan(fovYDeg / 2 * Math.PI / 180);
734
+ if (!(halfFov > 0)) return false;
735
+ const rPx = venueRadius / (cameraDistance * halfFov) * (viewportHeightPx / 2);
736
+ const projectedArea = Math.PI * rPx * rPx;
737
+ return projectedArea / seatCount >= 64;
738
+ }
725
739
 
726
740
  // src/view3d/scene/nearField.ts
727
741
  var SEATS_PER_CELL = 4;
@@ -1758,17 +1772,45 @@ function themeSeatColorLUT(theme, order) {
1758
1772
  }
1759
1773
 
1760
1774
  // src/view3d/labels.ts
1775
+ function labelsForFloorView(labels, floorCount, focusedFloor) {
1776
+ if (focusedFloor < 0) {
1777
+ return floorCount > 1 ? labels.filter((label) => label.kind === "floor") : [...labels];
1778
+ }
1779
+ return labels.filter((label) => label.floorIndex === void 0 || label.floorIndex === focusedFloor);
1780
+ }
1781
+ function spreadFloorLabels(items, viewportWidth) {
1782
+ const ordered = [...items].sort((left, right) => left.screen.y - right.screen.y);
1783
+ const clusters = [];
1784
+ for (const item of ordered) {
1785
+ 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));
1786
+ if (cluster) cluster.push(item);
1787
+ else clusters.push([item]);
1788
+ }
1789
+ return clusters.flatMap((cluster) => {
1790
+ if (cluster.length < 2) return cluster;
1791
+ return cluster.map((item, index) => ({
1792
+ ...item,
1793
+ screen: {
1794
+ ...item.screen,
1795
+ x: Math.max(54, Math.min(viewportWidth - 54, item.screen.x + (index - (cluster.length - 1) / 2) * 104))
1796
+ }
1797
+ }));
1798
+ });
1799
+ }
1761
1800
  var ZONE_MIN_DISTANCE = 1.15;
1762
- var SECTION_MAX_DISTANCE = 2.2;
1801
+ var SECTION_MAX_DISTANCE = 3.25;
1802
+ var SECTION_MAX_DISTANCE_NO_ZONES = 4.5;
1763
1803
  var NEAR_MAX_DISTANCE = 0.85;
1764
1804
  var ROW_MAX_DISTANCE = 0.5;
1765
1805
  var SEAT_MAX_DISTANCE = 0.26;
1766
- function visibleLabelKinds(distance, venueRadius) {
1806
+ function visibleLabelKinds(distance, venueRadius, opts) {
1767
1807
  const r = Math.max(1e-6, venueRadius);
1768
1808
  const d = distance / r;
1809
+ const hasZones = opts?.hasZoneLabels ?? true;
1769
1810
  const out = /* @__PURE__ */ new Set();
1811
+ if (d >= 0.75) out.add("floor");
1770
1812
  if (d >= ZONE_MIN_DISTANCE) out.add("zone");
1771
- if (d <= SECTION_MAX_DISTANCE) {
1813
+ if (d <= (hasZones ? SECTION_MAX_DISTANCE : SECTION_MAX_DISTANCE_NO_ZONES)) {
1772
1814
  out.add("section");
1773
1815
  out.add("ga");
1774
1816
  }
@@ -2919,7 +2961,7 @@ function pointInRing2(ring, x, y) {
2919
2961
  return inside;
2920
2962
  }
2921
2963
  var MAX_TIER_RISE_M = 25;
2922
- function buildVenueSurfaces(units, seats) {
2964
+ function buildVenueSurfaces(units, seats, seatFloorIndices) {
2923
2965
  const bySection = /* @__PURE__ */ new Map();
2924
2966
  const seatOwner = new Array(seats.length).fill(null);
2925
2967
  const seatDeck = new Float64Array(seats.length);
@@ -2928,18 +2970,20 @@ function buildVenueSurfaces(units, seats) {
2928
2970
  const acc = /* @__PURE__ */ new Map();
2929
2971
  const boxes = [];
2930
2972
  const tableRowIds = /* @__PURE__ */ new Set();
2931
- for (const unit of units) {
2973
+ for (let floorIndex = 0; floorIndex < units.length; floorIndex += 1) {
2974
+ const unit = units[floorIndex];
2932
2975
  for (const o of unit.objects) {
2933
2976
  if (o.type === "table") tableRowIds.add(o.id);
2934
2977
  if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2935
2978
  const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2936
2979
  acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2937
- boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2980
+ boxes.push({ id: o.id, floorIndex, box: bboxOf(owned), section: o, unit, outline: owned });
2938
2981
  }
2939
2982
  }
2940
2983
  for (let i = 0; i < seats.length; i++) {
2941
2984
  const s = seats[i];
2942
2985
  for (const b of boxes) {
2986
+ if (seatFloorIndices && b.floorIndex !== seatFloorIndices[i]) continue;
2943
2987
  if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2944
2988
  if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2945
2989
  seatOwner[i] = b.id;
@@ -3097,6 +3141,634 @@ function buildVenueSurfaces(units, seats) {
3097
3141
  };
3098
3142
  }
3099
3143
 
3144
+ // src/core/bowlProfile.ts
3145
+ var DEG3 = Math.PI / 180;
3146
+ var BIN_COUNT = 96;
3147
+ var GAP_FILL_BINS = 6;
3148
+ var MIN_ARC_DEG = 60;
3149
+ var MIN_RING_DEG = 150;
3150
+ var MIN_HOLE_RATIO = 0.2;
3151
+ var RING_OVERLAP_RATIO = 0.35;
3152
+ function bearingDeg(c, p) {
3153
+ const d = Math.atan2(p.x - c.x, p.y - c.y) / DEG3;
3154
+ return d < 0 ? d + 360 : d;
3155
+ }
3156
+ function median(values) {
3157
+ if (!values.length) return NaN;
3158
+ const s = [...values].sort((a, b) => a - b);
3159
+ return s[Math.floor(s.length / 2)];
3160
+ }
3161
+ function smoothRun(values, wrap, window2 = 2) {
3162
+ const n = values.length;
3163
+ const out = new Float64Array(n);
3164
+ for (let i = 0; i < n; i++) {
3165
+ let sum = 0, cnt = 0;
3166
+ for (let k = -window2; k <= window2; k++) {
3167
+ let j = i + k;
3168
+ if (wrap) j = (j % n + n) % n;
3169
+ else if (j < 0 || j >= n) continue;
3170
+ sum += values[j];
3171
+ cnt++;
3172
+ }
3173
+ out[i] = sum / cnt;
3174
+ }
3175
+ return out;
3176
+ }
3177
+ function fitBowlProfile(sections, focal) {
3178
+ const usable = sections.filter((s) => s.rows.length >= 1);
3179
+ if (usable.length < 1) return null;
3180
+ const centres = [];
3181
+ let cx = 0, cy = 0, np = 0;
3182
+ for (const s of usable) {
3183
+ for (const row of s.rows) {
3184
+ for (const p of row.pts) {
3185
+ cx += p.x;
3186
+ cy += p.y;
3187
+ np++;
3188
+ }
3189
+ if (row.pts.length >= 4) {
3190
+ const c = fitCircle(row.pts);
3191
+ if (c && Number.isFinite(c.cx) && Number.isFinite(c.cy)) centres.push({ x: c.cx, y: c.cy });
3192
+ }
3193
+ }
3194
+ }
3195
+ if (!np) return null;
3196
+ const centroid2 = { x: cx / np, y: cy / np };
3197
+ let centre = centroid2;
3198
+ if (centres.length >= 8) {
3199
+ const mx = median(centres.map((c) => c.x));
3200
+ const my = median(centres.map((c) => c.y));
3201
+ let spread = 0;
3202
+ for (const s of usable) for (const row of s.rows) for (const p of row.pts) {
3203
+ const d = Math.hypot(p.x - centroid2.x, p.y - centroid2.y);
3204
+ if (d > spread) spread = d;
3205
+ }
3206
+ let near = 0;
3207
+ const nearR = spread * 0.2;
3208
+ for (const s of usable) for (const row of s.rows) for (const p of row.pts) {
3209
+ if (Math.hypot(p.x - mx, p.y - my) < nearR) near++;
3210
+ }
3211
+ if (Math.hypot(mx - centroid2.x, my - centroid2.y) <= spread && near / np < 0.01) centre = { x: mx, y: my };
3212
+ }
3213
+ const stats = [];
3214
+ for (const s of usable) {
3215
+ const st = { sectionId: s.sectionId, rMin: Infinity, rMax: -Infinity, samples: [] };
3216
+ for (const row of s.rows) {
3217
+ for (const p of row.pts) {
3218
+ const r = Math.hypot(p.x - centre.x, p.y - centre.y);
3219
+ const bin = Math.min(BIN_COUNT - 1, Math.floor(bearingDeg(centre, p) / (360 / BIN_COUNT)));
3220
+ st.samples.push({ r, bin, levelM: row.levelM });
3221
+ if (r < st.rMin) st.rMin = r;
3222
+ if (r > st.rMax) st.rMax = r;
3223
+ }
3224
+ }
3225
+ if (st.samples.length) stats.push(st);
3226
+ }
3227
+ if (!stats.length) return null;
3228
+ stats.sort((a, b) => a.rMin + a.rMax - (b.rMin + b.rMax));
3229
+ const ringAccs = [];
3230
+ for (const st of stats) {
3231
+ let joined = false;
3232
+ for (const ring of ringAccs) {
3233
+ const overlap = Math.min(st.rMax, ring.rMax) - Math.max(st.rMin, ring.rMin);
3234
+ const thinner = Math.min(st.rMax - st.rMin, ring.rMax - ring.rMin);
3235
+ if (thinner > 1e-9 && overlap >= RING_OVERLAP_RATIO * thinner) {
3236
+ ring.members.push(st);
3237
+ ring.rMin = Math.min(ring.rMin, st.rMin);
3238
+ ring.rMax = Math.max(ring.rMax, st.rMax);
3239
+ joined = true;
3240
+ break;
3241
+ }
3242
+ }
3243
+ if (!joined) ringAccs.push({ members: [st], rMin: st.rMin, rMax: st.rMax });
3244
+ }
3245
+ const rings = [];
3246
+ for (const ring of ringAccs) {
3247
+ const bins = new Array(BIN_COUNT).fill(null);
3248
+ for (const st of ring.members) {
3249
+ for (const s of st.samples) {
3250
+ let b = bins[s.bin];
3251
+ if (!b) {
3252
+ b = { samples: [] };
3253
+ bins[s.bin] = b;
3254
+ }
3255
+ b.samples.push({ r: s.r, levelM: s.levelM });
3256
+ }
3257
+ }
3258
+ const covered = bins.map((b) => !!b);
3259
+ const coveredCount = covered.filter(Boolean).length;
3260
+ if (!coveredCount) continue;
3261
+ const innerSeed = [];
3262
+ const outerSeed = [];
3263
+ for (const b of bins) {
3264
+ if (!b) continue;
3265
+ let lo = Infinity, hi = -Infinity;
3266
+ for (const s of b.samples) {
3267
+ if (s.r < lo) lo = s.r;
3268
+ if (s.r > hi) hi = s.r;
3269
+ }
3270
+ innerSeed.push(lo);
3271
+ outerSeed.push(hi);
3272
+ }
3273
+ const innerRMedian = median(innerSeed);
3274
+ const outerRMedian = median(outerSeed);
3275
+ if (!(innerRMedian >= MIN_HOLE_RATIO * outerRMedian) || !(outerRMedian > 0)) continue;
3276
+ const sortedInner = [...innerSeed].sort((a, b) => a - b);
3277
+ const innerP10 = sortedInner[Math.floor(sortedInner.length * 0.1)];
3278
+ if (!(innerP10 >= MIN_HOLE_RATIO * outerRMedian)) continue;
3279
+ const vals = bins.map((b) => {
3280
+ if (!b) return null;
3281
+ const sorted = [...b.samples].sort((u, v) => u.r - v.r);
3282
+ const inner = sorted[0].r;
3283
+ const outer = sorted[sorted.length - 1].r;
3284
+ const front = sorted[0].levelM;
3285
+ let top = -Infinity;
3286
+ for (const s of sorted) if (s.levelM > top) top = s.levelM;
3287
+ const hull = [];
3288
+ for (const s of sorted) {
3289
+ const pt = { r: s.r, l: s.levelM };
3290
+ while (hull.length && Math.abs(hull[hull.length - 1].r - pt.r) < 1e-9) {
3291
+ if (hull[hull.length - 1].l <= pt.l) {
3292
+ pt.skip = true;
3293
+ break;
3294
+ }
3295
+ hull.pop();
3296
+ }
3297
+ if (pt.skip) continue;
3298
+ while (hull.length >= 2) {
3299
+ const a = hull[hull.length - 2], b2 = hull[hull.length - 1];
3300
+ if ((b2.r - a.r) * (pt.l - a.l) - (b2.l - a.l) * (pt.r - a.r) <= 0) hull.pop();
3301
+ else break;
3302
+ }
3303
+ hull.push(pt);
3304
+ }
3305
+ const rs = hull.map((p) => p.r);
3306
+ const ls = hull.map((p) => p.l);
3307
+ return { inner, outer, front, top, profile: { rs, ls } };
3308
+ });
3309
+ const filled = covered.slice();
3310
+ for (let i = 0; i < BIN_COUNT; i++) {
3311
+ if (filled[i]) continue;
3312
+ let before = -1, after = -1, gap = 0;
3313
+ for (let k = 1; k <= BIN_COUNT; k++) {
3314
+ const j = (i - k + BIN_COUNT * 2) % BIN_COUNT;
3315
+ if (filled[j]) {
3316
+ before = j;
3317
+ break;
3318
+ }
3319
+ }
3320
+ for (let k = 1; k <= BIN_COUNT; k++) {
3321
+ const j = (i + k) % BIN_COUNT;
3322
+ if (filled[j]) {
3323
+ after = j;
3324
+ gap = k;
3325
+ break;
3326
+ }
3327
+ }
3328
+ if (before < 0 || after < 0) continue;
3329
+ let back = 0;
3330
+ for (let k = 1; k <= BIN_COUNT; k++) {
3331
+ const j = (i - k + BIN_COUNT * 2) % BIN_COUNT;
3332
+ if (covered[j]) {
3333
+ back = k;
3334
+ break;
3335
+ }
3336
+ }
3337
+ const gapLen = back - 1 + gap;
3338
+ if (gapLen > GAP_FILL_BINS) continue;
3339
+ const a = vals[before], b = vals[after];
3340
+ const t = back / (back + gap);
3341
+ const inner = a.inner + (b.inner - a.inner) * t;
3342
+ const outer = a.outer + (b.outer - a.outer) * t;
3343
+ const front = a.front + (b.front - a.front) * t;
3344
+ const top = a.top + (b.top - a.top) * t;
3345
+ vals[i] = { inner, outer, front, top, profile: { rs: [inner, outer], ls: [front, top] } };
3346
+ }
3347
+ for (let i = 0; i < BIN_COUNT; i++) if (vals[i]) filled[i] = true;
3348
+ const stepDeg = 360 / BIN_COUNT;
3349
+ const arcs = [];
3350
+ const filledCount = filled.filter(Boolean).length;
3351
+ const runs = [];
3352
+ if (filledCount === BIN_COUNT) {
3353
+ runs.push({ start: 0, len: BIN_COUNT });
3354
+ } else {
3355
+ let scan = 0;
3356
+ while (filled[scan]) scan = (scan + 1) % BIN_COUNT;
3357
+ let runStart = -1;
3358
+ for (let k = 0; k <= BIN_COUNT; k++) {
3359
+ const j = (scan + k) % BIN_COUNT;
3360
+ const on = k < BIN_COUNT && filled[j];
3361
+ if (on && runStart < 0) runStart = j;
3362
+ else if (!on && runStart >= 0) {
3363
+ const len = (j - runStart + BIN_COUNT) % BIN_COUNT || BIN_COUNT;
3364
+ runs.push({ start: runStart, len });
3365
+ runStart = -1;
3366
+ }
3367
+ }
3368
+ }
3369
+ for (const run of runs) {
3370
+ if (run.len * stepDeg < MIN_ARC_DEG && run.len < BIN_COUNT) continue;
3371
+ const closed = run.len === BIN_COUNT;
3372
+ const innerR = new Float64Array(run.len);
3373
+ const outerR = new Float64Array(run.len);
3374
+ const frontL = new Float64Array(run.len);
3375
+ const topL = new Float64Array(run.len);
3376
+ const profiles = [];
3377
+ for (let k = 0; k < run.len; k++) {
3378
+ const v = vals[(run.start + k) % BIN_COUNT];
3379
+ innerR[k] = v.inner;
3380
+ outerR[k] = v.outer;
3381
+ frontL[k] = v.front;
3382
+ topL[k] = v.top;
3383
+ profiles.push(v.profile ?? { rs: [v.inner, v.outer], ls: [v.front, v.top] });
3384
+ }
3385
+ const sInner = smoothRun(innerR, closed);
3386
+ const sOuter = smoothRun(outerR, closed);
3387
+ const sFront = smoothRun(frontL, closed);
3388
+ const sTop = smoothRun(topL, closed);
3389
+ for (let k = 0; k < run.len; k++) {
3390
+ if (sInner[k] > innerR[k]) sInner[k] = innerR[k];
3391
+ if (sOuter[k] < outerR[k]) sOuter[k] = outerR[k];
3392
+ if (sInner[k] > sOuter[k] - 1e-6) sInner[k] = Math.max(0, sOuter[k] - 1e-6);
3393
+ if (sTop[k] < sFront[k]) sTop[k] = sFront[k];
3394
+ }
3395
+ const arc = { startBin: run.start, binCount: run.len, closed, innerR: sInner, outerR: sOuter, frontLevelM: sFront, topLevelM: sTop, profiles };
3396
+ const clampProfile = (k, r, levelM) => {
3397
+ const prof = profiles[k];
3398
+ const rs = prof.rs, ls = prof.ls;
3399
+ if (r <= rs[0]) {
3400
+ if (ls[0] > levelM) ls[0] = levelM;
3401
+ return;
3402
+ }
3403
+ if (r >= rs[rs.length - 1]) {
3404
+ if (ls[ls.length - 1] > levelM) ls[ls.length - 1] = levelM;
3405
+ return;
3406
+ }
3407
+ for (let j = 0; j + 1 < rs.length; j++) {
3408
+ if (r <= rs[j + 1]) {
3409
+ if (ls[j] > levelM) ls[j] = levelM;
3410
+ if (ls[j + 1] > levelM) ls[j + 1] = levelM;
3411
+ return;
3412
+ }
3413
+ }
3414
+ };
3415
+ for (let k = 0; k < run.len; k++) {
3416
+ const b = bins[(run.start + k) % BIN_COUNT];
3417
+ if (!b) continue;
3418
+ for (const s of b.samples) {
3419
+ clampProfile(k, s.r, s.levelM);
3420
+ const prev = closed ? (k - 1 + run.len) % run.len : k - 1;
3421
+ const next = closed ? (k + 1) % run.len : k + 1;
3422
+ if (prev >= 0) clampProfile(prev, s.r, s.levelM);
3423
+ if (next < run.len) clampProfile(next, s.r, s.levelM);
3424
+ }
3425
+ }
3426
+ arcs.push(arc);
3427
+ }
3428
+ const totalDeg = arcs.reduce((sum, a) => sum + a.binCount * stepDeg, 0);
3429
+ if (totalDeg < MIN_RING_DEG) continue;
3430
+ rings.push({
3431
+ arcs,
3432
+ sectionIds: ring.members.map((m) => m.sectionId),
3433
+ innerRMedian,
3434
+ outerRMedian
3435
+ });
3436
+ }
3437
+ if (!rings.length) return null;
3438
+ rings.sort((a, b) => a.innerRMedian - b.innerRMedian);
3439
+ if (focal) {
3440
+ const outermost = rings[rings.length - 1].outerRMedian;
3441
+ if (Math.hypot(focal.x - centre.x, focal.y - centre.y) > 0.9 * outermost) return null;
3442
+ }
3443
+ return { centre, binCount: BIN_COUNT, binStepDeg: 360 / BIN_COUNT, rings };
3444
+ }
3445
+ function arcLevelAt(fit, arc, bearing, r) {
3446
+ const step = fit.binStepDeg;
3447
+ let f = bearing / step - 0.5 - arc.startBin;
3448
+ f = (f % fit.binCount + fit.binCount) % fit.binCount;
3449
+ if (f > arc.binCount - 1) {
3450
+ if (arc.closed) f = f - arc.binCount < arc.binCount - f ? 0 : arc.binCount - 1;
3451
+ else f = f - (arc.binCount - 1) < fit.binCount - f ? arc.binCount - 1 : 0;
3452
+ }
3453
+ const i0 = Math.floor(f);
3454
+ const i1 = arc.closed ? (i0 + 1) % arc.binCount : Math.min(arc.binCount - 1, i0 + 1);
3455
+ const t = f - i0;
3456
+ const sample = (i) => {
3457
+ const prof = arc.profiles[i];
3458
+ const rs = prof.rs, ls = prof.ls;
3459
+ if (r <= rs[0]) return ls[0];
3460
+ if (r >= rs[rs.length - 1]) return ls[ls.length - 1];
3461
+ for (let k = 0; k + 1 < rs.length; k++) {
3462
+ if (r <= rs[k + 1]) {
3463
+ const span = rs[k + 1] - rs[k];
3464
+ const u = span > 1e-9 ? (r - rs[k]) / span : 0;
3465
+ return ls[k] + (ls[k + 1] - ls[k]) * u;
3466
+ }
3467
+ }
3468
+ return ls[ls.length - 1];
3469
+ };
3470
+ return sample(i0) * (1 - t) + sample(i1) * t;
3471
+ }
3472
+
3473
+ // src/view3d/scene/bowlShell.ts
3474
+ var BAND_RECESS_M = 0.25;
3475
+ var BAND_MIN_RELIEF_M = 1.2;
3476
+ var SHELL_GAP_M = 0.6;
3477
+ var SHELL_THICKNESS_M = 0.45;
3478
+ var SHELL_PARAPET_M = 2.3;
3479
+ var DEG4 = Math.PI / 180;
3480
+ function polar(c, r, thetaDeg) {
3481
+ const t = thetaDeg * DEG4;
3482
+ return { x: c.x + r * Math.sin(t), y: c.y + r * Math.cos(t) };
3483
+ }
3484
+ function hasAuthoredPerimeter(shapes, fit) {
3485
+ const outer = fit.rings[fit.rings.length - 1];
3486
+ if (!outer) return false;
3487
+ const threshold = outer.outerRMedian * 0.85;
3488
+ for (const s of shapes) {
3489
+ if (s.role !== "wall") continue;
3490
+ 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 ? [
3491
+ { x: s.x, y: s.y },
3492
+ { x: s.x + s.width, y: s.y },
3493
+ { x: s.x + s.width, y: s.y + s.height },
3494
+ { x: s.x, y: s.y + s.height }
3495
+ ] : [];
3496
+ if (pts.length < 2) continue;
3497
+ let beyond = 0;
3498
+ for (const p of pts) {
3499
+ if (Math.hypot(p.x - fit.centre.x, p.y - fit.centre.y) >= threshold) beyond++;
3500
+ }
3501
+ if (beyond >= pts.length / 2) return true;
3502
+ }
3503
+ return false;
3504
+ }
3505
+ var BAND_RADIAL_CELLS = 24;
3506
+ function emitBandGrid(builder, fit, arc, innerEdge, outerEdge, bottomY, colors, ao, seatGuards) {
3507
+ const K = arc.binCount;
3508
+ const M2 = BAND_RADIAL_CELLS;
3509
+ const U = METRES_PER_CHART_UNIT;
3510
+ const floorY = bottomY + 0.02;
3511
+ const h = [];
3512
+ for (let k = 0; k < K; k++) {
3513
+ const bearing = (arc.startBin + k + 0.5) * fit.binStepDeg;
3514
+ const row = new Float64Array(M2 + 1);
3515
+ for (let j = 0; j <= M2; j++) {
3516
+ const r = innerEdge[k] + (outerEdge[k] - innerEdge[k]) * j / M2;
3517
+ row[j] = Math.max(floorY, arcLevelAt(fit, arc, bearing, r) - BAND_RECESS_M);
3518
+ }
3519
+ h.push(row);
3520
+ }
3521
+ for (const g of seatGuards) {
3522
+ const bearing = bearingDeg(fit.centre, g);
3523
+ const r = Math.hypot(g.x - fit.centre.x, g.y - fit.centre.y);
3524
+ let f = bearing / fit.binStepDeg - 0.5 - arc.startBin;
3525
+ f = (f % fit.binCount + fit.binCount) % fit.binCount;
3526
+ if (f > K - 1 && !arc.closed) {
3527
+ if (f < K - 0.5 || f > fit.binCount - 0.5) {
3528
+ } else continue;
3529
+ }
3530
+ const cap = g.deckY - BAND_RECESS_M;
3531
+ const k0 = Math.min(K - 1, Math.max(0, Math.floor(f)));
3532
+ const kk = [k0, arc.closed ? (k0 + 1) % K : Math.min(K - 1, k0 + 1)];
3533
+ for (const k of kk) {
3534
+ const span = outerEdge[k] - innerEdge[k];
3535
+ if (!(span > 1e-9)) continue;
3536
+ const jf = (r - innerEdge[k]) / span * M2;
3537
+ if (jf < -0.75 || jf > M2 + 0.75) continue;
3538
+ const j0 = Math.min(M2, Math.max(0, Math.floor(jf)));
3539
+ const j1 = Math.min(M2, j0 + 1);
3540
+ if (h[k][j0] > cap) h[k][j0] = Math.max(floorY, cap);
3541
+ if (h[k][j1] > cap) h[k][j1] = Math.max(floorY, cap);
3542
+ }
3543
+ }
3544
+ const cTop = [colors.bandTop[0] * ao.top, colors.bandTop[1] * ao.top, colors.bandTop[2] * ao.top];
3545
+ const cBot = [colors.bandTop[0] * ao.bottomCap, colors.bandTop[1] * ao.bottomCap, colors.bandTop[2] * ao.bottomCap];
3546
+ const cWallTop = [colors.bandWall[0] * ao.top, colors.bandWall[1] * ao.top, colors.bandWall[2] * ao.top];
3547
+ const cWallBot = [colors.bandWall[0] * ao.wallBottom, colors.bandWall[1] * ao.wallBottom, colors.bandWall[2] * ao.wallBottom];
3548
+ const point = (k, j) => {
3549
+ const bearing = (arc.startBin + k + 0.5) * fit.binStepDeg;
3550
+ const r = innerEdge[k] + (outerEdge[k] - innerEdge[k]) * j / M2;
3551
+ const p = polar(fit.centre, r, bearing);
3552
+ return [p.x * U, h[k][j], p.y * U];
3553
+ };
3554
+ {
3555
+ const TAN_LIMIT = Math.tan(12 * Math.PI / 180);
3556
+ for (let pass = 0; pass < 6; pass++) {
3557
+ let changed = false;
3558
+ for (let k = 0; k < K; k++) {
3559
+ for (let j = M2 - 1; j >= 0; j--) {
3560
+ if (h[k][j] > h[k][j + 1]) {
3561
+ h[k][j] = h[k][j + 1];
3562
+ changed = true;
3563
+ }
3564
+ }
3565
+ }
3566
+ const lastPair = arc.closed ? K : K - 1;
3567
+ for (let k = 0; k < lastPair; k++) {
3568
+ const k1 = (k + 1) % K;
3569
+ for (let j = 0; j <= M2; j++) {
3570
+ const d = h[k1][j] - h[k][j];
3571
+ if (Math.abs(d) > 1) continue;
3572
+ const r0 = innerEdge[k] + (outerEdge[k] - innerEdge[k]) * j / M2;
3573
+ const r1 = innerEdge[k1] + (outerEdge[k1] - innerEdge[k1]) * j / M2;
3574
+ const b0 = polar(fit.centre, r0, (arc.startBin + k + 0.5) * fit.binStepDeg);
3575
+ const b1 = polar(fit.centre, r1, (arc.startBin + k1 + 0.5) * fit.binStepDeg);
3576
+ const lim = TAN_LIMIT * Math.hypot(b1.x - b0.x, b1.y - b0.y) * U;
3577
+ if (d > lim) {
3578
+ h[k1][j] = h[k][j] + lim;
3579
+ changed = true;
3580
+ } else if (-d > lim) {
3581
+ h[k][j] = h[k1][j] + lim;
3582
+ changed = true;
3583
+ }
3584
+ }
3585
+ }
3586
+ if (!changed) break;
3587
+ }
3588
+ }
3589
+ const JUMP_M = 1;
3590
+ const cliffBetween = (k, k1) => {
3591
+ for (let j = 0; j <= M2; j += 4) {
3592
+ if (Math.abs(h[k][j] - h[k1][j]) > JUMP_M) return true;
3593
+ }
3594
+ return Math.abs(h[k][M2] - h[k1][M2]) > JUMP_M;
3595
+ };
3596
+ const TAN_WALL = Math.tan(38 * Math.PI / 180);
3597
+ const radialCellM = (k) => (outerEdge[k] - innerEdge[k]) / M2 * U;
3598
+ const radialStep = (k, j) => h[k][j + 1] - h[k][j] > TAN_WALL * Math.max(1e-6, radialCellM(k));
3599
+ const vertexNormal = (k, j) => {
3600
+ let kPrev = arc.closed ? (k - 1 + K) % K : Math.max(0, k - 1);
3601
+ let kNext = arc.closed ? (k + 1) % K : Math.min(K - 1, k + 1);
3602
+ if (Math.abs(h[kPrev][j] - h[k][j]) > JUMP_M) kPrev = k;
3603
+ if (Math.abs(h[kNext][j] - h[k][j]) > JUMP_M) kNext = k;
3604
+ let jPrev = Math.max(0, j - 1);
3605
+ let jNext = Math.min(M2, j + 1);
3606
+ if (jPrev < j && radialStep(k, jPrev)) jPrev = j;
3607
+ if (jNext > j && radialStep(k, j)) jNext = j;
3608
+ const pk0 = point(kPrev, j), pk1 = point(kNext, j);
3609
+ const pj0 = point(k, jPrev), pj1 = point(k, jNext);
3610
+ const ux = pk1[0] - pk0[0], uy = pk1[1] - pk0[1], uz = pk1[2] - pk0[2];
3611
+ const vx = pj1[0] - pj0[0], vy = pj1[1] - pj0[1], vz = pj1[2] - pj0[2];
3612
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
3613
+ const len = Math.hypot(nx, ny, nz);
3614
+ if (!(len > 1e-12)) return [0, 1, 0];
3615
+ nx /= len;
3616
+ ny /= len;
3617
+ nz /= len;
3618
+ if (ny < 0) {
3619
+ nx = -nx;
3620
+ ny = -ny;
3621
+ nz = -nz;
3622
+ }
3623
+ return [nx, ny, nz];
3624
+ };
3625
+ const wallQuad = (a, b) => {
3626
+ const a0 = [a[0], bottomY, a[2]];
3627
+ const b0 = [b[0], bottomY, b[2]];
3628
+ const ex = b[0] - a[0], ez = b[2] - a[2];
3629
+ const len = Math.hypot(ex, ez);
3630
+ if (!(len > 1e-12)) return;
3631
+ const n = [ez / len, 0, -ex / len];
3632
+ builder.tri(a, b, b0, n, cWallTop, cWallTop, cWallBot);
3633
+ builder.tri(a, b0, a0, n, cWallTop, cWallBot, cWallBot);
3634
+ };
3635
+ const MIN_SPAN_U = 0.05 * CHART_UNITS_PER_METRE;
3636
+ const pinched = (k) => outerEdge[k] - innerEdge[k] < MIN_SPAN_U;
3637
+ const lastK = arc.closed ? K : K - 1;
3638
+ for (let k = 0; k < lastK; k++) {
3639
+ const k1 = (k + 1) % K;
3640
+ if (pinched(k) || pinched(k1)) continue;
3641
+ if (cliffBetween(k, k1)) {
3642
+ const bearingMid = (arc.startBin + k + 1) * fit.binStepDeg;
3643
+ const midAt = (side, j) => {
3644
+ const rIn = (innerEdge[k] + innerEdge[k1]) / 2;
3645
+ const rOut = (outerEdge[k] + outerEdge[k1]) / 2;
3646
+ const r = rIn + (rOut - rIn) * j / M2;
3647
+ const p = polar(fit.centre, r, bearingMid);
3648
+ return [p.x * U, h[side][j], p.y * U];
3649
+ };
3650
+ for (let j = 0; j < M2; j++) {
3651
+ const aL = point(k, j), dL = point(k, j + 1);
3652
+ const bL = midAt(k, j), cL = midAt(k, j + 1);
3653
+ const nL0 = vertexNormal(k, j), nL1 = vertexNormal(k, j + 1);
3654
+ builder.triN(aL, bL, cL, nL0, nL0, nL1, cTop);
3655
+ builder.triN(aL, cL, dL, nL0, nL1, nL1, cTop);
3656
+ const bR = point(k1, j), cR = point(k1, j + 1);
3657
+ const aR = midAt(k1, j), dR = midAt(k1, j + 1);
3658
+ const nR0 = vertexNormal(k1, j), nR1 = vertexNormal(k1, j + 1);
3659
+ builder.triN(aR, bR, cR, nR0, nR0, nR1, cTop);
3660
+ builder.triN(aR, cR, dR, nR0, nR1, nR1, cTop);
3661
+ const w0 = midAt(k, j), w1 = midAt(k, j + 1), w2 = midAt(k1, j + 1), w3 = midAt(k1, j);
3662
+ const ex = w1[0] - w0[0], ez = w1[2] - w0[2];
3663
+ const el = Math.hypot(ex, ez);
3664
+ if (el > 1e-12) {
3665
+ const n = [ez / el, 0, -ex / el];
3666
+ builder.tri(w0, w1, w2, n, cWallTop);
3667
+ builder.tri(w0, w2, w3, n, cWallTop);
3668
+ }
3669
+ }
3670
+ const ai2 = point(k, 0), bi2 = point(k1, 0), ao_2 = point(k1, M2), di2 = point(k, M2);
3671
+ const flat2 = (p) => [p[0], bottomY, p[2]];
3672
+ builder.tri(flat2(ai2), flat2(ao_2), flat2(bi2), [0, -1, 0], cBot);
3673
+ builder.tri(flat2(ai2), flat2(di2), flat2(ao_2), [0, -1, 0], cBot);
3674
+ wallQuad(ai2, bi2);
3675
+ wallQuad(di2, ao_2);
3676
+ continue;
3677
+ }
3678
+ for (let j = 0; j < M2; j++) {
3679
+ const na = vertexNormal(k, j), nb = vertexNormal(k1, j), nc = vertexNormal(k1, j + 1), nd = vertexNormal(k, j + 1);
3680
+ if (radialStep(k, j) || radialStep(k1, j)) {
3681
+ const midPt = (kk, hh) => {
3682
+ const bearing = (arc.startBin + kk + 0.5) * fit.binStepDeg;
3683
+ const r = innerEdge[kk] + (outerEdge[kk] - innerEdge[kk]) * (j + 0.5) / M2;
3684
+ const p = polar(fit.centre, r, bearing);
3685
+ return [p.x * U, hh, p.y * U];
3686
+ };
3687
+ const a2 = point(k, j), b2 = point(k1, j);
3688
+ const mLo0 = midPt(k, h[k][j]), mLo1 = midPt(k1, h[k1][j]);
3689
+ builder.triN(a2, b2, mLo1, na, nb, nb, cTop);
3690
+ builder.triN(a2, mLo1, mLo0, na, nb, na, cTop);
3691
+ const c2 = point(k1, j + 1), d2 = point(k, j + 1);
3692
+ const mHi0 = midPt(k, h[k][j + 1]), mHi1 = midPt(k1, h[k1][j + 1]);
3693
+ builder.triN(mHi0, mHi1, c2, nd, nc, nc, cTop);
3694
+ builder.triN(mHi0, c2, d2, nd, nc, nd, cTop);
3695
+ const ex = mLo1[0] - mLo0[0], ez = mLo1[2] - mLo0[2];
3696
+ const el = Math.hypot(ex, ez);
3697
+ if (el > 1e-12) {
3698
+ const n = [-ez / el, 0, ex / el];
3699
+ builder.tri(mLo0, mLo1, mHi1, n, cWallTop);
3700
+ builder.tri(mLo0, mHi1, mHi0, n, cWallTop);
3701
+ }
3702
+ continue;
3703
+ }
3704
+ const a = point(k, j), b = point(k1, j), c = point(k1, j + 1), d = point(k, j + 1);
3705
+ builder.triN(a, b, c, na, nb, nc, cTop);
3706
+ builder.triN(a, c, d, na, nc, nd, cTop);
3707
+ }
3708
+ const ai = point(k, 0), bi = point(k1, 0), ao_ = point(k1, M2), di = point(k, M2);
3709
+ const flat = (p) => [p[0], bottomY, p[2]];
3710
+ builder.tri(flat(ai), flat(ao_), flat(bi), [0, -1, 0], cBot);
3711
+ builder.tri(flat(ai), flat(di), flat(ao_), [0, -1, 0], cBot);
3712
+ wallQuad(ai, bi);
3713
+ wallQuad(di, ao_);
3714
+ }
3715
+ if (!arc.closed) {
3716
+ for (const k of [0, K - 1]) {
3717
+ for (let j = 0; j < M2; j++) wallQuad(point(k, j), point(k, j + 1));
3718
+ }
3719
+ }
3720
+ }
3721
+ function emitBowlGeometry(builder, fit, opts) {
3722
+ const { bottomY, colors, ao } = opts;
3723
+ const drawnOuter = new Float64Array(fit.binCount).fill(-Infinity);
3724
+ for (const ring of fit.rings) {
3725
+ for (const arc of ring.arcs) {
3726
+ const innerEdge = new Float64Array(arc.binCount);
3727
+ const outerEdge = new Float64Array(arc.binCount);
3728
+ for (let k = 0; k < arc.binCount; k++) {
3729
+ const bin = (arc.startBin + k) % fit.binCount;
3730
+ const inner = Math.max(0, arc.innerR[k] - SEAT_OWNERSHIP_PAD_U);
3731
+ innerEdge[k] = Math.max(inner, drawnOuter[bin] + 1e-3);
3732
+ outerEdge[k] = Math.max(innerEdge[k] + 1e-3, arc.outerR[k] + SEAT_OWNERSHIP_PAD_U);
3733
+ }
3734
+ let relief = 0;
3735
+ for (let k = 0; k < arc.binCount; k++) {
3736
+ const d = arc.topLevelM[k] - bottomY;
3737
+ if (d > relief) relief = d;
3738
+ }
3739
+ if (relief >= BAND_MIN_RELIEF_M) {
3740
+ emitBandGrid(builder, fit, arc, innerEdge, outerEdge, bottomY, colors, ao, opts.seatGuards);
3741
+ }
3742
+ for (let k = 0; k < arc.binCount; k++) {
3743
+ const bin = (arc.startBin + k) % fit.binCount;
3744
+ if (outerEdge[k] > drawnOuter[bin]) drawnOuter[bin] = outerEdge[k];
3745
+ }
3746
+ }
3747
+ }
3748
+ if (hasAuthoredPerimeter(opts.shapes, fit)) return;
3749
+ const outerRing = fit.rings[fit.rings.length - 1];
3750
+ const gapU = SHELL_GAP_M * CHART_UNITS_PER_METRE;
3751
+ const thickU = SHELL_THICKNESS_M * CHART_UNITS_PER_METRE;
3752
+ for (const arc of outerRing.arcs) {
3753
+ const inner = [];
3754
+ const outer = [];
3755
+ for (let k = 0; k < arc.binCount; k++) {
3756
+ const bearing = (arc.startBin + k + 0.5) * fit.binStepDeg;
3757
+ const r0 = arc.outerR[k] + SEAT_OWNERSHIP_PAD_U + gapU;
3758
+ inner.push(polar(fit.centre, r0, bearing));
3759
+ outer.push(polar(fit.centre, r0 + thickU, bearing));
3760
+ }
3761
+ let crest = -Infinity;
3762
+ for (let k = 0; k < arc.binCount; k++) if (arc.topLevelM[k] > crest) crest = arc.topLevelM[k];
3763
+ const topY = () => crest + SHELL_PARAPET_M;
3764
+ if (arc.closed) {
3765
+ extrudePrism(builder, outer, [inner.slice().reverse()], topY, bottomY, colors.shellTop, colors.shellWall, ao);
3766
+ } else {
3767
+ extrudePrism(builder, [...outer, ...inner.slice().reverse()], void 0, topY, bottomY, colors.shellTop, colors.shellWall, ao);
3768
+ }
3769
+ }
3770
+ }
3771
+
3100
3772
  // src/view3d/scene/sceneModel.ts
3101
3773
  function shapeVisibleInConfiguration(shape, activeConfigurationId) {
3102
3774
  const ids = shape.eventConfigurationIds;
@@ -3106,14 +3778,28 @@ function visibleObjects(objects, activeConfigurationId) {
3106
3778
  return objects.filter((object) => object.type !== "shape" || shapeVisibleInConfiguration(object, activeConfigurationId));
3107
3779
  }
3108
3780
  function floorUnits(doc, activeConfigurationId) {
3781
+ const docFocal = doc.focalPoint ?? { x: 0, y: 0 };
3109
3782
  if (doc.floors?.length) {
3110
- return doc.floors.map((f) => ({
3783
+ return doc.floors.map((f, index) => ({
3111
3784
  objects: visibleObjects(f.objects, activeConfigurationId),
3112
- focal: f.focalPoint ?? doc.focalPoint,
3113
- baseHeightM: f.baseHeightM ?? 0
3785
+ focal: {
3786
+ x: (f.focalPoint ?? docFocal).x,
3787
+ y: (f.focalPoint ?? docFocal).y
3788
+ },
3789
+ baseHeightM: f.baseHeightM ?? 0,
3790
+ index
3791
+ // 2D deliberately packs levels into a non-overlapping grid. In 3D the
3792
+ // physical datum already separates them, so retaining each floor's
3793
+ // authored x/z registration is what turns independent drawings into one
3794
+ // coherent building stack. The source scene graph is never rewritten.
3114
3795
  }));
3115
3796
  }
3116
- return [{ objects: visibleObjects(doc.objects, activeConfigurationId), focal: doc.focalPoint, baseHeightM: 0 }];
3797
+ return [{
3798
+ objects: visibleObjects(doc.objects, activeConfigurationId),
3799
+ focal: docFocal,
3800
+ baseHeightM: 0,
3801
+ index: 0
3802
+ }];
3117
3803
  }
3118
3804
  var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
3119
3805
  function tintTop(fill, neutral) {
@@ -3288,6 +3974,79 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
3288
3974
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
3289
3975
  }
3290
3976
  }
3977
+ function collectBowlSamples(unit, seats, surfaces) {
3978
+ const unitSections = /* @__PURE__ */ new Set();
3979
+ for (const o of unit.objects) if (o.type === "section") unitSections.add(o.id);
3980
+ const flatRows = /* @__PURE__ */ new Map();
3981
+ for (let i = 0; i < seats.length; i++) {
3982
+ const owner = surfaces.seatOwner[i];
3983
+ if (!owner || !unitSections.has(owner)) continue;
3984
+ const surf = surfaces.bySection.get(owner);
3985
+ if (surf && surf.rowLevels.length >= 2) continue;
3986
+ const s = seats[i];
3987
+ let rows = flatRows.get(owner);
3988
+ if (!rows) {
3989
+ rows = /* @__PURE__ */ new Map();
3990
+ flatRows.set(owner, rows);
3991
+ }
3992
+ const key = s.rowId || `__seat-${i}`;
3993
+ let row = rows.get(key);
3994
+ if (!row) {
3995
+ row = { pts: [], levelM: 0, n: 0 };
3996
+ rows.set(key, row);
3997
+ }
3998
+ row.pts.push({ x: s.x, y: s.y });
3999
+ row.levelM += surfaces.seatDeckY(i);
4000
+ row.n++;
4001
+ }
4002
+ const samples = [];
4003
+ for (const o of unit.objects) {
4004
+ if (o.type !== "section") continue;
4005
+ const surf = surfaces.bySection.get(o.id);
4006
+ if (surf && surf.rowLevels.length >= 2) {
4007
+ const rows2 = surf.rowLevels.filter((r) => !r.patch).map((r) => ({ pts: r.pts, levelM: r.y }));
4008
+ if (rows2.length >= 2) samples.push({ sectionId: o.id, rows: rows2 });
4009
+ continue;
4010
+ }
4011
+ const rows = flatRows.get(o.id);
4012
+ if (!rows) continue;
4013
+ samples.push({
4014
+ sectionId: o.id,
4015
+ rows: [...rows.values()].map((r) => ({ pts: r.pts, levelM: r.levelM / r.n }))
4016
+ });
4017
+ }
4018
+ return samples;
4019
+ }
4020
+ function buildBowl(builder, unit, seats, surfaces, S) {
4021
+ const samples = collectBowlSamples(unit, seats, surfaces);
4022
+ if (!samples.length) return;
4023
+ const fit = fitBowlProfile(samples, unit.focal);
4024
+ if (!fit) return;
4025
+ const unitSections = /* @__PURE__ */ new Set();
4026
+ for (const o of unit.objects) if (o.type === "section") unitSections.add(o.id);
4027
+ const seatGuards = [];
4028
+ for (let i = 0; i < seats.length; i++) {
4029
+ const owner = surfaces.seatOwner[i];
4030
+ if (owner ? unitSections.has(owner) : true) {
4031
+ seatGuards.push({ x: seats[i].x, y: seats[i].y, deckY: surfaces.seatDeckY(i) });
4032
+ }
4033
+ }
4034
+ builder.setMaterial(0);
4035
+ emitBowlGeometry(builder, fit, {
4036
+ bottomY: unit.baseHeightM,
4037
+ colors: {
4038
+ // A shade below the tier caps, so stands read as decks resting ON the
4039
+ // bowl rather than the bowl swallowing them.
4040
+ bandTop: scaleRgb(S.tierTop, 0.8),
4041
+ bandWall: S.tierWall,
4042
+ shellTop: scaleRgb(S.tierWall, 1.15),
4043
+ shellWall: S.tierWall
4044
+ },
4045
+ ao: AO,
4046
+ shapes: unit.objects.filter((o) => o.type === "shape"),
4047
+ seatGuards
4048
+ });
4049
+ }
3291
4050
  function coplanarLevels(a, b) {
3292
4051
  if (a === void 0 || b === void 0) return true;
3293
4052
  return Math.abs(a - b) < 1e-3;
@@ -3453,7 +4212,11 @@ function architectureForShape(shape) {
3453
4212
  restroom: { kind: "solid", heightM: 2.4 },
3454
4213
  bar: { kind: "solid", heightM: 1.1 },
3455
4214
  concession: { kind: "solid", heightM: 1.1 },
3456
- coat: { kind: "solid", heightM: 1.1 }
4215
+ coat: { kind: "solid", heightM: 1.1 },
4216
+ // Sports-surface imports describe a goal as one thin rectangular portal.
4217
+ // It therefore becomes a real open goal frame in 3D without a bespoke
4218
+ // chart model or a copied bitmap.
4219
+ goal: { kind: "portal", heightM: 1.8 }
3457
4220
  };
3458
4221
  const resolved = defaults[role] ?? { kind: "plate", heightM: 0.25 };
3459
4222
  return { ...resolved, heightM: shape.heightM ?? resolved.heightM };
@@ -3652,20 +4415,46 @@ function chartFootprint(units, seats) {
3652
4415
  return { minX, minY, maxX, maxY };
3653
4416
  }
3654
4417
  function buildSceneModel(input) {
3655
- const { doc, seats } = input;
4418
+ const { doc } = input;
3656
4419
  const theme = resolveTheme3D(doc.theme);
3657
4420
  const S = theme.structure;
3658
4421
  const units = floorUnits(doc, input.eventConfigurationId ?? doc.eventConfigurationId);
4422
+ const seats = input.seats;
3659
4423
  const builder = new MeshBuilder();
4424
+ const floorByOwner = /* @__PURE__ */ new Map();
4425
+ for (const unit of units) {
4426
+ for (const object of unit.objects) {
4427
+ floorByOwner.set(object.id, unit.index);
4428
+ if (object.type === "section") floorByOwner.set(object.logicalSectionId ?? object.id, unit.index);
4429
+ }
4430
+ }
4431
+ const hintedSeatFloor = seats.map((seat) => floorByOwner.get(seat.sectionId ?? "") ?? floorByOwner.get(seat.rowId ?? "") ?? 0);
3660
4432
  const fp = chartFootprint(units, seats);
3661
- const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
3662
- const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
3663
- extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
4433
+ if (units.length === 1) {
4434
+ const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
4435
+ const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
4436
+ extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
4437
+ } else {
4438
+ const unit = units.reduce((lowest, candidate) => candidate.baseHeightM < lowest.baseHeightM ? candidate : lowest);
4439
+ builder.setFloor(unit.index);
4440
+ const unitSeats = seats.filter((_seat, index) => hintedSeatFloor[index] === unit.index);
4441
+ const floorFp = chartFootprint([unit], unitSeats);
4442
+ const span = Math.hypot(floorFp.maxX - floorFp.minX, floorFp.maxY - floorFp.minY);
4443
+ const padU = Math.max(28, Math.min(80, span * 0.04));
4444
+ const plinth = rectPolygon(
4445
+ floorFp.minX - padU,
4446
+ floorFp.minY - padU,
4447
+ floorFp.maxX - floorFp.minX + padU * 2,
4448
+ floorFp.maxY - floorFp.minY + padU * 2
4449
+ );
4450
+ const top = unit.baseHeightM - 0.03;
4451
+ extrudePrism(builder, plinth, void 0, () => top, top - 0.22, S.ground, S.ground, AO);
4452
+ }
3664
4453
  const sectionFills = resolveSectionFills(doc, seats);
3665
4454
  const catColor = /* @__PURE__ */ new Map();
3666
4455
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
3667
4456
  const stageBounds = [];
3668
- const surfaces = buildVenueSurfaces(units, seats);
4457
+ const surfaces = buildVenueSurfaces(units, seats, hintedSeatFloor);
3669
4458
  const seatFloor = new Float32Array(seats.length);
3670
4459
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
3671
4460
  const unit = units[unitIndex];
@@ -3692,8 +4481,12 @@ function buildSceneModel(input) {
3692
4481
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3693
4482
  else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
3694
4483
  }
4484
+ buildBowl(builder, unit, seats, surfaces, S);
3695
4485
  }
3696
- const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
4486
+ const focal = units.length > 1 ? {
4487
+ x: units.reduce((sum, unit) => sum + unit.focal.x, 0) / units.length,
4488
+ y: units.reduce((sum, unit) => sum + unit.focal.y, 0) / units.length
4489
+ } : doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
3697
4490
  const solids = mergeMeshData([builder.build()]);
3698
4491
  const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor, catColor);
3699
4492
  const zoneDefs = doc.zones ?? [];
@@ -3799,9 +4592,11 @@ function buildSceneModel(input) {
3799
4592
  if (!a || a.n === 0) continue;
3800
4593
  const e = ext.get(o.id);
3801
4594
  const centre = [a.x / a.n * M, a.deck / a.n, a.y / a.n * M];
4595
+ const sectionLabel = o.displayLabel || o.label || o.id;
3802
4596
  sections.push({
3803
4597
  id: o.id,
3804
- label: o.displayLabel || o.label || o.id,
4598
+ floorIndex: unit.index,
4599
+ label: sectionLabel,
3805
4600
  seatCount: a.n,
3806
4601
  center: centre,
3807
4602
  // Half-diagonal of the seat extent — the same fit the zones use.
@@ -3810,13 +4605,17 @@ function buildSceneModel(input) {
3810
4605
  // look along so the seats present their fronts.
3811
4606
  focalWorld: [unit.focal.x * M, unit.baseHeightM + 1.5, unit.focal.y * M]
3812
4607
  });
3813
- labels.push({
3814
- id: `section:${o.id}`,
3815
- kind: "section",
3816
- // The buyer-facing name wins over the technical one, as it does in 2D.
3817
- text: o.displayLabel || o.label || o.id,
3818
- anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]]
3819
- });
4608
+ const floorLabel = doc.floors?.[unit.index]?.name?.trim().toLocaleLowerCase();
4609
+ if (!(units.length > 1 && floorLabel === sectionLabel.trim().toLocaleLowerCase())) {
4610
+ labels.push({
4611
+ id: `section:${o.id}`,
4612
+ kind: "section",
4613
+ // The buyer-facing name wins over the technical one, as it does in 2D.
4614
+ text: sectionLabel,
4615
+ anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]],
4616
+ floorIndex: unit.index
4617
+ });
4618
+ }
3820
4619
  }
3821
4620
  }
3822
4621
  }
@@ -3831,7 +4630,8 @@ function buildSceneModel(input) {
3831
4630
  id: `ga:${o.id}`,
3832
4631
  kind: "ga",
3833
4632
  text: o.capacity > 0 ? `${name} \xB7 ${o.capacity.toLocaleString()}` : name,
3834
- anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M]
4633
+ anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M],
4634
+ floorIndex: unit.index
3835
4635
  });
3836
4636
  }
3837
4637
  }
@@ -3850,7 +4650,8 @@ function buildSceneModel(input) {
3850
4650
  id: `row:${rowId}`,
3851
4651
  kind: "row",
3852
4652
  text,
3853
- anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
4653
+ anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M],
4654
+ floorIndex: hintedSeatFloor[end.index]
3854
4655
  });
3855
4656
  }
3856
4657
  const rowAcc = /* @__PURE__ */ new Map();
@@ -3905,7 +4706,8 @@ function buildSceneModel(input) {
3905
4706
  id: `seat:${s.id}`,
3906
4707
  kind: "seat",
3907
4708
  text,
3908
- anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M]
4709
+ anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M],
4710
+ floorIndex: hintedSeatFloor[i]
3909
4711
  });
3910
4712
  }
3911
4713
  }
@@ -3921,7 +4723,8 @@ function buildSceneModel(input) {
3921
4723
  kind: "annotation",
3922
4724
  text: o.label,
3923
4725
  anchor: [centre.x * M, unit.baseHeightM + architectureForShape(o).heightM + ANNOTATION_LIFT_M, centre.y * M],
3924
- color: o.fill
4726
+ color: o.fill,
4727
+ floorIndex: unit.index
3925
4728
  });
3926
4729
  } else if (o.type === "text") {
3927
4730
  if (!o.text) continue;
@@ -3931,7 +4734,8 @@ function buildSceneModel(input) {
3931
4734
  text: o.text,
3932
4735
  anchor: [o.position.x * M, unit.baseHeightM + ANNOTATION_LIFT_M, o.position.y * M],
3933
4736
  color: o.color,
3934
- rotation: o.rotation
4737
+ rotation: o.rotation,
4738
+ floorIndex: unit.index
3935
4739
  });
3936
4740
  } else if (o.type === "booth") {
3937
4741
  const poly = boothPolygon(o);
@@ -3942,21 +4746,52 @@ function buildSceneModel(input) {
3942
4746
  id: `booth:${o.id}`,
3943
4747
  kind: "booth",
3944
4748
  text: o.displayLabel || o.label || o.id,
3945
- anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M]
4749
+ anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M],
4750
+ floorIndex: unit.index
3946
4751
  });
3947
4752
  }
3948
4753
  }
3949
4754
  }
3950
4755
  const floors = [];
3951
4756
  {
4757
+ const solidBounds = units.map(() => ({
4758
+ minX: Infinity,
4759
+ minY: Infinity,
4760
+ minZ: Infinity,
4761
+ maxX: -Infinity,
4762
+ maxY: -Infinity,
4763
+ maxZ: -Infinity
4764
+ }));
4765
+ for (let vertex = 0; vertex < solids.floor.length; vertex += 1) {
4766
+ const floorIndex = Math.max(0, Math.min(units.length - 1, Math.round(solids.floor[vertex])));
4767
+ const offset = vertex * 3;
4768
+ const bound = solidBounds[floorIndex];
4769
+ bound.minX = Math.min(bound.minX, solids.position[offset]);
4770
+ bound.minY = Math.min(bound.minY, solids.position[offset + 1]);
4771
+ bound.minZ = Math.min(bound.minZ, solids.position[offset + 2]);
4772
+ bound.maxX = Math.max(bound.maxX, solids.position[offset]);
4773
+ bound.maxY = Math.max(bound.maxY, solids.position[offset + 1]);
4774
+ bound.maxZ = Math.max(bound.maxZ, solids.position[offset + 2]);
4775
+ }
3952
4776
  const sectionFloor = /* @__PURE__ */ new Map();
3953
4777
  for (let ui = 0; ui < units.length; ui++) {
3954
4778
  for (const o of units[ui].objects) if (o.type === "section") sectionFloor.set(o.id, ui);
3955
4779
  }
3956
- const acc = units.map(() => ({ n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 }));
4780
+ const acc = units.map(() => ({
4781
+ n: 0,
4782
+ minX: Infinity,
4783
+ minY: Infinity,
4784
+ minDeck: Infinity,
4785
+ maxX: -Infinity,
4786
+ maxY: -Infinity,
4787
+ maxDeck: -Infinity,
4788
+ sumX: 0,
4789
+ sumDeck: 0,
4790
+ sumZ: 0
4791
+ }));
3957
4792
  for (let i = 0; i < seats.length; i++) {
3958
4793
  const owner = surfaces.seatOwner[i];
3959
- const ui = owner !== null ? sectionFloor.get(owner) : void 0;
4794
+ const ui = (owner !== null ? sectionFloor.get(owner) : void 0) ?? hintedSeatFloor[i];
3960
4795
  if (ui === void 0) continue;
3961
4796
  const a = acc[ui];
3962
4797
  const s = seats[i];
@@ -3965,19 +4800,78 @@ function buildSceneModel(input) {
3965
4800
  if (s.y < a.minY) a.minY = s.y;
3966
4801
  if (s.x > a.maxX) a.maxX = s.x;
3967
4802
  if (s.y > a.maxY) a.maxY = s.y;
3968
- a.sumY += surfaces.seatDeckY(i);
4803
+ const deck = surfaces.seatDeckY(i);
4804
+ a.sumX += s.x * M;
4805
+ a.sumDeck += deck;
4806
+ a.sumZ += s.y * M;
4807
+ a.minDeck = Math.min(a.minDeck, deck);
4808
+ a.maxDeck = Math.max(a.maxDeck, deck + 1.6);
3969
4809
  seatFloor[i] = ui;
3970
4810
  }
3971
4811
  for (let ui = 0; ui < units.length; ui++) {
3972
4812
  const a = acc[ui];
3973
4813
  const f = doc.floors?.[ui];
4814
+ const solid = solidBounds[ui];
4815
+ const minWorldX = Math.min(
4816
+ a.n ? a.minX * M : units[ui].focal.x * M - 1,
4817
+ Number.isFinite(solid.minX) ? solid.minX : Infinity
4818
+ );
4819
+ const maxWorldX = Math.max(
4820
+ a.n ? a.maxX * M : units[ui].focal.x * M + 1,
4821
+ Number.isFinite(solid.maxX) ? solid.maxX : -Infinity
4822
+ );
4823
+ const minWorldZ = Math.min(
4824
+ a.n ? a.minY * M : units[ui].focal.y * M - 1,
4825
+ Number.isFinite(solid.minZ) ? solid.minZ : Infinity
4826
+ );
4827
+ const maxWorldZ = Math.max(
4828
+ a.n ? a.maxY * M : units[ui].focal.y * M + 1,
4829
+ Number.isFinite(solid.maxZ) ? solid.maxZ : -Infinity
4830
+ );
4831
+ const minWorldY = Math.min(
4832
+ a.n ? Math.min(units[ui].baseHeightM, a.minDeck) : units[ui].baseHeightM,
4833
+ Number.isFinite(solid.minY) ? solid.minY : Infinity
4834
+ );
4835
+ const maxWorldY = Math.max(
4836
+ a.n ? Math.max(a.maxDeck, minWorldY + 0.5) : minWorldY + 0.5,
4837
+ Number.isFinite(solid.maxY) ? solid.maxY : -Infinity
4838
+ );
4839
+ const center = [
4840
+ // Aim the floor camera at its sellable topology rather than the centre
4841
+ // of a generous imported outline/plinth. The complete box is still
4842
+ // fitted below, so stages, annotations and deck edges cannot clip.
4843
+ a.n ? a.sumX / a.n : (minWorldX + maxWorldX) / 2,
4844
+ a.n ? a.sumDeck / a.n : (minWorldY + maxWorldY) / 2,
4845
+ a.n ? a.sumZ / a.n : (minWorldZ + maxWorldZ) / 2
4846
+ ];
4847
+ const radius2 = Math.max(1, 0.5 * Math.hypot(
4848
+ maxWorldX - minWorldX,
4849
+ maxWorldY - minWorldY,
4850
+ maxWorldZ - minWorldZ
4851
+ ));
3974
4852
  floors.push({
3975
4853
  index: ui,
3976
4854
  id: f?.id ?? `floor-${ui}`,
3977
4855
  label: f?.name ?? (units.length > 1 ? `Floor ${ui + 1}` : "Venue"),
3978
4856
  seatCount: a.n,
3979
- center: a.n ? [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M] : [0, 0, 0],
3980
- radius: a.n ? 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1 : 0
4857
+ center,
4858
+ radius: radius2,
4859
+ box: {
4860
+ min: [minWorldX, minWorldY, minWorldZ],
4861
+ max: [maxWorldX, maxWorldY, maxWorldZ]
4862
+ },
4863
+ focalWorld: [units[ui].focal.x * M, units[ui].baseHeightM + 1.5, units[ui].focal.y * M]
4864
+ });
4865
+ }
4866
+ }
4867
+ if (floors.length > 1) {
4868
+ for (const floor of floors) {
4869
+ labels.push({
4870
+ id: `floor:${floor.id}`,
4871
+ kind: "floor",
4872
+ text: floor.label,
4873
+ anchor: [floor.center[0], floor.box.max[1] + 1.2, floor.center[2]],
4874
+ floorIndex: floor.index
3981
4875
  });
3982
4876
  }
3983
4877
  }
@@ -4055,18 +4949,20 @@ function buildSceneModel(input) {
4055
4949
  boxMinZ = Math.max(fpMinZ, contentMinZ - pad);
4056
4950
  boxMaxZ = Math.min(fpMaxZ, contentMaxZ + pad);
4057
4951
  }
4952
+ const boxMinY = Math.min(0, renderMinY);
4953
+ const boxMaxY = Math.max(renderMaxY, radius * 0.05);
4058
4954
  return {
4059
4955
  solids,
4060
4956
  seats: seatData,
4061
4957
  bounds: {
4062
4958
  // Centred on the CONTENT box, so an off-centre table cluster arrives
4063
4959
  // centred on screen instead of wherever the floor's midpoint put it.
4064
- center: [(boxMinX + boxMaxX) / 2, radius * 0.08, (boxMinZ + boxMaxZ) / 2],
4960
+ center: [(boxMinX + boxMaxX) / 2, (boxMinY + boxMaxY) / 2, (boxMinZ + boxMaxZ) / 2],
4065
4961
  radius,
4066
4962
  groundY: 0,
4067
4963
  box: {
4068
- min: [boxMinX, Math.min(0, renderMinY), boxMinZ],
4069
- max: [boxMaxX, Math.max(renderMaxY, radius * 0.05), boxMaxZ]
4964
+ min: [boxMinX, boxMinY, boxMinZ],
4965
+ max: [boxMaxX, boxMaxY, boxMaxZ]
4070
4966
  }
4071
4967
  },
4072
4968
  renderBounds: { center: renderCenter, radius: renderRadius },
@@ -4088,6 +4984,7 @@ function buildSceneModel(input) {
4088
4984
  var SEPARATION_X_PX = 88;
4089
4985
  var SEPARATION_Y_PX = 20;
4090
4986
  var KIND_STYLE = {
4987
+ floor: { size: 14, weight: "700", opacity: 0.98 },
4091
4988
  zone: { size: 15, weight: "600", opacity: 0.95 },
4092
4989
  section: { size: 12, weight: "500", opacity: 0.88 },
4093
4990
  ga: { size: 12, weight: "500", opacity: 0.88 },
@@ -4108,6 +5005,27 @@ var DENSE_SEPARATION = {
4108
5005
  // seating instead of clustering into one stack.
4109
5006
  seat: { x: 46, y: 18 }
4110
5007
  };
5008
+ function hexRgb(value) {
5009
+ const match = /^#([0-9a-f]{6})$/i.exec(value?.trim() ?? "");
5010
+ if (!match) return null;
5011
+ return [0, 2, 4].map((offset) => Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255);
5012
+ }
5013
+ function luminance(rgb) {
5014
+ const linear = (channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
5015
+ return 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]);
5016
+ }
5017
+ function ratio(left, right) {
5018
+ const a = luminance(left);
5019
+ const b = luminance(right);
5020
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
5021
+ }
5022
+ function resolve3dLabelTreatment(preferredInk, backgroundRgb = [0.04, 0.06, 0.1]) {
5023
+ const darkScene = luminance(backgroundRgb) < 0.42;
5024
+ const fallbackInk = darkScene ? "#f4f7fb" : "#0b1220";
5025
+ const authored = hexRgb(preferredInk);
5026
+ const ink = authored && ratio(authored, backgroundRgb) >= 4.5 ? preferredInk : fallbackInk;
5027
+ 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)" };
5028
+ }
4111
5029
  var LabelOverlay = class {
4112
5030
  constructor(container, opts = {}) {
4113
5031
  this.nodes = /* @__PURE__ */ new Map();
@@ -4166,7 +5084,9 @@ var LabelOverlay = class {
4166
5084
  */
4167
5085
  update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
4168
5086
  if (!this.labels.length) return;
4169
- const kinds = visibleLabelKinds(cameraDistance, venueRadius);
5087
+ const kinds = visibleLabelKinds(cameraDistance, venueRadius, {
5088
+ hasZoneLabels: this.labels.some((label) => label.kind === "zone")
5089
+ });
4170
5090
  if (this.forcedDense) {
4171
5091
  kinds.add("row");
4172
5092
  kinds.add("seat");
@@ -4188,7 +5108,14 @@ var LabelOverlay = class {
4188
5108
  ...this.selectedSeatLabelIds.has(label.id) ? { priority: 1 } : {}
4189
5109
  });
4190
5110
  }
4191
- const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
5111
+ const floorLabels = spreadFloorLabels(
5112
+ candidates.filter((candidate) => candidate.label.kind === "floor"),
5113
+ width
5114
+ );
5115
+ const structure = [
5116
+ ...candidates.filter((candidate) => !DENSE_KINDS.has(candidate.label.kind) && candidate.label.kind !== "floor"),
5117
+ ...floorLabels
5118
+ ];
4192
5119
  const kept = [
4193
5120
  ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
4194
5121
  // The dense rungs are BUDGETED, not merely deduplicated — see
@@ -4235,14 +5162,16 @@ var LabelOverlay = class {
4235
5162
  s.fontSize = `${style.size}px`;
4236
5163
  s.fontWeight = selected ? "800" : style.weight;
4237
5164
  s.opacity = selected ? "1" : String(style.opacity);
4238
- s.color = selected ? "#ecf8ff" : label.color ?? this.opts.ink ?? "#e8edf5";
4239
- s.background = selected ? "rgba(10,28,45,.9)" : "";
4240
- s.border = selected ? "1px solid rgba(89,199,255,.92)" : "";
4241
- s.borderRadius = selected ? "999px" : "";
4242
- s.padding = selected ? "3px 7px" : "";
4243
- s.boxShadow = selected ? "0 0 0 2px rgba(41,176,255,.18), 0 5px 14px rgba(0,0,0,.35)" : "";
4244
- s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
4245
- if (label.kind === "zone") s.textTransform = "uppercase";
5165
+ const treatment = resolve3dLabelTreatment(label.color ?? this.opts.ink, this.opts.backgroundRgb);
5166
+ const backed = !DENSE_KINDS.has(label.kind);
5167
+ s.color = selected ? "#ecf8ff" : treatment.ink;
5168
+ s.background = selected ? "rgba(10,28,45,.9)" : backed ? treatment.backing : "";
5169
+ s.border = selected ? "1px solid rgba(89,199,255,.92)" : backed ? `1px solid ${treatment.border}` : "";
5170
+ s.borderRadius = selected || backed ? "999px" : "";
5171
+ s.padding = selected ? "3px 7px" : backed ? "3px 7px" : "";
5172
+ 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)" : "";
5173
+ s.letterSpacing = label.kind === "zone" || label.kind === "floor" ? "0.08em" : "0.02em";
5174
+ if (label.kind === "zone" || label.kind === "floor") s.textTransform = "uppercase";
4246
5175
  return node;
4247
5176
  }
4248
5177
  dispose() {
@@ -4296,8 +5225,7 @@ out float vDim;
4296
5225
  out float vMaterial;
4297
5226
  void main() {
4298
5227
  // Per-floor isolation without splitting the merged mesh into a draw call per
4299
- // floor: a floor that is not the focused one is dimmed, not hidden, so the
4300
- // buyer keeps the whole venue as context while looking at one level.
5228
+ // floor. The fragment pass removes non-focused floors for a clean level view.
4301
5229
  vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
4302
5230
  vMaterial = materialIndex;
4303
5231
  vec4 world = modelMatrix * vec4(position, 1.0);
@@ -4328,13 +5256,19 @@ uniform vec3 uKeyDir; // WORLD space, unit, points at
4328
5256
  uniform float uStructureDetail;
4329
5257
  out vec4 fragColor;
4330
5258
  void main() {
5259
+ if (vDim > 0.5) discard;
4331
5260
  // Row treads/risers are honest and useful when the buyer is inside a stand.
4332
5261
  // At whole-venue scale their overlapping centimetre-scale surfaces quantise
4333
5262
  // and alias into camera-dependent radial combs. The clean structural block
4334
5263
  // cap remains, so overview keeps every stand without pretending to show row
4335
5264
  // detail below a useful pixel size.
4336
5265
  float rowDetail = step(1.5, vMaterial);
4337
- if (rowDetail > 0.5 && uStructureDetail < 0.5) discard;
5266
+ // The rung DISSOLVES rather than popping: uStructureDetail eases 0..1 over a
5267
+ // few frames (see the render loop) and each pixel holds a stable screen-door
5268
+ // threshold against it, so free-zooming across the handover reads as detail
5269
+ // developing instead of the venue changing in a single frame.
5270
+ float stipple = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
5271
+ if (rowDetail > 0.5 && stipple >= uStructureDetail) discard;
4338
5272
  vec3 N = normalize(vNormalWorld);
4339
5273
  vec3 V = normalize(-vPosView);
4340
5274
  float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
@@ -4370,8 +5304,6 @@ void main() {
4370
5304
  + vec3(0.62, 0.46, 0.20) * band * 0.28
4371
5305
  + vec3(0.20, 0.10, 0.34) * focalHalo;
4372
5306
  base = mix(base, display, poweredDisplay * verticalFace * 0.92);
4373
- // Unfocused floors fall back toward the background rather than vanishing.
4374
- base = mix(base, base * 0.45, vDim);
4375
5307
  fragColor = vec4(base, 1.0);
4376
5308
  }`
4377
5309
  );
@@ -4455,7 +5387,11 @@ uniform float uSeatDetail; // 0 for venue/area overview; 1 in section/row/sea
4455
5387
  uniform vec3 uFadeColor;
4456
5388
  out vec4 fragColor;
4457
5389
  void main() {
4458
- if (uSeatDetail < 0.5) discard;
5390
+ if (vDim > 0.5) discard;
5391
+ // Same screen-door dissolve as the structure rung (uStructureDetail): the
5392
+ // dot layer develops over ~a quarter second instead of appearing at once.
5393
+ float stipple = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
5394
+ if (stipple >= uSeatDetail) discard;
4459
5395
  // Empty wheelchair provision is a square bay, never a round chair marker.
4460
5396
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
4461
5397
  if (d > 1.0) discard;
@@ -4488,9 +5424,6 @@ void main() {
4488
5424
  // while zooming. uSeatFade already measures this exact LOD transition: let
4489
5425
  // the category-tinted tier carry the overview, then reveal individual live
4490
5426
  // availability as the buyer approaches a section.
4491
- // Seats on an unfocused floor recede with their structure.
4492
- c = mix(c, uFadeColor, vDim * 0.75);
4493
- alpha *= mix(1.0, 0.30, vDim);
4494
5427
  // Yield to the chair. The chair grows out of this exact point, so through the
4495
5428
  // band the dot is always at least as big as the chair inside it and the seat
4496
5429
  // never thins out to nothing in between.
@@ -4635,6 +5568,7 @@ uniform vec3 uKeyDir;
4635
5568
  uniform vec3 uFadeColor;
4636
5569
  out vec4 fragColor;
4637
5570
  void main() {
5571
+ if (vDim > 0.5) discard;
4638
5572
  vec3 N = normalize(vNormalWorld);
4639
5573
  vec3 V = normalize(-vPosView);
4640
5574
  // The solids' rig, unchanged, so the chairs sit in the venue's light.
@@ -4667,7 +5601,6 @@ void main() {
4667
5601
  // A brighter rim than the solids get: it picks out every chair's own edge,
4668
5602
  // which is what stops a block of them merging into one mass up close.
4669
5603
  base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
4670
- base = mix(base, uFadeColor, vDim * 0.75);
4671
5604
  fragColor = vec4(base, 1.0);
4672
5605
  }`
4673
5606
  );
@@ -4705,6 +5638,7 @@ in vec2 position;
4705
5638
  in vec3 iOffset;
4706
5639
  in float iMaxRadius;
4707
5640
  in float iPhysicalSeat;
5641
+ in float iFloor;
4708
5642
  uniform mat4 modelViewMatrix;
4709
5643
  uniform mat4 projectionMatrix;
4710
5644
  uniform float uSeatRadius;
@@ -4713,9 +5647,11 @@ uniform float uMinPixels;
4713
5647
  uniform float uPixelToWorld;
4714
5648
  uniform float uChairFull;
4715
5649
  uniform float uChairNone;
5650
+ uniform float uFocusFloor;
4716
5651
  out vec2 vUv;
4717
5652
  out float vPhysicalSeat;
4718
5653
  out float vDotWeight;
5654
+ out float vHidden;
4719
5655
  flat out vec3 vPick;
4720
5656
  ${CHAIR_WEIGHT_GLSL}
4721
5657
  void main() {
@@ -4731,6 +5667,7 @@ void main() {
4731
5667
  mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
4732
5668
  vUv = position;
4733
5669
  vPhysicalSeat = iPhysicalSeat;
5670
+ vHidden = (uFocusFloor > -0.5 && abs(iFloor - uFocusFloor) > 0.5) ? 1.0 : 0.0;
4734
5671
  gl_Position = projectionMatrix * mv;
4735
5672
  }`
4736
5673
  );
@@ -4741,9 +5678,11 @@ precision highp float;
4741
5678
  in vec2 vUv;
4742
5679
  in float vPhysicalSeat;
4743
5680
  in float vDotWeight;
5681
+ in float vHidden;
4744
5682
  flat in vec3 vPick;
4745
5683
  out vec4 fragColor;
4746
5684
  void main() {
5685
+ if (vHidden > 0.5) discard;
4747
5686
  if (vDotWeight <= 0.02) discard; // invisible dot cannot steal a chair tap
4748
5687
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
4749
5688
  if (d > 1.0) discard; // hit-mask matches chair/bay shape
@@ -4762,18 +5701,22 @@ in float iYaw;
4762
5701
  in float iSeed;
4763
5702
  in float iPhysicalSeat;
4764
5703
  in float iPickIndex;
5704
+ in float iFloor;
4765
5705
  uniform mat4 modelViewMatrix;
4766
5706
  uniform mat4 projectionMatrix;
4767
5707
  uniform float uChairFull;
4768
5708
  uniform float uChairNone;
4769
5709
  uniform float uBackRake;
4770
5710
  uniform float uBackBase;
5711
+ uniform float uFocusFloor;
4771
5712
  flat out vec3 vPick;
5713
+ out float vHidden;
4772
5714
  ${CHAIR_WEIGHT_GLSL}
4773
5715
  ${OCCUPANT_WEIGHT_GLSL}
4774
5716
  void main() {
4775
5717
  int id = int(iPickIndex + 0.5); // already global index + 1
4776
5718
  vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
5719
+ vHidden = (uFocusFloor > -0.5 && abs(iFloor - uFocusFloor) > 0.5) ? 1.0 : 0.0;
4777
5720
  vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
4778
5721
  float viewDepth = -anchor.z;
4779
5722
  float depth = max(viewDepth, 0.001);
@@ -4806,17 +5749,25 @@ var CHAIR_PICK_FRAG = (
4806
5749
  `#version 300 es
4807
5750
  precision highp float;
4808
5751
  flat in vec3 vPick;
5752
+ in float vHidden;
4809
5753
  out vec4 fragColor;
4810
- void main() { fragColor = vec4(vPick, 1.0); }`
5754
+ void main() {
5755
+ if (vHidden > 0.5) discard;
5756
+ fragColor = vec4(vPick, 1.0);
5757
+ }`
4811
5758
  );
4812
5759
  var PICK_DEPTH_VERT = (
4813
5760
  /* glsl */
4814
5761
  `#version 300 es
4815
5762
  precision highp float;
4816
5763
  in vec3 position;
5764
+ in float floorIndex;
4817
5765
  uniform mat4 modelViewMatrix;
4818
5766
  uniform mat4 projectionMatrix;
5767
+ uniform float uFocusFloor;
5768
+ out float vHidden;
4819
5769
  void main() {
5770
+ vHidden = (uFocusFloor > -0.5 && abs(floorIndex - uFocusFloor) > 0.5) ? 1.0 : 0.0;
4820
5771
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
4821
5772
  }`
4822
5773
  );
@@ -4824,8 +5775,12 @@ var PICK_DEPTH_FRAG = (
4824
5775
  /* glsl */
4825
5776
  `#version 300 es
4826
5777
  precision highp float;
5778
+ in float vHidden;
4827
5779
  out vec4 fragColor;
4828
- void main() { fragColor = vec4(0.0, 0.0, 0.0, 1.0); }`
5780
+ void main() {
5781
+ if (vHidden > 0.5) discard;
5782
+ fragColor = vec4(0.0, 0.0, 0.0, 1.0);
5783
+ }`
4829
5784
  );
4830
5785
  function createSeatPickProgram(gl) {
4831
5786
  return new import_ogl3.Program(gl, {
@@ -4841,7 +5796,8 @@ function createSeatPickProgram(gl) {
4841
5796
  uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
4842
5797
  uPixelToWorld: { value: 2e-3 },
4843
5798
  uChairFull: { value: CHAIR_FULL_M },
4844
- uChairNone: { value: CHAIR_NONE_M }
5799
+ uChairNone: { value: CHAIR_NONE_M },
5800
+ uFocusFloor: { value: -1 }
4845
5801
  }
4846
5802
  });
4847
5803
  }
@@ -4857,7 +5813,8 @@ function createChairPickProgram(gl) {
4857
5813
  uChairFull: { value: CHAIR_FULL_M },
4858
5814
  uChairNone: { value: CHAIR_NONE_M },
4859
5815
  uBackRake: { value: BACK_RAKE_SLOPE },
4860
- uBackBase: { value: BACK_BASE_M }
5816
+ uBackBase: { value: BACK_BASE_M },
5817
+ uFocusFloor: { value: -1 }
4861
5818
  }
4862
5819
  });
4863
5820
  }
@@ -4868,7 +5825,10 @@ function createPickDepthProgram(gl) {
4868
5825
  transparent: false,
4869
5826
  depthTest: true,
4870
5827
  depthWrite: true,
4871
- cullFace: false
5828
+ cullFace: false,
5829
+ uniforms: {
5830
+ uFocusFloor: { value: -1 }
5831
+ }
4872
5832
  });
4873
5833
  }
4874
5834
  function createSolidProgram(gl) {
@@ -5202,6 +6162,10 @@ var PickPipeline = class {
5202
6162
  /** Match the display seat sizing so the pick mask lines up with the dots. */
5203
6163
  syncFromSeatProgram(seatProgram) {
5204
6164
  for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
6165
+ const focusFloor = seatProgram.uniforms.uFocusFloor.value;
6166
+ this.seatProg.uniforms.uFocusFloor.value = focusFloor;
6167
+ this.chairProg.uniforms.uFocusFloor.value = focusFloor;
6168
+ this.depthProg.uniforms.uFocusFloor.value = focusFloor;
5205
6169
  }
5206
6170
  ensureTarget() {
5207
6171
  const w = this.gl.drawingBufferWidth;
@@ -5548,8 +6512,8 @@ function clampPanoramaFov(fovDeg) {
5548
6512
  return Math.max(MIN_VFOV_DEG, Math.min(MAX_VFOV_DEG, fovDeg));
5549
6513
  }
5550
6514
  var MAX_PITCH_DEG = 35;
5551
- function bearingToOffsetPx(bearingDeg, viewportW, bgW) {
5552
- const col = (0.5 + bearingDeg / 360) * bgW;
6515
+ function bearingToOffsetPx(bearingDeg2, viewportW, bgW) {
6516
+ const col = (0.5 + bearingDeg2 / 360) * bgW;
5553
6517
  return viewportW / 2 - col;
5554
6518
  }
5555
6519
  function windowedBgHeight(viewportH, vfovDeg = VFOV_DEG) {
@@ -5861,9 +6825,9 @@ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEG
5861
6825
  const uv = new Float32Array(vertexCount * 2);
5862
6826
  for (let iLat = 0; iLat < latCount; iLat++) {
5863
6827
  const v = iLat / segmentsLat;
5864
- const polar = v * Math.PI;
5865
- const sinPolar = Math.sin(polar);
5866
- const cosPolar = Math.cos(polar);
6828
+ const polar2 = v * Math.PI;
6829
+ const sinPolar = Math.sin(polar2);
6830
+ const cosPolar = Math.cos(polar2);
5867
6831
  for (let iLon = 0; iLon < lonCount; iLon++) {
5868
6832
  const u = iLon / segmentsLon;
5869
6833
  const azimuth = (u - 0.5) * Math.PI * 2;
@@ -5891,8 +6855,8 @@ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEG
5891
6855
  }
5892
6856
  return { position, uv, index };
5893
6857
  }
5894
- function bearingPitchToDirection(bearingDeg, pitchDeg) {
5895
- const yaw = bearingDeg * Math.PI / 180;
6858
+ function bearingPitchToDirection(bearingDeg2, pitchDeg) {
6859
+ const yaw = bearingDeg2 * Math.PI / 180;
5896
6860
  const pitch = pitchDeg * Math.PI / 180;
5897
6861
  const cosPitch = Math.cos(pitch);
5898
6862
  return [cosPitch * Math.sin(yaw), Math.sin(pitch), -cosPitch * Math.cos(yaw)];
@@ -6397,7 +7361,7 @@ function prepareVenue3D(input) {
6397
7361
  }
6398
7362
 
6399
7363
  // src/view3d/index.ts
6400
- var DEG3 = Math.PI / 180;
7364
+ var DEG5 = Math.PI / 180;
6401
7365
  var TAP_SLOP = 6;
6402
7366
  var TAP_MS = 500;
6403
7367
  function mountVenue3D(container, input, opts = {}) {
@@ -6465,11 +7429,11 @@ function mountVenue3D(container, input, opts = {}) {
6465
7429
  const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
6466
7430
  let lastGatherX = Infinity;
6467
7431
  let lastGatherZ = Infinity;
6468
- let nearFieldDetailEnabled = false;
6469
7432
  let cameraDetailVisible = false;
7433
+ let seatDetailMix = 0;
7434
+ let structureDetailMix = 0;
6470
7435
  let panoramaSphereVisible = false;
6471
7436
  const setNearFieldDetailEnabled = (enabled) => {
6472
- nearFieldDetailEnabled = enabled;
6473
7437
  if (!enabled) cameraDetailVisible = false;
6474
7438
  lastGatherX = Infinity;
6475
7439
  lastGatherZ = Infinity;
@@ -6518,27 +7482,36 @@ function mountVenue3D(container, input, opts = {}) {
6518
7482
  // first real drag/wheel/pinch (not the intro ease)
6519
7483
  );
6520
7484
  orbit.setAspect(glctx.aspect);
7485
+ const inferredImportedGeometry = (input.doc.floors?.flatMap((floor) => floor.objects) ?? input.doc.objects).some((object) => object.type === "section" && object.physical3dProvenance?.surfaceKind?.status === "inferred");
6521
7486
  const stageAzimuth = (() => {
7487
+ if (inferredImportedGeometry && model.stages.length === 0) return void 0;
6522
7488
  const dx = model.focalWorld[0] - model.bounds.center[0];
6523
7489
  const dz = model.focalWorld[2] - model.bounds.center[2];
6524
7490
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
6525
7491
  })();
7492
+ const overviewFraming = model.floors.length > 1 ? { polarDeg: 48, margin: 1.1 } : void 0;
6526
7493
  const reduceMotionOnMount = typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
6527
- orbit.frame(model.bounds, !reduceMotionOnMount, stageAzimuth, opts.portraitOverviewCrop === true);
7494
+ orbit.frame(model.bounds, !reduceMotionOnMount, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
6528
7495
  const cinematic = new Cinematic(orbit.camera);
6529
7496
  const restoreContainerPosition = establishPositioningContext(container);
6530
7497
  const labelOverlay = new LabelOverlay(container, {
6531
7498
  fontFamily: input.doc.theme?.fontFamily,
6532
- ink: input.doc.theme?.textColor
7499
+ ink: input.doc.theme?.textColor,
7500
+ backgroundRgb: model.theme.background.top
6533
7501
  });
6534
- labelOverlay.setLabels(model.labels);
6535
7502
  const baseLabels = model.labels;
7503
+ const labelsForFocusedFloor = () => labelsForFloorView(
7504
+ baseLabels,
7505
+ model.floors.length,
7506
+ focusedFloor
7507
+ );
7508
+ labelOverlay.setLabels(labelsForFocusedFloor());
6536
7509
  let focusedSectionId = null;
6537
7510
  const showSectionSeatLabels = (sectionId) => {
6538
7511
  focusedSectionId = sectionId;
6539
7512
  labelOverlay.setForcedDense(!!sectionId);
6540
7513
  if (!sectionId) {
6541
- labelOverlay.setLabels(baseLabels);
7514
+ labelOverlay.setLabels(labelsForFocusedFloor());
6542
7515
  return;
6543
7516
  }
6544
7517
  const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
@@ -6546,7 +7519,7 @@ function mountVenue3D(container, input, opts = {}) {
6546
7519
  const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
6547
7520
  const selectedIds = new Set(selection.keys());
6548
7521
  const selectedInSection = new Set(sectionSeats.filter((seat) => selectedIds.has(seat.id)).map((seat) => seat.id));
6549
- 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)))));
7522
+ 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)))));
6550
7523
  if (model.seatCount <= 6e3 && selectedInSection.size === 0) {
6551
7524
  labelOverlay.setLabels(focusedBase);
6552
7525
  return;
@@ -6577,29 +7550,58 @@ function mountVenue3D(container, input, opts = {}) {
6577
7550
  ]);
6578
7551
  };
6579
7552
  rebuildGpu();
6580
- const loop = new RenderLoop(() => {
7553
+ const loop = new RenderLoop((dt) => {
6581
7554
  if (contextLost || !gpu || frozen) return false;
6582
7555
  const flying = cinematic.active;
6583
7556
  const moving = flying ? cinematic.update(performance.now()) : orbit.update();
6584
- const lod = computeSeatLod(orbit.currentDistance, model.bounds.radius);
7557
+ const activeFloorBounds = focusedFloor >= 0 ? model.floors[focusedFloor] : null;
7558
+ const lodRadius = activeFloorBounds?.radius ?? model.bounds.radius;
7559
+ const lod = computeSeatLod(orbit.currentDistance, lodRadius);
6585
7560
  const clippingBounds = panoramaSphereVisible ? { center: [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z], radius: 520 } : model.renderBounds;
6586
7561
  orbit.updateClipping(clippingBounds);
6587
- const cameraDx = orbit.camera.position.x - model.renderBounds.center[0];
6588
- const cameraDy = orbit.camera.position.y - model.renderBounds.center[1];
6589
- const cameraDz = orbit.camera.position.z - model.renderBounds.center[2];
6590
- const cameraVenueDistance = Math.hypot(cameraDx, cameraDy, cameraDz);
6591
- const detailEnter = model.renderBounds.radius * 1.55;
6592
- const detailExit = model.renderBounds.radius * 1.8;
6593
- if (!nearFieldDetailEnabled) cameraDetailVisible = false;
6594
- else if (cameraDetailVisible) cameraDetailVisible = cameraVenueDistance <= detailExit;
6595
- else cameraDetailVisible = cameraVenueDistance <= detailEnter;
7562
+ const detailCenter = activeFloorBounds?.center ?? model.renderBounds.center;
7563
+ const detailRadius = activeFloorBounds?.radius ?? model.renderBounds.radius;
7564
+ const withinScope = (center, radius) => {
7565
+ const d = Math.hypot(
7566
+ orbit.camera.position.x - center[0],
7567
+ orbit.camera.position.y - center[1],
7568
+ orbit.camera.position.z - center[2]
7569
+ );
7570
+ return d <= radius * (cameraDetailVisible ? 1.8 : 1.55);
7571
+ };
7572
+ let withinDetail = withinScope(detailCenter, detailRadius);
7573
+ if (!withinDetail && focusedSectionId) {
7574
+ const sec = model.sections.find((candidate) => candidate.id === focusedSectionId);
7575
+ if (sec) withinDetail = withinScope(sec.center, Math.max(12, sec.radius * 2));
7576
+ }
7577
+ cameraDetailVisible = withinDetail;
6596
7578
  const u = gpu.seatProgram.uniforms;
6597
7579
  u.uSeatScale.value = lod.scale;
6598
7580
  u.uSeatFade.value = lod.fade;
6599
- u.uSeatDetail.value = cameraDetailVisible ? 1 : 0;
6600
- gpu.solidProgram.uniforms.uStructureDetail.value = cameraDetailVisible ? 1 : 0;
7581
+ const floorSeatDetail = !!activeFloorBounds && floorOverviewSeatDetailVisible(
7582
+ activeFloorBounds.seatCount,
7583
+ glctx.canvas.clientWidth || 1,
7584
+ glctx.canvas.clientHeight || 1
7585
+ );
7586
+ const venueSeatDetail = projectedSeatDetailVisible(
7587
+ model.seats.count,
7588
+ lodRadius,
7589
+ orbit.currentDistance,
7590
+ orbit.camera.fov,
7591
+ glctx.canvas.clientHeight || 1
7592
+ );
7593
+ const seatDetailTarget = cameraDetailVisible || floorSeatDetail || venueSeatDetail ? 1 : 0;
7594
+ const structureDetailTarget = cameraDetailVisible || focusedFloor >= 0 || model.floors.length > 1 ? 1 : 0;
7595
+ const easeIn = 1 - Math.exp(-dt * 9);
7596
+ const easeOut = 1 - Math.exp(-dt * 20);
7597
+ seatDetailMix += (seatDetailTarget - seatDetailMix) * (seatDetailTarget > seatDetailMix ? easeIn : easeOut);
7598
+ structureDetailMix += (structureDetailTarget - structureDetailMix) * (structureDetailTarget > structureDetailMix ? easeIn : easeOut);
7599
+ if (Math.abs(seatDetailTarget - seatDetailMix) < 0.01) seatDetailMix = seatDetailTarget;
7600
+ if (Math.abs(structureDetailTarget - structureDetailMix) < 0.01) structureDetailMix = structureDetailTarget;
7601
+ u.uSeatDetail.value = seatDetailMix;
7602
+ gpu.solidProgram.uniforms.uStructureDetail.value = structureDetailMix;
6601
7603
  u.uMinPixels.value = lod.minPixels;
6602
- u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
7604
+ u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG5 / 2) / Math.max(1, glctx.pixelHeight);
6603
7605
  updateNearField(cameraDetailVisible);
6604
7606
  glctx.renderer.render({ scene: gpu.background, clear: true });
6605
7607
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
@@ -6608,12 +7610,12 @@ function mountVenue3D(container, input, opts = {}) {
6608
7610
  glctx.canvas.clientWidth || 1,
6609
7611
  glctx.canvas.clientHeight || 1,
6610
7612
  orbit.currentDistance,
6611
- model.bounds.radius,
7613
+ lodRadius,
6612
7614
  // The dense label rungs rank by real distance from the eye, not by the
6613
7615
  // orbit radius — at the arrival pose those are wildly different numbers.
6614
7616
  [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
6615
7617
  );
6616
- return moving;
7618
+ return moving || seatDetailMix !== seatDetailTarget || structureDetailMix !== structureDetailTarget;
6617
7619
  });
6618
7620
  const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
6619
7621
  ro?.observe(container);
@@ -6657,14 +7659,20 @@ function mountVenue3D(container, input, opts = {}) {
6657
7659
  const resolvedSeatViewPose = (seatId, idx) => {
6658
7660
  const seat = expandedSeatById.get(seatId);
6659
7661
  const floorIndex = Math.max(0, Math.round(model.seats.iFloor[idx] ?? 0));
6660
- const floor = input.doc.floors?.[floorIndex];
6661
- const focalPoint = seat?.focalPoint ?? floor?.focalPoint ?? input.doc.focalPoint;
7662
+ const sourceFloor = input.doc.floors?.[floorIndex];
7663
+ const sceneFloor = model.floors[floorIndex];
7664
+ const sourceFocal = sourceFloor?.focalPoint ?? input.doc.focalPoint;
7665
+ const sceneFocal = sceneFloor ? { x: sceneFloor.focalWorld[0] / M, y: sceneFloor.focalWorld[2] / M } : sourceFocal;
7666
+ const focalPoint = seat?.focalPoint ? {
7667
+ x: seat.focalPoint.x + sceneFocal.x - sourceFocal.x,
7668
+ y: seat.focalPoint.y + sceneFocal.y - sourceFocal.y
7669
+ } : sceneFocal;
6662
7670
  const deck = [
6663
7671
  model.seats.iPosition[idx * 3],
6664
7672
  model.seats.iPosition[idx * 3 + 1],
6665
7673
  model.seats.iPosition[idx * 3 + 2]
6666
7674
  ];
6667
- return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0, seat?.eyeHeightM);
7675
+ return seatViewPose(deck, focalPoint, sourceFloor?.baseHeightM ?? 0, seat?.eyeHeightM);
6668
7676
  };
6669
7677
  const placeCameraFinal = (finalPos, focal, fov = FOV_END) => {
6670
7678
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
@@ -6894,8 +7902,8 @@ function mountVenue3D(container, input, opts = {}) {
6894
7902
  overviewChip.className = "sl-3d-overview-control sl-3d-icon-control";
6895
7903
  overviewChip.type = "button";
6896
7904
  overviewChip.textContent = "\u2302";
6897
- overviewChip.setAttribute("aria-label", "Fit the whole venue in view");
6898
- overviewChip.dataset.tooltip = "Fit venue";
7905
+ overviewChip.setAttribute("aria-label", "Fit the active level or whole venue in view");
7906
+ overviewChip.dataset.tooltip = "Fit view";
6899
7907
  Object.assign(overviewChip.style, {
6900
7908
  width: "44px",
6901
7909
  minWidth: "44px",
@@ -6914,6 +7922,15 @@ function mountVenue3D(container, input, opts = {}) {
6914
7922
  overviewChip.textContent = "\u2302";
6915
7923
  };
6916
7924
  layoutOverviewChip();
7925
+ const applyFloorFocus = (value) => {
7926
+ focusedFloor = value;
7927
+ if (gpu) {
7928
+ gpu.seatProgram.uniforms.uFocusFloor.value = value;
7929
+ gpu.solidProgram.uniforms.uFocusFloor.value = value;
7930
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
7931
+ }
7932
+ showSectionSeatLabels(null);
7933
+ };
6917
7934
  const focusOverview = () => {
6918
7935
  if (disposed) return;
6919
7936
  opts.onViewTargetChange?.(null);
@@ -6930,13 +7947,25 @@ function mountVenue3D(container, input, opts = {}) {
6930
7947
  overviewChip.style.display = "";
6931
7948
  labelOverlay.setVisible(true);
6932
7949
  frozen = false;
6933
- setNearFieldDetailEnabled(false);
7950
+ const activeFloor = focusedFloor >= 0 ? model.floors[focusedFloor] : null;
7951
+ setNearFieldDetailEnabled(!!activeFloor);
6934
7952
  setSeatViewLocked(false);
6935
7953
  cancelFlight();
6936
7954
  removeArriveChip();
6937
7955
  setNavigationMode("orbit");
6938
- if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
6939
- else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
7956
+ if (activeFloor) {
7957
+ const dx = activeFloor.focalWorld[0] - activeFloor.center[0];
7958
+ const dz = activeFloor.focalWorld[2] - activeFloor.center[2];
7959
+ const azimuth = Math.hypot(dx, dz) > activeFloor.radius * 0.12 ? Math.atan2(dx, dz) : stageAzimuth;
7960
+ const floorBounds = { center: activeFloor.center, radius: activeFloor.radius, box: activeFloor.box };
7961
+ const floorFraming = { polarDeg: 58, margin: 1.12 };
7962
+ if (reducedMotion()) orbit.frame(floorBounds, false, azimuth, false, floorFraming);
7963
+ else orbit.frameSoft(floorBounds, azimuth, false, floorFraming);
7964
+ } else if (reducedMotion()) {
7965
+ orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
7966
+ } else {
7967
+ orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
7968
+ }
6940
7969
  showSectionSeatLabels(null);
6941
7970
  loop.requestRender();
6942
7971
  };
@@ -7123,6 +8152,7 @@ function mountVenue3D(container, input, opts = {}) {
7123
8152
  setSeatViewLocked(false);
7124
8153
  cinematic.cancel();
7125
8154
  removeArriveChip();
8155
+ if (model.floors.length > 1 && focusedFloor !== sec.floorIndex) applyFloorFocus(sec.floorIndex);
7126
8156
  setNavigationMode("pan");
7127
8157
  showSectionSeatLabels(sectionId);
7128
8158
  const dx = sec.focalWorld[0] - sec.center[0];
@@ -7305,20 +8335,25 @@ function mountVenue3D(container, input, opts = {}) {
7305
8335
  focusFloor(index) {
7306
8336
  if (index !== null && !model.floors.some((f) => f.index === index)) return false;
7307
8337
  const value = index ?? -1;
7308
- setNearFieldDetailEnabled(false);
8338
+ setNearFieldDetailEnabled(index !== null);
7309
8339
  setSeatViewLocked(false);
7310
- if (gpu) {
7311
- gpu.seatProgram.uniforms.uFocusFloor.value = value;
7312
- gpu.solidProgram.uniforms.uFocusFloor.value = value;
7313
- gpu.chairProgram.uniforms.uFocusFloor.value = value;
7314
- }
7315
- focusedFloor = value;
8340
+ applyFloorFocus(value);
7316
8341
  setNavigationMode("orbit");
7317
- if (index !== null) {
8342
+ if (index === null) {
8343
+ cinematic.cancel();
8344
+ if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
8345
+ else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true, overviewFraming);
8346
+ } else {
7318
8347
  const f = model.floors[index];
7319
8348
  if (f.seatCount > 0) {
7320
8349
  cinematic.cancel();
7321
- orbit.frame({ center: f.center, radius: f.radius * 1.25 });
8350
+ const dx = f.focalWorld[0] - f.center[0];
8351
+ const dz = f.focalWorld[2] - f.center[2];
8352
+ const azimuth = Math.hypot(dx, dz) > f.radius * 0.12 ? Math.atan2(dx, dz) : stageAzimuth;
8353
+ orbit.frame({ center: f.center, radius: f.radius, box: f.box }, false, azimuth, false, {
8354
+ polarDeg: 58,
8355
+ margin: 1.12
8356
+ });
7322
8357
  }
7323
8358
  }
7324
8359
  loop.requestRender();