@seatlayer/core 0.30.1 → 0.32.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.
@@ -207,10 +207,18 @@ var OrbitCamera = class {
207
207
  this.maxDist = 100;
208
208
  this.gestureFired = false;
209
209
  this.dragging = false;
210
+ this.panning = false;
210
211
  this.lastX = 0;
211
212
  this.lastY = 0;
212
213
  this.activePointers = /* @__PURE__ */ new Map();
213
214
  this.pinchDist = 0;
215
+ this.pinchCx = 0;
216
+ this.pinchCy = 0;
217
+ /** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
218
+ this.targetT = new import_ogl2.Vec3();
219
+ /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
220
+ this.panAnchor = new import_ogl2.Vec3();
221
+ this.panLimit = 0;
214
222
  this.camera = new import_ogl2.Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
215
223
  this.canvas = canvas;
216
224
  this.requestRender = requestRender;
@@ -222,12 +230,17 @@ var OrbitCamera = class {
222
230
  }
223
231
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
224
232
  if (this.activePointers.size === 1) {
225
- this.dragging = true;
233
+ this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
234
+ this.dragging = !this.panning;
226
235
  this.lastX = e.clientX;
227
236
  this.lastY = e.clientY;
228
237
  } else if (this.activePointers.size === 2) {
229
238
  this.dragging = false;
239
+ this.panning = false;
230
240
  this.pinchDist = this.currentPinchDistance();
241
+ const c = this.pinchCentroid();
242
+ this.pinchCx = c.x;
243
+ this.pinchCy = c.y;
231
244
  }
232
245
  };
233
246
  this.onPointerMove = (e) => {
@@ -240,11 +253,24 @@ var OrbitCamera = class {
240
253
  this.fireGesture();
241
254
  }
242
255
  this.pinchDist = d;
256
+ const c = this.pinchCentroid();
257
+ this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
258
+ this.pinchCx = c.x;
259
+ this.pinchCy = c.y;
243
260
  return;
244
261
  }
245
- if (!this.dragging) return;
246
262
  const dx = e.clientX - this.lastX;
247
263
  const dy = e.clientY - this.lastY;
264
+ if (this.panning) {
265
+ this.lastX = e.clientX;
266
+ this.lastY = e.clientY;
267
+ if (dx !== 0 || dy !== 0) {
268
+ this.panBy(dx, dy);
269
+ this.fireGesture();
270
+ }
271
+ return;
272
+ }
273
+ if (!this.dragging) return;
248
274
  this.lastX = e.clientX;
249
275
  this.lastY = e.clientY;
250
276
  if (dx !== 0 || dy !== 0) this.fireGesture();
@@ -259,7 +285,13 @@ var OrbitCamera = class {
259
285
  } catch {
260
286
  }
261
287
  if (this.activePointers.size < 2) this.pinchDist = 0;
262
- if (this.activePointers.size === 0) this.dragging = false;
288
+ if (this.activePointers.size === 0) {
289
+ this.dragging = false;
290
+ this.panning = false;
291
+ }
292
+ };
293
+ this.onContextMenu = (e) => {
294
+ e.preventDefault();
263
295
  };
264
296
  this.onWheel = (e) => {
265
297
  e.preventDefault();
@@ -273,6 +305,55 @@ var OrbitCamera = class {
273
305
  canvas.addEventListener("pointerup", this.onPointerUp);
274
306
  canvas.addEventListener("pointercancel", this.onPointerUp);
275
307
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
308
+ canvas.addEventListener("contextmenu", this.onContextMenu);
309
+ }
310
+ pinchCentroid() {
311
+ const pts = [...this.activePointers.values()];
312
+ if (!pts.length) return { x: 0, y: 0 };
313
+ let x = 0;
314
+ let y = 0;
315
+ for (const p of pts) {
316
+ x += p.x;
317
+ y += p.y;
318
+ }
319
+ return { x: x / pts.length, y: y / pts.length };
320
+ }
321
+ /**
322
+ * Slide the orbit pivot across the camera's own screen plane.
323
+ *
324
+ * Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
325
+ * under the cursor whatever the zoom: at the overview a drag sweeps the whole
326
+ * bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
327
+ * would be unusable at one end or the other.
328
+ */
329
+ panBy(dxPx, dyPx) {
330
+ const h = this.canvas.clientHeight || 1;
331
+ const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
332
+ const sinA = Math.sin(this.azimuth);
333
+ const cosA = Math.cos(this.azimuth);
334
+ const rightX = cosA;
335
+ const rightZ = -sinA;
336
+ const cp = Math.cos(this.polar);
337
+ const sp = Math.sin(this.polar);
338
+ const fwdX = -sinA * cp;
339
+ const fwdZ = -cosA * cp;
340
+ this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
341
+ this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
342
+ this.targetT.y += dyPx * sp * perPx;
343
+ this.clampPan();
344
+ this.requestRender();
345
+ }
346
+ /** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
347
+ * venue entirely — the Overview chip should never be the only way back. */
348
+ clampPan() {
349
+ if (this.panLimit <= 0) return;
350
+ const lim = this.panLimit;
351
+ const cx = this.panAnchor.x;
352
+ const cy = this.panAnchor.y;
353
+ const cz = this.panAnchor.z;
354
+ this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
355
+ this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
356
+ this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
276
357
  }
277
358
  /** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
278
359
  fireGesture() {
@@ -299,6 +380,9 @@ var OrbitCamera = class {
299
380
  */
300
381
  frame(bounds, intro = false, stageAzimuth) {
301
382
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
383
+ this.targetT.copy(this.target);
384
+ this.panAnchor.copy(this.target);
385
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
302
386
  const r = Math.max(1, bounds.radius);
303
387
  const halfV = this.fovY * DEG / 2;
304
388
  const aspect = this.camera.aspect || 1;
@@ -330,6 +414,9 @@ var OrbitCamera = class {
330
414
  */
331
415
  frameSoft(bounds, stageAzimuth) {
332
416
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
417
+ this.targetT.copy(this.target);
418
+ this.panAnchor.copy(this.target);
419
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
333
420
  this.syncFromCamera();
334
421
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
335
422
  const r = Math.max(1, bounds.radius);
@@ -346,10 +433,17 @@ var OrbitCamera = class {
346
433
  const da = this.azT - this.azimuth;
347
434
  const dp = this.polT - this.polar;
348
435
  const dd = this.distT - this.distance;
349
- const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
436
+ const tx = this.targetT.x - this.target.x;
437
+ const ty = this.targetT.y - this.target.y;
438
+ const tz = this.targetT.z - this.target.z;
439
+ const panEps = Math.max(1e-4, this.distance * 1e-4);
440
+ const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4 || Math.abs(tx) > panEps || Math.abs(ty) > panEps || Math.abs(tz) > panEps;
350
441
  this.azimuth += da * DAMP;
351
442
  this.polar += dp * DAMP;
352
443
  this.distance += dd * DAMP;
444
+ this.target.x += tx * DAMP;
445
+ this.target.y += ty * DAMP;
446
+ this.target.z += tz * DAMP;
353
447
  if (moving) this.applyPosition();
354
448
  return moving;
355
449
  }
@@ -375,6 +469,7 @@ var OrbitCamera = class {
375
469
  /** Point the orbit pivot at a new world target without moving the camera. */
376
470
  setTarget(target) {
377
471
  this.target.set(target[0], target[1], target[2]);
472
+ this.targetT.copy(this.target);
378
473
  }
379
474
  /** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
380
475
  * flight ends looking at `target` (the venue focal), so re-pivot there first —
@@ -382,6 +477,7 @@ var OrbitCamera = class {
382
477
  resumeAfterFlight(target) {
383
478
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
384
479
  if (target) this.target.set(target[0], target[1], target[2]);
480
+ this.targetT.copy(this.target);
385
481
  this.syncFromCamera();
386
482
  }
387
483
  applyPosition() {
@@ -398,6 +494,7 @@ var OrbitCamera = class {
398
494
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
399
495
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
400
496
  this.canvas.removeEventListener("wheel", this.onWheel);
497
+ this.canvas.removeEventListener("contextmenu", this.onContextMenu);
401
498
  this.activePointers.clear();
402
499
  }
403
500
  };
@@ -446,17 +543,129 @@ var RenderLoop = class {
446
543
  };
447
544
 
448
545
  // src/view3d/lod.ts
546
+ var SEAT_MIN_PIXELS_NEAR = 2.5;
547
+ var SEAT_MIN_PIXELS_FAR = 1.15;
548
+ var CHAIR_FULL_M = 12;
549
+ var CHAIR_NONE_M = 22;
550
+ var CHAIR_GATHER_M = 30;
551
+ var CHAIR_REBUILD_M = 4;
552
+ var CHAIR_MAX_INSTANCES = 8192;
553
+ var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
449
554
  function computeSeatLod(distance, radius) {
450
555
  const near = radius * 1.4;
451
556
  const far = radius * 3.2;
452
- if (distance <= near) return { scale: 1, fade: 0 };
557
+ if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
453
558
  const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
454
559
  return {
455
560
  scale: 1 - t * 0.4,
456
- fade: t * 0.55
561
+ fade: t * 0.55,
562
+ // Tapered on the same ramp as the fade: as the block starts reading by its
563
+ // tier tint, the dots stop fighting each other for pixels.
564
+ minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
457
565
  };
458
566
  }
459
567
 
568
+ // src/view3d/scene/nearField.ts
569
+ var SEATS_PER_CELL = 4;
570
+ var NearFieldIndex = class {
571
+ constructor(iPosition, count) {
572
+ this.minX = 0;
573
+ this.minZ = 0;
574
+ this.cell = 1;
575
+ this.cols = 1;
576
+ this.rows = 1;
577
+ /** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
578
+ this.cellStart = null;
579
+ this.cellItems = null;
580
+ this.iPosition = iPosition;
581
+ this.count = count;
582
+ }
583
+ /** Built on demand; safe to call repeatedly. */
584
+ ensureGrid() {
585
+ if (this.cellStart || this.count === 0) return;
586
+ const p = this.iPosition;
587
+ let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
588
+ for (let i = 0; i < this.count; i++) {
589
+ const x = p[i * 3], z = p[i * 3 + 2];
590
+ if (x < minX) minX = x;
591
+ if (x > maxX) maxX = x;
592
+ if (z < minZ) minZ = z;
593
+ if (z > maxZ) maxZ = z;
594
+ }
595
+ const w = Math.max(maxX - minX, 1e-3);
596
+ const h = Math.max(maxZ - minZ, 1e-3);
597
+ this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
598
+ this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
599
+ this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
600
+ this.minX = minX;
601
+ this.minZ = minZ;
602
+ const nCells = this.cols * this.rows;
603
+ const start = new Int32Array(nCells + 1);
604
+ const cellOf = (i) => {
605
+ const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
606
+ const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
607
+ return cz * this.cols + cx;
608
+ };
609
+ for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
610
+ for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
611
+ const items = new Int32Array(this.count);
612
+ const cursor = start.slice(0, nCells);
613
+ for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
614
+ this.cellStart = start;
615
+ this.cellItems = items;
616
+ }
617
+ /**
618
+ * Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
619
+ * nearest cell-ring first, and return how many were written.
620
+ *
621
+ * Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
622
+ * bites it drops the OUTERMOST seats, which are the ones already past the fade
623
+ * band and drawing nothing. A cap that truncated in index order would instead
624
+ * punch holes in the row you are sitting in.
625
+ *
626
+ * Note this is a horizontal (XZ) query and ignores height. A stacked venue's
627
+ * upper tier is therefore gathered along with the stalls beneath it — which is
628
+ * correct, because the fade weight is re-derived from true view depth in the
629
+ * shader anyway. The grid's only job is to bound the candidate set.
630
+ */
631
+ gather(camX, camZ, radius, out) {
632
+ this.ensureGrid();
633
+ const start = this.cellStart;
634
+ const items = this.cellItems;
635
+ if (!start || !items) return 0;
636
+ const cap = out.length;
637
+ const r2 = radius * radius;
638
+ const cx = Math.floor((camX - this.minX) / this.cell);
639
+ const cz = Math.floor((camZ - this.minZ) / this.cell);
640
+ const maxRing = Math.ceil(radius / this.cell) + 1;
641
+ const p = this.iPosition;
642
+ let n = 0;
643
+ for (let ring = 0; ring <= maxRing && n < cap; ring++) {
644
+ const z0 = cz - ring, z1 = cz + ring;
645
+ const x0 = cx - ring, x1 = cx + ring;
646
+ for (let gz = z0; gz <= z1 && n < cap; gz++) {
647
+ if (gz < 0 || gz >= this.rows) continue;
648
+ const edge = gz === z0 || gz === z1;
649
+ for (let gx = x0; gx <= x1 && n < cap; gx++) {
650
+ if (!edge && gx !== x0 && gx !== x1) {
651
+ gx = x1 - 1;
652
+ continue;
653
+ }
654
+ if (gx < 0 || gx >= this.cols) continue;
655
+ const c = gz * this.cols + gx;
656
+ for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
657
+ const i = items[k];
658
+ const dx = p[i * 3] - camX;
659
+ const dz = p[i * 3 + 2] - camZ;
660
+ if (dx * dx + dz * dz <= r2) out[n++] = i;
661
+ }
662
+ }
663
+ }
664
+ }
665
+ return n;
666
+ }
667
+ };
668
+
460
669
  // src/core/types.ts
461
670
  var ACCESSIBILITY_TYPES = [
462
671
  { key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
@@ -483,6 +692,12 @@ function accessibilityRingColor(types) {
483
692
  const primary = types?.[0];
484
693
  return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
485
694
  }
695
+ var SEAT_COMMERCIAL_MARKS = [
696
+ { key: "obstructedView", label: "Obstructed view", short: "Obstructed", icon: "\u26D4" },
697
+ { key: "restrictedView", label: "Restricted view", short: "Restricted", icon: "\u{1F441}" },
698
+ { key: "premium", label: "Premium seat", short: "Premium", icon: "\u2605" }
699
+ ];
700
+ var SEAT_COMMERCIAL_LABEL = new Map(SEAT_COMMERCIAL_MARKS.map((mark) => [mark.key, mark]));
486
701
  var SURROUNDINGS_SHAPE_ROLES = [
487
702
  "reference-focal",
488
703
  "bar",
@@ -548,12 +763,42 @@ function isDegenerate(p0, p1, p2) {
548
763
  const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
549
764
  return 2 * area / longest < MIN_TRI_ALTITUDE_M;
550
765
  }
766
+ var F32Buffer = class {
767
+ constructor(initial = 1 << 14) {
768
+ this.len = 0;
769
+ this.buf = new Float32Array(initial);
770
+ }
771
+ push3(a, b, c) {
772
+ if (this.len + 3 > this.buf.length) this.grow(this.len + 3);
773
+ this.buf[this.len++] = a;
774
+ this.buf[this.len++] = b;
775
+ this.buf[this.len++] = c;
776
+ }
777
+ push1(a) {
778
+ if (this.len + 1 > this.buf.length) this.grow(this.len + 1);
779
+ this.buf[this.len++] = a;
780
+ }
781
+ grow(need) {
782
+ let cap = this.buf.length * 2;
783
+ while (cap < need) cap *= 2;
784
+ const next = new Float32Array(cap);
785
+ next.set(this.buf.subarray(0, this.len));
786
+ this.buf = next;
787
+ }
788
+ get length() {
789
+ return this.len;
790
+ }
791
+ /** A copy trimmed to the used length. */
792
+ toArray() {
793
+ return this.buf.slice(0, this.len);
794
+ }
795
+ };
551
796
  var MeshBuilder = class {
552
797
  constructor() {
553
- this.pos = [];
554
- this.nor = [];
555
- this.col = [];
556
- this.flr = [];
798
+ this.pos = new F32Buffer();
799
+ this.nor = new F32Buffer();
800
+ this.col = new F32Buffer();
801
+ this.flr = new F32Buffer();
557
802
  /** Floor index stamped onto every triangle emitted from now on. */
558
803
  this.currentFloor = 0;
559
804
  }
@@ -564,28 +809,44 @@ var MeshBuilder = class {
564
809
  /** One triangle with a shared (flat) normal and per-vertex colours. */
565
810
  tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
566
811
  if (isDegenerate(p0, p1, p2)) return;
567
- this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
568
- this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
569
- this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
570
- this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
812
+ this.pos.push3(p0[0], p0[1], p0[2]);
813
+ this.pos.push3(p1[0], p1[1], p1[2]);
814
+ this.pos.push3(p2[0], p2[1], p2[2]);
815
+ this.nor.push3(n[0], n[1], n[2]);
816
+ this.nor.push3(n[0], n[1], n[2]);
817
+ this.nor.push3(n[0], n[1], n[2]);
818
+ this.col.push3(c0[0], c0[1], c0[2]);
819
+ this.col.push3(c1[0], c1[1], c1[2]);
820
+ this.col.push3(c2[0], c2[1], c2[2]);
821
+ this.flr.push1(this.currentFloor);
822
+ this.flr.push1(this.currentFloor);
823
+ this.flr.push1(this.currentFloor);
571
824
  }
572
825
  /** One triangle with independent per-vertex normals (smooth shading). */
573
826
  triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
574
827
  if (isDegenerate(p0, p1, p2)) return;
575
- this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
576
- this.nor.push(n0[0], n0[1], n0[2], n1[0], n1[1], n1[2], n2[0], n2[1], n2[2]);
577
- this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
578
- this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
828
+ this.pos.push3(p0[0], p0[1], p0[2]);
829
+ this.pos.push3(p1[0], p1[1], p1[2]);
830
+ this.pos.push3(p2[0], p2[1], p2[2]);
831
+ this.nor.push3(n0[0], n0[1], n0[2]);
832
+ this.nor.push3(n1[0], n1[1], n1[2]);
833
+ this.nor.push3(n2[0], n2[1], n2[2]);
834
+ this.col.push3(c0[0], c0[1], c0[2]);
835
+ this.col.push3(c1[0], c1[1], c1[2]);
836
+ this.col.push3(c2[0], c2[1], c2[2]);
837
+ this.flr.push1(this.currentFloor);
838
+ this.flr.push1(this.currentFloor);
839
+ this.flr.push1(this.currentFloor);
579
840
  }
580
841
  get vertexCount() {
581
842
  return this.pos.length / 3;
582
843
  }
583
844
  build() {
584
845
  return {
585
- position: new Float32Array(this.pos),
586
- normal: new Float32Array(this.nor),
587
- color: new Float32Array(this.col),
588
- floor: new Float32Array(this.flr),
846
+ position: this.pos.toArray(),
847
+ normal: this.nor.toArray(),
848
+ color: this.col.toArray(),
849
+ floor: this.flr.toArray(),
589
850
  count: this.pos.length / 3
590
851
  };
591
852
  }
@@ -913,6 +1174,152 @@ function rectPolygon(x, y, w, h) {
913
1174
  ];
914
1175
  }
915
1176
 
1177
+ // src/view3d/scene/seatChair.ts
1178
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
1179
+ var CHAIR_PITCH_FRACTION = 0.44;
1180
+ var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1181
+ var CHAIR_HALF_WIDTH_MAX_M = 0.3;
1182
+ var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
1183
+ function chairHalfWidth(pitchM) {
1184
+ if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
1185
+ return CHAIR_HALF_WIDTH_DEFAULT_M;
1186
+ }
1187
+ return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
1188
+ }
1189
+ var BACK_RAKE_SLOPE = 0.21;
1190
+ var PAD_BACK_GAP_M = 0.05;
1191
+ var PAD_TOP_M = 0.45;
1192
+ var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1193
+ var BOXES = [
1194
+ // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1195
+ // over the deck and the row reads as hovering trays.
1196
+ { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
1197
+ // Seat pad — a full seat width across and about as deep, which is what a real
1198
+ // one is. Its depth is bounded by the same pitch as its width, because the
1199
+ // pitch measure is the tighter of the in-row and row-to-row spacings, so a
1200
+ // tightly-raked tier cannot drive a pad into the back of the row in front.
1201
+ { part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
1202
+ // Back panel — thin, raked, and the tallest thing in the row, so it is what
1203
+ // carries the state colour when you look along a row from behind.
1204
+ {
1205
+ part: CHAIR_PART.back,
1206
+ min: [-1, BACK_BASE_M, -1],
1207
+ max: [1, 0.92, -0.72]
1208
+ }
1209
+ ];
1210
+ var FACES = [
1211
+ // +X
1212
+ { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
1213
+ // -X
1214
+ { n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
1215
+ // +Y
1216
+ { n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
1217
+ // -Y
1218
+ { n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
1219
+ // +Z
1220
+ { n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
1221
+ // -Z
1222
+ { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1223
+ ];
1224
+ function buildChairMesh() {
1225
+ const vertexCount = BOXES.length * FACES.length * 4;
1226
+ const indexCount = BOXES.length * FACES.length * 6;
1227
+ const position = new Float32Array(vertexCount * 3);
1228
+ const normal = new Float32Array(vertexCount * 3);
1229
+ const part = new Float32Array(vertexCount);
1230
+ const index = new Uint16Array(indexCount);
1231
+ let v = 0;
1232
+ let t = 0;
1233
+ for (const box of BOXES) {
1234
+ for (const face of FACES) {
1235
+ const base = v;
1236
+ const corner3 = new Float32Array(12);
1237
+ for (let ci = 0; ci < 4; ci++) {
1238
+ const corner = face.c[ci];
1239
+ for (let a = 0; a < 3; a++) {
1240
+ corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1241
+ }
1242
+ }
1243
+ const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1244
+ const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1245
+ let nx = ay * bz - az * by;
1246
+ let ny = az * bx - ax * bz;
1247
+ let nz = ax * by - ay * bx;
1248
+ const nl = Math.hypot(nx, ny, nz);
1249
+ if (nl > 1e-9) {
1250
+ nx /= nl;
1251
+ ny /= nl;
1252
+ nz /= nl;
1253
+ } else {
1254
+ nx = face.n[0];
1255
+ ny = face.n[1];
1256
+ nz = face.n[2];
1257
+ }
1258
+ if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1259
+ nx = -nx;
1260
+ ny = -ny;
1261
+ nz = -nz;
1262
+ }
1263
+ for (let ci = 0; ci < 4; ci++) {
1264
+ position[v * 3] = corner3[ci * 3];
1265
+ position[v * 3 + 1] = corner3[ci * 3 + 1];
1266
+ position[v * 3 + 2] = corner3[ci * 3 + 2];
1267
+ normal[v * 3] = nx;
1268
+ normal[v * 3 + 1] = ny;
1269
+ normal[v * 3 + 2] = nz;
1270
+ part[v] = box.part;
1271
+ v++;
1272
+ }
1273
+ index[t++] = base;
1274
+ index[t++] = base + 1;
1275
+ index[t++] = base + 2;
1276
+ index[t++] = base;
1277
+ index[t++] = base + 2;
1278
+ index[t++] = base + 3;
1279
+ }
1280
+ }
1281
+ return { position, normal, part, index, vertexCount, indexCount };
1282
+ }
1283
+ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1284
+ const yaw = new Float32Array(count);
1285
+ const px = (i) => iPosition[i * 3];
1286
+ const pz = (i) => iPosition[i * 3 + 2];
1287
+ let runStart = 0;
1288
+ const flushRun = (start, end) => {
1289
+ const n = end - start;
1290
+ for (let i = start; i < end; i++) {
1291
+ const [fx, fz] = focal(i);
1292
+ let dx = fx - px(i);
1293
+ let dz = fz - pz(i);
1294
+ if (n >= 2) {
1295
+ const a = Math.max(start, i - 1);
1296
+ const b = Math.min(end - 1, i + 1);
1297
+ const tx = px(b) - px(a);
1298
+ const tz = pz(b) - pz(a);
1299
+ const tl = Math.hypot(tx, tz);
1300
+ if (tl > 1e-6) {
1301
+ let nx = -tz / tl;
1302
+ let nz = tx / tl;
1303
+ if (nx * dx + nz * dz < 0) {
1304
+ nx = -nx;
1305
+ nz = -nz;
1306
+ }
1307
+ dx = nx;
1308
+ dz = nz;
1309
+ }
1310
+ }
1311
+ yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
1312
+ }
1313
+ };
1314
+ for (let i = 1; i <= count; i++) {
1315
+ if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
1316
+ flushRun(runStart, i);
1317
+ runStart = i;
1318
+ }
1319
+ }
1320
+ return yaw;
1321
+ }
1322
+
916
1323
  // src/view3d/scene/seatInstances.ts
917
1324
  var SEAT_DOT_RADIUS_M = 0.22;
918
1325
  var SEAT_PITCH_FRACTION = 0.42;
@@ -982,6 +1389,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
982
1389
  const iPosition = new Float32Array(count * 3);
983
1390
  const iState = new Float32Array(count);
984
1391
  const iMaxRadius = new Float32Array(count);
1392
+ const iChairWidth = new Float32Array(count);
985
1393
  const iRing = new Float32Array(count * 3);
986
1394
  const idToIndex = /* @__PURE__ */ new Map();
987
1395
  const spacing = nearestNeighbourSpacing(seats);
@@ -990,6 +1398,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
990
1398
  const resolved = surfaces?.seatPitchU(i);
991
1399
  const pitchM = (resolved ?? spacing[i]) * M;
992
1400
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1401
+ iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
993
1402
  iPosition[i * 3] = seat.x * M;
994
1403
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
995
1404
  iPosition[i * 3 + 2] = seat.y * M;
@@ -1011,7 +1420,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1011
1420
  iMaxRadius,
1012
1421
  iRing,
1013
1422
  idToIndex,
1014
- iFloor: seatFloor ?? new Float32Array(count)
1423
+ iChairWidth,
1424
+ iFloor: seatFloor ?? new Float32Array(count),
1425
+ iYaw: new Float32Array(count)
1015
1426
  };
1016
1427
  }
1017
1428
  var RUN_MERGE_GAP = 64;
@@ -1093,16 +1504,23 @@ function themeSeatColorLUT(theme, order) {
1093
1504
  var ZONE_MIN_DISTANCE = 1.15;
1094
1505
  var SECTION_MAX_DISTANCE = 2.2;
1095
1506
  var NEAR_MAX_DISTANCE = 0.85;
1507
+ var ROW_MAX_DISTANCE = 0.5;
1508
+ var SEAT_MAX_DISTANCE = 0.26;
1096
1509
  function visibleLabelKinds(distance, venueRadius) {
1097
1510
  const r = Math.max(1e-6, venueRadius);
1098
1511
  const d = distance / r;
1099
1512
  const out = /* @__PURE__ */ new Set();
1100
1513
  if (d >= ZONE_MIN_DISTANCE) out.add("zone");
1101
- if (d <= SECTION_MAX_DISTANCE) out.add("section");
1514
+ if (d <= SECTION_MAX_DISTANCE) {
1515
+ out.add("section");
1516
+ out.add("ga");
1517
+ }
1102
1518
  if (d <= NEAR_MAX_DISTANCE) {
1103
1519
  out.add("annotation");
1104
1520
  out.add("booth");
1105
1521
  }
1522
+ if (d <= ROW_MAX_DISTANCE) out.add("row");
1523
+ if (d <= SEAT_MAX_DISTANCE) out.add("seat");
1106
1524
  return out;
1107
1525
  }
1108
1526
  function projectToScreen(viewProjection, p, width, height) {
@@ -1139,6 +1557,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
1139
1557
  }
1140
1558
  return kept;
1141
1559
  }
1560
+ var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
1561
+ function focusScore(screen, worldDistance, width, height) {
1562
+ const half = Math.max(1, Math.min(width, height) * 0.5);
1563
+ const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
1564
+ return worldDistance * (1 + 1.5 * off);
1565
+ }
1566
+ function pickDenseLabels(items, separationX, separationY, budget) {
1567
+ const ordered = [...items].sort((a, b) => a.focus - b.focus);
1568
+ const kept = [];
1569
+ for (const item of ordered) {
1570
+ if (kept.length >= budget) break;
1571
+ let clash = false;
1572
+ for (const k of kept) {
1573
+ if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
1574
+ clash = true;
1575
+ break;
1576
+ }
1577
+ }
1578
+ if (!clash) kept.push(item);
1579
+ }
1580
+ return kept;
1581
+ }
1142
1582
  function centroidOf(points) {
1143
1583
  if (!points.length) return null;
1144
1584
  let x = 0, y = 0;
@@ -1679,191 +2119,6 @@ function buildSectionRake(rows, focal) {
1679
2119
  return rowsRake(fits);
1680
2120
  }
1681
2121
 
1682
- // src/view3d/scene/surface.ts
1683
- var FLAT_SLAB_TOP_M = 0.05;
1684
- var CAP_MAX_ERROR_M = 0.05;
1685
- var SEAT_CLEARANCE_M = 0.15;
1686
- var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1687
- function bboxOf(pts) {
1688
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1689
- for (const p of pts) {
1690
- if (p.x < minX) minX = p.x;
1691
- if (p.y < minY) minY = p.y;
1692
- if (p.x > maxX) maxX = p.x;
1693
- if (p.y > maxY) maxY = p.y;
1694
- }
1695
- return { minX, minY, maxX, maxY };
1696
- }
1697
- var MAX_TIER_RISE_M = 25;
1698
- function buildVenueSurfaces(units, seats) {
1699
- const bySection = /* @__PURE__ */ new Map();
1700
- const seatOwner = new Array(seats.length).fill(null);
1701
- const seatDeck = new Float64Array(seats.length);
1702
- const seatRowLevel = new Array(seats.length).fill(void 0);
1703
- const seatPitch = new Array(seats.length).fill(void 0);
1704
- const acc = /* @__PURE__ */ new Map();
1705
- const boxes = [];
1706
- for (const unit of units) {
1707
- for (const o of unit.objects) {
1708
- if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1709
- const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
1710
- acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
1711
- boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
1712
- }
1713
- }
1714
- for (let i = 0; i < seats.length; i++) {
1715
- const s = seats[i];
1716
- for (const b of boxes) {
1717
- if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
1718
- if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
1719
- seatOwner[i] = b.id;
1720
- const a = acc.get(b.id);
1721
- a.hasSeats = true;
1722
- const f = s.focalPoint ?? b.unit.focal;
1723
- const d = Math.hypot(s.x - f.x, s.y - f.y);
1724
- if (d < a.frontU) a.frontU = d;
1725
- a.seatIndices.push(i);
1726
- const rowKey = s.rowId || `__seat-${i}`;
1727
- const arr = a.rows.get(rowKey);
1728
- if (arr) arr.push({ x: s.x, y: s.y });
1729
- else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
1730
- break;
1731
- }
1732
- }
1733
- for (const [id, a] of acc) {
1734
- const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
1735
- const bottomY = a.unit.baseHeightM;
1736
- const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
1737
- const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
1738
- const kind = a.section.surfaceKind;
1739
- const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
1740
- const rake = buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal);
1741
- let frontU = Infinity;
1742
- if (a.hasSeats) {
1743
- for (const pts of a.rows.values()) {
1744
- for (const p of pts) {
1745
- const d = rake.depthAt(p.x, p.y);
1746
- if (d < frontU) frontU = d;
1747
- }
1748
- }
1749
- }
1750
- if (!a.hasSeats || !Number.isFinite(frontU)) {
1751
- frontU = Infinity;
1752
- for (const p of a.section.outline) {
1753
- const d = rake.depthAt(p.x, p.y);
1754
- if (d < frontU) frontU = d;
1755
- }
1756
- }
1757
- const flatTop = bottomY + FLAT_SLAB_TOP_M;
1758
- const baseFloor = bottomY + FLAT_SLAB_TOP_M;
1759
- const levelFor = (depthU) => {
1760
- const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
1761
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1762
- return Math.max(baseFloor, geo.height + rise);
1763
- };
1764
- const levelForBlockDepth = (blockDepthU) => {
1765
- const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
1766
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1767
- return Math.max(baseFloor, geo.height + rise);
1768
- };
1769
- const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
1770
- const rowLevels = flat ? [] : structure.rows.map((r) => ({
1771
- pts: r.pts,
1772
- y: levelForBlockDepth(r.blockDepth),
1773
- depth: r.blockDepth,
1774
- blockId: r.blockId
1775
- }));
1776
- const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
1777
- const rowBounds = rowLevels.map((r) => {
1778
- let cx = 0, cy = 0;
1779
- for (const p of r.pts) {
1780
- cx += p.x;
1781
- cy += p.y;
1782
- }
1783
- const n = r.pts.length || 1;
1784
- cx /= n;
1785
- cy /= n;
1786
- let rad = 0;
1787
- for (const p of r.pts) {
1788
- const d = Math.hypot(p.x - cx, p.y - cy);
1789
- if (d > rad) rad = d;
1790
- }
1791
- return { cx, cy, rad };
1792
- });
1793
- const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
1794
- let best = Infinity, bestY = landingY;
1795
- for (let i = 0; i < rowLevels.length; i++) {
1796
- const b = rowBounds[i];
1797
- if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
1798
- const d = distanceToPolyline(rowLevels[i].pts, x, y);
1799
- if (d < best) {
1800
- best = d;
1801
- bestY = rowLevels[i].y;
1802
- }
1803
- }
1804
- return bestY;
1805
- } : (x, y) => levelFor(rake.depthAt(x, y));
1806
- const UP = [0, 1, 0];
1807
- const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
1808
- const d = rake.depthAt(x, y);
1809
- if (d <= frontU) return UP;
1810
- const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
1811
- if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
1812
- if (geo.height + depthM * rakeTan <= baseFloor) return UP;
1813
- const [gx, gy] = rake.gradientAt(x, y);
1814
- if (gx === 0 && gy === 0) return UP;
1815
- const inv = 1 / Math.hypot(rakeTan, 1);
1816
- return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
1817
- };
1818
- if (!flat) {
1819
- for (const r of structure.rows) {
1820
- const y = levelForBlockDepth(r.blockDepth);
1821
- for (const si of r.seatIndices) seatRowLevel[si] = y;
1822
- }
1823
- }
1824
- for (const r of structure.rows) {
1825
- const gaps = [];
1826
- for (let k = 1; k < r.pts.length; k++) {
1827
- const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
1828
- if (d > 1e-6) gaps.push(d);
1829
- }
1830
- gaps.sort((x, y) => x - y);
1831
- const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
1832
- let across = Infinity;
1833
- const probe = r.pts[Math.floor(r.pts.length / 2)];
1834
- if (probe) {
1835
- for (const other of structure.rows) {
1836
- if (other === r || other.blockId !== r.blockId) continue;
1837
- const d = distanceToPolyline(other.pts, probe.x, probe.y);
1838
- if (d > 1e-6 && d < across) across = d;
1839
- }
1840
- }
1841
- const pitch = Math.min(along, Math.max(across, along * 0.5));
1842
- if (Number.isFinite(pitch) && pitch > 0) {
1843
- for (const si of r.seatIndices) seatPitch[si] = pitch;
1844
- }
1845
- }
1846
- bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1847
- }
1848
- for (let i = 0; i < seats.length; i++) {
1849
- const ownerId = seatOwner[i];
1850
- const s = seats[i];
1851
- if (ownerId) {
1852
- const own = seatRowLevel[i];
1853
- seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
1854
- continue;
1855
- }
1856
- const eye = s.eyeHeightM;
1857
- seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1858
- }
1859
- return {
1860
- bySection,
1861
- seatOwner,
1862
- seatDeckY: (i) => seatDeck[i],
1863
- seatPitchU: (i) => seatPitch[i]
1864
- };
1865
- }
1866
-
1867
2122
  // src/view3d/scene/deckBands.ts
1868
2123
  var import_polygon_clipping2 = __toESM(require("polygon-clipping"), 1);
1869
2124
  var import_earcut2 = __toESM(require("earcut"), 1);
@@ -1912,9 +2167,71 @@ function extendEnds(pts, by) {
1912
2167
  });
1913
2168
  return out;
1914
2169
  }
1915
- function distToPolyline(pts, x, y) {
1916
- if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1917
- let best = Infinity;
2170
+ var CORNER_STEP_RAD = Math.PI / 12;
2171
+ function convexHull(pts) {
2172
+ if (pts.length < 3) return [...pts];
2173
+ const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
2174
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
2175
+ const half = (src) => {
2176
+ const out = [];
2177
+ for (const p of src) {
2178
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
2179
+ out.push(p);
2180
+ }
2181
+ out.pop();
2182
+ return out;
2183
+ };
2184
+ const hull = [...half(s), ...half([...s].reverse())];
2185
+ return hull.length >= 3 ? hull : [...pts];
2186
+ }
2187
+ function seatClusterPatch(pts, pad) {
2188
+ if (!pts.length || !(pad > 0)) return [];
2189
+ const hull = convexHull(pts);
2190
+ const arc = (v, from, to, out2) => {
2191
+ let sweep = to - from;
2192
+ while (sweep < 0) sweep += Math.PI * 2;
2193
+ while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
2194
+ const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
2195
+ const r = pad / Math.cos(sweep / steps / 2);
2196
+ for (let k = 0; k <= steps; k++) {
2197
+ const a = from + sweep * k / steps;
2198
+ out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
2199
+ }
2200
+ };
2201
+ if (hull.length < 3) {
2202
+ let cx = 0, cy = 0;
2203
+ for (const p of hull) {
2204
+ cx += p.x;
2205
+ cy += p.y;
2206
+ }
2207
+ cx /= hull.length || 1;
2208
+ cy /= hull.length || 1;
2209
+ let far = 0;
2210
+ for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
2211
+ const out2 = [];
2212
+ const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
2213
+ const r = (far + pad) / Math.cos(Math.PI / steps);
2214
+ for (let k = 0; k < steps; k++) {
2215
+ const a = k / steps * Math.PI * 2;
2216
+ out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
2217
+ }
2218
+ return out2;
2219
+ }
2220
+ const n = hull.length;
2221
+ const edgeAngle = [];
2222
+ for (let i = 0; i < n; i++) {
2223
+ const a = hull[i], b = hull[(i + 1) % n];
2224
+ edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
2225
+ }
2226
+ const out = [];
2227
+ for (let i = 0; i < n; i++) {
2228
+ arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
2229
+ }
2230
+ return dedupeAdjacent(out);
2231
+ }
2232
+ function distToPolyline(pts, x, y) {
2233
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
2234
+ let best = Infinity;
1918
2235
  for (let i = 0; i + 1 < pts.length; i++) {
1919
2236
  const a = pts[i], b = pts[i + 1];
1920
2237
  const vx = b.x - a.x, vy = b.y - a.y;
@@ -1927,7 +2244,23 @@ function distToPolyline(pts, x, y) {
1927
2244
  }
1928
2245
  return best;
1929
2246
  }
1930
- function neighbourhoods(rows) {
2247
+ function rowNeighbourhoods(rows) {
2248
+ const bounds = rows.map((r) => {
2249
+ let cx = 0, cy = 0;
2250
+ for (const p of r.pts) {
2251
+ cx += p.x;
2252
+ cy += p.y;
2253
+ }
2254
+ const n = r.pts.length || 1;
2255
+ cx /= n;
2256
+ cy /= n;
2257
+ let rad = 0;
2258
+ for (const p of r.pts) {
2259
+ const d = Math.hypot(p.x - cx, p.y - cy);
2260
+ if (d > rad) rad = d;
2261
+ }
2262
+ return { cx, cy, rad };
2263
+ });
1931
2264
  const probesOf2 = (pts) => {
1932
2265
  const n = pts.length;
1933
2266
  if (n <= 2) return [...pts];
@@ -1942,6 +2275,9 @@ function neighbourhoods(rows) {
1942
2275
  let nearest = Infinity;
1943
2276
  for (let j = 0; j < rows.length; j++) {
1944
2277
  if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
2278
+ const b = bounds[j];
2279
+ const lower = Math.hypot(c.x - b.cx, c.y - b.cy) - b.rad;
2280
+ if (lower >= nearest && lower >= bestFrontD) continue;
1945
2281
  const d = distToPolyline(rows[j].pts, c.x, c.y);
1946
2282
  if (d < nearest) nearest = d;
1947
2283
  if (rows[j].depth < rows[i].depth && d < bestFrontD) {
@@ -1986,65 +2322,8 @@ function ribbonOf(rows, i, nbrs, focal) {
1986
2322
  back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
1987
2323
  };
1988
2324
  }
1989
- function deckFootprints(rows, focal) {
1990
- if (rows.length < 2) return [];
1991
- const nbrs = neighbourhoods(rows);
1992
- const rings = [];
1993
- const ribbons = [];
1994
- for (let i = 0; i < rows.length; i++) {
1995
- const r = ribbonOf(rows, i, nbrs, focal);
1996
- ribbons.push(r);
1997
- if (!r) continue;
1998
- const f = [];
1999
- const b = [];
2000
- const rf = r.front + FOOTPRINT_MARGIN_U;
2001
- const rb = r.back + FOOTPRINT_MARGIN_U;
2002
- for (let k = 0; k < r.pts.length; k++) {
2003
- const p = r.pts[k], n = r.nrm[k];
2004
- f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
2005
- b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
2006
- }
2007
- const ring = [...f, ...b.reverse()];
2008
- if (ring.length < 3) continue;
2009
- ring.push(ring[0]);
2010
- rings.push([ring]);
2011
- }
2012
- if (!rings.length) return [];
2013
- let merged;
2014
- try {
2015
- merged = import_polygon_clipping2.default.union(rings[0], ...rings.slice(1));
2016
- } catch {
2017
- return [];
2018
- }
2019
- const out = [];
2020
- for (const poly of merged) {
2021
- if (!poly.length || poly[0].length < 4) continue;
2022
- const toPts = (ring) => {
2023
- const pts = ring.map(([x, y]) => ({ x, y }));
2024
- const first = pts[0], last = pts[pts.length - 1];
2025
- if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
2026
- return pts;
2027
- };
2028
- const outline = toPts(poly[0]);
2029
- if (outline.length < 3) continue;
2030
- let topY = Infinity;
2031
- for (let i = 0; i < rows.length; i++) {
2032
- const r = ribbons[i];
2033
- if (!r) continue;
2034
- const mid = r.pts[Math.floor(r.pts.length / 2)];
2035
- if (pointInRing(outline, mid.x, mid.y) && rows[i].y < topY) topY = rows[i].y;
2036
- }
2037
- if (!Number.isFinite(topY)) continue;
2038
- out.push({
2039
- outline: simplifyRing(outline, FOOTPRINT_TOLERANCE_U),
2040
- holes: poly.slice(1).map(toPts).map((h) => simplifyRing(h, FOOTPRINT_TOLERANCE_U)).filter((h) => h.length >= 3),
2041
- topY
2042
- });
2043
- }
2044
- return out;
2045
- }
2046
- var FOOTPRINT_TOLERANCE_U = 0.3;
2047
2325
  var FOOTPRINT_MARGIN_U = 1.5;
2326
+ var FOOTPRINT_TOLERANCE_U = 0.3;
2048
2327
  function simplifyRun(pts, tol) {
2049
2328
  if (pts.length < 3) return pts;
2050
2329
  const a = pts[0], b = pts[pts.length - 1];
@@ -2070,6 +2349,109 @@ function simplifyRing(ring, tol) {
2070
2349
  out.pop();
2071
2350
  return out.length >= 3 ? out : ring;
2072
2351
  }
2352
+ function deckFootprints(rows, focal, shared) {
2353
+ if (rows.length < 2) return [];
2354
+ const nbrs = shared ?? rowNeighbourhoods(rows);
2355
+ const byBlock = /* @__PURE__ */ new Map();
2356
+ for (let i = 0; i < rows.length; i++) {
2357
+ const a = byBlock.get(rows[i].blockId);
2358
+ if (a) a.push(i);
2359
+ else byBlock.set(rows[i].blockId, [i]);
2360
+ }
2361
+ const out = [];
2362
+ for (const [, allIndices] of byBlock) {
2363
+ const indices = [];
2364
+ for (const i of allIndices) {
2365
+ const patch = rows[i].patch;
2366
+ if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
2367
+ else indices.push(i);
2368
+ }
2369
+ if (!indices.length) continue;
2370
+ indices.sort((a, b) => rows[a].depth - rows[b].depth);
2371
+ const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
2372
+ const usable = ribbons.filter((r) => r !== null);
2373
+ if (!usable.length) continue;
2374
+ const frontOf = (r, k) => ({
2375
+ x: r.pts[k].x - r.nrm[k][0] * (r.front + FOOTPRINT_MARGIN_U),
2376
+ y: r.pts[k].y - r.nrm[k][1] * (r.front + FOOTPRINT_MARGIN_U)
2377
+ });
2378
+ const backOf = (r, k) => ({
2379
+ x: r.pts[k].x + r.nrm[k][0] * (r.back + FOOTPRINT_MARGIN_U),
2380
+ y: r.pts[k].y + r.nrm[k][1] * (r.back + FOOTPRINT_MARGIN_U)
2381
+ });
2382
+ const first = usable[0], last = usable[usable.length - 1];
2383
+ const ring = [];
2384
+ for (let k = 0; k < first.pts.length; k++) ring.push(frontOf(first, k));
2385
+ for (const r of usable) ring.push(backOf(r, r.pts.length - 1));
2386
+ for (let k = last.pts.length - 1; k >= 0; k--) ring.push(backOf(last, k));
2387
+ for (let i = usable.length - 1; i >= 0; i--) ring.push(frontOf(usable[i], 0));
2388
+ const deduped = dedupeAdjacent(ring);
2389
+ let topY = Infinity;
2390
+ for (const i of indices) if (rows[i].y < topY) topY = rows[i].y;
2391
+ if (!Number.isFinite(topY)) continue;
2392
+ const covers = deduped.length >= 3 && Math.abs(ringArea(deduped)) > 1e-6 && usable.every((r) => {
2393
+ const mid = r.pts[Math.floor(r.pts.length / 2)];
2394
+ return pointInRing(deduped, mid.x, mid.y);
2395
+ });
2396
+ if (covers) {
2397
+ out.push({ outline: simplifyRing(deduped, FOOTPRINT_TOLERANCE_U), holes: [], topY });
2398
+ continue;
2399
+ }
2400
+ for (const poly of unionRibbons(usable)) out.push({ outline: poly, holes: [], topY });
2401
+ }
2402
+ return out;
2403
+ }
2404
+ function unionRibbons(ribbons) {
2405
+ const rings = [];
2406
+ for (const r of ribbons) {
2407
+ const f = [];
2408
+ const b = [];
2409
+ const rf = r.front + FOOTPRINT_MARGIN_U;
2410
+ const rb = r.back + FOOTPRINT_MARGIN_U;
2411
+ for (let k = 0; k < r.pts.length; k++) {
2412
+ const p = r.pts[k], n = r.nrm[k];
2413
+ f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
2414
+ b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
2415
+ }
2416
+ const ring = [...f, ...b.reverse()];
2417
+ if (ring.length < 3) continue;
2418
+ ring.push(ring[0]);
2419
+ rings.push([ring]);
2420
+ }
2421
+ if (!rings.length) return [];
2422
+ try {
2423
+ const merged = import_polygon_clipping2.default.union(rings[0], ...rings.slice(1));
2424
+ const out = [];
2425
+ for (const poly of merged) {
2426
+ if (!poly.length || poly[0].length < 4) continue;
2427
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
2428
+ const first = pts[0], last = pts[pts.length - 1];
2429
+ if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
2430
+ if (pts.length >= 3) out.push(simplifyRing(pts, FOOTPRINT_TOLERANCE_U));
2431
+ }
2432
+ return out;
2433
+ } catch {
2434
+ return [];
2435
+ }
2436
+ }
2437
+ function ringArea(ring) {
2438
+ let a = 0;
2439
+ for (let i = 0, n = ring.length; i < n; i++) {
2440
+ const p = ring[i], q = ring[(i + 1) % n];
2441
+ a += p.x * q.y - q.x * p.y;
2442
+ }
2443
+ return a / 2;
2444
+ }
2445
+ function dedupeAdjacent(ring) {
2446
+ const out = [];
2447
+ for (const p of ring) {
2448
+ const last = out[out.length - 1];
2449
+ if (last && Math.hypot(p.x - last.x, p.y - last.y) < 1e-9) continue;
2450
+ out.push(p);
2451
+ }
2452
+ while (out.length > 2 && Math.hypot(out[0].x - out[out.length - 1].x, out[0].y - out[out.length - 1].y) < 1e-9) out.pop();
2453
+ return out;
2454
+ }
2073
2455
  function pointInRing(ring, x, y) {
2074
2456
  let inside = false;
2075
2457
  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
@@ -2093,106 +2475,351 @@ var ClipTest = class {
2093
2475
  if (p.y > this.maxY) this.maxY = p.y;
2094
2476
  }
2095
2477
  }
2096
- }
2097
- /** True when every point lies strictly inside ONE ring of the clip region. */
2098
- containsAll(pts) {
2099
- for (const p of pts) {
2100
- if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
2101
- }
2102
- for (const ring of this.rings) {
2103
- let all = true;
2104
- for (const p of pts) {
2105
- if (!pointInRing(ring, p.x, p.y)) {
2106
- all = false;
2107
- break;
2478
+ }
2479
+ /** True when every point lies strictly inside ONE ring of the clip region. */
2480
+ containsAll(pts) {
2481
+ for (const p of pts) {
2482
+ if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
2483
+ }
2484
+ for (const ring of this.rings) {
2485
+ let all = true;
2486
+ for (const p of pts) {
2487
+ if (!pointInRing(ring, p.x, p.y)) {
2488
+ all = false;
2489
+ break;
2490
+ }
2491
+ }
2492
+ if (all) return true;
2493
+ }
2494
+ return false;
2495
+ }
2496
+ };
2497
+ function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
2498
+ if (poly.length < 3) return;
2499
+ if (clipTest.containsAll(poly)) {
2500
+ const UPF = [0, 1, 0];
2501
+ const a = poly[0];
2502
+ for (let i = 1; i + 1 < poly.length; i++) {
2503
+ const b = poly[i], c = poly[i + 1];
2504
+ builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
2505
+ }
2506
+ return;
2507
+ }
2508
+ const ring = poly.map((p) => [p.x, p.y]);
2509
+ ring.push(ring[0]);
2510
+ let pieces;
2511
+ try {
2512
+ pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
2513
+ } catch {
2514
+ return;
2515
+ }
2516
+ const UP = [0, 1, 0];
2517
+ for (const poly2 of pieces) {
2518
+ if (!poly2.length || poly2[0].length < 4) continue;
2519
+ const outer = poly2[0];
2520
+ const flat = [];
2521
+ const pts = [];
2522
+ for (let i = 0; i < outer.length - 1; i++) {
2523
+ flat.push(outer[i][0], outer[i][1]);
2524
+ pts.push([outer[i][0], outer[i][1]]);
2525
+ }
2526
+ if (pts.length < 3) continue;
2527
+ const tris = (0, import_earcut2.default)(flat, void 0, 2);
2528
+ for (let i = 0; i < tris.length; i += 3) {
2529
+ const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
2530
+ builder.tri(
2531
+ [a[0] * M, y, a[1] * M],
2532
+ [b[0] * M, y, b[1] * M],
2533
+ [c[0] * M, y, c[1] * M],
2534
+ UP,
2535
+ color
2536
+ );
2537
+ }
2538
+ }
2539
+ }
2540
+ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2541
+ if (rows.length < 2) return;
2542
+ const nbrs = shared ?? rowNeighbourhoods(rows);
2543
+ const UP = [0, 1, 0];
2544
+ const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2545
+ const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2546
+ for (let i = 0; i < rows.length; i++) {
2547
+ const row = rows[i];
2548
+ if (row.patch && row.patch.length >= 3) {
2549
+ const patch = [...row.patch];
2550
+ const belowY2 = nbrs[i].belowY ?? landingY;
2551
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2552
+ else {
2553
+ const a = patch[0];
2554
+ for (let k = 1; k + 1 < patch.length; k++) {
2555
+ const b = patch[k], c = patch[k + 1];
2556
+ builder.tri([a.x * M, row.y, a.y * M], [b.x * M, row.y, b.y * M], [c.x * M, row.y, c.y * M], UP, colors.tread);
2557
+ }
2558
+ }
2559
+ if (row.y > belowY2 + MIN_RISER_M) {
2560
+ let cx = 0, cy = 0;
2561
+ for (const p of patch) {
2562
+ cx += p.x;
2563
+ cy += p.y;
2564
+ }
2565
+ cx /= patch.length;
2566
+ cy /= patch.length;
2567
+ for (let k = 0; k < patch.length; k++) {
2568
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2569
+ const dx = q.x - p.x, dy = q.y - p.y;
2570
+ const len = Math.hypot(dx, dy);
2571
+ if (len < 1e-6) continue;
2572
+ let nx = dy / len, ny = -dx / len;
2573
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2574
+ nx = -nx;
2575
+ ny = -ny;
2576
+ }
2577
+ const rn = [nx, 0, ny];
2578
+ const pt = [p.x * M, row.y, p.y * M];
2579
+ const qt = [q.x * M, row.y, q.y * M];
2580
+ const pb = [p.x * M, belowY2, p.y * M];
2581
+ const qb = [q.x * M, belowY2, q.y * M];
2582
+ builder.tri(pt, qt, qb, rn, colors.riser);
2583
+ builder.tri(pt, qb, pb, rn, colors.riser);
2584
+ }
2585
+ }
2586
+ continue;
2587
+ }
2588
+ const rib = ribbonOf(rows, i, nbrs, focal);
2589
+ if (!rib) continue;
2590
+ const { pts, nrm, front, back } = rib;
2591
+ const belowY = nbrs[i].belowY ?? landingY;
2592
+ for (let k = 0; k + 1 < pts.length; k++) {
2593
+ const p = pts[k], q = pts[k + 1];
2594
+ const np = nrm[k], nq = nrm[k + 1];
2595
+ if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
2596
+ if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
2597
+ const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
2598
+ const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2599
+ const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2600
+ const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2601
+ if (clipRing && clipTest) {
2602
+ emitClippedPoly(builder, clipRing, clipTest, [
2603
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
2604
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2605
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2606
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
2607
+ ], row.y, colors.tread);
2608
+ } else {
2609
+ builder.tri(pF, qF, qB, UP, colors.tread);
2610
+ builder.tri(pF, qB, pB, UP, colors.tread);
2611
+ }
2612
+ if (row.y > belowY + MIN_RISER_M) {
2613
+ const pFd = [pF[0], belowY, pF[2]];
2614
+ const qFd = [qF[0], belowY, qF[2]];
2615
+ const rn = [-np[0], 0, -np[1]];
2616
+ builder.tri(pF, qF, qFd, rn, colors.riser);
2617
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
2618
+ }
2619
+ }
2620
+ }
2621
+ }
2622
+
2623
+ // src/view3d/scene/surface.ts
2624
+ var FLAT_SLAB_TOP_M = 0.05;
2625
+ var CAP_MAX_ERROR_M = 0.05;
2626
+ var SEAT_CLEARANCE_M = 0.15;
2627
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
2628
+ function bboxOf(pts) {
2629
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2630
+ for (const p of pts) {
2631
+ if (p.x < minX) minX = p.x;
2632
+ if (p.y < minY) minY = p.y;
2633
+ if (p.x > maxX) maxX = p.x;
2634
+ if (p.y > maxY) maxY = p.y;
2635
+ }
2636
+ return { minX, minY, maxX, maxY };
2637
+ }
2638
+ function pointInRing2(ring, x, y) {
2639
+ let inside = false;
2640
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2641
+ const a = ring[i], b = ring[j];
2642
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
2643
+ }
2644
+ return inside;
2645
+ }
2646
+ var MAX_TIER_RISE_M = 25;
2647
+ function buildVenueSurfaces(units, seats) {
2648
+ const bySection = /* @__PURE__ */ new Map();
2649
+ const seatOwner = new Array(seats.length).fill(null);
2650
+ const seatDeck = new Float64Array(seats.length);
2651
+ const seatRowLevel = new Array(seats.length).fill(void 0);
2652
+ const seatPitch = new Array(seats.length).fill(void 0);
2653
+ const acc = /* @__PURE__ */ new Map();
2654
+ const boxes = [];
2655
+ const tableRowIds = /* @__PURE__ */ new Set();
2656
+ for (const unit of units) {
2657
+ for (const o of unit.objects) {
2658
+ if (o.type === "table") tableRowIds.add(o.id);
2659
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2660
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2661
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2662
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2663
+ }
2664
+ }
2665
+ for (let i = 0; i < seats.length; i++) {
2666
+ const s = seats[i];
2667
+ for (const b of boxes) {
2668
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2669
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2670
+ seatOwner[i] = b.id;
2671
+ const a = acc.get(b.id);
2672
+ a.hasSeats = true;
2673
+ const f = s.focalPoint ?? b.unit.focal;
2674
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
2675
+ if (d < a.frontU) a.frontU = d;
2676
+ a.seatIndices.push(i);
2677
+ const rowKey = s.rowId || `__seat-${i}`;
2678
+ const arr = a.rows.get(rowKey);
2679
+ if (arr) arr.push({ x: s.x, y: s.y });
2680
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
2681
+ break;
2682
+ }
2683
+ }
2684
+ for (const [id, a] of acc) {
2685
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
2686
+ const bottomY = a.unit.baseHeightM;
2687
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
2688
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
2689
+ const kind = a.section.surfaceKind;
2690
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
2691
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
2692
+ const needsRake = structure.rows.length < 2;
2693
+ const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
2694
+ let frontU = Infinity;
2695
+ if (rake) {
2696
+ if (a.hasSeats) {
2697
+ for (const pts of a.rows.values()) {
2698
+ for (const p of pts) {
2699
+ const d = rake.depthAt(p.x, p.y);
2700
+ if (d < frontU) frontU = d;
2701
+ }
2702
+ }
2703
+ }
2704
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
2705
+ frontU = Infinity;
2706
+ for (const p of a.section.outline) {
2707
+ const d = rake.depthAt(p.x, p.y);
2708
+ if (d < frontU) frontU = d;
2709
+ }
2710
+ }
2711
+ }
2712
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
2713
+ const flatTop = Math.max(baseFloor, geo.height);
2714
+ const levelFor = (depthU) => {
2715
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
2716
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2717
+ return Math.max(baseFloor, geo.height + rise);
2718
+ };
2719
+ const levelForBlockDepth = (blockDepthU) => {
2720
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
2721
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2722
+ return Math.max(baseFloor, geo.height + rise);
2723
+ };
2724
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
2725
+ pts: r.pts,
2726
+ y: levelForBlockDepth(r.blockDepth),
2727
+ depth: r.blockDepth,
2728
+ blockId: r.blockId,
2729
+ ...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
2730
+ }));
2731
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
2732
+ const rowBounds = rowLevels.map((r) => {
2733
+ const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
2734
+ let cx = 0, cy = 0;
2735
+ for (const p of ext) {
2736
+ cx += p.x;
2737
+ cy += p.y;
2738
+ }
2739
+ const n = ext.length || 1;
2740
+ cx /= n;
2741
+ cy /= n;
2742
+ let rad = 0;
2743
+ for (const p of ext) {
2744
+ const d = Math.hypot(p.x - cx, p.y - cy);
2745
+ if (d > rad) rad = d;
2746
+ }
2747
+ return { cx, cy, rad };
2748
+ });
2749
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
2750
+ let best = Infinity, bestY = landingY;
2751
+ for (let i = 0; i < rowLevels.length; i++) {
2752
+ const b = rowBounds[i];
2753
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
2754
+ const patch = rowLevels[i].patch;
2755
+ const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
2756
+ if (d < best) {
2757
+ best = d;
2758
+ bestY = rowLevels[i].y;
2759
+ }
2760
+ }
2761
+ return bestY;
2762
+ } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
2763
+ const UP = [0, 1, 0];
2764
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
2765
+ if (!rake) return UP;
2766
+ const d = rake.depthAt(x, y);
2767
+ if (d <= frontU) return UP;
2768
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
2769
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
2770
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
2771
+ const [gx, gy] = rake.gradientAt(x, y);
2772
+ if (gx === 0 && gy === 0) return UP;
2773
+ const inv = 1 / Math.hypot(rakeTan, 1);
2774
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
2775
+ };
2776
+ if (!flat) {
2777
+ for (const r of structure.rows) {
2778
+ const y = levelForBlockDepth(r.blockDepth);
2779
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
2780
+ }
2781
+ }
2782
+ for (const r of structure.rows) {
2783
+ const gaps = [];
2784
+ for (let k = 1; k < r.pts.length; k++) {
2785
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
2786
+ if (d > 1e-6) gaps.push(d);
2787
+ }
2788
+ gaps.sort((x, y) => x - y);
2789
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
2790
+ let across = Infinity;
2791
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
2792
+ if (probe) {
2793
+ for (const other of structure.rows) {
2794
+ if (other === r || other.blockId !== r.blockId) continue;
2795
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
2796
+ if (d > 1e-6 && d < across) across = d;
2108
2797
  }
2109
2798
  }
2110
- if (all) return true;
2111
- }
2112
- return false;
2113
- }
2114
- };
2115
- function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
2116
- if (clipTest.containsAll(quad)) {
2117
- const UPF = [0, 1, 0];
2118
- const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
2119
- builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
2120
- builder.tri([a.x * M, y, a.y * M], [c.x * M, y, c.y * M], [d.x * M, y, d.y * M], UPF, color);
2121
- return;
2122
- }
2123
- const ring = quad.map((p) => [p.x, p.y]);
2124
- ring.push(ring[0]);
2125
- let pieces;
2126
- try {
2127
- pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
2128
- } catch {
2129
- return;
2130
- }
2131
- const UP = [0, 1, 0];
2132
- for (const poly of pieces) {
2133
- if (!poly.length || poly[0].length < 4) continue;
2134
- const outer = poly[0];
2135
- const flat = [];
2136
- const pts = [];
2137
- for (let i = 0; i < outer.length - 1; i++) {
2138
- flat.push(outer[i][0], outer[i][1]);
2139
- pts.push([outer[i][0], outer[i][1]]);
2140
- }
2141
- if (pts.length < 3) continue;
2142
- const tris = (0, import_earcut2.default)(flat, void 0, 2);
2143
- for (let i = 0; i < tris.length; i += 3) {
2144
- const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
2145
- builder.tri(
2146
- [a[0] * M, y, a[1] * M],
2147
- [b[0] * M, y, b[1] * M],
2148
- [c[0] * M, y, c[1] * M],
2149
- UP,
2150
- color
2151
- );
2799
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
2800
+ if (Number.isFinite(pitch) && pitch > 0) {
2801
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
2802
+ }
2152
2803
  }
2804
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
2153
2805
  }
2154
- }
2155
- function emitDeckBands(builder, rows, focal, landingY, colors, clip) {
2156
- if (rows.length < 2) return;
2157
- const nbrs = neighbourhoods(rows);
2158
- const UP = [0, 1, 0];
2159
- const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2160
- const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2161
- for (let i = 0; i < rows.length; i++) {
2162
- const row = rows[i];
2163
- const rib = ribbonOf(rows, i, nbrs, focal);
2164
- if (!rib) continue;
2165
- const { pts, nrm, front, back } = rib;
2166
- const belowY = nbrs[i].belowY ?? landingY;
2167
- for (let k = 0; k + 1 < pts.length; k++) {
2168
- const p = pts[k], q = pts[k + 1];
2169
- const np = nrm[k], nq = nrm[k + 1];
2170
- if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
2171
- if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
2172
- const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
2173
- const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2174
- const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2175
- const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2176
- if (clipRing && clipTest) {
2177
- emitClippedQuad(builder, clipRing, clipTest, [
2178
- { x: p.x - np[0] * front, y: p.y - np[1] * front },
2179
- { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2180
- { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2181
- { x: p.x + np[0] * back, y: p.y + np[1] * back }
2182
- ], row.y, colors.tread);
2183
- } else {
2184
- builder.tri(pF, qF, qB, UP, colors.tread);
2185
- builder.tri(pF, qB, pB, UP, colors.tread);
2186
- }
2187
- if (row.y > belowY + MIN_RISER_M) {
2188
- const pFd = [pF[0], belowY, pF[2]];
2189
- const qFd = [qF[0], belowY, qF[2]];
2190
- const rn = [-np[0], 0, -np[1]];
2191
- builder.tri(pF, qF, qFd, rn, colors.riser);
2192
- builder.tri(pF, qFd, pFd, rn, colors.riser);
2193
- }
2806
+ for (let i = 0; i < seats.length; i++) {
2807
+ const ownerId = seatOwner[i];
2808
+ const s = seats[i];
2809
+ if (ownerId) {
2810
+ const own = seatRowLevel[i];
2811
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
2812
+ continue;
2194
2813
  }
2814
+ const eye = s.eyeHeightM;
2815
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
2195
2816
  }
2817
+ return {
2818
+ bySection,
2819
+ seatOwner,
2820
+ seatDeckY: (i) => seatDeck[i],
2821
+ seatPitchU: (i) => seatPitch[i]
2822
+ };
2196
2823
  }
2197
2824
 
2198
2825
  // src/view3d/scene/sceneModel.ts
@@ -2257,9 +2884,7 @@ function boxesOverlap(a, b) {
2257
2884
  return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
2258
2885
  }
2259
2886
  function paddedFootprint(section, siblings) {
2260
- const __tp = performance.now();
2261
2887
  const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
2262
- __t("outsetRing", __tp);
2263
2888
  const box = bboxOfRing(padded);
2264
2889
  const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
2265
2890
  if (!others.length) return [padded];
@@ -2323,9 +2948,8 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2323
2948
  const footprint = paddedFootprint(section, siblings);
2324
2949
  const outline = footprint[0] ?? section.outline;
2325
2950
  if (surface.rowLevels.length >= 2) {
2326
- const __tf = performance.now();
2327
- const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
2328
- __t("deckFootprints", __tf);
2951
+ const nbrs = rowNeighbourhoods(surface.rowLevels);
2952
+ const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal, nbrs), footprint);
2329
2953
  const capUp = () => [0, 1, 0];
2330
2954
  for (const b of blocks) {
2331
2955
  extrudePrism(
@@ -2353,34 +2977,49 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2353
2977
  AO
2354
2978
  );
2355
2979
  }
2356
- const __tb = performance.now();
2357
2980
  emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
2358
2981
  tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
2359
2982
  // Risers read as the structure they are, a shade below their tread, which
2360
2983
  // is what makes the stepping legible from a low angle.
2361
2984
  riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
2362
- }, footprint.length === 1 ? outline : footprint.flat());
2363
- __t("emitDeckBands", __tb);
2985
+ }, footprint.length === 1 ? outline : footprint.flat(), nbrs);
2364
2986
  return;
2365
2987
  }
2366
2988
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
2367
2989
  const topN = (p) => surface.normalAt(p.x, p.y);
2368
- for (const ring of claimed.subtract(outline)) {
2990
+ const level = surface.flat ? surface.landingY : void 0;
2991
+ for (const ring of claimed.subtract(outline, level)) {
2369
2992
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
2370
2993
  }
2371
2994
  }
2995
+ function coplanarLevels(a, b) {
2996
+ if (a === void 0 || b === void 0) return true;
2997
+ return Math.abs(a - b) < 1e-3;
2998
+ }
2372
2999
  var ClaimedArea = class {
2373
3000
  constructor() {
2374
3001
  this.rings = [];
2375
3002
  this.boxes = [];
3003
+ /** Constant deck height of each claim, or undefined when it is not level. */
3004
+ this.levels = [];
2376
3005
  }
2377
- /** `ring` minus everything claimed so far; then claim what is returned. */
2378
- subtract(ring) {
3006
+ /**
3007
+ * `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
3008
+ *
3009
+ * `level` is the claim's constant deck height when it has one. Two decks only
3010
+ * z-fight when they are coplanar, so only a coplanar claim may take ground
3011
+ * away — an elevated box hanging over a ground-level section overlaps it in
3012
+ * plan and must still draw its whole floor, or the box loses the part of its
3013
+ * deck that shares a footprint with whatever is underneath it. Passing
3014
+ * undefined (a surface with no single height) keeps the original
3015
+ * clip-against-everything behaviour.
3016
+ */
3017
+ subtract(ring, level) {
2379
3018
  const closed = ring.map((p) => [p.x, p.y]);
2380
3019
  if (closed.length < 3) return [];
2381
3020
  closed.push(closed[0]);
2382
3021
  const box = bboxOfRing(ring);
2383
- const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
3022
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
2384
3023
  let pieces = [[closed]];
2385
3024
  if (overlapping.length) {
2386
3025
  try {
@@ -2392,6 +3031,7 @@ var ClaimedArea = class {
2392
3031
  }
2393
3032
  this.rings.push([closed]);
2394
3033
  this.boxes.push(box);
3034
+ this.levels.push(level);
2395
3035
  const out = [];
2396
3036
  for (const poly of pieces) {
2397
3037
  if (!poly.length) continue;
@@ -2410,7 +3050,12 @@ var ZONE_LABEL_LIFT_M = 6;
2410
3050
  var SECTION_LABEL_LIFT_M = 2.2;
2411
3051
  var ANNOTATION_LIFT_M = 0.1;
2412
3052
  var BOOTH_LABEL_LIFT_M = 0.3;
3053
+ var GA_LABEL_LIFT_M = 1.8;
3054
+ var ROW_LABEL_LIFT_M = 0.9;
3055
+ var SEAT_LABEL_LIFT_M = 0.55;
3056
+ var SEAT_LABEL_MAX = 6e3;
2413
3057
  var TABLE_HEIGHT_M = 0.75;
3058
+ var DECOR_PLATE_M = 0.15;
2414
3059
  function boothPolygon(booth) {
2415
3060
  if (booth.points && booth.points.length >= 3) return booth.points;
2416
3061
  const { center, width, height, rotation } = booth;
@@ -2484,10 +3129,63 @@ function buildShape(builder, shape, base, S) {
2484
3129
  if (!poly) return;
2485
3130
  const isStage = shape.role === "stage";
2486
3131
  const height = isStage ? base + 1 : base + 0.25;
2487
- const colTop = isStage ? S.stageTop : S.decorTop;
3132
+ const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
2488
3133
  const colWall = isStage ? S.stageWall : S.decorWall;
2489
3134
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
2490
3135
  }
3136
+ function decorPolygon(decor) {
3137
+ const { x, y, width, height } = decor;
3138
+ if (!width || !height) return null;
3139
+ const cx = x + width / 2, cy = y + height / 2;
3140
+ const a = (decor.rotation ?? 0) * Math.PI / 180;
3141
+ const cos = Math.cos(a), sin = Math.sin(a);
3142
+ const hw = width / 2, hh = height / 2;
3143
+ return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
3144
+ x: cx + lx * cos - ly * sin,
3145
+ y: cy + lx * sin + ly * cos
3146
+ }));
3147
+ }
3148
+ function decorPlatePaint(href) {
3149
+ if (!href || !href.startsWith("data:image/svg+xml")) return null;
3150
+ let svg = href.slice(href.indexOf(",") + 1);
3151
+ if (/;base64/i.test(href.slice(0, href.indexOf(",")))) {
3152
+ try {
3153
+ svg = atob(svg);
3154
+ } catch {
3155
+ return null;
3156
+ }
3157
+ } else {
3158
+ try {
3159
+ svg = decodeURIComponent(svg);
3160
+ } catch {
3161
+ }
3162
+ }
3163
+ const covering = svg.match(/<(?:rect|path)\b[^>]*\bfill="([^"]+)"/i);
3164
+ if (!covering) return null;
3165
+ const paint = covering[1].trim();
3166
+ if (paint === "none" || paint === "transparent") return null;
3167
+ const gradient = paint.match(/^url\(#([^)]+)\)$/);
3168
+ if (!gradient) return hexToRgb(paint);
3169
+ const def = svg.match(new RegExp(`<(?:linear|radial)Gradient\\b[^>]*\\bid="${gradient[1]}"[^>]*>([\\s\\S]*?)</(?:linear|radial)Gradient>`, "i"));
3170
+ if (!def) return null;
3171
+ const stops = [];
3172
+ for (const m of def[1].matchAll(/stop-color="([^"]+)"/gi)) {
3173
+ const c = hexToRgb(m[1].trim());
3174
+ if (c) stops.push(c);
3175
+ }
3176
+ if (!stops.length) return null;
3177
+ return stops.reduce((acc, c, i) => mix(acc, c, 1 / (i + 1)), stops[0]);
3178
+ }
3179
+ function buildDecorImage(builder, decor, base, S) {
3180
+ if (decor.layer === "foreground") return;
3181
+ const poly = decorPolygon(decor);
3182
+ if (!poly) return;
3183
+ const top = base + DECOR_PLATE_M;
3184
+ let paint = tintTop(decorPlatePaint(decor.href), S.decorTop);
3185
+ const opacity = Math.max(0, Math.min(1, decor.opacity ?? 1));
3186
+ if (opacity < 1) paint = mix(S.ground, paint, opacity);
3187
+ extrudePrism(builder, poly, void 0, () => top, base, paint, mix(paint, S.decorWall, 0.5), AO);
3188
+ }
2491
3189
  function buildGa(builder, ga, base, fill, S) {
2492
3190
  if (!ga.points || ga.points.length < 3) return;
2493
3191
  const colTop = tintTop(fill, S.gaTop);
@@ -2513,6 +3211,9 @@ function chartFootprint(units, seats) {
2513
3211
  } else if (o.type === "table") {
2514
3212
  const t = tablePolygon(o);
2515
3213
  if (t) for (const p of t) acc(p.x, p.y);
3214
+ } else if (o.type === "decorImage") {
3215
+ const d = decorPolygon(o);
3216
+ if (d) for (const p of d) acc(p.x, p.y);
2516
3217
  }
2517
3218
  }
2518
3219
  }
@@ -2524,12 +3225,7 @@ function chartFootprint(units, seats) {
2524
3225
  }
2525
3226
  return { minX, minY, maxX, maxY };
2526
3227
  }
2527
- var __PROF = {};
2528
- var __t = (k, t0) => {
2529
- __PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
2530
- };
2531
3228
  function buildSceneModel(input) {
2532
- for (const k of Object.keys(__PROF)) delete __PROF[k];
2533
3229
  const { doc, seats } = input;
2534
3230
  const theme = resolveTheme3D(doc.theme);
2535
3231
  const S = theme.structure;
@@ -2542,9 +3238,7 @@ function buildSceneModel(input) {
2542
3238
  const sectionFills = resolveSectionFills(doc, seats);
2543
3239
  const catColor = /* @__PURE__ */ new Map();
2544
3240
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
2545
- const __t0 = performance.now();
2546
3241
  const surfaces = buildVenueSurfaces(units, seats);
2547
- __t("surfaces", __t0);
2548
3242
  const seatFloor = new Float32Array(seats.length);
2549
3243
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
2550
3244
  const unit = units[unitIndex];
@@ -2552,23 +3246,17 @@ function buildSceneModel(input) {
2552
3246
  const claimed = new ClaimedArea();
2553
3247
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
2554
3248
  for (const o of unit.objects) {
2555
- if (o.type === "section") {
2556
- const t = performance.now();
2557
- buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2558
- __t("buildTier", t);
2559
- } else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
3249
+ if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
3250
+ else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
2560
3251
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2561
3252
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2562
3253
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3254
+ else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
2563
3255
  }
2564
3256
  }
2565
3257
  const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
2566
- const __tm = performance.now();
2567
3258
  const solids = mergeMeshData([builder.build()]);
2568
- __t("meshBuild+merge", __tm);
2569
- const __ts = performance.now();
2570
3259
  const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
2571
- __t("seatInstances", __ts);
2572
3260
  const zoneDefs = doc.zones ?? [];
2573
3261
  const zones = [];
2574
3262
  if (zoneDefs.length) {
@@ -2628,6 +3316,7 @@ function buildSceneModel(input) {
2628
3316
  }
2629
3317
  }
2630
3318
  const labels = [];
3319
+ const sections = [];
2631
3320
  for (const z of zones) {
2632
3321
  if (z.seatCount === 0) continue;
2633
3322
  labels.push({
@@ -2640,6 +3329,7 @@ function buildSceneModel(input) {
2640
3329
  }
2641
3330
  {
2642
3331
  const acc = /* @__PURE__ */ new Map();
3332
+ const ext = /* @__PURE__ */ new Map();
2643
3333
  for (let i = 0; i < seats.length; i++) {
2644
3334
  const owner = surfaces.seatOwner[i];
2645
3335
  if (!owner) continue;
@@ -2652,18 +3342,87 @@ function buildSceneModel(input) {
2652
3342
  a.x += seats[i].x;
2653
3343
  a.y += seats[i].y;
2654
3344
  a.deck += surfaces.seatDeckY(i);
3345
+ let e = ext.get(owner);
3346
+ if (!e) {
3347
+ e = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
3348
+ ext.set(owner, e);
3349
+ }
3350
+ if (seats[i].x < e.minX) e.minX = seats[i].x;
3351
+ if (seats[i].x > e.maxX) e.maxX = seats[i].x;
3352
+ if (seats[i].y < e.minY) e.minY = seats[i].y;
3353
+ if (seats[i].y > e.maxY) e.maxY = seats[i].y;
2655
3354
  }
2656
3355
  for (const unit of units) {
2657
3356
  for (const o of unit.objects) {
2658
3357
  if (o.type !== "section") continue;
2659
3358
  const a = acc.get(o.id);
2660
3359
  if (!a || a.n === 0) continue;
3360
+ const e = ext.get(o.id);
3361
+ const centre = [a.x / a.n * M, a.deck / a.n, a.y / a.n * M];
3362
+ sections.push({
3363
+ id: o.id,
3364
+ label: o.displayLabel || o.label || o.id,
3365
+ seatCount: a.n,
3366
+ center: centre,
3367
+ // Half-diagonal of the seat extent — the same fit the zones use.
3368
+ radius: Math.max(1, Math.hypot(e.maxX - e.minX, e.maxY - e.minY) * 0.5 * M),
3369
+ // A section faces its floor's focal, which is what the camera should
3370
+ // look along so the seats present their fronts.
3371
+ focalWorld: [unit.focal.x * M, unit.baseHeightM + 1.5, unit.focal.y * M]
3372
+ });
2661
3373
  labels.push({
2662
3374
  id: `section:${o.id}`,
2663
3375
  kind: "section",
2664
3376
  // The buyer-facing name wins over the technical one, as it does in 2D.
2665
3377
  text: o.displayLabel || o.label || o.id,
2666
- anchor: [a.x / a.n * M, a.deck / a.n + SECTION_LABEL_LIFT_M, a.y / a.n * M]
3378
+ anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]]
3379
+ });
3380
+ }
3381
+ }
3382
+ }
3383
+ {
3384
+ for (const unit of units) {
3385
+ for (const o of unit.objects) {
3386
+ if (o.type !== "gaArea") continue;
3387
+ const c = centroidOf(o.points);
3388
+ if (!c) continue;
3389
+ const name = o.displayLabel || o.label || o.id;
3390
+ labels.push({
3391
+ id: `ga:${o.id}`,
3392
+ kind: "ga",
3393
+ text: o.capacity > 0 ? `${name} \xB7 ${o.capacity.toLocaleString()}` : name,
3394
+ anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M]
3395
+ });
3396
+ }
3397
+ }
3398
+ const rowEnds = /* @__PURE__ */ new Map();
3399
+ for (let i = 0; i < seats.length; i++) {
3400
+ const s = seats[i];
3401
+ const far = Math.hypot(s.x - focal.x, s.y - focal.y);
3402
+ const cur = rowEnds.get(s.rowId);
3403
+ if (!cur || far > cur.far) rowEnds.set(s.rowId, { seat: s, index: i, far });
3404
+ }
3405
+ for (const [rowId, end] of rowEnds) {
3406
+ const cut = end.seat.label ? end.seat.label.lastIndexOf("-") : -1;
3407
+ const text = cut > 0 ? end.seat.label.slice(0, cut) : rowId;
3408
+ if (!text) continue;
3409
+ labels.push({
3410
+ id: `row:${rowId}`,
3411
+ kind: "row",
3412
+ text,
3413
+ anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
3414
+ });
3415
+ }
3416
+ if (seats.length <= SEAT_LABEL_MAX) {
3417
+ for (let i = 0; i < seats.length; i++) {
3418
+ const s = seats[i];
3419
+ const text = s.displayLabel || s.label;
3420
+ if (!text) continue;
3421
+ labels.push({
3422
+ id: `seat:${s.id}`,
3423
+ kind: "seat",
3424
+ text,
3425
+ anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M]
2667
3426
  });
2668
3427
  }
2669
3428
  }
@@ -2728,6 +3487,15 @@ function buildSceneModel(input) {
2728
3487
  });
2729
3488
  }
2730
3489
  }
3490
+ seatData.iYaw = computeSeatYaw(
3491
+ seatData.iPosition,
3492
+ seats.length,
3493
+ (i) => seats[i].rowId,
3494
+ (i) => {
3495
+ const f = units[seatFloor[i]]?.focal ?? focal;
3496
+ return [f.x * M, f.y * M];
3497
+ }
3498
+ );
2731
3499
  const cx = (fp.minX + fp.maxX) / 2 * M;
2732
3500
  const cz = (fp.minY + fp.maxY) / 2 * M;
2733
3501
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
@@ -2741,6 +3509,7 @@ function buildSceneModel(input) {
2741
3509
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
2742
3510
  focalWorld: [focal.x * M, 1.5, focal.y * M],
2743
3511
  zones,
3512
+ sections,
2744
3513
  labels,
2745
3514
  floors
2746
3515
  };
@@ -2752,8 +3521,23 @@ var SEPARATION_Y_PX = 20;
2752
3521
  var KIND_STYLE = {
2753
3522
  zone: { size: 15, weight: "600", opacity: 0.95 },
2754
3523
  section: { size: 12, weight: "500", opacity: 0.88 },
3524
+ ga: { size: 12, weight: "500", opacity: 0.88 },
2755
3525
  booth: { size: 11, weight: "500", opacity: 0.85 },
2756
- annotation: { size: 11, weight: "400", opacity: 0.75 }
3526
+ annotation: { size: 11, weight: "400", opacity: 0.75 },
3527
+ // Row and seat identity are quieter than the structure they sit inside: at
3528
+ // this range the venue is already understood and the label is a detail, so it
3529
+ // must not compete with the seating it is printed over.
3530
+ row: { size: 10.5, weight: "600", opacity: 0.8 },
3531
+ seat: { size: 9.5, weight: "500", opacity: 0.72 }
3532
+ };
3533
+ var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
3534
+ var DENSE_SEPARATION = {
3535
+ row: { x: 62, y: 16 },
3536
+ // Widened from 24: at close range seat labels stopped overlapping at all, so
3537
+ // the separation had no work left to do and the budget was carrying the whole
3538
+ // load. A wider box means the few labels that ARE kept are spread across the
3539
+ // seating instead of clustering into one stack.
3540
+ seat: { x: 46, y: 18 }
2757
3541
  };
2758
3542
  var LabelOverlay = class {
2759
3543
  constructor(container, opts = {}) {
@@ -2784,7 +3568,7 @@ var LabelOverlay = class {
2784
3568
  *
2785
3569
  * `viewProjection` is column-major, as OGL supplies it.
2786
3570
  */
2787
- update(viewProjection, width, height, cameraDistance, venueRadius) {
3571
+ update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
2788
3572
  if (!this.labels.length) return;
2789
3573
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
2790
3574
  const candidates = [];
@@ -2792,9 +3576,26 @@ var LabelOverlay = class {
2792
3576
  if (!kinds.has(label.kind)) continue;
2793
3577
  const screen = projectToScreen(viewProjection, label.anchor, width, height);
2794
3578
  if (!screen.visible) continue;
2795
- candidates.push({ label, screen });
3579
+ const world = cameraWorld ? Math.hypot(
3580
+ label.anchor[0] - cameraWorld[0],
3581
+ label.anchor[1] - cameraWorld[1],
3582
+ label.anchor[2] - cameraWorld[2]
3583
+ ) : 1;
3584
+ candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
2796
3585
  }
2797
- const kept = cullOverlapping(candidates, SEPARATION_X_PX, SEPARATION_Y_PX);
3586
+ const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
3587
+ const kept = [
3588
+ ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
3589
+ // The dense rungs are BUDGETED, not merely deduplicated — see
3590
+ // DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
3591
+ // the camera gets.
3592
+ ...["row", "seat"].flatMap((kind) => pickDenseLabels(
3593
+ candidates.filter((c) => c.label.kind === kind),
3594
+ DENSE_SEPARATION[kind].x,
3595
+ DENSE_SEPARATION[kind].y,
3596
+ DENSE_LABEL_BUDGET[kind]
3597
+ ))
3598
+ ];
2798
3599
  const keptIds = new Set(kept.map((k) => k.label.id));
2799
3600
  for (const { label, screen } of kept) {
2800
3601
  const node = this.nodeFor(label);
@@ -2841,6 +3642,13 @@ var import_ogl4 = require("ogl");
2841
3642
 
2842
3643
  // src/view3d/scene/materials.ts
2843
3644
  var import_ogl3 = require("ogl");
3645
+ var CHAIR_WEIGHT_GLSL = (
3646
+ /* glsl */
3647
+ `
3648
+ float chairWeight(float depth) {
3649
+ return 1.0 - smoothstep(uChairFull, uChairNone, depth);
3650
+ }`
3651
+ );
2844
3652
  var SOLID_VERT = (
2845
3653
  /* glsl */
2846
3654
  `#version 300 es
@@ -2920,14 +3728,23 @@ uniform float uSeatScale;
2920
3728
  uniform float uMinPixels;
2921
3729
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
2922
3730
  uniform float uFocusFloor; // -1 = show every floor
3731
+ uniform float uChairFull; // view depth at which the chair mesh is full size
3732
+ uniform float uChairNone; // ...and at which it has scaled away entirely
2923
3733
  out vec2 vUv;
2924
3734
  out vec3 vColor;
2925
3735
  out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
2926
3736
  out vec3 vRing;
2927
3737
  out float vDim;
3738
+ out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3739
+ ${CHAIR_WEIGHT_GLSL}
2928
3740
  void main() {
2929
3741
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
2930
3742
  float depth = max(-mv.z, 0.001);
3743
+ // Hand the seat over to the chair mesh as it comes into range. Derived from
3744
+ // this instance's OWN depth rather than from a global uniform, so a row two
3745
+ // metres away and the far side of the bowl resolve differently in the same
3746
+ // frame \u2014 which is the entire point of a ladder over a switch.
3747
+ vDotWeight = 1.0 - chairWeight(depth);
2931
3748
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
2932
3749
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
2933
3750
  // unbounded growth is what merges neighbouring rows into one mass at range.
@@ -2961,6 +3778,7 @@ in vec3 vColor;
2961
3778
  in float vBudget;
2962
3779
  in vec3 vRing;
2963
3780
  in float vDim;
3781
+ in float vDotWeight;
2964
3782
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
2965
3783
  uniform vec3 uFadeColor;
2966
3784
  out vec4 fragColor;
@@ -2985,9 +3803,131 @@ void main() {
2985
3803
  // Seats on an unfocused floor recede with their structure.
2986
3804
  c = mix(c, uFadeColor, vDim * 0.75);
2987
3805
  alpha *= mix(1.0, 0.30, vDim);
3806
+ // Yield to the chair. The chair grows out of this exact point, so through the
3807
+ // band the dot is always at least as big as the chair inside it and the seat
3808
+ // never thins out to nothing in between.
3809
+ alpha *= vDotWeight;
3810
+ if (alpha <= 0.0) discard;
2988
3811
  fragColor = vec4(c, alpha);
2989
3812
  }`
2990
3813
  );
3814
+ var CHAIR_VERT = (
3815
+ /* glsl */
3816
+ `#version 300 es
3817
+ precision highp float;
3818
+ in vec3 position; // local: x/z in units of the seat radius, y in METRES
3819
+ in vec3 normal;
3820
+ in float part; // 0 = pedestal, 1 = pad, 2 = back
3821
+ in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3822
+ in vec3 iColor; // per-instance state colour
3823
+ in float iRadius; // per-instance horizontal half-width, world metres
3824
+ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3825
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3826
+ in float iFloor;
3827
+ uniform mat4 modelViewMatrix;
3828
+ uniform mat4 projectionMatrix;
3829
+ uniform float uChairFull;
3830
+ uniform float uChairNone;
3831
+ uniform float uFocusFloor;
3832
+ uniform float uBackRake; // metres of z per metre of rise, above uBackBase
3833
+ uniform float uBackBase; // local height at which the back starts
3834
+ out vec3 vColor;
3835
+ out vec3 vNormalWorld;
3836
+ out vec3 vNormalView;
3837
+ out vec3 vPosView;
3838
+ out float vPart;
3839
+ out float vHeight; // local height in metres, for the vertical occlusion ramp
3840
+ out vec3 vRing;
3841
+ out float vDim;
3842
+ ${CHAIR_WEIGHT_GLSL}
3843
+ void main() {
3844
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
3845
+ float w = chairWeight(max(-anchor.z, 0.001));
3846
+ // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
3847
+ // chairs, but nobody gets a short one (people are the same height at every
3848
+ // seat pitch).
3849
+ vec3 p = position;
3850
+ p.xz *= iRadius;
3851
+ // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3852
+ // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3853
+ // width and the same chair would lean 20 degrees on a wide stadium row and
3854
+ // 6 on a tight theatre one.
3855
+ float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3856
+ p.z -= rake;
3857
+ // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3858
+ // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
3859
+ // the growth so the chair is already near full size while the dot is still
3860
+ // half there; see chairScale() in lod.ts.
3861
+ p *= sqrt(w);
3862
+ float c = cos(iYaw), s = sin(iYaw);
3863
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
3864
+ // Normals under the same two transforms, in reverse and inverted-transposed.
3865
+ // The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
3866
+ // unchanged; and the rake is a shear, whose normal transform adds a y term.
3867
+ // Skipping either lights the raked back as though it were still vertical.
3868
+ vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3869
+ if (part > 1.5) n.y += uBackRake * n.z;
3870
+ n = normalize(n);
3871
+ vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
3872
+ vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
3873
+ vPosView = mv.xyz;
3874
+ vNormalWorld = rn;
3875
+ vNormalView = normalize(mat3(modelViewMatrix) * rn);
3876
+ vColor = iColor;
3877
+ vPart = part;
3878
+ vHeight = position.y;
3879
+ vRing = iRing;
3880
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3881
+ gl_Position = projectionMatrix * mv;
3882
+ }`
3883
+ );
3884
+ var CHAIR_FRAG = (
3885
+ /* glsl */
3886
+ `#version 300 es
3887
+ precision highp float;
3888
+ in vec3 vColor;
3889
+ in vec3 vNormalWorld;
3890
+ in vec3 vNormalView;
3891
+ in vec3 vPosView;
3892
+ in float vPart;
3893
+ in float vHeight;
3894
+ in vec3 vRing;
3895
+ in float vDim;
3896
+ uniform vec3 uKeyDir;
3897
+ uniform vec3 uFadeColor;
3898
+ out vec4 fragColor;
3899
+ void main() {
3900
+ vec3 N = normalize(vNormalWorld);
3901
+ vec3 V = normalize(-vPosView);
3902
+ // The solids' rig, unchanged, so the chairs sit in the venue's light.
3903
+ float hemi = 0.5 + 0.5 * N.y;
3904
+ float key = max(dot(N, uKeyDir), 0.0);
3905
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
3906
+ float fill = max(dot(N, fillDir), 0.0);
3907
+ vec3 tint = vColor;
3908
+ // The accommodation ring, kept legible once the dot (which drew it) is gone:
3909
+ // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
3910
+ // survives to close range instead of vanishing exactly when the buyer arrives.
3911
+ float ringMask = step(0.001, dot(vRing, vRing));
3912
+ if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
3913
+ // Pad brightest, back a step below it, pedestal darkest. Three untextured
3914
+ // boxes only read as one object if they are separated tonally \u2014 with a single
3915
+ // flat colour the chair silhouettes as a crate.
3916
+ float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
3917
+ // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
3918
+ // sits to the deck the less sky it can actually see. This is the depth cue \u2014
3919
+ // without it the pad top, the back and the deck all resolve to the same flat
3920
+ // value and the row loses its form entirely.
3921
+ float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
3922
+ vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
3923
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3924
+ // A brighter rim than the solids get: it picks out every chair's own edge,
3925
+ // which is what stops a block of them merging into one mass up close.
3926
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
3927
+ base = mix(base, uFadeColor, vDim * 0.75);
3928
+ fragColor = vec4(base, 1.0);
3929
+ }`
3930
+ );
2991
3931
  var BG_VERT = (
2992
3932
  /* glsl */
2993
3933
  `#version 300 es
@@ -3084,7 +4024,7 @@ function createSeatPickProgram(gl) {
3084
4024
  uniforms: {
3085
4025
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
3086
4026
  uSeatScale: { value: 1 },
3087
- uMinPixels: { value: 2.5 },
4027
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
3088
4028
  uPixelToWorld: { value: 2e-3 }
3089
4029
  }
3090
4030
  });
@@ -3129,11 +4069,32 @@ function createSeatProgram(gl) {
3129
4069
  uniforms: {
3130
4070
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
3131
4071
  uSeatScale: { value: 1 },
3132
- uMinPixels: { value: 2.5 },
4072
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
3133
4073
  uPixelToWorld: { value: 2e-3 },
3134
4074
  uSeatFade: { value: 0 },
3135
4075
  uFocusFloor: { value: -1 },
3136
- uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
4076
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4077
+ uChairFull: { value: CHAIR_FULL_M },
4078
+ uChairNone: { value: CHAIR_NONE_M }
4079
+ }
4080
+ });
4081
+ }
4082
+ function createChairProgram(gl) {
4083
+ return new import_ogl3.Program(gl, {
4084
+ vertex: CHAIR_VERT,
4085
+ fragment: CHAIR_FRAG,
4086
+ transparent: false,
4087
+ depthTest: true,
4088
+ depthWrite: true,
4089
+ cullFace: false,
4090
+ uniforms: {
4091
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
4092
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4093
+ uChairFull: { value: CHAIR_FULL_M },
4094
+ uChairNone: { value: CHAIR_NONE_M },
4095
+ uBackRake: { value: BACK_RAKE_SLOPE },
4096
+ uBackBase: { value: BACK_BASE_M },
4097
+ uFocusFloor: { value: -1 }
3137
4098
  }
3138
4099
  });
3139
4100
  }
@@ -3199,16 +4160,90 @@ function buildGpuScene(gl, model) {
3199
4160
  seatMesh.frustumCulled = false;
3200
4161
  if (model.seats.count > 0) seatMesh.setParent(main);
3201
4162
  const colorAttr = seatGeo.attributes.iColor;
3202
- return {
4163
+ const chairBase = buildChairMesh();
4164
+ const chairProg = createChairProgram(gl);
4165
+ const CAP = CHAIR_MAX_INSTANCES;
4166
+ const cOffset = new Float32Array(CAP * 3);
4167
+ const cColor = new Float32Array(CAP * 3);
4168
+ const cRadius = new Float32Array(CAP);
4169
+ const cYaw = new Float32Array(CAP);
4170
+ const cRing = new Float32Array(CAP * 3);
4171
+ const cFloor = new Float32Array(CAP);
4172
+ const chairGeo = new import_ogl4.Geometry(gl, {
4173
+ position: { size: 3, data: chairBase.position },
4174
+ normal: { size: 3, data: chairBase.normal },
4175
+ part: { size: 1, data: chairBase.part },
4176
+ index: { data: chairBase.index },
4177
+ iOffset: { size: 3, data: cOffset, instanced: 1 },
4178
+ iColor: { size: 3, data: cColor, instanced: 1 },
4179
+ iRadius: { size: 1, data: cRadius, instanced: 1 },
4180
+ iYaw: { size: 1, data: cYaw, instanced: 1 },
4181
+ iRing: { size: 3, data: cRing, instanced: 1 },
4182
+ iFloor: { size: 1, data: cFloor, instanced: 1 }
4183
+ });
4184
+ const chairMesh = new import_ogl4.Mesh(gl, { geometry: chairGeo, program: chairProg });
4185
+ chairMesh.frustumCulled = false;
4186
+ let nearCount = 0;
4187
+ let nearIndices = new Int32Array(0);
4188
+ const writeChairColors = () => {
4189
+ const src = model.seats;
4190
+ for (let k = 0; k < nearCount; k++) {
4191
+ const i = nearIndices[k];
4192
+ const c = stateColors[src.iState[i]] ?? stateColors[0];
4193
+ cColor[k * 3] = c[0];
4194
+ cColor[k * 3 + 1] = c[1];
4195
+ cColor[k * 3 + 2] = c[2];
4196
+ }
4197
+ chairGeo.attributes.iColor.needsUpdate = true;
4198
+ };
4199
+ const scene = {
3203
4200
  main,
3204
4201
  background,
3205
4202
  seatProgram: seatProg,
3206
4203
  solidProgram: solidProg,
4204
+ chairProgram: chairProg,
3207
4205
  seatGeometry: seatGeo,
3208
4206
  solidGeometry: solidGeo,
3209
4207
  drawCalls: 3,
4208
+ setNearSeats(indices, count) {
4209
+ const n = Math.min(count, CAP);
4210
+ nearIndices = indices;
4211
+ nearCount = n;
4212
+ if (n === 0) {
4213
+ if (chairMesh.parent) chairMesh.setParent(null);
4214
+ chairGeo.instancedCount = 0;
4215
+ scene.drawCalls = 3;
4216
+ return;
4217
+ }
4218
+ const src = model.seats;
4219
+ for (let k = 0; k < n; k++) {
4220
+ const i = indices[k];
4221
+ cOffset[k * 3] = src.iPosition[i * 3];
4222
+ cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
4223
+ cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
4224
+ cRadius[k] = src.iChairWidth[i];
4225
+ cYaw[k] = src.iYaw[i];
4226
+ cRing[k * 3] = src.iRing[i * 3];
4227
+ cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
4228
+ cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
4229
+ cFloor[k] = src.iFloor[i];
4230
+ }
4231
+ writeChairColors();
4232
+ chairGeo.attributes.iOffset.needsUpdate = true;
4233
+ chairGeo.attributes.iRadius.needsUpdate = true;
4234
+ chairGeo.attributes.iYaw.needsUpdate = true;
4235
+ chairGeo.attributes.iRing.needsUpdate = true;
4236
+ chairGeo.attributes.iFloor.needsUpdate = true;
4237
+ chairGeo.instancedCount = n;
4238
+ if (!chairMesh.parent) chairMesh.setParent(main);
4239
+ scene.drawCalls = 4;
4240
+ },
4241
+ nearSeatCount() {
4242
+ return nearCount;
4243
+ },
3210
4244
  uploadSeatStateRuns(runs) {
3211
4245
  if (!runs.length) return;
4246
+ if (nearCount) writeChairColors();
3212
4247
  for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
3213
4248
  const buffer = colorAttr.buffer;
3214
4249
  if (!buffer) {
@@ -3228,8 +4263,11 @@ function buildGpuScene(gl, model) {
3228
4263
  solidProg.remove();
3229
4264
  seatGeo.remove();
3230
4265
  seatProg.remove();
4266
+ chairGeo.remove();
4267
+ chairProg.remove();
3231
4268
  }
3232
4269
  };
4270
+ return scene;
3233
4271
  }
3234
4272
 
3235
4273
  // src/view3d/pick/pickPipeline.ts
@@ -3797,6 +4835,28 @@ function mountVenue3D(container, input, opts = {}) {
3797
4835
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
3798
4836
  gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
3799
4837
  gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
4838
+ gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
4839
+ lastGatherX = Infinity;
4840
+ };
4841
+ const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
4842
+ const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
4843
+ let lastGatherX = Infinity;
4844
+ let lastGatherZ = Infinity;
4845
+ const updateNearField = () => {
4846
+ if (!gpu) return;
4847
+ const cam = orbit.camera.position;
4848
+ const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
4849
+ if (moved2 < CHAIR_REBUILD_M) return;
4850
+ lastGatherX = cam.x;
4851
+ lastGatherZ = cam.z;
4852
+ const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
4853
+ if (outside > CHAIR_GATHER_M) {
4854
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
4855
+ return;
4856
+ }
4857
+ const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
4858
+ if (n === 0 && gpu.nearSeatCount() === 0) return;
4859
+ gpu.setNearSeats(nearBuf, n);
3800
4860
  };
3801
4861
  const glctx = new GLContext(container, {
3802
4862
  onContextLost: () => {
@@ -3843,7 +4903,9 @@ function mountVenue3D(container, input, opts = {}) {
3843
4903
  const u = gpu.seatProgram.uniforms;
3844
4904
  u.uSeatScale.value = lod.scale;
3845
4905
  u.uSeatFade.value = lod.fade;
4906
+ u.uMinPixels.value = lod.minPixels;
3846
4907
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
4908
+ updateNearField();
3847
4909
  glctx.renderer.render({ scene: gpu.background, clear: true });
3848
4910
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
3849
4911
  labelOverlay.update(
@@ -3851,7 +4913,10 @@ function mountVenue3D(container, input, opts = {}) {
3851
4913
  glctx.canvas.clientWidth || 1,
3852
4914
  glctx.canvas.clientHeight || 1,
3853
4915
  orbit.currentDistance,
3854
- model.bounds.radius
4916
+ model.bounds.radius,
4917
+ // The dense label rungs rank by real distance from the eye, not by the
4918
+ // orbit radius — at the arrival pose those are wildly different numbers.
4919
+ [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
3855
4920
  );
3856
4921
  return moving;
3857
4922
  });
@@ -4124,6 +5189,7 @@ function mountVenue3D(container, input, opts = {}) {
4124
5189
  if (gpu) {
4125
5190
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
4126
5191
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
5192
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
4127
5193
  }
4128
5194
  focusedFloor = value;
4129
5195
  if (index !== null) {
@@ -4150,6 +5216,20 @@ function mountVenue3D(container, input, opts = {}) {
4150
5216
  loop.requestRender();
4151
5217
  return true;
4152
5218
  },
5219
+ sections() {
5220
+ return model.sections;
5221
+ },
5222
+ focusSection(sectionId) {
5223
+ const sec = model.sections.find((s) => s.id === sectionId);
5224
+ if (!sec || sec.seatCount === 0) return false;
5225
+ cinematic.cancel();
5226
+ const dx = sec.focalWorld[0] - sec.center[0];
5227
+ const dz = sec.focalWorld[2] - sec.center[2];
5228
+ const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5229
+ orbit.frame({ center: sec.center, radius: sec.radius * 1.45 }, false, azimuth);
5230
+ loop.requestRender();
5231
+ return true;
5232
+ },
4153
5233
  setReducedMotionForTest(value) {
4154
5234
  reducedForced = value;
4155
5235
  },