@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.
@@ -7,7 +7,7 @@ import {
7
7
  pointInPolygonWithHoles,
8
8
  resolveSection,
9
9
  sectionGeometry
10
- } from "../chunk-FGS67GT3.js";
10
+ } from "../chunk-MGC5BVAD.js";
11
11
 
12
12
  // src/view3d/index.ts
13
13
  import { Quat as Quat2, Vec3 as Vec33 } from "ogl";
@@ -183,10 +183,18 @@ var OrbitCamera = class {
183
183
  this.maxDist = 100;
184
184
  this.gestureFired = false;
185
185
  this.dragging = false;
186
+ this.panning = false;
186
187
  this.lastX = 0;
187
188
  this.lastY = 0;
188
189
  this.activePointers = /* @__PURE__ */ new Map();
189
190
  this.pinchDist = 0;
191
+ this.pinchCx = 0;
192
+ this.pinchCy = 0;
193
+ /** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
194
+ this.targetT = new Vec3();
195
+ /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
196
+ this.panAnchor = new Vec3();
197
+ this.panLimit = 0;
190
198
  this.camera = new Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
191
199
  this.canvas = canvas;
192
200
  this.requestRender = requestRender;
@@ -198,12 +206,17 @@ var OrbitCamera = class {
198
206
  }
199
207
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
200
208
  if (this.activePointers.size === 1) {
201
- this.dragging = true;
209
+ this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
210
+ this.dragging = !this.panning;
202
211
  this.lastX = e.clientX;
203
212
  this.lastY = e.clientY;
204
213
  } else if (this.activePointers.size === 2) {
205
214
  this.dragging = false;
215
+ this.panning = false;
206
216
  this.pinchDist = this.currentPinchDistance();
217
+ const c = this.pinchCentroid();
218
+ this.pinchCx = c.x;
219
+ this.pinchCy = c.y;
207
220
  }
208
221
  };
209
222
  this.onPointerMove = (e) => {
@@ -216,11 +229,24 @@ var OrbitCamera = class {
216
229
  this.fireGesture();
217
230
  }
218
231
  this.pinchDist = d;
232
+ const c = this.pinchCentroid();
233
+ this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
234
+ this.pinchCx = c.x;
235
+ this.pinchCy = c.y;
219
236
  return;
220
237
  }
221
- if (!this.dragging) return;
222
238
  const dx = e.clientX - this.lastX;
223
239
  const dy = e.clientY - this.lastY;
240
+ if (this.panning) {
241
+ this.lastX = e.clientX;
242
+ this.lastY = e.clientY;
243
+ if (dx !== 0 || dy !== 0) {
244
+ this.panBy(dx, dy);
245
+ this.fireGesture();
246
+ }
247
+ return;
248
+ }
249
+ if (!this.dragging) return;
224
250
  this.lastX = e.clientX;
225
251
  this.lastY = e.clientY;
226
252
  if (dx !== 0 || dy !== 0) this.fireGesture();
@@ -235,7 +261,13 @@ var OrbitCamera = class {
235
261
  } catch {
236
262
  }
237
263
  if (this.activePointers.size < 2) this.pinchDist = 0;
238
- if (this.activePointers.size === 0) this.dragging = false;
264
+ if (this.activePointers.size === 0) {
265
+ this.dragging = false;
266
+ this.panning = false;
267
+ }
268
+ };
269
+ this.onContextMenu = (e) => {
270
+ e.preventDefault();
239
271
  };
240
272
  this.onWheel = (e) => {
241
273
  e.preventDefault();
@@ -249,6 +281,55 @@ var OrbitCamera = class {
249
281
  canvas.addEventListener("pointerup", this.onPointerUp);
250
282
  canvas.addEventListener("pointercancel", this.onPointerUp);
251
283
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
284
+ canvas.addEventListener("contextmenu", this.onContextMenu);
285
+ }
286
+ pinchCentroid() {
287
+ const pts = [...this.activePointers.values()];
288
+ if (!pts.length) return { x: 0, y: 0 };
289
+ let x = 0;
290
+ let y = 0;
291
+ for (const p of pts) {
292
+ x += p.x;
293
+ y += p.y;
294
+ }
295
+ return { x: x / pts.length, y: y / pts.length };
296
+ }
297
+ /**
298
+ * Slide the orbit pivot across the camera's own screen plane.
299
+ *
300
+ * Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
301
+ * under the cursor whatever the zoom: at the overview a drag sweeps the whole
302
+ * bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
303
+ * would be unusable at one end or the other.
304
+ */
305
+ panBy(dxPx, dyPx) {
306
+ const h = this.canvas.clientHeight || 1;
307
+ const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
308
+ const sinA = Math.sin(this.azimuth);
309
+ const cosA = Math.cos(this.azimuth);
310
+ const rightX = cosA;
311
+ const rightZ = -sinA;
312
+ const cp = Math.cos(this.polar);
313
+ const sp = Math.sin(this.polar);
314
+ const fwdX = -sinA * cp;
315
+ const fwdZ = -cosA * cp;
316
+ this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
317
+ this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
318
+ this.targetT.y += dyPx * sp * perPx;
319
+ this.clampPan();
320
+ this.requestRender();
321
+ }
322
+ /** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
323
+ * venue entirely — the Overview chip should never be the only way back. */
324
+ clampPan() {
325
+ if (this.panLimit <= 0) return;
326
+ const lim = this.panLimit;
327
+ const cx = this.panAnchor.x;
328
+ const cy = this.panAnchor.y;
329
+ const cz = this.panAnchor.z;
330
+ this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
331
+ this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
332
+ this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
252
333
  }
253
334
  /** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
254
335
  fireGesture() {
@@ -275,6 +356,9 @@ var OrbitCamera = class {
275
356
  */
276
357
  frame(bounds, intro = false, stageAzimuth) {
277
358
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
359
+ this.targetT.copy(this.target);
360
+ this.panAnchor.copy(this.target);
361
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
278
362
  const r = Math.max(1, bounds.radius);
279
363
  const halfV = this.fovY * DEG / 2;
280
364
  const aspect = this.camera.aspect || 1;
@@ -306,6 +390,9 @@ var OrbitCamera = class {
306
390
  */
307
391
  frameSoft(bounds, stageAzimuth) {
308
392
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
393
+ this.targetT.copy(this.target);
394
+ this.panAnchor.copy(this.target);
395
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
309
396
  this.syncFromCamera();
310
397
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
311
398
  const r = Math.max(1, bounds.radius);
@@ -322,10 +409,17 @@ var OrbitCamera = class {
322
409
  const da = this.azT - this.azimuth;
323
410
  const dp = this.polT - this.polar;
324
411
  const dd = this.distT - this.distance;
325
- const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
412
+ const tx = this.targetT.x - this.target.x;
413
+ const ty = this.targetT.y - this.target.y;
414
+ const tz = this.targetT.z - this.target.z;
415
+ const panEps = Math.max(1e-4, this.distance * 1e-4);
416
+ 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;
326
417
  this.azimuth += da * DAMP;
327
418
  this.polar += dp * DAMP;
328
419
  this.distance += dd * DAMP;
420
+ this.target.x += tx * DAMP;
421
+ this.target.y += ty * DAMP;
422
+ this.target.z += tz * DAMP;
329
423
  if (moving) this.applyPosition();
330
424
  return moving;
331
425
  }
@@ -351,6 +445,7 @@ var OrbitCamera = class {
351
445
  /** Point the orbit pivot at a new world target without moving the camera. */
352
446
  setTarget(target) {
353
447
  this.target.set(target[0], target[1], target[2]);
448
+ this.targetT.copy(this.target);
354
449
  }
355
450
  /** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
356
451
  * flight ends looking at `target` (the venue focal), so re-pivot there first —
@@ -358,6 +453,7 @@ var OrbitCamera = class {
358
453
  resumeAfterFlight(target) {
359
454
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
360
455
  if (target) this.target.set(target[0], target[1], target[2]);
456
+ this.targetT.copy(this.target);
361
457
  this.syncFromCamera();
362
458
  }
363
459
  applyPosition() {
@@ -374,6 +470,7 @@ var OrbitCamera = class {
374
470
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
375
471
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
376
472
  this.canvas.removeEventListener("wheel", this.onWheel);
473
+ this.canvas.removeEventListener("contextmenu", this.onContextMenu);
377
474
  this.activePointers.clear();
378
475
  }
379
476
  };
@@ -422,17 +519,129 @@ var RenderLoop = class {
422
519
  };
423
520
 
424
521
  // src/view3d/lod.ts
522
+ var SEAT_MIN_PIXELS_NEAR = 2.5;
523
+ var SEAT_MIN_PIXELS_FAR = 1.15;
524
+ var CHAIR_FULL_M = 12;
525
+ var CHAIR_NONE_M = 22;
526
+ var CHAIR_GATHER_M = 30;
527
+ var CHAIR_REBUILD_M = 4;
528
+ var CHAIR_MAX_INSTANCES = 8192;
529
+ var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
425
530
  function computeSeatLod(distance, radius) {
426
531
  const near = radius * 1.4;
427
532
  const far = radius * 3.2;
428
- if (distance <= near) return { scale: 1, fade: 0 };
533
+ if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
429
534
  const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
430
535
  return {
431
536
  scale: 1 - t * 0.4,
432
- fade: t * 0.55
537
+ fade: t * 0.55,
538
+ // Tapered on the same ramp as the fade: as the block starts reading by its
539
+ // tier tint, the dots stop fighting each other for pixels.
540
+ minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
433
541
  };
434
542
  }
435
543
 
544
+ // src/view3d/scene/nearField.ts
545
+ var SEATS_PER_CELL = 4;
546
+ var NearFieldIndex = class {
547
+ constructor(iPosition, count) {
548
+ this.minX = 0;
549
+ this.minZ = 0;
550
+ this.cell = 1;
551
+ this.cols = 1;
552
+ this.rows = 1;
553
+ /** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
554
+ this.cellStart = null;
555
+ this.cellItems = null;
556
+ this.iPosition = iPosition;
557
+ this.count = count;
558
+ }
559
+ /** Built on demand; safe to call repeatedly. */
560
+ ensureGrid() {
561
+ if (this.cellStart || this.count === 0) return;
562
+ const p = this.iPosition;
563
+ let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
564
+ for (let i = 0; i < this.count; i++) {
565
+ const x = p[i * 3], z = p[i * 3 + 2];
566
+ if (x < minX) minX = x;
567
+ if (x > maxX) maxX = x;
568
+ if (z < minZ) minZ = z;
569
+ if (z > maxZ) maxZ = z;
570
+ }
571
+ const w = Math.max(maxX - minX, 1e-3);
572
+ const h = Math.max(maxZ - minZ, 1e-3);
573
+ this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
574
+ this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
575
+ this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
576
+ this.minX = minX;
577
+ this.minZ = minZ;
578
+ const nCells = this.cols * this.rows;
579
+ const start = new Int32Array(nCells + 1);
580
+ const cellOf = (i) => {
581
+ const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
582
+ const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
583
+ return cz * this.cols + cx;
584
+ };
585
+ for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
586
+ for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
587
+ const items = new Int32Array(this.count);
588
+ const cursor = start.slice(0, nCells);
589
+ for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
590
+ this.cellStart = start;
591
+ this.cellItems = items;
592
+ }
593
+ /**
594
+ * Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
595
+ * nearest cell-ring first, and return how many were written.
596
+ *
597
+ * Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
598
+ * bites it drops the OUTERMOST seats, which are the ones already past the fade
599
+ * band and drawing nothing. A cap that truncated in index order would instead
600
+ * punch holes in the row you are sitting in.
601
+ *
602
+ * Note this is a horizontal (XZ) query and ignores height. A stacked venue's
603
+ * upper tier is therefore gathered along with the stalls beneath it — which is
604
+ * correct, because the fade weight is re-derived from true view depth in the
605
+ * shader anyway. The grid's only job is to bound the candidate set.
606
+ */
607
+ gather(camX, camZ, radius, out) {
608
+ this.ensureGrid();
609
+ const start = this.cellStart;
610
+ const items = this.cellItems;
611
+ if (!start || !items) return 0;
612
+ const cap = out.length;
613
+ const r2 = radius * radius;
614
+ const cx = Math.floor((camX - this.minX) / this.cell);
615
+ const cz = Math.floor((camZ - this.minZ) / this.cell);
616
+ const maxRing = Math.ceil(radius / this.cell) + 1;
617
+ const p = this.iPosition;
618
+ let n = 0;
619
+ for (let ring = 0; ring <= maxRing && n < cap; ring++) {
620
+ const z0 = cz - ring, z1 = cz + ring;
621
+ const x0 = cx - ring, x1 = cx + ring;
622
+ for (let gz = z0; gz <= z1 && n < cap; gz++) {
623
+ if (gz < 0 || gz >= this.rows) continue;
624
+ const edge = gz === z0 || gz === z1;
625
+ for (let gx = x0; gx <= x1 && n < cap; gx++) {
626
+ if (!edge && gx !== x0 && gx !== x1) {
627
+ gx = x1 - 1;
628
+ continue;
629
+ }
630
+ if (gx < 0 || gx >= this.cols) continue;
631
+ const c = gz * this.cols + gx;
632
+ for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
633
+ const i = items[k];
634
+ const dx = p[i * 3] - camX;
635
+ const dz = p[i * 3 + 2] - camZ;
636
+ if (dx * dx + dz * dz <= r2) out[n++] = i;
637
+ }
638
+ }
639
+ }
640
+ }
641
+ return n;
642
+ }
643
+ };
644
+
436
645
  // src/view3d/scene/geometry.ts
437
646
  import earcut from "earcut";
438
647
  import polygonClipping from "polygon-clipping";
@@ -448,12 +657,42 @@ function isDegenerate(p0, p1, p2) {
448
657
  const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
449
658
  return 2 * area / longest < MIN_TRI_ALTITUDE_M;
450
659
  }
660
+ var F32Buffer = class {
661
+ constructor(initial = 1 << 14) {
662
+ this.len = 0;
663
+ this.buf = new Float32Array(initial);
664
+ }
665
+ push3(a, b, c) {
666
+ if (this.len + 3 > this.buf.length) this.grow(this.len + 3);
667
+ this.buf[this.len++] = a;
668
+ this.buf[this.len++] = b;
669
+ this.buf[this.len++] = c;
670
+ }
671
+ push1(a) {
672
+ if (this.len + 1 > this.buf.length) this.grow(this.len + 1);
673
+ this.buf[this.len++] = a;
674
+ }
675
+ grow(need) {
676
+ let cap = this.buf.length * 2;
677
+ while (cap < need) cap *= 2;
678
+ const next = new Float32Array(cap);
679
+ next.set(this.buf.subarray(0, this.len));
680
+ this.buf = next;
681
+ }
682
+ get length() {
683
+ return this.len;
684
+ }
685
+ /** A copy trimmed to the used length. */
686
+ toArray() {
687
+ return this.buf.slice(0, this.len);
688
+ }
689
+ };
451
690
  var MeshBuilder = class {
452
691
  constructor() {
453
- this.pos = [];
454
- this.nor = [];
455
- this.col = [];
456
- this.flr = [];
692
+ this.pos = new F32Buffer();
693
+ this.nor = new F32Buffer();
694
+ this.col = new F32Buffer();
695
+ this.flr = new F32Buffer();
457
696
  /** Floor index stamped onto every triangle emitted from now on. */
458
697
  this.currentFloor = 0;
459
698
  }
@@ -464,28 +703,44 @@ var MeshBuilder = class {
464
703
  /** One triangle with a shared (flat) normal and per-vertex colours. */
465
704
  tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
466
705
  if (isDegenerate(p0, p1, p2)) return;
467
- this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
468
- this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
469
- this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
470
- this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
706
+ this.pos.push3(p0[0], p0[1], p0[2]);
707
+ this.pos.push3(p1[0], p1[1], p1[2]);
708
+ this.pos.push3(p2[0], p2[1], p2[2]);
709
+ this.nor.push3(n[0], n[1], n[2]);
710
+ this.nor.push3(n[0], n[1], n[2]);
711
+ this.nor.push3(n[0], n[1], n[2]);
712
+ this.col.push3(c0[0], c0[1], c0[2]);
713
+ this.col.push3(c1[0], c1[1], c1[2]);
714
+ this.col.push3(c2[0], c2[1], c2[2]);
715
+ this.flr.push1(this.currentFloor);
716
+ this.flr.push1(this.currentFloor);
717
+ this.flr.push1(this.currentFloor);
471
718
  }
472
719
  /** One triangle with independent per-vertex normals (smooth shading). */
473
720
  triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
474
721
  if (isDegenerate(p0, p1, p2)) return;
475
- this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
476
- this.nor.push(n0[0], n0[1], n0[2], n1[0], n1[1], n1[2], n2[0], n2[1], n2[2]);
477
- this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
478
- this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
722
+ this.pos.push3(p0[0], p0[1], p0[2]);
723
+ this.pos.push3(p1[0], p1[1], p1[2]);
724
+ this.pos.push3(p2[0], p2[1], p2[2]);
725
+ this.nor.push3(n0[0], n0[1], n0[2]);
726
+ this.nor.push3(n1[0], n1[1], n1[2]);
727
+ this.nor.push3(n2[0], n2[1], n2[2]);
728
+ this.col.push3(c0[0], c0[1], c0[2]);
729
+ this.col.push3(c1[0], c1[1], c1[2]);
730
+ this.col.push3(c2[0], c2[1], c2[2]);
731
+ this.flr.push1(this.currentFloor);
732
+ this.flr.push1(this.currentFloor);
733
+ this.flr.push1(this.currentFloor);
479
734
  }
480
735
  get vertexCount() {
481
736
  return this.pos.length / 3;
482
737
  }
483
738
  build() {
484
739
  return {
485
- position: new Float32Array(this.pos),
486
- normal: new Float32Array(this.nor),
487
- color: new Float32Array(this.col),
488
- floor: new Float32Array(this.flr),
740
+ position: this.pos.toArray(),
741
+ normal: this.nor.toArray(),
742
+ color: this.col.toArray(),
743
+ floor: this.flr.toArray(),
489
744
  count: this.pos.length / 3
490
745
  };
491
746
  }
@@ -813,6 +1068,152 @@ function rectPolygon(x, y, w, h) {
813
1068
  ];
814
1069
  }
815
1070
 
1071
+ // src/view3d/scene/seatChair.ts
1072
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
1073
+ var CHAIR_PITCH_FRACTION = 0.44;
1074
+ var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1075
+ var CHAIR_HALF_WIDTH_MAX_M = 0.3;
1076
+ var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
1077
+ function chairHalfWidth(pitchM) {
1078
+ if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
1079
+ return CHAIR_HALF_WIDTH_DEFAULT_M;
1080
+ }
1081
+ return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
1082
+ }
1083
+ var BACK_RAKE_SLOPE = 0.21;
1084
+ var PAD_BACK_GAP_M = 0.05;
1085
+ var PAD_TOP_M = 0.45;
1086
+ var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1087
+ var BOXES = [
1088
+ // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1089
+ // over the deck and the row reads as hovering trays.
1090
+ { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
1091
+ // Seat pad — a full seat width across and about as deep, which is what a real
1092
+ // one is. Its depth is bounded by the same pitch as its width, because the
1093
+ // pitch measure is the tighter of the in-row and row-to-row spacings, so a
1094
+ // tightly-raked tier cannot drive a pad into the back of the row in front.
1095
+ { part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
1096
+ // Back panel — thin, raked, and the tallest thing in the row, so it is what
1097
+ // carries the state colour when you look along a row from behind.
1098
+ {
1099
+ part: CHAIR_PART.back,
1100
+ min: [-1, BACK_BASE_M, -1],
1101
+ max: [1, 0.92, -0.72]
1102
+ }
1103
+ ];
1104
+ var FACES = [
1105
+ // +X
1106
+ { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
1107
+ // -X
1108
+ { n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
1109
+ // +Y
1110
+ { n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
1111
+ // -Y
1112
+ { n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
1113
+ // +Z
1114
+ { n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
1115
+ // -Z
1116
+ { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1117
+ ];
1118
+ function buildChairMesh() {
1119
+ const vertexCount = BOXES.length * FACES.length * 4;
1120
+ const indexCount = BOXES.length * FACES.length * 6;
1121
+ const position = new Float32Array(vertexCount * 3);
1122
+ const normal = new Float32Array(vertexCount * 3);
1123
+ const part = new Float32Array(vertexCount);
1124
+ const index = new Uint16Array(indexCount);
1125
+ let v = 0;
1126
+ let t = 0;
1127
+ for (const box of BOXES) {
1128
+ for (const face of FACES) {
1129
+ const base = v;
1130
+ const corner3 = new Float32Array(12);
1131
+ for (let ci = 0; ci < 4; ci++) {
1132
+ const corner = face.c[ci];
1133
+ for (let a = 0; a < 3; a++) {
1134
+ corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1135
+ }
1136
+ }
1137
+ const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1138
+ const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1139
+ let nx = ay * bz - az * by;
1140
+ let ny = az * bx - ax * bz;
1141
+ let nz = ax * by - ay * bx;
1142
+ const nl = Math.hypot(nx, ny, nz);
1143
+ if (nl > 1e-9) {
1144
+ nx /= nl;
1145
+ ny /= nl;
1146
+ nz /= nl;
1147
+ } else {
1148
+ nx = face.n[0];
1149
+ ny = face.n[1];
1150
+ nz = face.n[2];
1151
+ }
1152
+ if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1153
+ nx = -nx;
1154
+ ny = -ny;
1155
+ nz = -nz;
1156
+ }
1157
+ for (let ci = 0; ci < 4; ci++) {
1158
+ position[v * 3] = corner3[ci * 3];
1159
+ position[v * 3 + 1] = corner3[ci * 3 + 1];
1160
+ position[v * 3 + 2] = corner3[ci * 3 + 2];
1161
+ normal[v * 3] = nx;
1162
+ normal[v * 3 + 1] = ny;
1163
+ normal[v * 3 + 2] = nz;
1164
+ part[v] = box.part;
1165
+ v++;
1166
+ }
1167
+ index[t++] = base;
1168
+ index[t++] = base + 1;
1169
+ index[t++] = base + 2;
1170
+ index[t++] = base;
1171
+ index[t++] = base + 2;
1172
+ index[t++] = base + 3;
1173
+ }
1174
+ }
1175
+ return { position, normal, part, index, vertexCount, indexCount };
1176
+ }
1177
+ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1178
+ const yaw = new Float32Array(count);
1179
+ const px = (i) => iPosition[i * 3];
1180
+ const pz = (i) => iPosition[i * 3 + 2];
1181
+ let runStart = 0;
1182
+ const flushRun = (start, end) => {
1183
+ const n = end - start;
1184
+ for (let i = start; i < end; i++) {
1185
+ const [fx, fz] = focal(i);
1186
+ let dx = fx - px(i);
1187
+ let dz = fz - pz(i);
1188
+ if (n >= 2) {
1189
+ const a = Math.max(start, i - 1);
1190
+ const b = Math.min(end - 1, i + 1);
1191
+ const tx = px(b) - px(a);
1192
+ const tz = pz(b) - pz(a);
1193
+ const tl = Math.hypot(tx, tz);
1194
+ if (tl > 1e-6) {
1195
+ let nx = -tz / tl;
1196
+ let nz = tx / tl;
1197
+ if (nx * dx + nz * dz < 0) {
1198
+ nx = -nx;
1199
+ nz = -nz;
1200
+ }
1201
+ dx = nx;
1202
+ dz = nz;
1203
+ }
1204
+ }
1205
+ yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
1206
+ }
1207
+ };
1208
+ for (let i = 1; i <= count; i++) {
1209
+ if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
1210
+ flushRun(runStart, i);
1211
+ runStart = i;
1212
+ }
1213
+ }
1214
+ return yaw;
1215
+ }
1216
+
816
1217
  // src/view3d/scene/seatInstances.ts
817
1218
  var SEAT_DOT_RADIUS_M = 0.22;
818
1219
  var SEAT_PITCH_FRACTION = 0.42;
@@ -882,6 +1283,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
882
1283
  const iPosition = new Float32Array(count * 3);
883
1284
  const iState = new Float32Array(count);
884
1285
  const iMaxRadius = new Float32Array(count);
1286
+ const iChairWidth = new Float32Array(count);
885
1287
  const iRing = new Float32Array(count * 3);
886
1288
  const idToIndex = /* @__PURE__ */ new Map();
887
1289
  const spacing = nearestNeighbourSpacing(seats);
@@ -890,6 +1292,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
890
1292
  const resolved = surfaces?.seatPitchU(i);
891
1293
  const pitchM = (resolved ?? spacing[i]) * M;
892
1294
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1295
+ iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
893
1296
  iPosition[i * 3] = seat.x * M;
894
1297
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
895
1298
  iPosition[i * 3 + 2] = seat.y * M;
@@ -911,7 +1314,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
911
1314
  iMaxRadius,
912
1315
  iRing,
913
1316
  idToIndex,
914
- iFloor: seatFloor ?? new Float32Array(count)
1317
+ iChairWidth,
1318
+ iFloor: seatFloor ?? new Float32Array(count),
1319
+ iYaw: new Float32Array(count)
915
1320
  };
916
1321
  }
917
1322
  var RUN_MERGE_GAP = 64;
@@ -993,16 +1398,23 @@ function themeSeatColorLUT(theme, order) {
993
1398
  var ZONE_MIN_DISTANCE = 1.15;
994
1399
  var SECTION_MAX_DISTANCE = 2.2;
995
1400
  var NEAR_MAX_DISTANCE = 0.85;
1401
+ var ROW_MAX_DISTANCE = 0.5;
1402
+ var SEAT_MAX_DISTANCE = 0.26;
996
1403
  function visibleLabelKinds(distance, venueRadius) {
997
1404
  const r = Math.max(1e-6, venueRadius);
998
1405
  const d = distance / r;
999
1406
  const out = /* @__PURE__ */ new Set();
1000
1407
  if (d >= ZONE_MIN_DISTANCE) out.add("zone");
1001
- if (d <= SECTION_MAX_DISTANCE) out.add("section");
1408
+ if (d <= SECTION_MAX_DISTANCE) {
1409
+ out.add("section");
1410
+ out.add("ga");
1411
+ }
1002
1412
  if (d <= NEAR_MAX_DISTANCE) {
1003
1413
  out.add("annotation");
1004
1414
  out.add("booth");
1005
1415
  }
1416
+ if (d <= ROW_MAX_DISTANCE) out.add("row");
1417
+ if (d <= SEAT_MAX_DISTANCE) out.add("seat");
1006
1418
  return out;
1007
1419
  }
1008
1420
  function projectToScreen(viewProjection, p, width, height) {
@@ -1039,6 +1451,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
1039
1451
  }
1040
1452
  return kept;
1041
1453
  }
1454
+ var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
1455
+ function focusScore(screen, worldDistance, width, height) {
1456
+ const half = Math.max(1, Math.min(width, height) * 0.5);
1457
+ const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
1458
+ return worldDistance * (1 + 1.5 * off);
1459
+ }
1460
+ function pickDenseLabels(items, separationX, separationY, budget) {
1461
+ const ordered = [...items].sort((a, b) => a.focus - b.focus);
1462
+ const kept = [];
1463
+ for (const item of ordered) {
1464
+ if (kept.length >= budget) break;
1465
+ let clash = false;
1466
+ for (const k of kept) {
1467
+ if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
1468
+ clash = true;
1469
+ break;
1470
+ }
1471
+ }
1472
+ if (!clash) kept.push(item);
1473
+ }
1474
+ return kept;
1475
+ }
1042
1476
  function centroidOf(points) {
1043
1477
  if (!points.length) return null;
1044
1478
  let x = 0, y = 0;
@@ -1212,191 +1646,6 @@ function buildSectionRake(rows, focal) {
1212
1646
  return rowsRake(fits);
1213
1647
  }
1214
1648
 
1215
- // src/view3d/scene/surface.ts
1216
- var FLAT_SLAB_TOP_M = 0.05;
1217
- var CAP_MAX_ERROR_M = 0.05;
1218
- var SEAT_CLEARANCE_M = 0.15;
1219
- var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1220
- function bboxOf(pts) {
1221
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1222
- for (const p of pts) {
1223
- if (p.x < minX) minX = p.x;
1224
- if (p.y < minY) minY = p.y;
1225
- if (p.x > maxX) maxX = p.x;
1226
- if (p.y > maxY) maxY = p.y;
1227
- }
1228
- return { minX, minY, maxX, maxY };
1229
- }
1230
- var MAX_TIER_RISE_M = 25;
1231
- function buildVenueSurfaces(units, seats) {
1232
- const bySection = /* @__PURE__ */ new Map();
1233
- const seatOwner = new Array(seats.length).fill(null);
1234
- const seatDeck = new Float64Array(seats.length);
1235
- const seatRowLevel = new Array(seats.length).fill(void 0);
1236
- const seatPitch = new Array(seats.length).fill(void 0);
1237
- const acc = /* @__PURE__ */ new Map();
1238
- const boxes = [];
1239
- for (const unit of units) {
1240
- for (const o of unit.objects) {
1241
- if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1242
- const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
1243
- acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
1244
- boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
1245
- }
1246
- }
1247
- for (let i = 0; i < seats.length; i++) {
1248
- const s = seats[i];
1249
- for (const b of boxes) {
1250
- if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
1251
- if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
1252
- seatOwner[i] = b.id;
1253
- const a = acc.get(b.id);
1254
- a.hasSeats = true;
1255
- const f = s.focalPoint ?? b.unit.focal;
1256
- const d = Math.hypot(s.x - f.x, s.y - f.y);
1257
- if (d < a.frontU) a.frontU = d;
1258
- a.seatIndices.push(i);
1259
- const rowKey = s.rowId || `__seat-${i}`;
1260
- const arr = a.rows.get(rowKey);
1261
- if (arr) arr.push({ x: s.x, y: s.y });
1262
- else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
1263
- break;
1264
- }
1265
- }
1266
- for (const [id, a] of acc) {
1267
- const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
1268
- const bottomY = a.unit.baseHeightM;
1269
- const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
1270
- const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
1271
- const kind = a.section.surfaceKind;
1272
- const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
1273
- const rake = buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal);
1274
- let frontU = Infinity;
1275
- if (a.hasSeats) {
1276
- for (const pts of a.rows.values()) {
1277
- for (const p of pts) {
1278
- const d = rake.depthAt(p.x, p.y);
1279
- if (d < frontU) frontU = d;
1280
- }
1281
- }
1282
- }
1283
- if (!a.hasSeats || !Number.isFinite(frontU)) {
1284
- frontU = Infinity;
1285
- for (const p of a.section.outline) {
1286
- const d = rake.depthAt(p.x, p.y);
1287
- if (d < frontU) frontU = d;
1288
- }
1289
- }
1290
- const flatTop = bottomY + FLAT_SLAB_TOP_M;
1291
- const baseFloor = bottomY + FLAT_SLAB_TOP_M;
1292
- const levelFor = (depthU) => {
1293
- const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
1294
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1295
- return Math.max(baseFloor, geo.height + rise);
1296
- };
1297
- const levelForBlockDepth = (blockDepthU) => {
1298
- const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
1299
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1300
- return Math.max(baseFloor, geo.height + rise);
1301
- };
1302
- const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
1303
- const rowLevels = flat ? [] : structure.rows.map((r) => ({
1304
- pts: r.pts,
1305
- y: levelForBlockDepth(r.blockDepth),
1306
- depth: r.blockDepth,
1307
- blockId: r.blockId
1308
- }));
1309
- const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
1310
- const rowBounds = rowLevels.map((r) => {
1311
- let cx = 0, cy = 0;
1312
- for (const p of r.pts) {
1313
- cx += p.x;
1314
- cy += p.y;
1315
- }
1316
- const n = r.pts.length || 1;
1317
- cx /= n;
1318
- cy /= n;
1319
- let rad = 0;
1320
- for (const p of r.pts) {
1321
- const d = Math.hypot(p.x - cx, p.y - cy);
1322
- if (d > rad) rad = d;
1323
- }
1324
- return { cx, cy, rad };
1325
- });
1326
- const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
1327
- let best = Infinity, bestY = landingY;
1328
- for (let i = 0; i < rowLevels.length; i++) {
1329
- const b = rowBounds[i];
1330
- if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
1331
- const d = distanceToPolyline(rowLevels[i].pts, x, y);
1332
- if (d < best) {
1333
- best = d;
1334
- bestY = rowLevels[i].y;
1335
- }
1336
- }
1337
- return bestY;
1338
- } : (x, y) => levelFor(rake.depthAt(x, y));
1339
- const UP = [0, 1, 0];
1340
- const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
1341
- const d = rake.depthAt(x, y);
1342
- if (d <= frontU) return UP;
1343
- const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
1344
- if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
1345
- if (geo.height + depthM * rakeTan <= baseFloor) return UP;
1346
- const [gx, gy] = rake.gradientAt(x, y);
1347
- if (gx === 0 && gy === 0) return UP;
1348
- const inv = 1 / Math.hypot(rakeTan, 1);
1349
- return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
1350
- };
1351
- if (!flat) {
1352
- for (const r of structure.rows) {
1353
- const y = levelForBlockDepth(r.blockDepth);
1354
- for (const si of r.seatIndices) seatRowLevel[si] = y;
1355
- }
1356
- }
1357
- for (const r of structure.rows) {
1358
- const gaps = [];
1359
- for (let k = 1; k < r.pts.length; k++) {
1360
- const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
1361
- if (d > 1e-6) gaps.push(d);
1362
- }
1363
- gaps.sort((x, y) => x - y);
1364
- const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
1365
- let across = Infinity;
1366
- const probe = r.pts[Math.floor(r.pts.length / 2)];
1367
- if (probe) {
1368
- for (const other of structure.rows) {
1369
- if (other === r || other.blockId !== r.blockId) continue;
1370
- const d = distanceToPolyline(other.pts, probe.x, probe.y);
1371
- if (d > 1e-6 && d < across) across = d;
1372
- }
1373
- }
1374
- const pitch = Math.min(along, Math.max(across, along * 0.5));
1375
- if (Number.isFinite(pitch) && pitch > 0) {
1376
- for (const si of r.seatIndices) seatPitch[si] = pitch;
1377
- }
1378
- }
1379
- bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1380
- }
1381
- for (let i = 0; i < seats.length; i++) {
1382
- const ownerId = seatOwner[i];
1383
- const s = seats[i];
1384
- if (ownerId) {
1385
- const own = seatRowLevel[i];
1386
- seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
1387
- continue;
1388
- }
1389
- const eye = s.eyeHeightM;
1390
- seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1391
- }
1392
- return {
1393
- bySection,
1394
- seatOwner,
1395
- seatDeckY: (i) => seatDeck[i],
1396
- seatPitchU: (i) => seatPitch[i]
1397
- };
1398
- }
1399
-
1400
1649
  // src/view3d/scene/deckBands.ts
1401
1650
  import polygonClipping2 from "polygon-clipping";
1402
1651
  import earcut2 from "earcut";
@@ -1445,22 +1694,100 @@ function extendEnds(pts, by) {
1445
1694
  });
1446
1695
  return out;
1447
1696
  }
1448
- function distToPolyline(pts, x, y) {
1449
- if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1450
- let best = Infinity;
1451
- for (let i = 0; i + 1 < pts.length; i++) {
1452
- const a = pts[i], b = pts[i + 1];
1453
- const vx = b.x - a.x, vy = b.y - a.y;
1454
- const len2 = vx * vx + vy * vy;
1455
- let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
1456
- if (t < 0) t = 0;
1457
- else if (t > 1) t = 1;
1458
- const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
1697
+ var CORNER_STEP_RAD = Math.PI / 12;
1698
+ function convexHull(pts) {
1699
+ if (pts.length < 3) return [...pts];
1700
+ const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
1701
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
1702
+ const half = (src) => {
1703
+ const out = [];
1704
+ for (const p of src) {
1705
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
1706
+ out.push(p);
1707
+ }
1708
+ out.pop();
1709
+ return out;
1710
+ };
1711
+ const hull = [...half(s), ...half([...s].reverse())];
1712
+ return hull.length >= 3 ? hull : [...pts];
1713
+ }
1714
+ function seatClusterPatch(pts, pad) {
1715
+ if (!pts.length || !(pad > 0)) return [];
1716
+ const hull = convexHull(pts);
1717
+ const arc = (v, from, to, out2) => {
1718
+ let sweep = to - from;
1719
+ while (sweep < 0) sweep += Math.PI * 2;
1720
+ while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
1721
+ const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
1722
+ const r = pad / Math.cos(sweep / steps / 2);
1723
+ for (let k = 0; k <= steps; k++) {
1724
+ const a = from + sweep * k / steps;
1725
+ out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
1726
+ }
1727
+ };
1728
+ if (hull.length < 3) {
1729
+ let cx = 0, cy = 0;
1730
+ for (const p of hull) {
1731
+ cx += p.x;
1732
+ cy += p.y;
1733
+ }
1734
+ cx /= hull.length || 1;
1735
+ cy /= hull.length || 1;
1736
+ let far = 0;
1737
+ for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
1738
+ const out2 = [];
1739
+ const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
1740
+ const r = (far + pad) / Math.cos(Math.PI / steps);
1741
+ for (let k = 0; k < steps; k++) {
1742
+ const a = k / steps * Math.PI * 2;
1743
+ out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
1744
+ }
1745
+ return out2;
1746
+ }
1747
+ const n = hull.length;
1748
+ const edgeAngle = [];
1749
+ for (let i = 0; i < n; i++) {
1750
+ const a = hull[i], b = hull[(i + 1) % n];
1751
+ edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
1752
+ }
1753
+ const out = [];
1754
+ for (let i = 0; i < n; i++) {
1755
+ arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
1756
+ }
1757
+ return dedupeAdjacent(out);
1758
+ }
1759
+ function distToPolyline(pts, x, y) {
1760
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1761
+ let best = Infinity;
1762
+ for (let i = 0; i + 1 < pts.length; i++) {
1763
+ const a = pts[i], b = pts[i + 1];
1764
+ const vx = b.x - a.x, vy = b.y - a.y;
1765
+ const len2 = vx * vx + vy * vy;
1766
+ let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
1767
+ if (t < 0) t = 0;
1768
+ else if (t > 1) t = 1;
1769
+ const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
1459
1770
  if (d < best) best = d;
1460
1771
  }
1461
1772
  return best;
1462
1773
  }
1463
- function neighbourhoods(rows) {
1774
+ function rowNeighbourhoods(rows) {
1775
+ const bounds = rows.map((r) => {
1776
+ let cx = 0, cy = 0;
1777
+ for (const p of r.pts) {
1778
+ cx += p.x;
1779
+ cy += p.y;
1780
+ }
1781
+ const n = r.pts.length || 1;
1782
+ cx /= n;
1783
+ cy /= n;
1784
+ let rad = 0;
1785
+ for (const p of r.pts) {
1786
+ const d = Math.hypot(p.x - cx, p.y - cy);
1787
+ if (d > rad) rad = d;
1788
+ }
1789
+ return { cx, cy, rad };
1790
+ });
1464
1791
  const probesOf = (pts) => {
1465
1792
  const n = pts.length;
1466
1793
  if (n <= 2) return [...pts];
@@ -1475,6 +1802,9 @@ function neighbourhoods(rows) {
1475
1802
  let nearest = Infinity;
1476
1803
  for (let j = 0; j < rows.length; j++) {
1477
1804
  if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
1805
+ const b = bounds[j];
1806
+ const lower = Math.hypot(c.x - b.cx, c.y - b.cy) - b.rad;
1807
+ if (lower >= nearest && lower >= bestFrontD) continue;
1478
1808
  const d = distToPolyline(rows[j].pts, c.x, c.y);
1479
1809
  if (d < nearest) nearest = d;
1480
1810
  if (rows[j].depth < rows[i].depth && d < bestFrontD) {
@@ -1519,65 +1849,8 @@ function ribbonOf(rows, i, nbrs, focal) {
1519
1849
  back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
1520
1850
  };
1521
1851
  }
1522
- function deckFootprints(rows, focal) {
1523
- if (rows.length < 2) return [];
1524
- const nbrs = neighbourhoods(rows);
1525
- const rings = [];
1526
- const ribbons = [];
1527
- for (let i = 0; i < rows.length; i++) {
1528
- const r = ribbonOf(rows, i, nbrs, focal);
1529
- ribbons.push(r);
1530
- if (!r) continue;
1531
- const f = [];
1532
- const b = [];
1533
- const rf = r.front + FOOTPRINT_MARGIN_U;
1534
- const rb = r.back + FOOTPRINT_MARGIN_U;
1535
- for (let k = 0; k < r.pts.length; k++) {
1536
- const p = r.pts[k], n = r.nrm[k];
1537
- f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
1538
- b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
1539
- }
1540
- const ring = [...f, ...b.reverse()];
1541
- if (ring.length < 3) continue;
1542
- ring.push(ring[0]);
1543
- rings.push([ring]);
1544
- }
1545
- if (!rings.length) return [];
1546
- let merged;
1547
- try {
1548
- merged = polygonClipping2.union(rings[0], ...rings.slice(1));
1549
- } catch {
1550
- return [];
1551
- }
1552
- const out = [];
1553
- for (const poly of merged) {
1554
- if (!poly.length || poly[0].length < 4) continue;
1555
- const toPts = (ring) => {
1556
- const pts = ring.map(([x, y]) => ({ x, y }));
1557
- const first = pts[0], last = pts[pts.length - 1];
1558
- if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
1559
- return pts;
1560
- };
1561
- const outline = toPts(poly[0]);
1562
- if (outline.length < 3) continue;
1563
- let topY = Infinity;
1564
- for (let i = 0; i < rows.length; i++) {
1565
- const r = ribbons[i];
1566
- if (!r) continue;
1567
- const mid = r.pts[Math.floor(r.pts.length / 2)];
1568
- if (pointInRing(outline, mid.x, mid.y) && rows[i].y < topY) topY = rows[i].y;
1569
- }
1570
- if (!Number.isFinite(topY)) continue;
1571
- out.push({
1572
- outline: simplifyRing(outline, FOOTPRINT_TOLERANCE_U),
1573
- holes: poly.slice(1).map(toPts).map((h) => simplifyRing(h, FOOTPRINT_TOLERANCE_U)).filter((h) => h.length >= 3),
1574
- topY
1575
- });
1576
- }
1577
- return out;
1578
- }
1579
- var FOOTPRINT_TOLERANCE_U = 0.3;
1580
1852
  var FOOTPRINT_MARGIN_U = 1.5;
1853
+ var FOOTPRINT_TOLERANCE_U = 0.3;
1581
1854
  function simplifyRun(pts, tol) {
1582
1855
  if (pts.length < 3) return pts;
1583
1856
  const a = pts[0], b = pts[pts.length - 1];
@@ -1603,6 +1876,109 @@ function simplifyRing(ring, tol) {
1603
1876
  out.pop();
1604
1877
  return out.length >= 3 ? out : ring;
1605
1878
  }
1879
+ function deckFootprints(rows, focal, shared) {
1880
+ if (rows.length < 2) return [];
1881
+ const nbrs = shared ?? rowNeighbourhoods(rows);
1882
+ const byBlock = /* @__PURE__ */ new Map();
1883
+ for (let i = 0; i < rows.length; i++) {
1884
+ const a = byBlock.get(rows[i].blockId);
1885
+ if (a) a.push(i);
1886
+ else byBlock.set(rows[i].blockId, [i]);
1887
+ }
1888
+ const out = [];
1889
+ for (const [, allIndices] of byBlock) {
1890
+ const indices = [];
1891
+ for (const i of allIndices) {
1892
+ const patch = rows[i].patch;
1893
+ if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
1894
+ else indices.push(i);
1895
+ }
1896
+ if (!indices.length) continue;
1897
+ indices.sort((a, b) => rows[a].depth - rows[b].depth);
1898
+ const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
1899
+ const usable = ribbons.filter((r) => r !== null);
1900
+ if (!usable.length) continue;
1901
+ const frontOf = (r, k) => ({
1902
+ x: r.pts[k].x - r.nrm[k][0] * (r.front + FOOTPRINT_MARGIN_U),
1903
+ y: r.pts[k].y - r.nrm[k][1] * (r.front + FOOTPRINT_MARGIN_U)
1904
+ });
1905
+ const backOf = (r, k) => ({
1906
+ x: r.pts[k].x + r.nrm[k][0] * (r.back + FOOTPRINT_MARGIN_U),
1907
+ y: r.pts[k].y + r.nrm[k][1] * (r.back + FOOTPRINT_MARGIN_U)
1908
+ });
1909
+ const first = usable[0], last = usable[usable.length - 1];
1910
+ const ring = [];
1911
+ for (let k = 0; k < first.pts.length; k++) ring.push(frontOf(first, k));
1912
+ for (const r of usable) ring.push(backOf(r, r.pts.length - 1));
1913
+ for (let k = last.pts.length - 1; k >= 0; k--) ring.push(backOf(last, k));
1914
+ for (let i = usable.length - 1; i >= 0; i--) ring.push(frontOf(usable[i], 0));
1915
+ const deduped = dedupeAdjacent(ring);
1916
+ let topY = Infinity;
1917
+ for (const i of indices) if (rows[i].y < topY) topY = rows[i].y;
1918
+ if (!Number.isFinite(topY)) continue;
1919
+ const covers = deduped.length >= 3 && Math.abs(ringArea(deduped)) > 1e-6 && usable.every((r) => {
1920
+ const mid = r.pts[Math.floor(r.pts.length / 2)];
1921
+ return pointInRing(deduped, mid.x, mid.y);
1922
+ });
1923
+ if (covers) {
1924
+ out.push({ outline: simplifyRing(deduped, FOOTPRINT_TOLERANCE_U), holes: [], topY });
1925
+ continue;
1926
+ }
1927
+ for (const poly of unionRibbons(usable)) out.push({ outline: poly, holes: [], topY });
1928
+ }
1929
+ return out;
1930
+ }
1931
+ function unionRibbons(ribbons) {
1932
+ const rings = [];
1933
+ for (const r of ribbons) {
1934
+ const f = [];
1935
+ const b = [];
1936
+ const rf = r.front + FOOTPRINT_MARGIN_U;
1937
+ const rb = r.back + FOOTPRINT_MARGIN_U;
1938
+ for (let k = 0; k < r.pts.length; k++) {
1939
+ const p = r.pts[k], n = r.nrm[k];
1940
+ f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
1941
+ b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
1942
+ }
1943
+ const ring = [...f, ...b.reverse()];
1944
+ if (ring.length < 3) continue;
1945
+ ring.push(ring[0]);
1946
+ rings.push([ring]);
1947
+ }
1948
+ if (!rings.length) return [];
1949
+ try {
1950
+ const merged = polygonClipping2.union(rings[0], ...rings.slice(1));
1951
+ const out = [];
1952
+ for (const poly of merged) {
1953
+ if (!poly.length || poly[0].length < 4) continue;
1954
+ const pts = poly[0].map(([x, y]) => ({ x, y }));
1955
+ const first = pts[0], last = pts[pts.length - 1];
1956
+ if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
1957
+ if (pts.length >= 3) out.push(simplifyRing(pts, FOOTPRINT_TOLERANCE_U));
1958
+ }
1959
+ return out;
1960
+ } catch {
1961
+ return [];
1962
+ }
1963
+ }
1964
+ function ringArea(ring) {
1965
+ let a = 0;
1966
+ for (let i = 0, n = ring.length; i < n; i++) {
1967
+ const p = ring[i], q = ring[(i + 1) % n];
1968
+ a += p.x * q.y - q.x * p.y;
1969
+ }
1970
+ return a / 2;
1971
+ }
1972
+ function dedupeAdjacent(ring) {
1973
+ const out = [];
1974
+ for (const p of ring) {
1975
+ const last = out[out.length - 1];
1976
+ if (last && Math.hypot(p.x - last.x, p.y - last.y) < 1e-9) continue;
1977
+ out.push(p);
1978
+ }
1979
+ 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();
1980
+ return out;
1981
+ }
1606
1982
  function pointInRing(ring, x, y) {
1607
1983
  let inside = false;
1608
1984
  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
@@ -1626,106 +2002,351 @@ var ClipTest = class {
1626
2002
  if (p.y > this.maxY) this.maxY = p.y;
1627
2003
  }
1628
2004
  }
1629
- }
1630
- /** True when every point lies strictly inside ONE ring of the clip region. */
1631
- containsAll(pts) {
1632
- for (const p of pts) {
1633
- if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
1634
- }
1635
- for (const ring of this.rings) {
1636
- let all = true;
1637
- for (const p of pts) {
1638
- if (!pointInRing(ring, p.x, p.y)) {
1639
- all = false;
1640
- break;
2005
+ }
2006
+ /** True when every point lies strictly inside ONE ring of the clip region. */
2007
+ containsAll(pts) {
2008
+ for (const p of pts) {
2009
+ if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
2010
+ }
2011
+ for (const ring of this.rings) {
2012
+ let all = true;
2013
+ for (const p of pts) {
2014
+ if (!pointInRing(ring, p.x, p.y)) {
2015
+ all = false;
2016
+ break;
2017
+ }
2018
+ }
2019
+ if (all) return true;
2020
+ }
2021
+ return false;
2022
+ }
2023
+ };
2024
+ function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
2025
+ if (poly.length < 3) return;
2026
+ if (clipTest.containsAll(poly)) {
2027
+ const UPF = [0, 1, 0];
2028
+ const a = poly[0];
2029
+ for (let i = 1; i + 1 < poly.length; i++) {
2030
+ const b = poly[i], c = poly[i + 1];
2031
+ 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);
2032
+ }
2033
+ return;
2034
+ }
2035
+ const ring = poly.map((p) => [p.x, p.y]);
2036
+ ring.push(ring[0]);
2037
+ let pieces;
2038
+ try {
2039
+ pieces = polygonClipping2.intersection([ring], clipRing);
2040
+ } catch {
2041
+ return;
2042
+ }
2043
+ const UP = [0, 1, 0];
2044
+ for (const poly2 of pieces) {
2045
+ if (!poly2.length || poly2[0].length < 4) continue;
2046
+ const outer = poly2[0];
2047
+ const flat = [];
2048
+ const pts = [];
2049
+ for (let i = 0; i < outer.length - 1; i++) {
2050
+ flat.push(outer[i][0], outer[i][1]);
2051
+ pts.push([outer[i][0], outer[i][1]]);
2052
+ }
2053
+ if (pts.length < 3) continue;
2054
+ const tris = earcut2(flat, void 0, 2);
2055
+ for (let i = 0; i < tris.length; i += 3) {
2056
+ const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
2057
+ builder.tri(
2058
+ [a[0] * M, y, a[1] * M],
2059
+ [b[0] * M, y, b[1] * M],
2060
+ [c[0] * M, y, c[1] * M],
2061
+ UP,
2062
+ color
2063
+ );
2064
+ }
2065
+ }
2066
+ }
2067
+ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2068
+ if (rows.length < 2) return;
2069
+ const nbrs = shared ?? rowNeighbourhoods(rows);
2070
+ const UP = [0, 1, 0];
2071
+ const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2072
+ const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2073
+ for (let i = 0; i < rows.length; i++) {
2074
+ const row = rows[i];
2075
+ if (row.patch && row.patch.length >= 3) {
2076
+ const patch = [...row.patch];
2077
+ const belowY2 = nbrs[i].belowY ?? landingY;
2078
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2079
+ else {
2080
+ const a = patch[0];
2081
+ for (let k = 1; k + 1 < patch.length; k++) {
2082
+ const b = patch[k], c = patch[k + 1];
2083
+ 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);
2084
+ }
2085
+ }
2086
+ if (row.y > belowY2 + MIN_RISER_M) {
2087
+ let cx = 0, cy = 0;
2088
+ for (const p of patch) {
2089
+ cx += p.x;
2090
+ cy += p.y;
2091
+ }
2092
+ cx /= patch.length;
2093
+ cy /= patch.length;
2094
+ for (let k = 0; k < patch.length; k++) {
2095
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2096
+ const dx = q.x - p.x, dy = q.y - p.y;
2097
+ const len = Math.hypot(dx, dy);
2098
+ if (len < 1e-6) continue;
2099
+ let nx = dy / len, ny = -dx / len;
2100
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2101
+ nx = -nx;
2102
+ ny = -ny;
2103
+ }
2104
+ const rn = [nx, 0, ny];
2105
+ const pt = [p.x * M, row.y, p.y * M];
2106
+ const qt = [q.x * M, row.y, q.y * M];
2107
+ const pb = [p.x * M, belowY2, p.y * M];
2108
+ const qb = [q.x * M, belowY2, q.y * M];
2109
+ builder.tri(pt, qt, qb, rn, colors.riser);
2110
+ builder.tri(pt, qb, pb, rn, colors.riser);
2111
+ }
2112
+ }
2113
+ continue;
2114
+ }
2115
+ const rib = ribbonOf(rows, i, nbrs, focal);
2116
+ if (!rib) continue;
2117
+ const { pts, nrm, front, back } = rib;
2118
+ const belowY = nbrs[i].belowY ?? landingY;
2119
+ for (let k = 0; k + 1 < pts.length; k++) {
2120
+ const p = pts[k], q = pts[k + 1];
2121
+ const np = nrm[k], nq = nrm[k + 1];
2122
+ if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
2123
+ if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
2124
+ const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
2125
+ const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2126
+ const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2127
+ const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2128
+ if (clipRing && clipTest) {
2129
+ emitClippedPoly(builder, clipRing, clipTest, [
2130
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
2131
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2132
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2133
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
2134
+ ], row.y, colors.tread);
2135
+ } else {
2136
+ builder.tri(pF, qF, qB, UP, colors.tread);
2137
+ builder.tri(pF, qB, pB, UP, colors.tread);
2138
+ }
2139
+ if (row.y > belowY + MIN_RISER_M) {
2140
+ const pFd = [pF[0], belowY, pF[2]];
2141
+ const qFd = [qF[0], belowY, qF[2]];
2142
+ const rn = [-np[0], 0, -np[1]];
2143
+ builder.tri(pF, qF, qFd, rn, colors.riser);
2144
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ // src/view3d/scene/surface.ts
2151
+ var FLAT_SLAB_TOP_M = 0.05;
2152
+ var CAP_MAX_ERROR_M = 0.05;
2153
+ var SEAT_CLEARANCE_M = 0.15;
2154
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
2155
+ function bboxOf(pts) {
2156
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2157
+ for (const p of pts) {
2158
+ if (p.x < minX) minX = p.x;
2159
+ if (p.y < minY) minY = p.y;
2160
+ if (p.x > maxX) maxX = p.x;
2161
+ if (p.y > maxY) maxY = p.y;
2162
+ }
2163
+ return { minX, minY, maxX, maxY };
2164
+ }
2165
+ function pointInRing2(ring, x, y) {
2166
+ let inside = false;
2167
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2168
+ const a = ring[i], b = ring[j];
2169
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
2170
+ }
2171
+ return inside;
2172
+ }
2173
+ var MAX_TIER_RISE_M = 25;
2174
+ function buildVenueSurfaces(units, seats) {
2175
+ const bySection = /* @__PURE__ */ new Map();
2176
+ const seatOwner = new Array(seats.length).fill(null);
2177
+ const seatDeck = new Float64Array(seats.length);
2178
+ const seatRowLevel = new Array(seats.length).fill(void 0);
2179
+ const seatPitch = new Array(seats.length).fill(void 0);
2180
+ const acc = /* @__PURE__ */ new Map();
2181
+ const boxes = [];
2182
+ const tableRowIds = /* @__PURE__ */ new Set();
2183
+ for (const unit of units) {
2184
+ for (const o of unit.objects) {
2185
+ if (o.type === "table") tableRowIds.add(o.id);
2186
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2187
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2188
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2189
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2190
+ }
2191
+ }
2192
+ for (let i = 0; i < seats.length; i++) {
2193
+ const s = seats[i];
2194
+ for (const b of boxes) {
2195
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2196
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2197
+ seatOwner[i] = b.id;
2198
+ const a = acc.get(b.id);
2199
+ a.hasSeats = true;
2200
+ const f = s.focalPoint ?? b.unit.focal;
2201
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
2202
+ if (d < a.frontU) a.frontU = d;
2203
+ a.seatIndices.push(i);
2204
+ const rowKey = s.rowId || `__seat-${i}`;
2205
+ const arr = a.rows.get(rowKey);
2206
+ if (arr) arr.push({ x: s.x, y: s.y });
2207
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
2208
+ break;
2209
+ }
2210
+ }
2211
+ for (const [id, a] of acc) {
2212
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
2213
+ const bottomY = a.unit.baseHeightM;
2214
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
2215
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
2216
+ const kind = a.section.surfaceKind;
2217
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
2218
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
2219
+ const needsRake = structure.rows.length < 2;
2220
+ const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
2221
+ let frontU = Infinity;
2222
+ if (rake) {
2223
+ if (a.hasSeats) {
2224
+ for (const pts of a.rows.values()) {
2225
+ for (const p of pts) {
2226
+ const d = rake.depthAt(p.x, p.y);
2227
+ if (d < frontU) frontU = d;
2228
+ }
2229
+ }
2230
+ }
2231
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
2232
+ frontU = Infinity;
2233
+ for (const p of a.section.outline) {
2234
+ const d = rake.depthAt(p.x, p.y);
2235
+ if (d < frontU) frontU = d;
2236
+ }
2237
+ }
2238
+ }
2239
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
2240
+ const flatTop = Math.max(baseFloor, geo.height);
2241
+ const levelFor = (depthU) => {
2242
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
2243
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2244
+ return Math.max(baseFloor, geo.height + rise);
2245
+ };
2246
+ const levelForBlockDepth = (blockDepthU) => {
2247
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
2248
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2249
+ return Math.max(baseFloor, geo.height + rise);
2250
+ };
2251
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
2252
+ pts: r.pts,
2253
+ y: levelForBlockDepth(r.blockDepth),
2254
+ depth: r.blockDepth,
2255
+ blockId: r.blockId,
2256
+ ...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
2257
+ }));
2258
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
2259
+ const rowBounds = rowLevels.map((r) => {
2260
+ const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
2261
+ let cx = 0, cy = 0;
2262
+ for (const p of ext) {
2263
+ cx += p.x;
2264
+ cy += p.y;
2265
+ }
2266
+ const n = ext.length || 1;
2267
+ cx /= n;
2268
+ cy /= n;
2269
+ let rad = 0;
2270
+ for (const p of ext) {
2271
+ const d = Math.hypot(p.x - cx, p.y - cy);
2272
+ if (d > rad) rad = d;
2273
+ }
2274
+ return { cx, cy, rad };
2275
+ });
2276
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
2277
+ let best = Infinity, bestY = landingY;
2278
+ for (let i = 0; i < rowLevels.length; i++) {
2279
+ const b = rowBounds[i];
2280
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
2281
+ const patch = rowLevels[i].patch;
2282
+ const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
2283
+ if (d < best) {
2284
+ best = d;
2285
+ bestY = rowLevels[i].y;
2286
+ }
2287
+ }
2288
+ return bestY;
2289
+ } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
2290
+ const UP = [0, 1, 0];
2291
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
2292
+ if (!rake) return UP;
2293
+ const d = rake.depthAt(x, y);
2294
+ if (d <= frontU) return UP;
2295
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
2296
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
2297
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
2298
+ const [gx, gy] = rake.gradientAt(x, y);
2299
+ if (gx === 0 && gy === 0) return UP;
2300
+ const inv = 1 / Math.hypot(rakeTan, 1);
2301
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
2302
+ };
2303
+ if (!flat) {
2304
+ for (const r of structure.rows) {
2305
+ const y = levelForBlockDepth(r.blockDepth);
2306
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
2307
+ }
2308
+ }
2309
+ for (const r of structure.rows) {
2310
+ const gaps = [];
2311
+ for (let k = 1; k < r.pts.length; k++) {
2312
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
2313
+ if (d > 1e-6) gaps.push(d);
2314
+ }
2315
+ gaps.sort((x, y) => x - y);
2316
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
2317
+ let across = Infinity;
2318
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
2319
+ if (probe) {
2320
+ for (const other of structure.rows) {
2321
+ if (other === r || other.blockId !== r.blockId) continue;
2322
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
2323
+ if (d > 1e-6 && d < across) across = d;
1641
2324
  }
1642
2325
  }
1643
- if (all) return true;
1644
- }
1645
- return false;
1646
- }
1647
- };
1648
- function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
1649
- if (clipTest.containsAll(quad)) {
1650
- const UPF = [0, 1, 0];
1651
- const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
1652
- builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
1653
- builder.tri([a.x * M, y, a.y * M], [c.x * M, y, c.y * M], [d.x * M, y, d.y * M], UPF, color);
1654
- return;
1655
- }
1656
- const ring = quad.map((p) => [p.x, p.y]);
1657
- ring.push(ring[0]);
1658
- let pieces;
1659
- try {
1660
- pieces = polygonClipping2.intersection([ring], clipRing);
1661
- } catch {
1662
- return;
1663
- }
1664
- const UP = [0, 1, 0];
1665
- for (const poly of pieces) {
1666
- if (!poly.length || poly[0].length < 4) continue;
1667
- const outer = poly[0];
1668
- const flat = [];
1669
- const pts = [];
1670
- for (let i = 0; i < outer.length - 1; i++) {
1671
- flat.push(outer[i][0], outer[i][1]);
1672
- pts.push([outer[i][0], outer[i][1]]);
1673
- }
1674
- if (pts.length < 3) continue;
1675
- const tris = earcut2(flat, void 0, 2);
1676
- for (let i = 0; i < tris.length; i += 3) {
1677
- const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
1678
- builder.tri(
1679
- [a[0] * M, y, a[1] * M],
1680
- [b[0] * M, y, b[1] * M],
1681
- [c[0] * M, y, c[1] * M],
1682
- UP,
1683
- color
1684
- );
2326
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
2327
+ if (Number.isFinite(pitch) && pitch > 0) {
2328
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
2329
+ }
1685
2330
  }
2331
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1686
2332
  }
1687
- }
1688
- function emitDeckBands(builder, rows, focal, landingY, colors, clip) {
1689
- if (rows.length < 2) return;
1690
- const nbrs = neighbourhoods(rows);
1691
- const UP = [0, 1, 0];
1692
- const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
1693
- const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
1694
- for (let i = 0; i < rows.length; i++) {
1695
- const row = rows[i];
1696
- const rib = ribbonOf(rows, i, nbrs, focal);
1697
- if (!rib) continue;
1698
- const { pts, nrm, front, back } = rib;
1699
- const belowY = nbrs[i].belowY ?? landingY;
1700
- for (let k = 0; k + 1 < pts.length; k++) {
1701
- const p = pts[k], q = pts[k + 1];
1702
- const np = nrm[k], nq = nrm[k + 1];
1703
- if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
1704
- if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
1705
- const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
1706
- const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
1707
- const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
1708
- const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
1709
- if (clipRing && clipTest) {
1710
- emitClippedQuad(builder, clipRing, clipTest, [
1711
- { x: p.x - np[0] * front, y: p.y - np[1] * front },
1712
- { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
1713
- { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
1714
- { x: p.x + np[0] * back, y: p.y + np[1] * back }
1715
- ], row.y, colors.tread);
1716
- } else {
1717
- builder.tri(pF, qF, qB, UP, colors.tread);
1718
- builder.tri(pF, qB, pB, UP, colors.tread);
1719
- }
1720
- if (row.y > belowY + MIN_RISER_M) {
1721
- const pFd = [pF[0], belowY, pF[2]];
1722
- const qFd = [qF[0], belowY, qF[2]];
1723
- const rn = [-np[0], 0, -np[1]];
1724
- builder.tri(pF, qF, qFd, rn, colors.riser);
1725
- builder.tri(pF, qFd, pFd, rn, colors.riser);
1726
- }
2333
+ for (let i = 0; i < seats.length; i++) {
2334
+ const ownerId = seatOwner[i];
2335
+ const s = seats[i];
2336
+ if (ownerId) {
2337
+ const own = seatRowLevel[i];
2338
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
2339
+ continue;
1727
2340
  }
2341
+ const eye = s.eyeHeightM;
2342
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1728
2343
  }
2344
+ return {
2345
+ bySection,
2346
+ seatOwner,
2347
+ seatDeckY: (i) => seatDeck[i],
2348
+ seatPitchU: (i) => seatPitch[i]
2349
+ };
1729
2350
  }
1730
2351
 
1731
2352
  // src/view3d/scene/sceneModel.ts
@@ -1790,9 +2411,7 @@ function boxesOverlap(a, b) {
1790
2411
  return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
1791
2412
  }
1792
2413
  function paddedFootprint(section, siblings) {
1793
- const __tp = performance.now();
1794
2414
  const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
1795
- __t("outsetRing", __tp);
1796
2415
  const box = bboxOfRing(padded);
1797
2416
  const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
1798
2417
  if (!others.length) return [padded];
@@ -1856,9 +2475,8 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
1856
2475
  const footprint = paddedFootprint(section, siblings);
1857
2476
  const outline = footprint[0] ?? section.outline;
1858
2477
  if (surface.rowLevels.length >= 2) {
1859
- const __tf = performance.now();
1860
- const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
1861
- __t("deckFootprints", __tf);
2478
+ const nbrs = rowNeighbourhoods(surface.rowLevels);
2479
+ const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal, nbrs), footprint);
1862
2480
  const capUp = () => [0, 1, 0];
1863
2481
  for (const b of blocks) {
1864
2482
  extrudePrism(
@@ -1886,34 +2504,49 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
1886
2504
  AO
1887
2505
  );
1888
2506
  }
1889
- const __tb = performance.now();
1890
2507
  emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
1891
2508
  tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
1892
2509
  // Risers read as the structure they are, a shade below their tread, which
1893
2510
  // is what makes the stepping legible from a low angle.
1894
2511
  riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
1895
- }, footprint.length === 1 ? outline : footprint.flat());
1896
- __t("emitDeckBands", __tb);
2512
+ }, footprint.length === 1 ? outline : footprint.flat(), nbrs);
1897
2513
  return;
1898
2514
  }
1899
2515
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
1900
2516
  const topN = (p) => surface.normalAt(p.x, p.y);
1901
- for (const ring of claimed.subtract(outline)) {
2517
+ const level = surface.flat ? surface.landingY : void 0;
2518
+ for (const ring of claimed.subtract(outline, level)) {
1902
2519
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
1903
2520
  }
1904
2521
  }
2522
+ function coplanarLevels(a, b) {
2523
+ if (a === void 0 || b === void 0) return true;
2524
+ return Math.abs(a - b) < 1e-3;
2525
+ }
1905
2526
  var ClaimedArea = class {
1906
2527
  constructor() {
1907
2528
  this.rings = [];
1908
2529
  this.boxes = [];
2530
+ /** Constant deck height of each claim, or undefined when it is not level. */
2531
+ this.levels = [];
1909
2532
  }
1910
- /** `ring` minus everything claimed so far; then claim what is returned. */
1911
- subtract(ring) {
2533
+ /**
2534
+ * `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
2535
+ *
2536
+ * `level` is the claim's constant deck height when it has one. Two decks only
2537
+ * z-fight when they are coplanar, so only a coplanar claim may take ground
2538
+ * away — an elevated box hanging over a ground-level section overlaps it in
2539
+ * plan and must still draw its whole floor, or the box loses the part of its
2540
+ * deck that shares a footprint with whatever is underneath it. Passing
2541
+ * undefined (a surface with no single height) keeps the original
2542
+ * clip-against-everything behaviour.
2543
+ */
2544
+ subtract(ring, level) {
1912
2545
  const closed = ring.map((p) => [p.x, p.y]);
1913
2546
  if (closed.length < 3) return [];
1914
2547
  closed.push(closed[0]);
1915
2548
  const box = bboxOfRing(ring);
1916
- const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
2549
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
1917
2550
  let pieces = [[closed]];
1918
2551
  if (overlapping.length) {
1919
2552
  try {
@@ -1925,6 +2558,7 @@ var ClaimedArea = class {
1925
2558
  }
1926
2559
  this.rings.push([closed]);
1927
2560
  this.boxes.push(box);
2561
+ this.levels.push(level);
1928
2562
  const out = [];
1929
2563
  for (const poly of pieces) {
1930
2564
  if (!poly.length) continue;
@@ -1943,7 +2577,12 @@ var ZONE_LABEL_LIFT_M = 6;
1943
2577
  var SECTION_LABEL_LIFT_M = 2.2;
1944
2578
  var ANNOTATION_LIFT_M = 0.1;
1945
2579
  var BOOTH_LABEL_LIFT_M = 0.3;
2580
+ var GA_LABEL_LIFT_M = 1.8;
2581
+ var ROW_LABEL_LIFT_M = 0.9;
2582
+ var SEAT_LABEL_LIFT_M = 0.55;
2583
+ var SEAT_LABEL_MAX = 6e3;
1946
2584
  var TABLE_HEIGHT_M = 0.75;
2585
+ var DECOR_PLATE_M = 0.15;
1947
2586
  function boothPolygon(booth) {
1948
2587
  if (booth.points && booth.points.length >= 3) return booth.points;
1949
2588
  const { center, width, height, rotation } = booth;
@@ -2017,10 +2656,63 @@ function buildShape(builder, shape, base, S) {
2017
2656
  if (!poly) return;
2018
2657
  const isStage = shape.role === "stage";
2019
2658
  const height = isStage ? base + 1 : base + 0.25;
2020
- const colTop = isStage ? S.stageTop : S.decorTop;
2659
+ const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
2021
2660
  const colWall = isStage ? S.stageWall : S.decorWall;
2022
2661
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
2023
2662
  }
2663
+ function decorPolygon(decor) {
2664
+ const { x, y, width, height } = decor;
2665
+ if (!width || !height) return null;
2666
+ const cx = x + width / 2, cy = y + height / 2;
2667
+ const a = (decor.rotation ?? 0) * Math.PI / 180;
2668
+ const cos = Math.cos(a), sin = Math.sin(a);
2669
+ const hw = width / 2, hh = height / 2;
2670
+ return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
2671
+ x: cx + lx * cos - ly * sin,
2672
+ y: cy + lx * sin + ly * cos
2673
+ }));
2674
+ }
2675
+ function decorPlatePaint(href) {
2676
+ if (!href || !href.startsWith("data:image/svg+xml")) return null;
2677
+ let svg = href.slice(href.indexOf(",") + 1);
2678
+ if (/;base64/i.test(href.slice(0, href.indexOf(",")))) {
2679
+ try {
2680
+ svg = atob(svg);
2681
+ } catch {
2682
+ return null;
2683
+ }
2684
+ } else {
2685
+ try {
2686
+ svg = decodeURIComponent(svg);
2687
+ } catch {
2688
+ }
2689
+ }
2690
+ const covering = svg.match(/<(?:rect|path)\b[^>]*\bfill="([^"]+)"/i);
2691
+ if (!covering) return null;
2692
+ const paint = covering[1].trim();
2693
+ if (paint === "none" || paint === "transparent") return null;
2694
+ const gradient = paint.match(/^url\(#([^)]+)\)$/);
2695
+ if (!gradient) return hexToRgb(paint);
2696
+ const def = svg.match(new RegExp(`<(?:linear|radial)Gradient\\b[^>]*\\bid="${gradient[1]}"[^>]*>([\\s\\S]*?)</(?:linear|radial)Gradient>`, "i"));
2697
+ if (!def) return null;
2698
+ const stops = [];
2699
+ for (const m of def[1].matchAll(/stop-color="([^"]+)"/gi)) {
2700
+ const c = hexToRgb(m[1].trim());
2701
+ if (c) stops.push(c);
2702
+ }
2703
+ if (!stops.length) return null;
2704
+ return stops.reduce((acc, c, i) => mix(acc, c, 1 / (i + 1)), stops[0]);
2705
+ }
2706
+ function buildDecorImage(builder, decor, base, S) {
2707
+ if (decor.layer === "foreground") return;
2708
+ const poly = decorPolygon(decor);
2709
+ if (!poly) return;
2710
+ const top = base + DECOR_PLATE_M;
2711
+ let paint = tintTop(decorPlatePaint(decor.href), S.decorTop);
2712
+ const opacity = Math.max(0, Math.min(1, decor.opacity ?? 1));
2713
+ if (opacity < 1) paint = mix(S.ground, paint, opacity);
2714
+ extrudePrism(builder, poly, void 0, () => top, base, paint, mix(paint, S.decorWall, 0.5), AO);
2715
+ }
2024
2716
  function buildGa(builder, ga, base, fill, S) {
2025
2717
  if (!ga.points || ga.points.length < 3) return;
2026
2718
  const colTop = tintTop(fill, S.gaTop);
@@ -2046,6 +2738,9 @@ function chartFootprint(units, seats) {
2046
2738
  } else if (o.type === "table") {
2047
2739
  const t = tablePolygon(o);
2048
2740
  if (t) for (const p of t) acc(p.x, p.y);
2741
+ } else if (o.type === "decorImage") {
2742
+ const d = decorPolygon(o);
2743
+ if (d) for (const p of d) acc(p.x, p.y);
2049
2744
  }
2050
2745
  }
2051
2746
  }
@@ -2057,12 +2752,7 @@ function chartFootprint(units, seats) {
2057
2752
  }
2058
2753
  return { minX, minY, maxX, maxY };
2059
2754
  }
2060
- var __PROF = {};
2061
- var __t = (k, t0) => {
2062
- __PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
2063
- };
2064
2755
  function buildSceneModel(input) {
2065
- for (const k of Object.keys(__PROF)) delete __PROF[k];
2066
2756
  const { doc, seats } = input;
2067
2757
  const theme = resolveTheme3D(doc.theme);
2068
2758
  const S = theme.structure;
@@ -2075,9 +2765,7 @@ function buildSceneModel(input) {
2075
2765
  const sectionFills = resolveSectionFills(doc, seats);
2076
2766
  const catColor = /* @__PURE__ */ new Map();
2077
2767
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
2078
- const __t0 = performance.now();
2079
2768
  const surfaces = buildVenueSurfaces(units, seats);
2080
- __t("surfaces", __t0);
2081
2769
  const seatFloor = new Float32Array(seats.length);
2082
2770
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
2083
2771
  const unit = units[unitIndex];
@@ -2085,23 +2773,17 @@ function buildSceneModel(input) {
2085
2773
  const claimed = new ClaimedArea();
2086
2774
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
2087
2775
  for (const o of unit.objects) {
2088
- if (o.type === "section") {
2089
- const t = performance.now();
2090
- buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2091
- __t("buildTier", t);
2092
- } else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
2776
+ if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2777
+ else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
2093
2778
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2094
2779
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2095
2780
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2781
+ else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
2096
2782
  }
2097
2783
  }
2098
2784
  const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
2099
- const __tm = performance.now();
2100
2785
  const solids = mergeMeshData([builder.build()]);
2101
- __t("meshBuild+merge", __tm);
2102
- const __ts = performance.now();
2103
2786
  const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
2104
- __t("seatInstances", __ts);
2105
2787
  const zoneDefs = doc.zones ?? [];
2106
2788
  const zones = [];
2107
2789
  if (zoneDefs.length) {
@@ -2161,6 +2843,7 @@ function buildSceneModel(input) {
2161
2843
  }
2162
2844
  }
2163
2845
  const labels = [];
2846
+ const sections = [];
2164
2847
  for (const z of zones) {
2165
2848
  if (z.seatCount === 0) continue;
2166
2849
  labels.push({
@@ -2173,6 +2856,7 @@ function buildSceneModel(input) {
2173
2856
  }
2174
2857
  {
2175
2858
  const acc = /* @__PURE__ */ new Map();
2859
+ const ext = /* @__PURE__ */ new Map();
2176
2860
  for (let i = 0; i < seats.length; i++) {
2177
2861
  const owner = surfaces.seatOwner[i];
2178
2862
  if (!owner) continue;
@@ -2185,18 +2869,87 @@ function buildSceneModel(input) {
2185
2869
  a.x += seats[i].x;
2186
2870
  a.y += seats[i].y;
2187
2871
  a.deck += surfaces.seatDeckY(i);
2872
+ let e = ext.get(owner);
2873
+ if (!e) {
2874
+ e = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
2875
+ ext.set(owner, e);
2876
+ }
2877
+ if (seats[i].x < e.minX) e.minX = seats[i].x;
2878
+ if (seats[i].x > e.maxX) e.maxX = seats[i].x;
2879
+ if (seats[i].y < e.minY) e.minY = seats[i].y;
2880
+ if (seats[i].y > e.maxY) e.maxY = seats[i].y;
2188
2881
  }
2189
2882
  for (const unit of units) {
2190
2883
  for (const o of unit.objects) {
2191
2884
  if (o.type !== "section") continue;
2192
2885
  const a = acc.get(o.id);
2193
2886
  if (!a || a.n === 0) continue;
2887
+ const e = ext.get(o.id);
2888
+ const centre = [a.x / a.n * M, a.deck / a.n, a.y / a.n * M];
2889
+ sections.push({
2890
+ id: o.id,
2891
+ label: o.displayLabel || o.label || o.id,
2892
+ seatCount: a.n,
2893
+ center: centre,
2894
+ // Half-diagonal of the seat extent — the same fit the zones use.
2895
+ radius: Math.max(1, Math.hypot(e.maxX - e.minX, e.maxY - e.minY) * 0.5 * M),
2896
+ // A section faces its floor's focal, which is what the camera should
2897
+ // look along so the seats present their fronts.
2898
+ focalWorld: [unit.focal.x * M, unit.baseHeightM + 1.5, unit.focal.y * M]
2899
+ });
2194
2900
  labels.push({
2195
2901
  id: `section:${o.id}`,
2196
2902
  kind: "section",
2197
2903
  // The buyer-facing name wins over the technical one, as it does in 2D.
2198
2904
  text: o.displayLabel || o.label || o.id,
2199
- anchor: [a.x / a.n * M, a.deck / a.n + SECTION_LABEL_LIFT_M, a.y / a.n * M]
2905
+ anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]]
2906
+ });
2907
+ }
2908
+ }
2909
+ }
2910
+ {
2911
+ for (const unit of units) {
2912
+ for (const o of unit.objects) {
2913
+ if (o.type !== "gaArea") continue;
2914
+ const c = centroidOf(o.points);
2915
+ if (!c) continue;
2916
+ const name = o.displayLabel || o.label || o.id;
2917
+ labels.push({
2918
+ id: `ga:${o.id}`,
2919
+ kind: "ga",
2920
+ text: o.capacity > 0 ? `${name} \xB7 ${o.capacity.toLocaleString()}` : name,
2921
+ anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M]
2922
+ });
2923
+ }
2924
+ }
2925
+ const rowEnds = /* @__PURE__ */ new Map();
2926
+ for (let i = 0; i < seats.length; i++) {
2927
+ const s = seats[i];
2928
+ const far = Math.hypot(s.x - focal.x, s.y - focal.y);
2929
+ const cur = rowEnds.get(s.rowId);
2930
+ if (!cur || far > cur.far) rowEnds.set(s.rowId, { seat: s, index: i, far });
2931
+ }
2932
+ for (const [rowId, end] of rowEnds) {
2933
+ const cut = end.seat.label ? end.seat.label.lastIndexOf("-") : -1;
2934
+ const text = cut > 0 ? end.seat.label.slice(0, cut) : rowId;
2935
+ if (!text) continue;
2936
+ labels.push({
2937
+ id: `row:${rowId}`,
2938
+ kind: "row",
2939
+ text,
2940
+ anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
2941
+ });
2942
+ }
2943
+ if (seats.length <= SEAT_LABEL_MAX) {
2944
+ for (let i = 0; i < seats.length; i++) {
2945
+ const s = seats[i];
2946
+ const text = s.displayLabel || s.label;
2947
+ if (!text) continue;
2948
+ labels.push({
2949
+ id: `seat:${s.id}`,
2950
+ kind: "seat",
2951
+ text,
2952
+ anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M]
2200
2953
  });
2201
2954
  }
2202
2955
  }
@@ -2261,6 +3014,15 @@ function buildSceneModel(input) {
2261
3014
  });
2262
3015
  }
2263
3016
  }
3017
+ seatData.iYaw = computeSeatYaw(
3018
+ seatData.iPosition,
3019
+ seats.length,
3020
+ (i) => seats[i].rowId,
3021
+ (i) => {
3022
+ const f = units[seatFloor[i]]?.focal ?? focal;
3023
+ return [f.x * M, f.y * M];
3024
+ }
3025
+ );
2264
3026
  const cx = (fp.minX + fp.maxX) / 2 * M;
2265
3027
  const cz = (fp.minY + fp.maxY) / 2 * M;
2266
3028
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
@@ -2274,6 +3036,7 @@ function buildSceneModel(input) {
2274
3036
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
2275
3037
  focalWorld: [focal.x * M, 1.5, focal.y * M],
2276
3038
  zones,
3039
+ sections,
2277
3040
  labels,
2278
3041
  floors
2279
3042
  };
@@ -2285,8 +3048,23 @@ var SEPARATION_Y_PX = 20;
2285
3048
  var KIND_STYLE = {
2286
3049
  zone: { size: 15, weight: "600", opacity: 0.95 },
2287
3050
  section: { size: 12, weight: "500", opacity: 0.88 },
3051
+ ga: { size: 12, weight: "500", opacity: 0.88 },
2288
3052
  booth: { size: 11, weight: "500", opacity: 0.85 },
2289
- annotation: { size: 11, weight: "400", opacity: 0.75 }
3053
+ annotation: { size: 11, weight: "400", opacity: 0.75 },
3054
+ // Row and seat identity are quieter than the structure they sit inside: at
3055
+ // this range the venue is already understood and the label is a detail, so it
3056
+ // must not compete with the seating it is printed over.
3057
+ row: { size: 10.5, weight: "600", opacity: 0.8 },
3058
+ seat: { size: 9.5, weight: "500", opacity: 0.72 }
3059
+ };
3060
+ var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
3061
+ var DENSE_SEPARATION = {
3062
+ row: { x: 62, y: 16 },
3063
+ // Widened from 24: at close range seat labels stopped overlapping at all, so
3064
+ // the separation had no work left to do and the budget was carrying the whole
3065
+ // load. A wider box means the few labels that ARE kept are spread across the
3066
+ // seating instead of clustering into one stack.
3067
+ seat: { x: 46, y: 18 }
2290
3068
  };
2291
3069
  var LabelOverlay = class {
2292
3070
  constructor(container, opts = {}) {
@@ -2317,7 +3095,7 @@ var LabelOverlay = class {
2317
3095
  *
2318
3096
  * `viewProjection` is column-major, as OGL supplies it.
2319
3097
  */
2320
- update(viewProjection, width, height, cameraDistance, venueRadius) {
3098
+ update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
2321
3099
  if (!this.labels.length) return;
2322
3100
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
2323
3101
  const candidates = [];
@@ -2325,9 +3103,26 @@ var LabelOverlay = class {
2325
3103
  if (!kinds.has(label.kind)) continue;
2326
3104
  const screen = projectToScreen(viewProjection, label.anchor, width, height);
2327
3105
  if (!screen.visible) continue;
2328
- candidates.push({ label, screen });
3106
+ const world = cameraWorld ? Math.hypot(
3107
+ label.anchor[0] - cameraWorld[0],
3108
+ label.anchor[1] - cameraWorld[1],
3109
+ label.anchor[2] - cameraWorld[2]
3110
+ ) : 1;
3111
+ candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
2329
3112
  }
2330
- const kept = cullOverlapping(candidates, SEPARATION_X_PX, SEPARATION_Y_PX);
3113
+ const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
3114
+ const kept = [
3115
+ ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
3116
+ // The dense rungs are BUDGETED, not merely deduplicated — see
3117
+ // DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
3118
+ // the camera gets.
3119
+ ...["row", "seat"].flatMap((kind) => pickDenseLabels(
3120
+ candidates.filter((c) => c.label.kind === kind),
3121
+ DENSE_SEPARATION[kind].x,
3122
+ DENSE_SEPARATION[kind].y,
3123
+ DENSE_LABEL_BUDGET[kind]
3124
+ ))
3125
+ ];
2331
3126
  const keptIds = new Set(kept.map((k) => k.label.id));
2332
3127
  for (const { label, screen } of kept) {
2333
3128
  const node = this.nodeFor(label);
@@ -2374,6 +3169,13 @@ import { Geometry, Mesh, Transform } from "ogl";
2374
3169
 
2375
3170
  // src/view3d/scene/materials.ts
2376
3171
  import { Program } from "ogl";
3172
+ var CHAIR_WEIGHT_GLSL = (
3173
+ /* glsl */
3174
+ `
3175
+ float chairWeight(float depth) {
3176
+ return 1.0 - smoothstep(uChairFull, uChairNone, depth);
3177
+ }`
3178
+ );
2377
3179
  var SOLID_VERT = (
2378
3180
  /* glsl */
2379
3181
  `#version 300 es
@@ -2453,14 +3255,23 @@ uniform float uSeatScale;
2453
3255
  uniform float uMinPixels;
2454
3256
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
2455
3257
  uniform float uFocusFloor; // -1 = show every floor
3258
+ uniform float uChairFull; // view depth at which the chair mesh is full size
3259
+ uniform float uChairNone; // ...and at which it has scaled away entirely
2456
3260
  out vec2 vUv;
2457
3261
  out vec3 vColor;
2458
3262
  out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
2459
3263
  out vec3 vRing;
2460
3264
  out float vDim;
3265
+ out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3266
+ ${CHAIR_WEIGHT_GLSL}
2461
3267
  void main() {
2462
3268
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
2463
3269
  float depth = max(-mv.z, 0.001);
3270
+ // Hand the seat over to the chair mesh as it comes into range. Derived from
3271
+ // this instance's OWN depth rather than from a global uniform, so a row two
3272
+ // metres away and the far side of the bowl resolve differently in the same
3273
+ // frame \u2014 which is the entire point of a ladder over a switch.
3274
+ vDotWeight = 1.0 - chairWeight(depth);
2464
3275
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
2465
3276
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
2466
3277
  // unbounded growth is what merges neighbouring rows into one mass at range.
@@ -2494,6 +3305,7 @@ in vec3 vColor;
2494
3305
  in float vBudget;
2495
3306
  in vec3 vRing;
2496
3307
  in float vDim;
3308
+ in float vDotWeight;
2497
3309
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
2498
3310
  uniform vec3 uFadeColor;
2499
3311
  out vec4 fragColor;
@@ -2518,9 +3330,131 @@ void main() {
2518
3330
  // Seats on an unfocused floor recede with their structure.
2519
3331
  c = mix(c, uFadeColor, vDim * 0.75);
2520
3332
  alpha *= mix(1.0, 0.30, vDim);
3333
+ // Yield to the chair. The chair grows out of this exact point, so through the
3334
+ // band the dot is always at least as big as the chair inside it and the seat
3335
+ // never thins out to nothing in between.
3336
+ alpha *= vDotWeight;
3337
+ if (alpha <= 0.0) discard;
2521
3338
  fragColor = vec4(c, alpha);
2522
3339
  }`
2523
3340
  );
3341
+ var CHAIR_VERT = (
3342
+ /* glsl */
3343
+ `#version 300 es
3344
+ precision highp float;
3345
+ in vec3 position; // local: x/z in units of the seat radius, y in METRES
3346
+ in vec3 normal;
3347
+ in float part; // 0 = pedestal, 1 = pad, 2 = back
3348
+ in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3349
+ in vec3 iColor; // per-instance state colour
3350
+ in float iRadius; // per-instance horizontal half-width, world metres
3351
+ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3352
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3353
+ in float iFloor;
3354
+ uniform mat4 modelViewMatrix;
3355
+ uniform mat4 projectionMatrix;
3356
+ uniform float uChairFull;
3357
+ uniform float uChairNone;
3358
+ uniform float uFocusFloor;
3359
+ uniform float uBackRake; // metres of z per metre of rise, above uBackBase
3360
+ uniform float uBackBase; // local height at which the back starts
3361
+ out vec3 vColor;
3362
+ out vec3 vNormalWorld;
3363
+ out vec3 vNormalView;
3364
+ out vec3 vPosView;
3365
+ out float vPart;
3366
+ out float vHeight; // local height in metres, for the vertical occlusion ramp
3367
+ out vec3 vRing;
3368
+ out float vDim;
3369
+ ${CHAIR_WEIGHT_GLSL}
3370
+ void main() {
3371
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
3372
+ float w = chairWeight(max(-anchor.z, 0.001));
3373
+ // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
3374
+ // chairs, but nobody gets a short one (people are the same height at every
3375
+ // seat pitch).
3376
+ vec3 p = position;
3377
+ p.xz *= iRadius;
3378
+ // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3379
+ // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3380
+ // width and the same chair would lean 20 degrees on a wide stadium row and
3381
+ // 6 on a tight theatre one.
3382
+ float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3383
+ p.z -= rake;
3384
+ // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3385
+ // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
3386
+ // the growth so the chair is already near full size while the dot is still
3387
+ // half there; see chairScale() in lod.ts.
3388
+ p *= sqrt(w);
3389
+ float c = cos(iYaw), s = sin(iYaw);
3390
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
3391
+ // Normals under the same two transforms, in reverse and inverted-transposed.
3392
+ // The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
3393
+ // unchanged; and the rake is a shear, whose normal transform adds a y term.
3394
+ // Skipping either lights the raked back as though it were still vertical.
3395
+ vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3396
+ if (part > 1.5) n.y += uBackRake * n.z;
3397
+ n = normalize(n);
3398
+ vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
3399
+ vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
3400
+ vPosView = mv.xyz;
3401
+ vNormalWorld = rn;
3402
+ vNormalView = normalize(mat3(modelViewMatrix) * rn);
3403
+ vColor = iColor;
3404
+ vPart = part;
3405
+ vHeight = position.y;
3406
+ vRing = iRing;
3407
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3408
+ gl_Position = projectionMatrix * mv;
3409
+ }`
3410
+ );
3411
+ var CHAIR_FRAG = (
3412
+ /* glsl */
3413
+ `#version 300 es
3414
+ precision highp float;
3415
+ in vec3 vColor;
3416
+ in vec3 vNormalWorld;
3417
+ in vec3 vNormalView;
3418
+ in vec3 vPosView;
3419
+ in float vPart;
3420
+ in float vHeight;
3421
+ in vec3 vRing;
3422
+ in float vDim;
3423
+ uniform vec3 uKeyDir;
3424
+ uniform vec3 uFadeColor;
3425
+ out vec4 fragColor;
3426
+ void main() {
3427
+ vec3 N = normalize(vNormalWorld);
3428
+ vec3 V = normalize(-vPosView);
3429
+ // The solids' rig, unchanged, so the chairs sit in the venue's light.
3430
+ float hemi = 0.5 + 0.5 * N.y;
3431
+ float key = max(dot(N, uKeyDir), 0.0);
3432
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
3433
+ float fill = max(dot(N, fillDir), 0.0);
3434
+ vec3 tint = vColor;
3435
+ // The accommodation ring, kept legible once the dot (which drew it) is gone:
3436
+ // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
3437
+ // survives to close range instead of vanishing exactly when the buyer arrives.
3438
+ float ringMask = step(0.001, dot(vRing, vRing));
3439
+ if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
3440
+ // Pad brightest, back a step below it, pedestal darkest. Three untextured
3441
+ // boxes only read as one object if they are separated tonally \u2014 with a single
3442
+ // flat colour the chair silhouettes as a crate.
3443
+ float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
3444
+ // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
3445
+ // sits to the deck the less sky it can actually see. This is the depth cue \u2014
3446
+ // without it the pad top, the back and the deck all resolve to the same flat
3447
+ // value and the row loses its form entirely.
3448
+ float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
3449
+ vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
3450
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3451
+ // A brighter rim than the solids get: it picks out every chair's own edge,
3452
+ // which is what stops a block of them merging into one mass up close.
3453
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
3454
+ base = mix(base, uFadeColor, vDim * 0.75);
3455
+ fragColor = vec4(base, 1.0);
3456
+ }`
3457
+ );
2524
3458
  var BG_VERT = (
2525
3459
  /* glsl */
2526
3460
  `#version 300 es
@@ -2617,7 +3551,7 @@ function createSeatPickProgram(gl) {
2617
3551
  uniforms: {
2618
3552
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
2619
3553
  uSeatScale: { value: 1 },
2620
- uMinPixels: { value: 2.5 },
3554
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
2621
3555
  uPixelToWorld: { value: 2e-3 }
2622
3556
  }
2623
3557
  });
@@ -2662,11 +3596,32 @@ function createSeatProgram(gl) {
2662
3596
  uniforms: {
2663
3597
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
2664
3598
  uSeatScale: { value: 1 },
2665
- uMinPixels: { value: 2.5 },
3599
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
2666
3600
  uPixelToWorld: { value: 2e-3 },
2667
3601
  uSeatFade: { value: 0 },
2668
3602
  uFocusFloor: { value: -1 },
2669
- uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
3603
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
3604
+ uChairFull: { value: CHAIR_FULL_M },
3605
+ uChairNone: { value: CHAIR_NONE_M }
3606
+ }
3607
+ });
3608
+ }
3609
+ function createChairProgram(gl) {
3610
+ return new Program(gl, {
3611
+ vertex: CHAIR_VERT,
3612
+ fragment: CHAIR_FRAG,
3613
+ transparent: false,
3614
+ depthTest: true,
3615
+ depthWrite: true,
3616
+ cullFace: false,
3617
+ uniforms: {
3618
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
3619
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
3620
+ uChairFull: { value: CHAIR_FULL_M },
3621
+ uChairNone: { value: CHAIR_NONE_M },
3622
+ uBackRake: { value: BACK_RAKE_SLOPE },
3623
+ uBackBase: { value: BACK_BASE_M },
3624
+ uFocusFloor: { value: -1 }
2670
3625
  }
2671
3626
  });
2672
3627
  }
@@ -2732,16 +3687,90 @@ function buildGpuScene(gl, model) {
2732
3687
  seatMesh.frustumCulled = false;
2733
3688
  if (model.seats.count > 0) seatMesh.setParent(main);
2734
3689
  const colorAttr = seatGeo.attributes.iColor;
2735
- return {
3690
+ const chairBase = buildChairMesh();
3691
+ const chairProg = createChairProgram(gl);
3692
+ const CAP = CHAIR_MAX_INSTANCES;
3693
+ const cOffset = new Float32Array(CAP * 3);
3694
+ const cColor = new Float32Array(CAP * 3);
3695
+ const cRadius = new Float32Array(CAP);
3696
+ const cYaw = new Float32Array(CAP);
3697
+ const cRing = new Float32Array(CAP * 3);
3698
+ const cFloor = new Float32Array(CAP);
3699
+ const chairGeo = new Geometry(gl, {
3700
+ position: { size: 3, data: chairBase.position },
3701
+ normal: { size: 3, data: chairBase.normal },
3702
+ part: { size: 1, data: chairBase.part },
3703
+ index: { data: chairBase.index },
3704
+ iOffset: { size: 3, data: cOffset, instanced: 1 },
3705
+ iColor: { size: 3, data: cColor, instanced: 1 },
3706
+ iRadius: { size: 1, data: cRadius, instanced: 1 },
3707
+ iYaw: { size: 1, data: cYaw, instanced: 1 },
3708
+ iRing: { size: 3, data: cRing, instanced: 1 },
3709
+ iFloor: { size: 1, data: cFloor, instanced: 1 }
3710
+ });
3711
+ const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
3712
+ chairMesh.frustumCulled = false;
3713
+ let nearCount = 0;
3714
+ let nearIndices = new Int32Array(0);
3715
+ const writeChairColors = () => {
3716
+ const src = model.seats;
3717
+ for (let k = 0; k < nearCount; k++) {
3718
+ const i = nearIndices[k];
3719
+ const c = stateColors[src.iState[i]] ?? stateColors[0];
3720
+ cColor[k * 3] = c[0];
3721
+ cColor[k * 3 + 1] = c[1];
3722
+ cColor[k * 3 + 2] = c[2];
3723
+ }
3724
+ chairGeo.attributes.iColor.needsUpdate = true;
3725
+ };
3726
+ const scene = {
2736
3727
  main,
2737
3728
  background,
2738
3729
  seatProgram: seatProg,
2739
3730
  solidProgram: solidProg,
3731
+ chairProgram: chairProg,
2740
3732
  seatGeometry: seatGeo,
2741
3733
  solidGeometry: solidGeo,
2742
3734
  drawCalls: 3,
3735
+ setNearSeats(indices, count) {
3736
+ const n = Math.min(count, CAP);
3737
+ nearIndices = indices;
3738
+ nearCount = n;
3739
+ if (n === 0) {
3740
+ if (chairMesh.parent) chairMesh.setParent(null);
3741
+ chairGeo.instancedCount = 0;
3742
+ scene.drawCalls = 3;
3743
+ return;
3744
+ }
3745
+ const src = model.seats;
3746
+ for (let k = 0; k < n; k++) {
3747
+ const i = indices[k];
3748
+ cOffset[k * 3] = src.iPosition[i * 3];
3749
+ cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
3750
+ cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
3751
+ cRadius[k] = src.iChairWidth[i];
3752
+ cYaw[k] = src.iYaw[i];
3753
+ cRing[k * 3] = src.iRing[i * 3];
3754
+ cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
3755
+ cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
3756
+ cFloor[k] = src.iFloor[i];
3757
+ }
3758
+ writeChairColors();
3759
+ chairGeo.attributes.iOffset.needsUpdate = true;
3760
+ chairGeo.attributes.iRadius.needsUpdate = true;
3761
+ chairGeo.attributes.iYaw.needsUpdate = true;
3762
+ chairGeo.attributes.iRing.needsUpdate = true;
3763
+ chairGeo.attributes.iFloor.needsUpdate = true;
3764
+ chairGeo.instancedCount = n;
3765
+ if (!chairMesh.parent) chairMesh.setParent(main);
3766
+ scene.drawCalls = 4;
3767
+ },
3768
+ nearSeatCount() {
3769
+ return nearCount;
3770
+ },
2743
3771
  uploadSeatStateRuns(runs) {
2744
3772
  if (!runs.length) return;
3773
+ if (nearCount) writeChairColors();
2745
3774
  for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
2746
3775
  const buffer = colorAttr.buffer;
2747
3776
  if (!buffer) {
@@ -2761,8 +3790,11 @@ function buildGpuScene(gl, model) {
2761
3790
  solidProg.remove();
2762
3791
  seatGeo.remove();
2763
3792
  seatProg.remove();
3793
+ chairGeo.remove();
3794
+ chairProg.remove();
2764
3795
  }
2765
3796
  };
3797
+ return scene;
2766
3798
  }
2767
3799
 
2768
3800
  // src/view3d/pick/pickPipeline.ts
@@ -3330,6 +4362,28 @@ function mountVenue3D(container, input, opts = {}) {
3330
4362
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
3331
4363
  gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
3332
4364
  gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
4365
+ gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
4366
+ lastGatherX = Infinity;
4367
+ };
4368
+ const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
4369
+ const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
4370
+ let lastGatherX = Infinity;
4371
+ let lastGatherZ = Infinity;
4372
+ const updateNearField = () => {
4373
+ if (!gpu) return;
4374
+ const cam = orbit.camera.position;
4375
+ const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
4376
+ if (moved2 < CHAIR_REBUILD_M) return;
4377
+ lastGatherX = cam.x;
4378
+ lastGatherZ = cam.z;
4379
+ const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
4380
+ if (outside > CHAIR_GATHER_M) {
4381
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
4382
+ return;
4383
+ }
4384
+ const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
4385
+ if (n === 0 && gpu.nearSeatCount() === 0) return;
4386
+ gpu.setNearSeats(nearBuf, n);
3333
4387
  };
3334
4388
  const glctx = new GLContext(container, {
3335
4389
  onContextLost: () => {
@@ -3376,7 +4430,9 @@ function mountVenue3D(container, input, opts = {}) {
3376
4430
  const u = gpu.seatProgram.uniforms;
3377
4431
  u.uSeatScale.value = lod.scale;
3378
4432
  u.uSeatFade.value = lod.fade;
4433
+ u.uMinPixels.value = lod.minPixels;
3379
4434
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
4435
+ updateNearField();
3380
4436
  glctx.renderer.render({ scene: gpu.background, clear: true });
3381
4437
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
3382
4438
  labelOverlay.update(
@@ -3384,7 +4440,10 @@ function mountVenue3D(container, input, opts = {}) {
3384
4440
  glctx.canvas.clientWidth || 1,
3385
4441
  glctx.canvas.clientHeight || 1,
3386
4442
  orbit.currentDistance,
3387
- model.bounds.radius
4443
+ model.bounds.radius,
4444
+ // The dense label rungs rank by real distance from the eye, not by the
4445
+ // orbit radius — at the arrival pose those are wildly different numbers.
4446
+ [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
3388
4447
  );
3389
4448
  return moving;
3390
4449
  });
@@ -3657,6 +4716,7 @@ function mountVenue3D(container, input, opts = {}) {
3657
4716
  if (gpu) {
3658
4717
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
3659
4718
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
4719
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
3660
4720
  }
3661
4721
  focusedFloor = value;
3662
4722
  if (index !== null) {
@@ -3683,6 +4743,20 @@ function mountVenue3D(container, input, opts = {}) {
3683
4743
  loop.requestRender();
3684
4744
  return true;
3685
4745
  },
4746
+ sections() {
4747
+ return model.sections;
4748
+ },
4749
+ focusSection(sectionId) {
4750
+ const sec = model.sections.find((s) => s.id === sectionId);
4751
+ if (!sec || sec.seatCount === 0) return false;
4752
+ cinematic.cancel();
4753
+ const dx = sec.focalWorld[0] - sec.center[0];
4754
+ const dz = sec.focalWorld[2] - sec.center[2];
4755
+ const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
4756
+ orbit.frame({ center: sec.center, radius: sec.radius * 1.45 }, false, azimuth);
4757
+ loop.requestRender();
4758
+ return true;
4759
+ },
3686
4760
  setReducedMotionForTest(value) {
3687
4761
  reducedForced = value;
3688
4762
  },