@seatlayer/core 0.31.0 → 0.33.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";
@@ -28,13 +28,36 @@ var SEAT_STATE_COLORS = {
28
28
  selected: [0.24, 0.74, 1],
29
29
  dimmed: [0.28, 0.32, 0.37]
30
30
  };
31
+ function upholsteryTone(rgb) {
32
+ const luma = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
33
+ const SAT = 0.42;
34
+ const VALUE = 0.62;
35
+ return [
36
+ (luma + (rgb[0] - luma) * SAT) * VALUE,
37
+ (luma + (rgb[1] - luma) * SAT) * VALUE,
38
+ (luma + (rgb[2] - luma) * SAT) * VALUE
39
+ ];
40
+ }
31
41
  var STRUCTURE = {
32
42
  ground: [0.07, 0.085, 0.11],
33
43
  tierTop: [0.24, 0.28, 0.34],
34
44
  tierWall: [0.17, 0.2, 0.25],
35
- stageTop: [0.42, 0.36, 0.26],
36
- // warm, slightly emissive read
37
- stageWall: [0.26, 0.22, 0.16],
45
+ /**
46
+ * The lit performance surface.
47
+ *
48
+ * This was [0.42, 0.36, 0.26] and commented "slightly emissive read", which it
49
+ * was not — under the scene's rig it resolved to a mid-brown slab, and from a
50
+ * seat the stage was the DIMMEST large surface in a dark hall. That is exactly
51
+ * backwards: the stage is the one place in a venue with light pointed at it,
52
+ * and it is what a buyer's eye should land on when they arrive at their seat.
53
+ *
54
+ * Bright and warm enough to hold that role against the surrounding structure,
55
+ * which sits around 0.24. An authored `fill` still tints it (see `buildShape`),
56
+ * so an organizer's own stage colour survives — it is just no longer lit like
57
+ * a basement.
58
+ */
59
+ stageTop: [0.86, 0.64, 0.36],
60
+ stageWall: [0.34, 0.26, 0.18],
38
61
  decorTop: [0.22, 0.25, 0.29],
39
62
  decorWall: [0.15, 0.17, 0.2],
40
63
  gaTop: [0.24, 0.28, 0.33],
@@ -183,10 +206,18 @@ var OrbitCamera = class {
183
206
  this.maxDist = 100;
184
207
  this.gestureFired = false;
185
208
  this.dragging = false;
209
+ this.panning = false;
186
210
  this.lastX = 0;
187
211
  this.lastY = 0;
188
212
  this.activePointers = /* @__PURE__ */ new Map();
189
213
  this.pinchDist = 0;
214
+ this.pinchCx = 0;
215
+ this.pinchCy = 0;
216
+ /** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
217
+ this.targetT = new Vec3();
218
+ /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
219
+ this.panAnchor = new Vec3();
220
+ this.panLimit = 0;
190
221
  this.camera = new Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
191
222
  this.canvas = canvas;
192
223
  this.requestRender = requestRender;
@@ -198,12 +229,17 @@ var OrbitCamera = class {
198
229
  }
199
230
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
200
231
  if (this.activePointers.size === 1) {
201
- this.dragging = true;
232
+ this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
233
+ this.dragging = !this.panning;
202
234
  this.lastX = e.clientX;
203
235
  this.lastY = e.clientY;
204
236
  } else if (this.activePointers.size === 2) {
205
237
  this.dragging = false;
238
+ this.panning = false;
206
239
  this.pinchDist = this.currentPinchDistance();
240
+ const c = this.pinchCentroid();
241
+ this.pinchCx = c.x;
242
+ this.pinchCy = c.y;
207
243
  }
208
244
  };
209
245
  this.onPointerMove = (e) => {
@@ -216,11 +252,24 @@ var OrbitCamera = class {
216
252
  this.fireGesture();
217
253
  }
218
254
  this.pinchDist = d;
255
+ const c = this.pinchCentroid();
256
+ this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
257
+ this.pinchCx = c.x;
258
+ this.pinchCy = c.y;
219
259
  return;
220
260
  }
221
- if (!this.dragging) return;
222
261
  const dx = e.clientX - this.lastX;
223
262
  const dy = e.clientY - this.lastY;
263
+ if (this.panning) {
264
+ this.lastX = e.clientX;
265
+ this.lastY = e.clientY;
266
+ if (dx !== 0 || dy !== 0) {
267
+ this.panBy(dx, dy);
268
+ this.fireGesture();
269
+ }
270
+ return;
271
+ }
272
+ if (!this.dragging) return;
224
273
  this.lastX = e.clientX;
225
274
  this.lastY = e.clientY;
226
275
  if (dx !== 0 || dy !== 0) this.fireGesture();
@@ -235,7 +284,13 @@ var OrbitCamera = class {
235
284
  } catch {
236
285
  }
237
286
  if (this.activePointers.size < 2) this.pinchDist = 0;
238
- if (this.activePointers.size === 0) this.dragging = false;
287
+ if (this.activePointers.size === 0) {
288
+ this.dragging = false;
289
+ this.panning = false;
290
+ }
291
+ };
292
+ this.onContextMenu = (e) => {
293
+ e.preventDefault();
239
294
  };
240
295
  this.onWheel = (e) => {
241
296
  e.preventDefault();
@@ -249,6 +304,55 @@ var OrbitCamera = class {
249
304
  canvas.addEventListener("pointerup", this.onPointerUp);
250
305
  canvas.addEventListener("pointercancel", this.onPointerUp);
251
306
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
307
+ canvas.addEventListener("contextmenu", this.onContextMenu);
308
+ }
309
+ pinchCentroid() {
310
+ const pts = [...this.activePointers.values()];
311
+ if (!pts.length) return { x: 0, y: 0 };
312
+ let x = 0;
313
+ let y = 0;
314
+ for (const p of pts) {
315
+ x += p.x;
316
+ y += p.y;
317
+ }
318
+ return { x: x / pts.length, y: y / pts.length };
319
+ }
320
+ /**
321
+ * Slide the orbit pivot across the camera's own screen plane.
322
+ *
323
+ * Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
324
+ * under the cursor whatever the zoom: at the overview a drag sweeps the whole
325
+ * bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
326
+ * would be unusable at one end or the other.
327
+ */
328
+ panBy(dxPx, dyPx) {
329
+ const h = this.canvas.clientHeight || 1;
330
+ const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
331
+ const sinA = Math.sin(this.azimuth);
332
+ const cosA = Math.cos(this.azimuth);
333
+ const rightX = cosA;
334
+ const rightZ = -sinA;
335
+ const cp = Math.cos(this.polar);
336
+ const sp = Math.sin(this.polar);
337
+ const fwdX = -sinA * cp;
338
+ const fwdZ = -cosA * cp;
339
+ this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
340
+ this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
341
+ this.targetT.y += dyPx * sp * perPx;
342
+ this.clampPan();
343
+ this.requestRender();
344
+ }
345
+ /** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
346
+ * venue entirely — the Overview chip should never be the only way back. */
347
+ clampPan() {
348
+ if (this.panLimit <= 0) return;
349
+ const lim = this.panLimit;
350
+ const cx = this.panAnchor.x;
351
+ const cy = this.panAnchor.y;
352
+ const cz = this.panAnchor.z;
353
+ this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
354
+ this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
355
+ this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
252
356
  }
253
357
  /** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
254
358
  fireGesture() {
@@ -275,6 +379,9 @@ var OrbitCamera = class {
275
379
  */
276
380
  frame(bounds, intro = false, stageAzimuth) {
277
381
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
382
+ this.targetT.copy(this.target);
383
+ this.panAnchor.copy(this.target);
384
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
278
385
  const r = Math.max(1, bounds.radius);
279
386
  const halfV = this.fovY * DEG / 2;
280
387
  const aspect = this.camera.aspect || 1;
@@ -306,6 +413,9 @@ var OrbitCamera = class {
306
413
  */
307
414
  frameSoft(bounds, stageAzimuth) {
308
415
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
416
+ this.targetT.copy(this.target);
417
+ this.panAnchor.copy(this.target);
418
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
309
419
  this.syncFromCamera();
310
420
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
311
421
  const r = Math.max(1, bounds.radius);
@@ -322,10 +432,17 @@ var OrbitCamera = class {
322
432
  const da = this.azT - this.azimuth;
323
433
  const dp = this.polT - this.polar;
324
434
  const dd = this.distT - this.distance;
325
- const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
435
+ const tx = this.targetT.x - this.target.x;
436
+ const ty = this.targetT.y - this.target.y;
437
+ const tz = this.targetT.z - this.target.z;
438
+ const panEps = Math.max(1e-4, this.distance * 1e-4);
439
+ 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
440
  this.azimuth += da * DAMP;
327
441
  this.polar += dp * DAMP;
328
442
  this.distance += dd * DAMP;
443
+ this.target.x += tx * DAMP;
444
+ this.target.y += ty * DAMP;
445
+ this.target.z += tz * DAMP;
329
446
  if (moving) this.applyPosition();
330
447
  return moving;
331
448
  }
@@ -351,6 +468,7 @@ var OrbitCamera = class {
351
468
  /** Point the orbit pivot at a new world target without moving the camera. */
352
469
  setTarget(target) {
353
470
  this.target.set(target[0], target[1], target[2]);
471
+ this.targetT.copy(this.target);
354
472
  }
355
473
  /** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
356
474
  * flight ends looking at `target` (the venue focal), so re-pivot there first —
@@ -358,6 +476,7 @@ var OrbitCamera = class {
358
476
  resumeAfterFlight(target) {
359
477
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
360
478
  if (target) this.target.set(target[0], target[1], target[2]);
479
+ this.targetT.copy(this.target);
361
480
  this.syncFromCamera();
362
481
  }
363
482
  applyPosition() {
@@ -374,6 +493,7 @@ var OrbitCamera = class {
374
493
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
375
494
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
376
495
  this.canvas.removeEventListener("wheel", this.onWheel);
496
+ this.canvas.removeEventListener("contextmenu", this.onContextMenu);
377
497
  this.activePointers.clear();
378
498
  }
379
499
  };
@@ -422,17 +542,129 @@ var RenderLoop = class {
422
542
  };
423
543
 
424
544
  // src/view3d/lod.ts
545
+ var SEAT_MIN_PIXELS_NEAR = 2.5;
546
+ var SEAT_MIN_PIXELS_FAR = 1.15;
547
+ var CHAIR_FULL_M = 12;
548
+ var CHAIR_NONE_M = 22;
549
+ var CHAIR_GATHER_M = 30;
550
+ var CHAIR_REBUILD_M = 4;
551
+ var CHAIR_MAX_INSTANCES = 8192;
552
+ var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
425
553
  function computeSeatLod(distance, radius) {
426
554
  const near = radius * 1.4;
427
555
  const far = radius * 3.2;
428
- if (distance <= near) return { scale: 1, fade: 0 };
556
+ if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
429
557
  const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
430
558
  return {
431
559
  scale: 1 - t * 0.4,
432
- fade: t * 0.55
560
+ fade: t * 0.55,
561
+ // Tapered on the same ramp as the fade: as the block starts reading by its
562
+ // tier tint, the dots stop fighting each other for pixels.
563
+ minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
433
564
  };
434
565
  }
435
566
 
567
+ // src/view3d/scene/nearField.ts
568
+ var SEATS_PER_CELL = 4;
569
+ var NearFieldIndex = class {
570
+ constructor(iPosition, count) {
571
+ this.minX = 0;
572
+ this.minZ = 0;
573
+ this.cell = 1;
574
+ this.cols = 1;
575
+ this.rows = 1;
576
+ /** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
577
+ this.cellStart = null;
578
+ this.cellItems = null;
579
+ this.iPosition = iPosition;
580
+ this.count = count;
581
+ }
582
+ /** Built on demand; safe to call repeatedly. */
583
+ ensureGrid() {
584
+ if (this.cellStart || this.count === 0) return;
585
+ const p = this.iPosition;
586
+ let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
587
+ for (let i = 0; i < this.count; i++) {
588
+ const x = p[i * 3], z = p[i * 3 + 2];
589
+ if (x < minX) minX = x;
590
+ if (x > maxX) maxX = x;
591
+ if (z < minZ) minZ = z;
592
+ if (z > maxZ) maxZ = z;
593
+ }
594
+ const w = Math.max(maxX - minX, 1e-3);
595
+ const h = Math.max(maxZ - minZ, 1e-3);
596
+ this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
597
+ this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
598
+ this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
599
+ this.minX = minX;
600
+ this.minZ = minZ;
601
+ const nCells = this.cols * this.rows;
602
+ const start = new Int32Array(nCells + 1);
603
+ const cellOf = (i) => {
604
+ const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
605
+ const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
606
+ return cz * this.cols + cx;
607
+ };
608
+ for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
609
+ for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
610
+ const items = new Int32Array(this.count);
611
+ const cursor = start.slice(0, nCells);
612
+ for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
613
+ this.cellStart = start;
614
+ this.cellItems = items;
615
+ }
616
+ /**
617
+ * Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
618
+ * nearest cell-ring first, and return how many were written.
619
+ *
620
+ * Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
621
+ * bites it drops the OUTERMOST seats, which are the ones already past the fade
622
+ * band and drawing nothing. A cap that truncated in index order would instead
623
+ * punch holes in the row you are sitting in.
624
+ *
625
+ * Note this is a horizontal (XZ) query and ignores height. A stacked venue's
626
+ * upper tier is therefore gathered along with the stalls beneath it — which is
627
+ * correct, because the fade weight is re-derived from true view depth in the
628
+ * shader anyway. The grid's only job is to bound the candidate set.
629
+ */
630
+ gather(camX, camZ, radius, out) {
631
+ this.ensureGrid();
632
+ const start = this.cellStart;
633
+ const items = this.cellItems;
634
+ if (!start || !items) return 0;
635
+ const cap = out.length;
636
+ const r2 = radius * radius;
637
+ const cx = Math.floor((camX - this.minX) / this.cell);
638
+ const cz = Math.floor((camZ - this.minZ) / this.cell);
639
+ const maxRing = Math.ceil(radius / this.cell) + 1;
640
+ const p = this.iPosition;
641
+ let n = 0;
642
+ for (let ring = 0; ring <= maxRing && n < cap; ring++) {
643
+ const z0 = cz - ring, z1 = cz + ring;
644
+ const x0 = cx - ring, x1 = cx + ring;
645
+ for (let gz = z0; gz <= z1 && n < cap; gz++) {
646
+ if (gz < 0 || gz >= this.rows) continue;
647
+ const edge = gz === z0 || gz === z1;
648
+ for (let gx = x0; gx <= x1 && n < cap; gx++) {
649
+ if (!edge && gx !== x0 && gx !== x1) {
650
+ gx = x1 - 1;
651
+ continue;
652
+ }
653
+ if (gx < 0 || gx >= this.cols) continue;
654
+ const c = gz * this.cols + gx;
655
+ for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
656
+ const i = items[k];
657
+ const dx = p[i * 3] - camX;
658
+ const dz = p[i * 3 + 2] - camZ;
659
+ if (dx * dx + dz * dz <= r2) out[n++] = i;
660
+ }
661
+ }
662
+ }
663
+ }
664
+ return n;
665
+ }
666
+ };
667
+
436
668
  // src/view3d/scene/geometry.ts
437
669
  import earcut from "earcut";
438
670
  import polygonClipping from "polygon-clipping";
@@ -859,8 +1091,195 @@ function rectPolygon(x, y, w, h) {
859
1091
  ];
860
1092
  }
861
1093
 
1094
+ // src/view3d/scene/seatChair.ts
1095
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2, body: 3, head: 4 };
1096
+ var CHAIR_PART_OCCUPANT_MIN = CHAIR_PART.body;
1097
+ var CHAIR_PITCH_FRACTION = 0.44;
1098
+ var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1099
+ var CHAIR_HALF_WIDTH_MAX_M = 0.3;
1100
+ var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
1101
+ function chairHalfWidth(pitchM) {
1102
+ if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
1103
+ return CHAIR_HALF_WIDTH_DEFAULT_M;
1104
+ }
1105
+ return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
1106
+ }
1107
+ var BACK_RAKE_SLOPE = 0.21;
1108
+ var PAD_BACK_GAP_M = 0.05;
1109
+ var PAD_TOP_M = 0.45;
1110
+ var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1111
+ var BOXES = [
1112
+ // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1113
+ // over the deck and the row reads as hovering trays.
1114
+ { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
1115
+ // Seat pad — a full seat width across and about as deep, which is what a real
1116
+ // one is. Its depth is bounded by the same pitch as its width, because the
1117
+ // pitch measure is the tighter of the in-row and row-to-row spacings, so a
1118
+ // tightly-raked tier cannot drive a pad into the back of the row in front.
1119
+ { part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
1120
+ // Back panel — thin, raked, and the tallest thing in the row, so it is what
1121
+ // carries the state colour when you look along a row from behind.
1122
+ {
1123
+ part: CHAIR_PART.back,
1124
+ min: [-1, BACK_BASE_M, -1],
1125
+ max: [1, 0.92, -0.72]
1126
+ },
1127
+ // --- the occupant ---------------------------------------------------------
1128
+ //
1129
+ // A hall with every seat empty reads as an architectural model, not a venue.
1130
+ // The 2048-px panorama this replaced drew a crowd; losing it was the price of
1131
+ // sharpness, and this is how it is bought back — in geometry, where there is
1132
+ // no resolution ceiling.
1133
+ //
1134
+ // Deliberately two blocks and no limbs. At the range these are visible a
1135
+ // person is a torso and a head, and every extra part multiplies by the number
1136
+ // of occupied seats in view. The silhouette is what carries it, exactly as it
1137
+ // does in the generated panorama's head-and-shoulder figures.
1138
+ //
1139
+ // Sized as a seated adult against the 0.92 m chair back: hips at the pad top,
1140
+ // shoulders just above the back panel, head clear of it. Torso is narrower
1141
+ // than the chair so neighbours never interpenetrate at any pitch, and it sits
1142
+ // forward of the back panel rather than inside it.
1143
+ {
1144
+ part: CHAIR_PART.body,
1145
+ min: [-0.66, PAD_TOP_M, -0.58],
1146
+ max: [0.66, 1, 0.26]
1147
+ },
1148
+ // The head is deliberately SMALL. Sized by eye against the chair it came out
1149
+ // near-cubic and read as Lego; a real head is about 0.16 m across and 0.22 m
1150
+ // tall, which against a 0.24 m chair half-width is roughly a third of the
1151
+ // chair's width and clearly taller than it is wide. Getting this ratio wrong
1152
+ // is what makes a crowd look like toys rather than people.
1153
+ {
1154
+ part: CHAIR_PART.head,
1155
+ min: [-0.34, 1.03, -0.4],
1156
+ max: [0.34, 1.27, 0.02]
1157
+ }
1158
+ ];
1159
+ var FACES = [
1160
+ // +X
1161
+ { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
1162
+ // -X
1163
+ { n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
1164
+ // +Y
1165
+ { n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
1166
+ // -Y
1167
+ { n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
1168
+ // +Z
1169
+ { n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
1170
+ // -Z
1171
+ { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1172
+ ];
1173
+ function buildChairMesh() {
1174
+ const vertexCount = BOXES.length * FACES.length * 4;
1175
+ const indexCount = BOXES.length * FACES.length * 6;
1176
+ const position = new Float32Array(vertexCount * 3);
1177
+ const normal = new Float32Array(vertexCount * 3);
1178
+ const part = new Float32Array(vertexCount);
1179
+ const index = new Uint16Array(indexCount);
1180
+ let v = 0;
1181
+ let t = 0;
1182
+ for (const box of BOXES) {
1183
+ for (const face of FACES) {
1184
+ const base = v;
1185
+ const corner3 = new Float32Array(12);
1186
+ for (let ci = 0; ci < 4; ci++) {
1187
+ const corner = face.c[ci];
1188
+ for (let a = 0; a < 3; a++) {
1189
+ corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1190
+ }
1191
+ }
1192
+ const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1193
+ const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1194
+ let nx = ay * bz - az * by;
1195
+ let ny = az * bx - ax * bz;
1196
+ let nz = ax * by - ay * bx;
1197
+ const nl = Math.hypot(nx, ny, nz);
1198
+ if (nl > 1e-9) {
1199
+ nx /= nl;
1200
+ ny /= nl;
1201
+ nz /= nl;
1202
+ } else {
1203
+ nx = face.n[0];
1204
+ ny = face.n[1];
1205
+ nz = face.n[2];
1206
+ }
1207
+ if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1208
+ nx = -nx;
1209
+ ny = -ny;
1210
+ nz = -nz;
1211
+ }
1212
+ for (let ci = 0; ci < 4; ci++) {
1213
+ position[v * 3] = corner3[ci * 3];
1214
+ position[v * 3 + 1] = corner3[ci * 3 + 1];
1215
+ position[v * 3 + 2] = corner3[ci * 3 + 2];
1216
+ normal[v * 3] = nx;
1217
+ normal[v * 3 + 1] = ny;
1218
+ normal[v * 3 + 2] = nz;
1219
+ part[v] = box.part;
1220
+ v++;
1221
+ }
1222
+ index[t++] = base;
1223
+ index[t++] = base + 1;
1224
+ index[t++] = base + 2;
1225
+ index[t++] = base;
1226
+ index[t++] = base + 2;
1227
+ index[t++] = base + 3;
1228
+ }
1229
+ }
1230
+ return { position, normal, part, index, vertexCount, indexCount };
1231
+ }
1232
+ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1233
+ const yaw = new Float32Array(count);
1234
+ const px = (i) => iPosition[i * 3];
1235
+ const pz = (i) => iPosition[i * 3 + 2];
1236
+ let runStart = 0;
1237
+ const flushRun = (start, end) => {
1238
+ const n = end - start;
1239
+ for (let i = start; i < end; i++) {
1240
+ const [fx, fz] = focal(i);
1241
+ let dx = fx - px(i);
1242
+ let dz = fz - pz(i);
1243
+ if (n >= 2) {
1244
+ const a = Math.max(start, i - 1);
1245
+ const b = Math.min(end - 1, i + 1);
1246
+ const tx = px(b) - px(a);
1247
+ const tz = pz(b) - pz(a);
1248
+ const tl = Math.hypot(tx, tz);
1249
+ if (tl > 1e-6) {
1250
+ let nx = -tz / tl;
1251
+ let nz = tx / tl;
1252
+ if (nx * dx + nz * dz < 0) {
1253
+ nx = -nx;
1254
+ nz = -nz;
1255
+ }
1256
+ dx = nx;
1257
+ dz = nz;
1258
+ }
1259
+ }
1260
+ yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
1261
+ }
1262
+ };
1263
+ for (let i = 1; i <= count; i++) {
1264
+ if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
1265
+ flushRun(runStart, i);
1266
+ runStart = i;
1267
+ }
1268
+ }
1269
+ return yaw;
1270
+ }
1271
+
862
1272
  // src/view3d/scene/seatInstances.ts
863
1273
  var SEAT_DOT_RADIUS_M = 0.22;
1274
+ function seatOccupantSeed(stateIndex, seatIndex) {
1275
+ const state = SEAT_STATES[stateIndex];
1276
+ if (state !== "sold" && state !== "held") return -1;
1277
+ let h = (seatIndex + 1) * 2654435761;
1278
+ h ^= h >>> 15;
1279
+ h = Math.imul(h, 2246822519);
1280
+ h ^= h >>> 13;
1281
+ return (h >>> 0) % 1e5 / 1e5;
1282
+ }
864
1283
  var SEAT_PITCH_FRACTION = 0.42;
865
1284
  function nearestNeighbourSpacing(seats) {
866
1285
  const n = seats.length;
@@ -923,11 +1342,13 @@ function seatSurfaceY(seat) {
923
1342
  if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
924
1343
  return 0;
925
1344
  }
926
- function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1345
+ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor) {
927
1346
  const count = seats.length;
928
1347
  const iPosition = new Float32Array(count * 3);
929
1348
  const iState = new Float32Array(count);
1349
+ const iCategory = new Float32Array(count * 3);
930
1350
  const iMaxRadius = new Float32Array(count);
1351
+ const iChairWidth = new Float32Array(count);
931
1352
  const iRing = new Float32Array(count * 3);
932
1353
  const idToIndex = /* @__PURE__ */ new Map();
933
1354
  const spacing = nearestNeighbourSpacing(seats);
@@ -936,10 +1357,15 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
936
1357
  const resolved = surfaces?.seatPitchU(i);
937
1358
  const pitchM = (resolved ?? spacing[i]) * M;
938
1359
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1360
+ iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
939
1361
  iPosition[i * 3] = seat.x * M;
940
1362
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
941
1363
  iPosition[i * 3 + 2] = seat.y * M;
942
1364
  iState[i] = seatStateIndex(initial ? initial(seat) : "available");
1365
+ const cat = hexToRgb(categoryColor?.get(seat.categoryKey) ?? "#6e7bff") ?? [0.43, 0.48, 1];
1366
+ iCategory[i * 3] = cat[0];
1367
+ iCategory[i * 3 + 1] = cat[1];
1368
+ iCategory[i * 3 + 2] = cat[2];
943
1369
  if (seat.accessibility?.length) {
944
1370
  const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
945
1371
  if (rgb) {
@@ -954,10 +1380,13 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
954
1380
  count,
955
1381
  iPosition,
956
1382
  iState,
1383
+ iCategory,
957
1384
  iMaxRadius,
958
1385
  iRing,
959
1386
  idToIndex,
960
- iFloor: seatFloor ?? new Float32Array(count)
1387
+ iChairWidth,
1388
+ iFloor: seatFloor ?? new Float32Array(count),
1389
+ iYaw: new Float32Array(count)
961
1390
  };
962
1391
  }
963
1392
  var RUN_MERGE_GAP = 64;
@@ -1092,6 +1521,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
1092
1521
  }
1093
1522
  return kept;
1094
1523
  }
1524
+ var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
1525
+ function focusScore(screen, worldDistance, width, height) {
1526
+ const half = Math.max(1, Math.min(width, height) * 0.5);
1527
+ const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
1528
+ return worldDistance * (1 + 1.5 * off);
1529
+ }
1530
+ function pickDenseLabels(items, separationX, separationY, budget) {
1531
+ const ordered = [...items].sort((a, b) => a.focus - b.focus);
1532
+ const kept = [];
1533
+ for (const item of ordered) {
1534
+ if (kept.length >= budget) break;
1535
+ let clash = false;
1536
+ for (const k of kept) {
1537
+ if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
1538
+ clash = true;
1539
+ break;
1540
+ }
1541
+ }
1542
+ if (!clash) kept.push(item);
1543
+ }
1544
+ return kept;
1545
+ }
1095
1546
  function centroidOf(points) {
1096
1547
  if (!points.length) return null;
1097
1548
  let x = 0, y = 0;
@@ -1265,195 +1716,6 @@ function buildSectionRake(rows, focal) {
1265
1716
  return rowsRake(fits);
1266
1717
  }
1267
1718
 
1268
- // src/view3d/scene/surface.ts
1269
- var FLAT_SLAB_TOP_M = 0.05;
1270
- var CAP_MAX_ERROR_M = 0.05;
1271
- var SEAT_CLEARANCE_M = 0.15;
1272
- var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1273
- function bboxOf(pts) {
1274
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1275
- for (const p of pts) {
1276
- if (p.x < minX) minX = p.x;
1277
- if (p.y < minY) minY = p.y;
1278
- if (p.x > maxX) maxX = p.x;
1279
- if (p.y > maxY) maxY = p.y;
1280
- }
1281
- return { minX, minY, maxX, maxY };
1282
- }
1283
- var MAX_TIER_RISE_M = 25;
1284
- function buildVenueSurfaces(units, seats) {
1285
- const bySection = /* @__PURE__ */ new Map();
1286
- const seatOwner = new Array(seats.length).fill(null);
1287
- const seatDeck = new Float64Array(seats.length);
1288
- const seatRowLevel = new Array(seats.length).fill(void 0);
1289
- const seatPitch = new Array(seats.length).fill(void 0);
1290
- const acc = /* @__PURE__ */ new Map();
1291
- const boxes = [];
1292
- for (const unit of units) {
1293
- for (const o of unit.objects) {
1294
- if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1295
- const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
1296
- acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
1297
- boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
1298
- }
1299
- }
1300
- for (let i = 0; i < seats.length; i++) {
1301
- const s = seats[i];
1302
- for (const b of boxes) {
1303
- if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
1304
- if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
1305
- seatOwner[i] = b.id;
1306
- const a = acc.get(b.id);
1307
- a.hasSeats = true;
1308
- const f = s.focalPoint ?? b.unit.focal;
1309
- const d = Math.hypot(s.x - f.x, s.y - f.y);
1310
- if (d < a.frontU) a.frontU = d;
1311
- a.seatIndices.push(i);
1312
- const rowKey = s.rowId || `__seat-${i}`;
1313
- const arr = a.rows.get(rowKey);
1314
- if (arr) arr.push({ x: s.x, y: s.y });
1315
- else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
1316
- break;
1317
- }
1318
- }
1319
- for (const [id, a] of acc) {
1320
- const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
1321
- const bottomY = a.unit.baseHeightM;
1322
- const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
1323
- const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
1324
- const kind = a.section.surfaceKind;
1325
- const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
1326
- const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
1327
- const needsRake = structure.rows.length < 2;
1328
- const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
1329
- let frontU = Infinity;
1330
- if (rake) {
1331
- if (a.hasSeats) {
1332
- for (const pts of a.rows.values()) {
1333
- for (const p of pts) {
1334
- const d = rake.depthAt(p.x, p.y);
1335
- if (d < frontU) frontU = d;
1336
- }
1337
- }
1338
- }
1339
- if (!a.hasSeats || !Number.isFinite(frontU)) {
1340
- frontU = Infinity;
1341
- for (const p of a.section.outline) {
1342
- const d = rake.depthAt(p.x, p.y);
1343
- if (d < frontU) frontU = d;
1344
- }
1345
- }
1346
- }
1347
- const flatTop = bottomY + FLAT_SLAB_TOP_M;
1348
- const baseFloor = bottomY + FLAT_SLAB_TOP_M;
1349
- const levelFor = (depthU) => {
1350
- const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
1351
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1352
- return Math.max(baseFloor, geo.height + rise);
1353
- };
1354
- const levelForBlockDepth = (blockDepthU) => {
1355
- const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
1356
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1357
- return Math.max(baseFloor, geo.height + rise);
1358
- };
1359
- const rowLevels = flat ? [] : structure.rows.map((r) => ({
1360
- pts: r.pts,
1361
- y: levelForBlockDepth(r.blockDepth),
1362
- depth: r.blockDepth,
1363
- blockId: r.blockId
1364
- }));
1365
- const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
1366
- const rowBounds = rowLevels.map((r) => {
1367
- let cx = 0, cy = 0;
1368
- for (const p of r.pts) {
1369
- cx += p.x;
1370
- cy += p.y;
1371
- }
1372
- const n = r.pts.length || 1;
1373
- cx /= n;
1374
- cy /= n;
1375
- let rad = 0;
1376
- for (const p of r.pts) {
1377
- const d = Math.hypot(p.x - cx, p.y - cy);
1378
- if (d > rad) rad = d;
1379
- }
1380
- return { cx, cy, rad };
1381
- });
1382
- const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
1383
- let best = Infinity, bestY = landingY;
1384
- for (let i = 0; i < rowLevels.length; i++) {
1385
- const b = rowBounds[i];
1386
- if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
1387
- const d = distanceToPolyline(rowLevels[i].pts, x, y);
1388
- if (d < best) {
1389
- best = d;
1390
- bestY = rowLevels[i].y;
1391
- }
1392
- }
1393
- return bestY;
1394
- } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
1395
- const UP = [0, 1, 0];
1396
- const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
1397
- if (!rake) return UP;
1398
- const d = rake.depthAt(x, y);
1399
- if (d <= frontU) return UP;
1400
- const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
1401
- if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
1402
- if (geo.height + depthM * rakeTan <= baseFloor) return UP;
1403
- const [gx, gy] = rake.gradientAt(x, y);
1404
- if (gx === 0 && gy === 0) return UP;
1405
- const inv = 1 / Math.hypot(rakeTan, 1);
1406
- return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
1407
- };
1408
- if (!flat) {
1409
- for (const r of structure.rows) {
1410
- const y = levelForBlockDepth(r.blockDepth);
1411
- for (const si of r.seatIndices) seatRowLevel[si] = y;
1412
- }
1413
- }
1414
- for (const r of structure.rows) {
1415
- const gaps = [];
1416
- for (let k = 1; k < r.pts.length; k++) {
1417
- const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
1418
- if (d > 1e-6) gaps.push(d);
1419
- }
1420
- gaps.sort((x, y) => x - y);
1421
- const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
1422
- let across = Infinity;
1423
- const probe = r.pts[Math.floor(r.pts.length / 2)];
1424
- if (probe) {
1425
- for (const other of structure.rows) {
1426
- if (other === r || other.blockId !== r.blockId) continue;
1427
- const d = distanceToPolyline(other.pts, probe.x, probe.y);
1428
- if (d > 1e-6 && d < across) across = d;
1429
- }
1430
- }
1431
- const pitch = Math.min(along, Math.max(across, along * 0.5));
1432
- if (Number.isFinite(pitch) && pitch > 0) {
1433
- for (const si of r.seatIndices) seatPitch[si] = pitch;
1434
- }
1435
- }
1436
- bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1437
- }
1438
- for (let i = 0; i < seats.length; i++) {
1439
- const ownerId = seatOwner[i];
1440
- const s = seats[i];
1441
- if (ownerId) {
1442
- const own = seatRowLevel[i];
1443
- seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
1444
- continue;
1445
- }
1446
- const eye = s.eyeHeightM;
1447
- seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1448
- }
1449
- return {
1450
- bySection,
1451
- seatOwner,
1452
- seatDeckY: (i) => seatDeck[i],
1453
- seatPitchU: (i) => seatPitch[i]
1454
- };
1455
- }
1456
-
1457
1719
  // src/view3d/scene/deckBands.ts
1458
1720
  import polygonClipping2 from "polygon-clipping";
1459
1721
  import earcut2 from "earcut";
@@ -1502,6 +1764,68 @@ function extendEnds(pts, by) {
1502
1764
  });
1503
1765
  return out;
1504
1766
  }
1767
+ var CORNER_STEP_RAD = Math.PI / 12;
1768
+ function convexHull(pts) {
1769
+ if (pts.length < 3) return [...pts];
1770
+ const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
1771
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
1772
+ const half = (src) => {
1773
+ const out = [];
1774
+ for (const p of src) {
1775
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
1776
+ out.push(p);
1777
+ }
1778
+ out.pop();
1779
+ return out;
1780
+ };
1781
+ const hull = [...half(s), ...half([...s].reverse())];
1782
+ return hull.length >= 3 ? hull : [...pts];
1783
+ }
1784
+ function seatClusterPatch(pts, pad) {
1785
+ if (!pts.length || !(pad > 0)) return [];
1786
+ const hull = convexHull(pts);
1787
+ const arc = (v, from, to, out2) => {
1788
+ let sweep = to - from;
1789
+ while (sweep < 0) sweep += Math.PI * 2;
1790
+ while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
1791
+ const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
1792
+ const r = pad / Math.cos(sweep / steps / 2);
1793
+ for (let k = 0; k <= steps; k++) {
1794
+ const a = from + sweep * k / steps;
1795
+ out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
1796
+ }
1797
+ };
1798
+ if (hull.length < 3) {
1799
+ let cx = 0, cy = 0;
1800
+ for (const p of hull) {
1801
+ cx += p.x;
1802
+ cy += p.y;
1803
+ }
1804
+ cx /= hull.length || 1;
1805
+ cy /= hull.length || 1;
1806
+ let far = 0;
1807
+ for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
1808
+ const out2 = [];
1809
+ const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
1810
+ const r = (far + pad) / Math.cos(Math.PI / steps);
1811
+ for (let k = 0; k < steps; k++) {
1812
+ const a = k / steps * Math.PI * 2;
1813
+ out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
1814
+ }
1815
+ return out2;
1816
+ }
1817
+ const n = hull.length;
1818
+ const edgeAngle = [];
1819
+ for (let i = 0; i < n; i++) {
1820
+ const a = hull[i], b = hull[(i + 1) % n];
1821
+ edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
1822
+ }
1823
+ const out = [];
1824
+ for (let i = 0; i < n; i++) {
1825
+ arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
1826
+ }
1827
+ return dedupeAdjacent(out);
1828
+ }
1505
1829
  function distToPolyline(pts, x, y) {
1506
1830
  if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1507
1831
  let best = Infinity;
@@ -1632,7 +1956,14 @@ function deckFootprints(rows, focal, shared) {
1632
1956
  else byBlock.set(rows[i].blockId, [i]);
1633
1957
  }
1634
1958
  const out = [];
1635
- for (const [, indices] of byBlock) {
1959
+ for (const [, allIndices] of byBlock) {
1960
+ const indices = [];
1961
+ for (const i of allIndices) {
1962
+ const patch = rows[i].patch;
1963
+ if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
1964
+ else indices.push(i);
1965
+ }
1966
+ if (!indices.length) continue;
1636
1967
  indices.sort((a, b) => rows[a].depth - rows[b].depth);
1637
1968
  const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
1638
1969
  const usable = ribbons.filter((r) => r !== null);
@@ -1760,15 +2091,18 @@ var ClipTest = class {
1760
2091
  return false;
1761
2092
  }
1762
2093
  };
1763
- function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
1764
- if (clipTest.containsAll(quad)) {
2094
+ function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
2095
+ if (poly.length < 3) return;
2096
+ if (clipTest.containsAll(poly)) {
1765
2097
  const UPF = [0, 1, 0];
1766
- const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
1767
- 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);
1768
- 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);
2098
+ const a = poly[0];
2099
+ for (let i = 1; i + 1 < poly.length; i++) {
2100
+ const b = poly[i], c = poly[i + 1];
2101
+ 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);
2102
+ }
1769
2103
  return;
1770
2104
  }
1771
- const ring = quad.map((p) => [p.x, p.y]);
2105
+ const ring = poly.map((p) => [p.x, p.y]);
1772
2106
  ring.push(ring[0]);
1773
2107
  let pieces;
1774
2108
  try {
@@ -1777,9 +2111,9 @@ function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
1777
2111
  return;
1778
2112
  }
1779
2113
  const UP = [0, 1, 0];
1780
- for (const poly of pieces) {
1781
- if (!poly.length || poly[0].length < 4) continue;
1782
- const outer = poly[0];
2114
+ for (const poly2 of pieces) {
2115
+ if (!poly2.length || poly2[0].length < 4) continue;
2116
+ const outer = poly2[0];
1783
2117
  const flat = [];
1784
2118
  const pts = [];
1785
2119
  for (let i = 0; i < outer.length - 1; i++) {
@@ -1808,6 +2142,46 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
1808
2142
  const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
1809
2143
  for (let i = 0; i < rows.length; i++) {
1810
2144
  const row = rows[i];
2145
+ if (row.patch && row.patch.length >= 3) {
2146
+ const patch = [...row.patch];
2147
+ const belowY2 = nbrs[i].belowY ?? landingY;
2148
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2149
+ else {
2150
+ const a = patch[0];
2151
+ for (let k = 1; k + 1 < patch.length; k++) {
2152
+ const b = patch[k], c = patch[k + 1];
2153
+ 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);
2154
+ }
2155
+ }
2156
+ if (row.y > belowY2 + MIN_RISER_M) {
2157
+ let cx = 0, cy = 0;
2158
+ for (const p of patch) {
2159
+ cx += p.x;
2160
+ cy += p.y;
2161
+ }
2162
+ cx /= patch.length;
2163
+ cy /= patch.length;
2164
+ for (let k = 0; k < patch.length; k++) {
2165
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2166
+ const dx = q.x - p.x, dy = q.y - p.y;
2167
+ const len = Math.hypot(dx, dy);
2168
+ if (len < 1e-6) continue;
2169
+ let nx = dy / len, ny = -dx / len;
2170
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2171
+ nx = -nx;
2172
+ ny = -ny;
2173
+ }
2174
+ const rn = [nx, 0, ny];
2175
+ const pt = [p.x * M, row.y, p.y * M];
2176
+ const qt = [q.x * M, row.y, q.y * M];
2177
+ const pb = [p.x * M, belowY2, p.y * M];
2178
+ const qb = [q.x * M, belowY2, q.y * M];
2179
+ builder.tri(pt, qt, qb, rn, colors.riser);
2180
+ builder.tri(pt, qb, pb, rn, colors.riser);
2181
+ }
2182
+ }
2183
+ continue;
2184
+ }
1811
2185
  const rib = ribbonOf(rows, i, nbrs, focal);
1812
2186
  if (!rib) continue;
1813
2187
  const { pts, nrm, front, back } = rib;
@@ -1822,7 +2196,7 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
1822
2196
  const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
1823
2197
  const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
1824
2198
  if (clipRing && clipTest) {
1825
- emitClippedQuad(builder, clipRing, clipTest, [
2199
+ emitClippedPoly(builder, clipRing, clipTest, [
1826
2200
  { x: p.x - np[0] * front, y: p.y - np[1] * front },
1827
2201
  { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
1828
2202
  { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
@@ -1843,6 +2217,208 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
1843
2217
  }
1844
2218
  }
1845
2219
 
2220
+ // src/view3d/scene/surface.ts
2221
+ var FLAT_SLAB_TOP_M = 0.05;
2222
+ var CAP_MAX_ERROR_M = 0.05;
2223
+ var SEAT_CLEARANCE_M = 0.15;
2224
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
2225
+ function bboxOf(pts) {
2226
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2227
+ for (const p of pts) {
2228
+ if (p.x < minX) minX = p.x;
2229
+ if (p.y < minY) minY = p.y;
2230
+ if (p.x > maxX) maxX = p.x;
2231
+ if (p.y > maxY) maxY = p.y;
2232
+ }
2233
+ return { minX, minY, maxX, maxY };
2234
+ }
2235
+ function pointInRing2(ring, x, y) {
2236
+ let inside = false;
2237
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2238
+ const a = ring[i], b = ring[j];
2239
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
2240
+ }
2241
+ return inside;
2242
+ }
2243
+ var MAX_TIER_RISE_M = 25;
2244
+ function buildVenueSurfaces(units, seats) {
2245
+ const bySection = /* @__PURE__ */ new Map();
2246
+ const seatOwner = new Array(seats.length).fill(null);
2247
+ const seatDeck = new Float64Array(seats.length);
2248
+ const seatRowLevel = new Array(seats.length).fill(void 0);
2249
+ const seatPitch = new Array(seats.length).fill(void 0);
2250
+ const acc = /* @__PURE__ */ new Map();
2251
+ const boxes = [];
2252
+ const tableRowIds = /* @__PURE__ */ new Set();
2253
+ for (const unit of units) {
2254
+ for (const o of unit.objects) {
2255
+ if (o.type === "table") tableRowIds.add(o.id);
2256
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2257
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2258
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2259
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2260
+ }
2261
+ }
2262
+ for (let i = 0; i < seats.length; i++) {
2263
+ const s = seats[i];
2264
+ for (const b of boxes) {
2265
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2266
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2267
+ seatOwner[i] = b.id;
2268
+ const a = acc.get(b.id);
2269
+ a.hasSeats = true;
2270
+ const f = s.focalPoint ?? b.unit.focal;
2271
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
2272
+ if (d < a.frontU) a.frontU = d;
2273
+ a.seatIndices.push(i);
2274
+ const rowKey = s.rowId || `__seat-${i}`;
2275
+ const arr = a.rows.get(rowKey);
2276
+ if (arr) arr.push({ x: s.x, y: s.y });
2277
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
2278
+ break;
2279
+ }
2280
+ }
2281
+ for (const [id, a] of acc) {
2282
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
2283
+ const bottomY = a.unit.baseHeightM;
2284
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
2285
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
2286
+ const kind = a.section.surfaceKind;
2287
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
2288
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
2289
+ const needsRake = structure.rows.length < 2;
2290
+ const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
2291
+ let frontU = Infinity;
2292
+ if (rake) {
2293
+ if (a.hasSeats) {
2294
+ for (const pts of a.rows.values()) {
2295
+ for (const p of pts) {
2296
+ const d = rake.depthAt(p.x, p.y);
2297
+ if (d < frontU) frontU = d;
2298
+ }
2299
+ }
2300
+ }
2301
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
2302
+ frontU = Infinity;
2303
+ for (const p of a.section.outline) {
2304
+ const d = rake.depthAt(p.x, p.y);
2305
+ if (d < frontU) frontU = d;
2306
+ }
2307
+ }
2308
+ }
2309
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
2310
+ const flatTop = Math.max(baseFloor, geo.height);
2311
+ const levelFor = (depthU) => {
2312
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
2313
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2314
+ return Math.max(baseFloor, geo.height + rise);
2315
+ };
2316
+ const levelForBlockDepth = (blockDepthU) => {
2317
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
2318
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2319
+ return Math.max(baseFloor, geo.height + rise);
2320
+ };
2321
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
2322
+ pts: r.pts,
2323
+ y: levelForBlockDepth(r.blockDepth),
2324
+ depth: r.blockDepth,
2325
+ blockId: r.blockId,
2326
+ ...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
2327
+ }));
2328
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
2329
+ const rowBounds = rowLevels.map((r) => {
2330
+ const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
2331
+ let cx = 0, cy = 0;
2332
+ for (const p of ext) {
2333
+ cx += p.x;
2334
+ cy += p.y;
2335
+ }
2336
+ const n = ext.length || 1;
2337
+ cx /= n;
2338
+ cy /= n;
2339
+ let rad = 0;
2340
+ for (const p of ext) {
2341
+ const d = Math.hypot(p.x - cx, p.y - cy);
2342
+ if (d > rad) rad = d;
2343
+ }
2344
+ return { cx, cy, rad };
2345
+ });
2346
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
2347
+ let best = Infinity, bestY = landingY;
2348
+ for (let i = 0; i < rowLevels.length; i++) {
2349
+ const b = rowBounds[i];
2350
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
2351
+ const patch = rowLevels[i].patch;
2352
+ const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
2353
+ if (d < best) {
2354
+ best = d;
2355
+ bestY = rowLevels[i].y;
2356
+ }
2357
+ }
2358
+ return bestY;
2359
+ } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
2360
+ const UP = [0, 1, 0];
2361
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
2362
+ if (!rake) return UP;
2363
+ const d = rake.depthAt(x, y);
2364
+ if (d <= frontU) return UP;
2365
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
2366
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
2367
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
2368
+ const [gx, gy] = rake.gradientAt(x, y);
2369
+ if (gx === 0 && gy === 0) return UP;
2370
+ const inv = 1 / Math.hypot(rakeTan, 1);
2371
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
2372
+ };
2373
+ if (!flat) {
2374
+ for (const r of structure.rows) {
2375
+ const y = levelForBlockDepth(r.blockDepth);
2376
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
2377
+ }
2378
+ }
2379
+ for (const r of structure.rows) {
2380
+ const gaps = [];
2381
+ for (let k = 1; k < r.pts.length; k++) {
2382
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
2383
+ if (d > 1e-6) gaps.push(d);
2384
+ }
2385
+ gaps.sort((x, y) => x - y);
2386
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
2387
+ let across = Infinity;
2388
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
2389
+ if (probe) {
2390
+ for (const other of structure.rows) {
2391
+ if (other === r || other.blockId !== r.blockId) continue;
2392
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
2393
+ if (d > 1e-6 && d < across) across = d;
2394
+ }
2395
+ }
2396
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
2397
+ if (Number.isFinite(pitch) && pitch > 0) {
2398
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
2399
+ }
2400
+ }
2401
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
2402
+ }
2403
+ for (let i = 0; i < seats.length; i++) {
2404
+ const ownerId = seatOwner[i];
2405
+ const s = seats[i];
2406
+ if (ownerId) {
2407
+ const own = seatRowLevel[i];
2408
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
2409
+ continue;
2410
+ }
2411
+ const eye = s.eyeHeightM;
2412
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
2413
+ }
2414
+ return {
2415
+ bySection,
2416
+ seatOwner,
2417
+ seatDeckY: (i) => seatDeck[i],
2418
+ seatPitchU: (i) => seatPitch[i]
2419
+ };
2420
+ }
2421
+
1846
2422
  // src/view3d/scene/sceneModel.ts
1847
2423
  function floorUnits(doc) {
1848
2424
  if (doc.floors?.length) {
@@ -2008,22 +2584,39 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2008
2584
  }
2009
2585
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
2010
2586
  const topN = (p) => surface.normalAt(p.x, p.y);
2011
- for (const ring of claimed.subtract(outline)) {
2587
+ const level = surface.flat ? surface.landingY : void 0;
2588
+ for (const ring of claimed.subtract(outline, level)) {
2012
2589
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
2013
2590
  }
2014
2591
  }
2592
+ function coplanarLevels(a, b) {
2593
+ if (a === void 0 || b === void 0) return true;
2594
+ return Math.abs(a - b) < 1e-3;
2595
+ }
2015
2596
  var ClaimedArea = class {
2016
2597
  constructor() {
2017
2598
  this.rings = [];
2018
2599
  this.boxes = [];
2600
+ /** Constant deck height of each claim, or undefined when it is not level. */
2601
+ this.levels = [];
2019
2602
  }
2020
- /** `ring` minus everything claimed so far; then claim what is returned. */
2021
- subtract(ring) {
2603
+ /**
2604
+ * `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
2605
+ *
2606
+ * `level` is the claim's constant deck height when it has one. Two decks only
2607
+ * z-fight when they are coplanar, so only a coplanar claim may take ground
2608
+ * away — an elevated box hanging over a ground-level section overlaps it in
2609
+ * plan and must still draw its whole floor, or the box loses the part of its
2610
+ * deck that shares a footprint with whatever is underneath it. Passing
2611
+ * undefined (a surface with no single height) keeps the original
2612
+ * clip-against-everything behaviour.
2613
+ */
2614
+ subtract(ring, level) {
2022
2615
  const closed = ring.map((p) => [p.x, p.y]);
2023
2616
  if (closed.length < 3) return [];
2024
2617
  closed.push(closed[0]);
2025
2618
  const box = bboxOfRing(ring);
2026
- const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
2619
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
2027
2620
  let pieces = [[closed]];
2028
2621
  if (overlapping.length) {
2029
2622
  try {
@@ -2035,6 +2628,7 @@ var ClaimedArea = class {
2035
2628
  }
2036
2629
  this.rings.push([closed]);
2037
2630
  this.boxes.push(box);
2631
+ this.levels.push(level);
2038
2632
  const out = [];
2039
2633
  for (const poly of pieces) {
2040
2634
  if (!poly.length) continue;
@@ -2127,11 +2721,27 @@ function shapePolygon(shape) {
2127
2721
  }
2128
2722
  return null;
2129
2723
  }
2130
- function buildShape(builder, shape, base, S) {
2724
+ function buildShape(builder, shape, base, S, stages) {
2131
2725
  const poly = shapePolygon(shape);
2132
2726
  if (!poly) return;
2133
2727
  const isStage = shape.role === "stage";
2134
2728
  const height = isStage ? base + 1 : base + 0.25;
2729
+ if (isStage && stages) {
2730
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
2731
+ for (const pt of poly) {
2732
+ if (pt.x < minX) minX = pt.x;
2733
+ if (pt.x > maxX) maxX = pt.x;
2734
+ if (pt.y < minZ) minZ = pt.y;
2735
+ if (pt.y > maxZ) maxZ = pt.y;
2736
+ }
2737
+ stages.push({
2738
+ cx: (minX + maxX) / 2 * M,
2739
+ cz: (minZ + maxZ) / 2 * M,
2740
+ y: height,
2741
+ halfX: Math.max((maxX - minX) / 2 * M, 0.5),
2742
+ halfZ: Math.max((maxZ - minZ) / 2 * M, 0.5)
2743
+ });
2744
+ }
2135
2745
  const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
2136
2746
  const colWall = isStage ? S.stageWall : S.decorWall;
2137
2747
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
@@ -2241,6 +2851,7 @@ function buildSceneModel(input) {
2241
2851
  const sectionFills = resolveSectionFills(doc, seats);
2242
2852
  const catColor = /* @__PURE__ */ new Map();
2243
2853
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
2854
+ const stageBounds = [];
2244
2855
  const surfaces = buildVenueSurfaces(units, seats);
2245
2856
  const seatFloor = new Float32Array(seats.length);
2246
2857
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
@@ -2250,7 +2861,7 @@ function buildSceneModel(input) {
2250
2861
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
2251
2862
  for (const o of unit.objects) {
2252
2863
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2253
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
2864
+ else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S, stageBounds);
2254
2865
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2255
2866
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2256
2867
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
@@ -2259,7 +2870,7 @@ function buildSceneModel(input) {
2259
2870
  }
2260
2871
  const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
2261
2872
  const solids = mergeMeshData([builder.build()]);
2262
- const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
2873
+ const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor, catColor);
2263
2874
  const zoneDefs = doc.zones ?? [];
2264
2875
  const zones = [];
2265
2876
  if (zoneDefs.length) {
@@ -2490,6 +3101,15 @@ function buildSceneModel(input) {
2490
3101
  });
2491
3102
  }
2492
3103
  }
3104
+ seatData.iYaw = computeSeatYaw(
3105
+ seatData.iPosition,
3106
+ seats.length,
3107
+ (i) => seats[i].rowId,
3108
+ (i) => {
3109
+ const f = units[seatFloor[i]]?.focal ?? focal;
3110
+ return [f.x * M, f.y * M];
3111
+ }
3112
+ );
2493
3113
  const cx = (fp.minX + fp.maxX) / 2 * M;
2494
3114
  const cz = (fp.minY + fp.maxY) / 2 * M;
2495
3115
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
@@ -2503,6 +3123,7 @@ function buildSceneModel(input) {
2503
3123
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
2504
3124
  focalWorld: [focal.x * M, 1.5, focal.y * M],
2505
3125
  zones,
3126
+ stages: stageBounds,
2506
3127
  sections,
2507
3128
  labels,
2508
3129
  floors
@@ -2527,7 +3148,11 @@ var KIND_STYLE = {
2527
3148
  var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
2528
3149
  var DENSE_SEPARATION = {
2529
3150
  row: { x: 62, y: 16 },
2530
- seat: { x: 24, y: 13 }
3151
+ // Widened from 24: at close range seat labels stopped overlapping at all, so
3152
+ // the separation had no work left to do and the budget was carrying the whole
3153
+ // load. A wider box means the few labels that ARE kept are spread across the
3154
+ // seating instead of clustering into one stack.
3155
+ seat: { x: 46, y: 18 }
2531
3156
  };
2532
3157
  var LabelOverlay = class {
2533
3158
  constructor(container, opts = {}) {
@@ -2544,6 +3169,20 @@ var LabelOverlay = class {
2544
3169
  if (opts.fontFamily) s.fontFamily = opts.fontFamily;
2545
3170
  container.appendChild(this.root);
2546
3171
  }
3172
+ /**
3173
+ * Hide or show the whole overlay without disturbing which labels exist.
3174
+ *
3175
+ * The in-scene panorama sphere is GL, so it draws BEHIND every DOM label —
3176
+ * row and seat labels floated on top of a 360 photo until this existed. The
3177
+ * old DOM panorama never needed it because its opaque div covered them.
3178
+ *
3179
+ * Deliberately not `setLabels([])`: that destroys the nodes and the declutter
3180
+ * state, so closing the panorama would rebuild and re-rank the whole overlay
3181
+ * and flash. Visibility is a view concern, not a data one.
3182
+ */
3183
+ setVisible(visible) {
3184
+ this.root.style.visibility = visible ? "" : "hidden";
3185
+ }
2547
3186
  setLabels(labels) {
2548
3187
  this.labels = labels;
2549
3188
  for (const [id, node] of this.nodes) {
@@ -2558,7 +3197,7 @@ var LabelOverlay = class {
2558
3197
  *
2559
3198
  * `viewProjection` is column-major, as OGL supplies it.
2560
3199
  */
2561
- update(viewProjection, width, height, cameraDistance, venueRadius) {
3200
+ update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
2562
3201
  if (!this.labels.length) return;
2563
3202
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
2564
3203
  const candidates = [];
@@ -2566,15 +3205,24 @@ var LabelOverlay = class {
2566
3205
  if (!kinds.has(label.kind)) continue;
2567
3206
  const screen = projectToScreen(viewProjection, label.anchor, width, height);
2568
3207
  if (!screen.visible) continue;
2569
- candidates.push({ label, screen });
3208
+ const world = cameraWorld ? Math.hypot(
3209
+ label.anchor[0] - cameraWorld[0],
3210
+ label.anchor[1] - cameraWorld[1],
3211
+ label.anchor[2] - cameraWorld[2]
3212
+ ) : 1;
3213
+ candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
2570
3214
  }
2571
3215
  const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
2572
3216
  const kept = [
2573
3217
  ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
2574
- ...["row", "seat"].flatMap((kind) => cullOverlapping(
3218
+ // The dense rungs are BUDGETED, not merely deduplicated — see
3219
+ // DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
3220
+ // the camera gets.
3221
+ ...["row", "seat"].flatMap((kind) => pickDenseLabels(
2575
3222
  candidates.filter((c) => c.label.kind === kind),
2576
3223
  DENSE_SEPARATION[kind].x,
2577
- DENSE_SEPARATION[kind].y
3224
+ DENSE_SEPARATION[kind].y,
3225
+ DENSE_LABEL_BUDGET[kind]
2578
3226
  ))
2579
3227
  ];
2580
3228
  const keptIds = new Set(kept.map((k) => k.label.id));
@@ -2623,6 +3271,13 @@ import { Geometry, Mesh, Transform } from "ogl";
2623
3271
 
2624
3272
  // src/view3d/scene/materials.ts
2625
3273
  import { Program } from "ogl";
3274
+ var CHAIR_WEIGHT_GLSL = (
3275
+ /* glsl */
3276
+ `
3277
+ float chairWeight(float depth) {
3278
+ return 1.0 - smoothstep(uChairFull, uChairNone, depth);
3279
+ }`
3280
+ );
2626
3281
  var SOLID_VERT = (
2627
3282
  /* glsl */
2628
3283
  `#version 300 es
@@ -2702,14 +3357,23 @@ uniform float uSeatScale;
2702
3357
  uniform float uMinPixels;
2703
3358
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
2704
3359
  uniform float uFocusFloor; // -1 = show every floor
3360
+ uniform float uChairFull; // view depth at which the chair mesh is full size
3361
+ uniform float uChairNone; // ...and at which it has scaled away entirely
2705
3362
  out vec2 vUv;
2706
3363
  out vec3 vColor;
2707
3364
  out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
2708
3365
  out vec3 vRing;
2709
3366
  out float vDim;
3367
+ out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3368
+ ${CHAIR_WEIGHT_GLSL}
2710
3369
  void main() {
2711
3370
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
2712
3371
  float depth = max(-mv.z, 0.001);
3372
+ // Hand the seat over to the chair mesh as it comes into range. Derived from
3373
+ // this instance's OWN depth rather than from a global uniform, so a row two
3374
+ // metres away and the far side of the bowl resolve differently in the same
3375
+ // frame \u2014 which is the entire point of a ladder over a switch.
3376
+ vDotWeight = 1.0 - chairWeight(depth);
2713
3377
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
2714
3378
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
2715
3379
  // unbounded growth is what merges neighbouring rows into one mass at range.
@@ -2743,6 +3407,7 @@ in vec3 vColor;
2743
3407
  in float vBudget;
2744
3408
  in vec3 vRing;
2745
3409
  in float vDim;
3410
+ in float vDotWeight;
2746
3411
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
2747
3412
  uniform vec3 uFadeColor;
2748
3413
  out vec4 fragColor;
@@ -2767,9 +3432,173 @@ void main() {
2767
3432
  // Seats on an unfocused floor recede with their structure.
2768
3433
  c = mix(c, uFadeColor, vDim * 0.75);
2769
3434
  alpha *= mix(1.0, 0.30, vDim);
3435
+ // Yield to the chair. The chair grows out of this exact point, so through the
3436
+ // band the dot is always at least as big as the chair inside it and the seat
3437
+ // never thins out to nothing in between.
3438
+ alpha *= vDotWeight;
3439
+ if (alpha <= 0.0) discard;
2770
3440
  fragColor = vec4(c, alpha);
2771
3441
  }`
2772
3442
  );
3443
+ var CHAIR_VERT = (
3444
+ /* glsl */
3445
+ `#version 300 es
3446
+ precision highp float;
3447
+ in vec3 position; // local: x/z in units of the seat radius, y in METRES
3448
+ in vec3 normal;
3449
+ in float part; // 0 = pedestal, 1 = pad, 2 = back, 3 = body, 4 = head
3450
+ in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3451
+ in vec3 iColor; // per-instance state colour
3452
+ in float iRadius; // per-instance horizontal half-width, world metres
3453
+ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3454
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3455
+ in float iFloor;
3456
+ in float iSeed; // <0 = seat is empty; else per-person hash in [0,1)
3457
+ uniform mat4 modelViewMatrix;
3458
+ uniform mat4 projectionMatrix;
3459
+ uniform float uChairFull;
3460
+ uniform float uChairNone;
3461
+ uniform float uFocusFloor;
3462
+ uniform float uBackRake; // metres of z per metre of rise, above uBackBase
3463
+ uniform float uBackBase; // local height at which the back starts
3464
+ out vec3 vColor;
3465
+ out vec3 vNormalWorld;
3466
+ out vec3 vNormalView;
3467
+ out vec3 vPosView;
3468
+ out float vPart;
3469
+ out float vHeight; // local height in metres, for the vertical occlusion ramp
3470
+ out vec3 vRing;
3471
+ out float vDim;
3472
+ out float vOccupant; // 1 = this vertex belongs to a person, not to the chair
3473
+ out vec3 vOccupantTint;
3474
+ ${CHAIR_WEIGHT_GLSL}
3475
+ void main() {
3476
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
3477
+ float w = chairWeight(max(-anchor.z, 0.001));
3478
+ // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
3479
+ // chairs, but nobody gets a short one (people are the same height at every
3480
+ // seat pitch).
3481
+ vec3 p = position;
3482
+ // The occupant. Parts 3 and 4 are a person; everything below is furniture.
3483
+ //
3484
+ // One instanced mesh draws both an empty seat and a taken one, because the
3485
+ // alternative \u2014 a second geometry and a second draw call gathered per frame \u2014
3486
+ // would double the near-field cost to show what is already known per instance.
3487
+ // An unoccupied seat collapses its person to a point at the seat, which the
3488
+ // rasteriser discards, exactly as the chair itself collapses at w=0.
3489
+ float occupant = step(2.5, part);
3490
+ float taken = step(0.0, iSeed);
3491
+ vOccupant = occupant;
3492
+ if (occupant > 0.5) {
3493
+ if (taken < 0.5) {
3494
+ p = vec3(0.0); // empty seat: no person
3495
+ } else {
3496
+ // Vary the build so a sold-out row is people rather than a rank of
3497
+ // identical mannequins: +/-6% height and +/-8% width off the hash.
3498
+ p.y *= 0.94 + 0.12 * iSeed;
3499
+ p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
3500
+ }
3501
+ }
3502
+ p.xz *= iRadius;
3503
+ // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3504
+ // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3505
+ // width and the same chair would lean 20 degrees on a wide stadium row and
3506
+ // 6 on a tight theatre one.
3507
+ // Only the BACK panel rakes. The old test (part > 1.5) meant "the back", and
3508
+ // now also catches the occupant \u2014 leaning a person by the panel's rule would
3509
+ // translate their head backwards by a rake measured from the panel's base.
3510
+ float rake = (part > 1.5 && part < 2.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3511
+ p.z -= rake;
3512
+ // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3513
+ // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
3514
+ // the growth so the chair is already near full size while the dot is still
3515
+ // half there; see chairScale() in lod.ts.
3516
+ p *= sqrt(w);
3517
+ float c = cos(iYaw), s = sin(iYaw);
3518
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
3519
+ // Normals under the same two transforms, in reverse and inverted-transposed.
3520
+ // The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
3521
+ // unchanged; and the rake is a shear, whose normal transform adds a y term.
3522
+ // Skipping either lights the raked back as though it were still vertical.
3523
+ vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3524
+ if (part > 1.5 && part < 2.5) n.y += uBackRake * n.z;
3525
+ n = normalize(n);
3526
+ vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
3527
+ vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
3528
+ vPosView = mv.xyz;
3529
+ vNormalWorld = rn;
3530
+ vNormalView = normalize(mat3(modelViewMatrix) * rn);
3531
+ vColor = iColor;
3532
+ // Occupant colour, resolved here so the fragment stage needs no extra
3533
+ // varyings beyond this one. A person is NOT painted in the seat's state
3534
+ // colour: a sold seat is red, and a hall of red people reads as a warning,
3535
+ // not an audience. Hair/clothing for the body, a warm tone for the head,
3536
+ // both varied by the same per-person hash.
3537
+ float t = fract(iSeed * 3.71);
3538
+ vec3 clothes = mix(vec3(0.13, 0.15, 0.20), vec3(0.34, 0.30, 0.36), t);
3539
+ vec3 skin = mix(vec3(0.52, 0.38, 0.29), vec3(0.86, 0.70, 0.58), fract(iSeed * 11.3));
3540
+ vOccupantTint = (part > 3.5) ? skin : clothes;
3541
+ vPart = part;
3542
+ vHeight = position.y;
3543
+ vRing = iRing;
3544
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3545
+ gl_Position = projectionMatrix * mv;
3546
+ }`
3547
+ );
3548
+ var CHAIR_FRAG = (
3549
+ /* glsl */
3550
+ `#version 300 es
3551
+ precision highp float;
3552
+ in vec3 vColor;
3553
+ in vec3 vNormalWorld;
3554
+ in vec3 vNormalView;
3555
+ in vec3 vPosView;
3556
+ in float vPart;
3557
+ in float vHeight;
3558
+ in vec3 vRing;
3559
+ in float vDim;
3560
+ in float vOccupant;
3561
+ in vec3 vOccupantTint;
3562
+ uniform vec3 uKeyDir;
3563
+ uniform vec3 uFadeColor;
3564
+ out vec4 fragColor;
3565
+ void main() {
3566
+ vec3 N = normalize(vNormalWorld);
3567
+ vec3 V = normalize(-vPosView);
3568
+ // The solids' rig, unchanged, so the chairs sit in the venue's light.
3569
+ float hemi = 0.5 + 0.5 * N.y;
3570
+ float key = max(dot(N, uKeyDir), 0.0);
3571
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
3572
+ float fill = max(dot(N, fillDir), 0.0);
3573
+ vec3 tint = vColor;
3574
+ if (vOccupant > 0.5) tint = vOccupantTint;
3575
+ // The accommodation ring, kept legible once the dot (which drew it) is gone:
3576
+ // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
3577
+ // survives to close range instead of vanishing exactly when the buyer arrives.
3578
+ float ringMask = step(0.001, dot(vRing, vRing));
3579
+ if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
3580
+ // Pad brightest, back a step below it, pedestal darkest. Three untextured
3581
+ // boxes only read as one object if they are separated tonally \u2014 with a single
3582
+ // flat colour the chair silhouettes as a crate.
3583
+ // A person is lit as a person, not as upholstery: no part shading, and less
3584
+ // of the pad's sheen, so a head does not read as a polished box.
3585
+ float partShade = vOccupant > 0.5 ? 0.95 : (vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80));
3586
+ // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
3587
+ // sits to the deck the less sky it can actually see. This is the depth cue \u2014
3588
+ // without it the pad top, the back and the deck all resolve to the same flat
3589
+ // value and the row loses its form entirely.
3590
+ // Occupants rise above the 0.92 m chair back, so their ramp uses their own
3591
+ // height or every head would clamp to full brightness and float.
3592
+ float ao = mix(0.58, 1.0, clamp(vHeight / (vOccupant > 0.5 ? 1.30 : 0.92), 0.0, 1.0));
3593
+ vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
3594
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3595
+ // A brighter rim than the solids get: it picks out every chair's own edge,
3596
+ // which is what stops a block of them merging into one mass up close.
3597
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
3598
+ base = mix(base, uFadeColor, vDim * 0.75);
3599
+ fragColor = vec4(base, 1.0);
3600
+ }`
3601
+ );
2773
3602
  var BG_VERT = (
2774
3603
  /* glsl */
2775
3604
  `#version 300 es
@@ -2866,7 +3695,7 @@ function createSeatPickProgram(gl) {
2866
3695
  uniforms: {
2867
3696
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
2868
3697
  uSeatScale: { value: 1 },
2869
- uMinPixels: { value: 2.5 },
3698
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
2870
3699
  uPixelToWorld: { value: 2e-3 }
2871
3700
  }
2872
3701
  });
@@ -2911,11 +3740,32 @@ function createSeatProgram(gl) {
2911
3740
  uniforms: {
2912
3741
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
2913
3742
  uSeatScale: { value: 1 },
2914
- uMinPixels: { value: 2.5 },
3743
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
2915
3744
  uPixelToWorld: { value: 2e-3 },
2916
3745
  uSeatFade: { value: 0 },
2917
3746
  uFocusFloor: { value: -1 },
2918
- uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
3747
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
3748
+ uChairFull: { value: CHAIR_FULL_M },
3749
+ uChairNone: { value: CHAIR_NONE_M }
3750
+ }
3751
+ });
3752
+ }
3753
+ function createChairProgram(gl) {
3754
+ return new Program(gl, {
3755
+ vertex: CHAIR_VERT,
3756
+ fragment: CHAIR_FRAG,
3757
+ transparent: false,
3758
+ depthTest: true,
3759
+ depthWrite: true,
3760
+ cullFace: false,
3761
+ uniforms: {
3762
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
3763
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
3764
+ uChairFull: { value: CHAIR_FULL_M },
3765
+ uChairNone: { value: CHAIR_NONE_M },
3766
+ uBackRake: { value: BACK_RAKE_SLOPE },
3767
+ uBackBase: { value: BACK_BASE_M },
3768
+ uFocusFloor: { value: -1 }
2919
3769
  }
2920
3770
  });
2921
3771
  }
@@ -2934,12 +3784,13 @@ function createBackgroundProgram(gl, top, bottom) {
2934
3784
  }
2935
3785
 
2936
3786
  // src/view3d/scene/build.ts
2937
- function writeSeatColors(iColor, iState, start, count, states) {
3787
+ function writeSeatColors(iColor, iState, start, count, states, iCategory) {
2938
3788
  for (let i = start; i < start + count; i++) {
2939
- const c = states[iState[i]] ?? states[0];
2940
- iColor[i * 3] = c[0];
2941
- iColor[i * 3 + 1] = c[1];
2942
- iColor[i * 3 + 2] = c[2];
3789
+ const useCategory = iCategory && iState[i] === 0;
3790
+ const c = useCategory ? null : states[iState[i]] ?? states[0];
3791
+ iColor[i * 3] = c ? c[0] : iCategory[i * 3];
3792
+ iColor[i * 3 + 1] = c ? c[1] : iCategory[i * 3 + 1];
3793
+ iColor[i * 3 + 2] = c ? c[2] : iCategory[i * 3 + 2];
2943
3794
  }
2944
3795
  }
2945
3796
  var SEAT_QUAD = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
@@ -2965,7 +3816,7 @@ function buildGpuScene(gl, model) {
2965
3816
  const seatProg = createSeatProgram(gl);
2966
3817
  const iColor = new Float32Array(model.seats.count * 3);
2967
3818
  const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
2968
- writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
3819
+ writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors, model.seats.iCategory);
2969
3820
  const seatGeo = new Geometry(gl, {
2970
3821
  position: { size: 2, data: SEAT_QUAD },
2971
3822
  iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
@@ -2981,17 +3832,105 @@ function buildGpuScene(gl, model) {
2981
3832
  seatMesh.frustumCulled = false;
2982
3833
  if (model.seats.count > 0) seatMesh.setParent(main);
2983
3834
  const colorAttr = seatGeo.attributes.iColor;
2984
- return {
3835
+ const chairBase = buildChairMesh();
3836
+ const chairProg = createChairProgram(gl);
3837
+ const CAP = CHAIR_MAX_INSTANCES;
3838
+ const cOffset = new Float32Array(CAP * 3);
3839
+ const cColor = new Float32Array(CAP * 3);
3840
+ const cRadius = new Float32Array(CAP);
3841
+ const cYaw = new Float32Array(CAP);
3842
+ const cRing = new Float32Array(CAP * 3);
3843
+ const cFloor = new Float32Array(CAP);
3844
+ const cSeed = new Float32Array(CAP);
3845
+ const chairGeo = new Geometry(gl, {
3846
+ position: { size: 3, data: chairBase.position },
3847
+ normal: { size: 3, data: chairBase.normal },
3848
+ part: { size: 1, data: chairBase.part },
3849
+ index: { data: chairBase.index },
3850
+ iOffset: { size: 3, data: cOffset, instanced: 1 },
3851
+ iColor: { size: 3, data: cColor, instanced: 1 },
3852
+ iRadius: { size: 1, data: cRadius, instanced: 1 },
3853
+ iYaw: { size: 1, data: cYaw, instanced: 1 },
3854
+ iRing: { size: 3, data: cRing, instanced: 1 },
3855
+ iFloor: { size: 1, data: cFloor, instanced: 1 },
3856
+ iSeed: { size: 1, data: cSeed, instanced: 1 }
3857
+ });
3858
+ const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
3859
+ chairMesh.frustumCulled = false;
3860
+ let nearCount = 0;
3861
+ let nearIndices = new Int32Array(0);
3862
+ const writeChairColors = () => {
3863
+ const src = model.seats;
3864
+ for (let k = 0; k < nearCount; k++) {
3865
+ const i = nearIndices[k];
3866
+ const useCategory = src.iCategory && src.iState[i] === 0;
3867
+ const c = useCategory ? null : stateColors[src.iState[i]] ?? stateColors[0];
3868
+ if (c) {
3869
+ cColor[k * 3] = c[0];
3870
+ cColor[k * 3 + 1] = c[1];
3871
+ cColor[k * 3 + 2] = c[2];
3872
+ } else {
3873
+ const u = upholsteryTone([src.iCategory[i * 3], src.iCategory[i * 3 + 1], src.iCategory[i * 3 + 2]]);
3874
+ cColor[k * 3] = u[0];
3875
+ cColor[k * 3 + 1] = u[1];
3876
+ cColor[k * 3 + 2] = u[2];
3877
+ }
3878
+ }
3879
+ chairGeo.attributes.iColor.needsUpdate = true;
3880
+ };
3881
+ const scene = {
2985
3882
  main,
2986
3883
  background,
2987
3884
  seatProgram: seatProg,
2988
3885
  solidProgram: solidProg,
3886
+ chairProgram: chairProg,
2989
3887
  seatGeometry: seatGeo,
2990
3888
  solidGeometry: solidGeo,
2991
3889
  drawCalls: 3,
3890
+ setNearSeats(indices, count) {
3891
+ const n = Math.min(count, CAP);
3892
+ nearIndices = indices;
3893
+ nearCount = n;
3894
+ if (n === 0) {
3895
+ if (chairMesh.parent) chairMesh.setParent(null);
3896
+ chairGeo.instancedCount = 0;
3897
+ scene.drawCalls = 3;
3898
+ return;
3899
+ }
3900
+ const src = model.seats;
3901
+ for (let k = 0; k < n; k++) {
3902
+ const i = indices[k];
3903
+ cOffset[k * 3] = src.iPosition[i * 3];
3904
+ cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
3905
+ cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
3906
+ cRadius[k] = src.iChairWidth[i];
3907
+ cYaw[k] = src.iYaw[i];
3908
+ cRing[k * 3] = src.iRing[i * 3];
3909
+ cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
3910
+ cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
3911
+ cFloor[k] = src.iFloor[i];
3912
+ cSeed[k] = seatOccupantSeed(src.iState[i], i);
3913
+ }
3914
+ writeChairColors();
3915
+ chairGeo.attributes.iOffset.needsUpdate = true;
3916
+ chairGeo.attributes.iRadius.needsUpdate = true;
3917
+ chairGeo.attributes.iYaw.needsUpdate = true;
3918
+ chairGeo.attributes.iRing.needsUpdate = true;
3919
+ chairGeo.attributes.iFloor.needsUpdate = true;
3920
+ chairGeo.attributes.iSeed.needsUpdate = true;
3921
+ chairGeo.instancedCount = n;
3922
+ if (!chairMesh.parent) chairMesh.setParent(main);
3923
+ scene.drawCalls = 4;
3924
+ },
3925
+ nearSeatCount() {
3926
+ return nearCount;
3927
+ },
2992
3928
  uploadSeatStateRuns(runs) {
2993
3929
  if (!runs.length) return;
2994
- for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
3930
+ if (nearCount) writeChairColors();
3931
+ for (const run of runs) {
3932
+ writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors, model.seats.iCategory);
3933
+ }
2995
3934
  const buffer = colorAttr.buffer;
2996
3935
  if (!buffer) {
2997
3936
  colorAttr.needsUpdate = true;
@@ -3010,8 +3949,11 @@ function buildGpuScene(gl, model) {
3010
3949
  solidProg.remove();
3011
3950
  seatGeo.remove();
3012
3951
  seatProg.remove();
3952
+ chairGeo.remove();
3953
+ chairProg.remove();
3013
3954
  }
3014
3955
  };
3956
+ return scene;
3015
3957
  }
3016
3958
 
3017
3959
  // src/view3d/pick/pickPipeline.ts
@@ -3494,6 +4436,317 @@ function mountPanorama(container, view, opts = {}) {
3494
4436
  };
3495
4437
  }
3496
4438
 
4439
+ // src/view3d/scene/panoSphere.ts
4440
+ import { Geometry as Geometry3, Mesh as Mesh3, Program as Program4, Texture } from "ogl";
4441
+ var PANO_SEGMENTS_LON = 64;
4442
+ var PANO_SEGMENTS_LAT = 32;
4443
+ var PANO_RADIUS_M = 500;
4444
+ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEGMENTS_LAT, radius = PANO_RADIUS_M) {
4445
+ const lonCount = segmentsLon + 1;
4446
+ const latCount = segmentsLat + 1;
4447
+ const vertexCount = lonCount * latCount;
4448
+ const position = new Float32Array(vertexCount * 3);
4449
+ const uv = new Float32Array(vertexCount * 2);
4450
+ for (let iLat = 0; iLat < latCount; iLat++) {
4451
+ const v = iLat / segmentsLat;
4452
+ const polar = v * Math.PI;
4453
+ const sinPolar = Math.sin(polar);
4454
+ const cosPolar = Math.cos(polar);
4455
+ for (let iLon = 0; iLon < lonCount; iLon++) {
4456
+ const u = iLon / segmentsLon;
4457
+ const azimuth = (u - 0.5) * Math.PI * 2;
4458
+ const i = iLat * lonCount + iLon;
4459
+ position[i * 3] = radius * sinPolar * Math.sin(azimuth);
4460
+ position[i * 3 + 1] = radius * cosPolar;
4461
+ position[i * 3 + 2] = -radius * sinPolar * Math.cos(azimuth);
4462
+ uv[i * 2] = u;
4463
+ uv[i * 2 + 1] = v;
4464
+ }
4465
+ }
4466
+ const index = new Uint16Array(segmentsLon * segmentsLat * 6);
4467
+ let w = 0;
4468
+ for (let iLat = 0; iLat < segmentsLat; iLat++) {
4469
+ for (let iLon = 0; iLon < segmentsLon; iLon++) {
4470
+ const a = iLat * lonCount + iLon;
4471
+ const b = a + lonCount;
4472
+ index[w++] = a;
4473
+ index[w++] = b;
4474
+ index[w++] = a + 1;
4475
+ index[w++] = a + 1;
4476
+ index[w++] = b;
4477
+ index[w++] = b + 1;
4478
+ }
4479
+ }
4480
+ return { position, uv, index };
4481
+ }
4482
+ function bearingPitchToDirection(bearingDeg, pitchDeg) {
4483
+ const yaw = bearingDeg * Math.PI / 180;
4484
+ const pitch = pitchDeg * Math.PI / 180;
4485
+ const cosPitch = Math.cos(pitch);
4486
+ return [cosPitch * Math.sin(yaw), Math.sin(pitch), -cosPitch * Math.cos(yaw)];
4487
+ }
4488
+ var PANO_VERT = (
4489
+ /* glsl */
4490
+ `#version 300 es
4491
+ precision highp float;
4492
+ in vec3 position;
4493
+ in vec2 uv;
4494
+ uniform mat4 modelViewMatrix;
4495
+ uniform mat4 projectionMatrix;
4496
+ out vec2 vUv;
4497
+ void main() {
4498
+ vUv = uv;
4499
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
4500
+ }`
4501
+ );
4502
+ var PANO_FRAG = (
4503
+ /* glsl */
4504
+ `#version 300 es
4505
+ precision highp float;
4506
+ in vec2 vUv;
4507
+ uniform sampler2D uPano;
4508
+ uniform float uOpacity;
4509
+ out vec4 fragColor;
4510
+ void main() {
4511
+ vec3 rgb = texture(uPano, vUv).rgb;
4512
+ fragColor = vec4(rgb, uOpacity);
4513
+ }`
4514
+ );
4515
+ function createPanoSphere(gl, image) {
4516
+ const { position, uv, index } = buildPanoSphere();
4517
+ const geometry = new Geometry3(gl, {
4518
+ position: { size: 3, data: position },
4519
+ uv: { size: 2, data: uv },
4520
+ index: { data: index }
4521
+ });
4522
+ const texture = new Texture(gl, {
4523
+ image,
4524
+ // The seam column is duplicated in geometry, so CLAMP is correct and avoids
4525
+ // a wrapped bilinear tap bleeding the far edge of the image into it.
4526
+ wrapS: gl.CLAMP_TO_EDGE,
4527
+ wrapT: gl.CLAMP_TO_EDGE,
4528
+ generateMipmaps: true,
4529
+ // MUST be false, and this is the one line that decides whether the whole
4530
+ // panorama is upside down.
4531
+ //
4532
+ // OGL defaults `flipY` to true for 2D textures, which is right for the usual
4533
+ // case: a UV of 0 means the BOTTOM of a quad, an image's first row is its
4534
+ // TOP, and flipping on upload reconciles the two. This sphere is the other
4535
+ // case. Its `v` is authored in IMAGE space — v=0 is the top of the equirect
4536
+ // and maps to the top of the sphere (`buildPanoSphere`, and the mapping
4537
+ // `generatePanorama` and the 2D viewer both use). Flipping on upload as well
4538
+ // applies the correction twice: the stadium pitch renders on the ceiling and
4539
+ // the audience sits overhead.
4540
+ //
4541
+ // The geometry tests state the v mapping and pass either way — they never
4542
+ // touch GL — so this is not something a unit test can defend. It was caught
4543
+ // by looking at a stadium.
4544
+ flipY: false
4545
+ });
4546
+ const program = new Program4(gl, {
4547
+ vertex: PANO_VERT,
4548
+ fragment: PANO_FRAG,
4549
+ uniforms: { uPano: { value: texture }, uOpacity: { value: 0 } },
4550
+ transparent: true,
4551
+ depthTest: false,
4552
+ depthWrite: false
4553
+ });
4554
+ const mesh = new Mesh3(gl, { geometry, program });
4555
+ return {
4556
+ mesh,
4557
+ setOpacity(value) {
4558
+ program.uniforms.uOpacity.value = Math.max(0, Math.min(1, value));
4559
+ },
4560
+ dispose() {
4561
+ mesh.setParent(null);
4562
+ geometry.remove();
4563
+ if (texture.texture) gl.deleteTexture(texture.texture);
4564
+ program.remove();
4565
+ }
4566
+ };
4567
+ }
4568
+
4569
+ // src/view3d/crossfade/panoramaSphere.ts
4570
+ var DEG_PER_PX = 0.15;
4571
+ function loadImage(url) {
4572
+ return new Promise((resolve, reject) => {
4573
+ const img = new Image();
4574
+ img.crossOrigin = "anonymous";
4575
+ img.onload = () => resolve(img);
4576
+ img.onerror = () => reject(new Error("panorama_image_failed"));
4577
+ img.src = url;
4578
+ });
4579
+ }
4580
+ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4581
+ const fadeMs = opts.fadeMs ?? 400;
4582
+ let bearing = view?.initialBearingDeg ?? 0;
4583
+ let pitch = 0;
4584
+ let disposed = false;
4585
+ if (!view && deps.focalWorld) {
4586
+ const p = deps.camera.position;
4587
+ const dx = deps.focalWorld[0] - p.x;
4588
+ const dy = deps.focalWorld[1] - p.y;
4589
+ const dz = deps.focalWorld[2] - p.z;
4590
+ const flat = Math.hypot(dx, dz);
4591
+ if (flat > 1e-3) {
4592
+ bearing = Math.atan2(dx, -dz) * 180 / Math.PI;
4593
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, Math.atan2(dy, flat) * 180 / Math.PI));
4594
+ }
4595
+ }
4596
+ let sphere = null;
4597
+ if (view) {
4598
+ let image;
4599
+ try {
4600
+ image = await loadImage(view.url);
4601
+ } catch {
4602
+ return null;
4603
+ }
4604
+ try {
4605
+ sphere = createPanoSphere(deps.gl, image);
4606
+ } catch {
4607
+ return null;
4608
+ }
4609
+ sphere.mesh.renderOrder = 999;
4610
+ sphere.mesh.setParent(deps.scene);
4611
+ }
4612
+ const priorFov = deps.camera.fov;
4613
+ deps.camera.perspective({ fov: VFOV_DEG, aspect: deps.camera.aspect });
4614
+ const priorQuat = deps.camera.quaternion.slice();
4615
+ const aim = () => {
4616
+ const p = deps.camera.position;
4617
+ sphere?.mesh.position.set(p.x, p.y, p.z);
4618
+ const [dx, dy, dz] = bearingPitchToDirection(bearing, pitch);
4619
+ deps.camera.lookAt([p.x + dx, p.y + dy, p.z + dz]);
4620
+ deps.requestRender();
4621
+ };
4622
+ aim();
4623
+ const root = document.createElement("div");
4624
+ root.setAttribute("role", "dialog");
4625
+ root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
4626
+ Object.assign(root.style, {
4627
+ position: "absolute",
4628
+ inset: "0",
4629
+ zIndex: "10",
4630
+ cursor: "grab",
4631
+ touchAction: "none"
4632
+ });
4633
+ const closeBtn = document.createElement("button");
4634
+ closeBtn.type = "button";
4635
+ closeBtn.setAttribute("aria-label", "Close");
4636
+ closeBtn.textContent = "\u2715";
4637
+ Object.assign(closeBtn.style, {
4638
+ position: "absolute",
4639
+ top: "12px",
4640
+ right: "12px",
4641
+ zIndex: "2",
4642
+ width: "34px",
4643
+ height: "34px",
4644
+ borderRadius: "999px",
4645
+ cursor: "pointer",
4646
+ border: "1px solid rgba(255,255,255,0.25)",
4647
+ background: "rgba(8,12,18,0.6)",
4648
+ color: "#e6edf3",
4649
+ fontSize: "15px",
4650
+ lineHeight: "1"
4651
+ });
4652
+ root.appendChild(closeBtn);
4653
+ const hint = document.createElement("div");
4654
+ hint.textContent = "Drag to look around \xB7 Esc to close";
4655
+ Object.assign(hint.style, {
4656
+ position: "absolute",
4657
+ bottom: "12px",
4658
+ left: "0",
4659
+ right: "0",
4660
+ textAlign: "center",
4661
+ color: "rgba(230,237,243,0.7)",
4662
+ font: "12px ui-sans-serif, system-ui, sans-serif",
4663
+ pointerEvents: "none"
4664
+ });
4665
+ root.appendChild(hint);
4666
+ container.appendChild(root);
4667
+ let dragging = false;
4668
+ let lastX = 0;
4669
+ let lastY = 0;
4670
+ const onDown = (e) => {
4671
+ if (e.target === closeBtn) return;
4672
+ dragging = true;
4673
+ lastX = e.clientX;
4674
+ lastY = e.clientY;
4675
+ root.setPointerCapture?.(e.pointerId);
4676
+ root.style.cursor = "grabbing";
4677
+ };
4678
+ const onMove = (e) => {
4679
+ if (!dragging) return;
4680
+ bearing += (e.clientX - lastX) * DEG_PER_PX;
4681
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.clientY - lastY) * DEG_PER_PX));
4682
+ lastX = e.clientX;
4683
+ lastY = e.clientY;
4684
+ aim();
4685
+ };
4686
+ const onUp = () => {
4687
+ dragging = false;
4688
+ root.style.cursor = "grab";
4689
+ };
4690
+ root.addEventListener("pointerdown", onDown);
4691
+ root.addEventListener("pointermove", onMove);
4692
+ root.addEventListener("pointerup", onUp);
4693
+ root.addEventListener("pointercancel", onUp);
4694
+ const onKey = (e) => {
4695
+ if (e.key !== "Escape" || disposed) return;
4696
+ e.stopPropagation();
4697
+ handle.close();
4698
+ };
4699
+ window.addEventListener("keydown", onKey);
4700
+ let raf = 0;
4701
+ const fadeTo = (target, done) => {
4702
+ if (!sphere) {
4703
+ done?.();
4704
+ return;
4705
+ }
4706
+ const from = target === 1 ? 0 : 1;
4707
+ const start = performance.now();
4708
+ const step = () => {
4709
+ if (disposed) return;
4710
+ const t = fadeMs <= 0 ? 1 : Math.min(1, (performance.now() - start) / fadeMs);
4711
+ sphere?.setOpacity(from + (target - from) * t);
4712
+ deps.requestRender();
4713
+ if (t < 1) raf = requestAnimationFrame(step);
4714
+ else done?.();
4715
+ };
4716
+ raf = requestAnimationFrame(step);
4717
+ };
4718
+ fadeTo(1);
4719
+ const teardown = () => {
4720
+ if (disposed) return;
4721
+ disposed = true;
4722
+ cancelAnimationFrame(raf);
4723
+ root.removeEventListener("pointerdown", onDown);
4724
+ root.removeEventListener("pointermove", onMove);
4725
+ root.removeEventListener("pointerup", onUp);
4726
+ root.removeEventListener("pointercancel", onUp);
4727
+ window.removeEventListener("keydown", onKey);
4728
+ root.remove();
4729
+ sphere?.dispose();
4730
+ deps.camera.perspective({ fov: priorFov, aspect: deps.camera.aspect });
4731
+ deps.camera.quaternion.set(priorQuat[0], priorQuat[1], priorQuat[2], priorQuat[3]);
4732
+ deps.requestRender();
4733
+ };
4734
+ const handle = {
4735
+ close() {
4736
+ if (disposed) return;
4737
+ const finish = () => {
4738
+ teardown();
4739
+ opts.onClose?.();
4740
+ };
4741
+ cancelAnimationFrame(raf);
4742
+ fadeTo(0, finish);
4743
+ },
4744
+ dispose: teardown
4745
+ };
4746
+ closeBtn.addEventListener("click", () => handle.close());
4747
+ return handle;
4748
+ }
4749
+
3497
4750
  // src/view3d/analytics.ts
3498
4751
  var now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
3499
4752
  var Analytics3D = class {
@@ -3579,6 +4832,28 @@ function mountVenue3D(container, input, opts = {}) {
3579
4832
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
3580
4833
  gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
3581
4834
  gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
4835
+ gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
4836
+ lastGatherX = Infinity;
4837
+ };
4838
+ const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
4839
+ const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
4840
+ let lastGatherX = Infinity;
4841
+ let lastGatherZ = Infinity;
4842
+ const updateNearField = () => {
4843
+ if (!gpu) return;
4844
+ const cam = orbit.camera.position;
4845
+ const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
4846
+ if (moved2 < CHAIR_REBUILD_M) return;
4847
+ lastGatherX = cam.x;
4848
+ lastGatherZ = cam.z;
4849
+ const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
4850
+ if (outside > CHAIR_GATHER_M) {
4851
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
4852
+ return;
4853
+ }
4854
+ const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
4855
+ if (n === 0 && gpu.nearSeatCount() === 0) return;
4856
+ gpu.setNearSeats(nearBuf, n);
3582
4857
  };
3583
4858
  const glctx = new GLContext(container, {
3584
4859
  onContextLost: () => {
@@ -3625,7 +4900,9 @@ function mountVenue3D(container, input, opts = {}) {
3625
4900
  const u = gpu.seatProgram.uniforms;
3626
4901
  u.uSeatScale.value = lod.scale;
3627
4902
  u.uSeatFade.value = lod.fade;
4903
+ u.uMinPixels.value = lod.minPixels;
3628
4904
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
4905
+ updateNearField();
3629
4906
  glctx.renderer.render({ scene: gpu.background, clear: true });
3630
4907
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
3631
4908
  labelOverlay.update(
@@ -3633,7 +4910,10 @@ function mountVenue3D(container, input, opts = {}) {
3633
4910
  glctx.canvas.clientWidth || 1,
3634
4911
  glctx.canvas.clientHeight || 1,
3635
4912
  orbit.currentDistance,
3636
- model.bounds.radius
4913
+ model.bounds.radius,
4914
+ // The dense label rungs rank by real distance from the eye, not by the
4915
+ // orbit radius — at the arrival pose those are wildly different numbers.
4916
+ [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
3637
4917
  );
3638
4918
  return moving;
3639
4919
  });
@@ -3741,7 +5021,7 @@ function mountVenue3D(container, input, opts = {}) {
3741
5021
  zIndex: "4"
3742
5022
  });
3743
5023
  overviewChip.addEventListener("click", () => {
3744
- if (disposed || frozen) return;
5024
+ if (disposed || frozen || panorama) return;
3745
5025
  cancelFlight();
3746
5026
  removeArriveChip();
3747
5027
  orbit.frameSoft(model.bounds, stageAzimuth);
@@ -3769,18 +5049,36 @@ function mountVenue3D(container, input, opts = {}) {
3769
5049
  }
3770
5050
  if (disposed || gen !== flightGen) return;
3771
5051
  removeArriveChip();
3772
- panorama = mountPanorama(container, view, {
3773
- fadeMs,
3774
- seatLabel: seatId,
3775
- onClose: () => {
3776
- panorama = null;
3777
- frozen = false;
3778
- analytics.panoramaClosed();
3779
- orbit.resumeAfterFlight(model.focalWorld);
3780
- loop.requestRender();
3781
- showArriveChip(seatId, flightGen);
3782
- }
3783
- });
5052
+ const onClose = () => {
5053
+ panorama = null;
5054
+ labelOverlay.setVisible(true);
5055
+ frozen = false;
5056
+ analytics.panoramaClosed();
5057
+ orbit.resumeAfterFlight(model.focalWorld);
5058
+ loop.requestRender();
5059
+ showArriveChip(seatId, flightGen);
5060
+ };
5061
+ const sceneMode = view.generated === true;
5062
+ const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5063
+ gl: glctx.gl,
5064
+ scene: gpu.main,
5065
+ camera: orbit.camera,
5066
+ requestRender: () => loop.requestRender(),
5067
+ focalWorld: model.focalWorld
5068
+ }, { fadeMs, seatLabel: seatId, onClose }) : null;
5069
+ if (disposed || gen !== flightGen) {
5070
+ spherical?.dispose();
5071
+ return;
5072
+ }
5073
+ if (spherical) {
5074
+ orbit.syncFromCamera();
5075
+ labelOverlay.setVisible(false);
5076
+ frozen = false;
5077
+ loop.requestRender();
5078
+ panorama = spherical;
5079
+ } else {
5080
+ panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
5081
+ }
3784
5082
  analytics.panoramaOpened();
3785
5083
  };
3786
5084
  const cancelFlight = () => {
@@ -3906,6 +5204,7 @@ function mountVenue3D(container, input, opts = {}) {
3906
5204
  if (gpu) {
3907
5205
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
3908
5206
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
5207
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
3909
5208
  }
3910
5209
  focusedFloor = value;
3911
5210
  if (index !== null) {