@seatlayer/core 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -207,10 +207,18 @@ var OrbitCamera = class {
207
207
  this.maxDist = 100;
208
208
  this.gestureFired = false;
209
209
  this.dragging = false;
210
+ this.panning = false;
210
211
  this.lastX = 0;
211
212
  this.lastY = 0;
212
213
  this.activePointers = /* @__PURE__ */ new Map();
213
214
  this.pinchDist = 0;
215
+ this.pinchCx = 0;
216
+ this.pinchCy = 0;
217
+ /** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
218
+ this.targetT = new import_ogl2.Vec3();
219
+ /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
220
+ this.panAnchor = new import_ogl2.Vec3();
221
+ this.panLimit = 0;
214
222
  this.camera = new import_ogl2.Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
215
223
  this.canvas = canvas;
216
224
  this.requestRender = requestRender;
@@ -222,12 +230,17 @@ var OrbitCamera = class {
222
230
  }
223
231
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
224
232
  if (this.activePointers.size === 1) {
225
- this.dragging = true;
233
+ this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
234
+ this.dragging = !this.panning;
226
235
  this.lastX = e.clientX;
227
236
  this.lastY = e.clientY;
228
237
  } else if (this.activePointers.size === 2) {
229
238
  this.dragging = false;
239
+ this.panning = false;
230
240
  this.pinchDist = this.currentPinchDistance();
241
+ const c = this.pinchCentroid();
242
+ this.pinchCx = c.x;
243
+ this.pinchCy = c.y;
231
244
  }
232
245
  };
233
246
  this.onPointerMove = (e) => {
@@ -240,11 +253,24 @@ var OrbitCamera = class {
240
253
  this.fireGesture();
241
254
  }
242
255
  this.pinchDist = d;
256
+ const c = this.pinchCentroid();
257
+ this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
258
+ this.pinchCx = c.x;
259
+ this.pinchCy = c.y;
243
260
  return;
244
261
  }
245
- if (!this.dragging) return;
246
262
  const dx = e.clientX - this.lastX;
247
263
  const dy = e.clientY - this.lastY;
264
+ if (this.panning) {
265
+ this.lastX = e.clientX;
266
+ this.lastY = e.clientY;
267
+ if (dx !== 0 || dy !== 0) {
268
+ this.panBy(dx, dy);
269
+ this.fireGesture();
270
+ }
271
+ return;
272
+ }
273
+ if (!this.dragging) return;
248
274
  this.lastX = e.clientX;
249
275
  this.lastY = e.clientY;
250
276
  if (dx !== 0 || dy !== 0) this.fireGesture();
@@ -259,7 +285,13 @@ var OrbitCamera = class {
259
285
  } catch {
260
286
  }
261
287
  if (this.activePointers.size < 2) this.pinchDist = 0;
262
- if (this.activePointers.size === 0) this.dragging = false;
288
+ if (this.activePointers.size === 0) {
289
+ this.dragging = false;
290
+ this.panning = false;
291
+ }
292
+ };
293
+ this.onContextMenu = (e) => {
294
+ e.preventDefault();
263
295
  };
264
296
  this.onWheel = (e) => {
265
297
  e.preventDefault();
@@ -273,6 +305,55 @@ var OrbitCamera = class {
273
305
  canvas.addEventListener("pointerup", this.onPointerUp);
274
306
  canvas.addEventListener("pointercancel", this.onPointerUp);
275
307
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
308
+ canvas.addEventListener("contextmenu", this.onContextMenu);
309
+ }
310
+ pinchCentroid() {
311
+ const pts = [...this.activePointers.values()];
312
+ if (!pts.length) return { x: 0, y: 0 };
313
+ let x = 0;
314
+ let y = 0;
315
+ for (const p of pts) {
316
+ x += p.x;
317
+ y += p.y;
318
+ }
319
+ return { x: x / pts.length, y: y / pts.length };
320
+ }
321
+ /**
322
+ * Slide the orbit pivot across the camera's own screen plane.
323
+ *
324
+ * Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
325
+ * under the cursor whatever the zoom: at the overview a drag sweeps the whole
326
+ * bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
327
+ * would be unusable at one end or the other.
328
+ */
329
+ panBy(dxPx, dyPx) {
330
+ const h = this.canvas.clientHeight || 1;
331
+ const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
332
+ const sinA = Math.sin(this.azimuth);
333
+ const cosA = Math.cos(this.azimuth);
334
+ const rightX = cosA;
335
+ const rightZ = -sinA;
336
+ const cp = Math.cos(this.polar);
337
+ const sp = Math.sin(this.polar);
338
+ const fwdX = -sinA * cp;
339
+ const fwdZ = -cosA * cp;
340
+ this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
341
+ this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
342
+ this.targetT.y += dyPx * sp * perPx;
343
+ this.clampPan();
344
+ this.requestRender();
345
+ }
346
+ /** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
347
+ * venue entirely — the Overview chip should never be the only way back. */
348
+ clampPan() {
349
+ if (this.panLimit <= 0) return;
350
+ const lim = this.panLimit;
351
+ const cx = this.panAnchor.x;
352
+ const cy = this.panAnchor.y;
353
+ const cz = this.panAnchor.z;
354
+ this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
355
+ this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
356
+ this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
276
357
  }
277
358
  /** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
278
359
  fireGesture() {
@@ -299,6 +380,9 @@ var OrbitCamera = class {
299
380
  */
300
381
  frame(bounds, intro = false, stageAzimuth) {
301
382
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
383
+ this.targetT.copy(this.target);
384
+ this.panAnchor.copy(this.target);
385
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
302
386
  const r = Math.max(1, bounds.radius);
303
387
  const halfV = this.fovY * DEG / 2;
304
388
  const aspect = this.camera.aspect || 1;
@@ -330,6 +414,9 @@ var OrbitCamera = class {
330
414
  */
331
415
  frameSoft(bounds, stageAzimuth) {
332
416
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
417
+ this.targetT.copy(this.target);
418
+ this.panAnchor.copy(this.target);
419
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
333
420
  this.syncFromCamera();
334
421
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
335
422
  const r = Math.max(1, bounds.radius);
@@ -346,10 +433,17 @@ var OrbitCamera = class {
346
433
  const da = this.azT - this.azimuth;
347
434
  const dp = this.polT - this.polar;
348
435
  const dd = this.distT - this.distance;
349
- const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
436
+ const tx = this.targetT.x - this.target.x;
437
+ const ty = this.targetT.y - this.target.y;
438
+ const tz = this.targetT.z - this.target.z;
439
+ const panEps = Math.max(1e-4, this.distance * 1e-4);
440
+ const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4 || Math.abs(tx) > panEps || Math.abs(ty) > panEps || Math.abs(tz) > panEps;
350
441
  this.azimuth += da * DAMP;
351
442
  this.polar += dp * DAMP;
352
443
  this.distance += dd * DAMP;
444
+ this.target.x += tx * DAMP;
445
+ this.target.y += ty * DAMP;
446
+ this.target.z += tz * DAMP;
353
447
  if (moving) this.applyPosition();
354
448
  return moving;
355
449
  }
@@ -375,6 +469,7 @@ var OrbitCamera = class {
375
469
  /** Point the orbit pivot at a new world target without moving the camera. */
376
470
  setTarget(target) {
377
471
  this.target.set(target[0], target[1], target[2]);
472
+ this.targetT.copy(this.target);
378
473
  }
379
474
  /** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
380
475
  * flight ends looking at `target` (the venue focal), so re-pivot there first —
@@ -382,6 +477,7 @@ var OrbitCamera = class {
382
477
  resumeAfterFlight(target) {
383
478
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
384
479
  if (target) this.target.set(target[0], target[1], target[2]);
480
+ this.targetT.copy(this.target);
385
481
  this.syncFromCamera();
386
482
  }
387
483
  applyPosition() {
@@ -398,6 +494,7 @@ var OrbitCamera = class {
398
494
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
399
495
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
400
496
  this.canvas.removeEventListener("wheel", this.onWheel);
497
+ this.canvas.removeEventListener("contextmenu", this.onContextMenu);
401
498
  this.activePointers.clear();
402
499
  }
403
500
  };
@@ -446,17 +543,129 @@ var RenderLoop = class {
446
543
  };
447
544
 
448
545
  // src/view3d/lod.ts
546
+ var SEAT_MIN_PIXELS_NEAR = 2.5;
547
+ var SEAT_MIN_PIXELS_FAR = 1.15;
548
+ var CHAIR_FULL_M = 12;
549
+ var CHAIR_NONE_M = 22;
550
+ var CHAIR_GATHER_M = 30;
551
+ var CHAIR_REBUILD_M = 4;
552
+ var CHAIR_MAX_INSTANCES = 8192;
553
+ var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
449
554
  function computeSeatLod(distance, radius) {
450
555
  const near = radius * 1.4;
451
556
  const far = radius * 3.2;
452
- if (distance <= near) return { scale: 1, fade: 0 };
557
+ if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
453
558
  const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
454
559
  return {
455
560
  scale: 1 - t * 0.4,
456
- fade: t * 0.55
561
+ fade: t * 0.55,
562
+ // Tapered on the same ramp as the fade: as the block starts reading by its
563
+ // tier tint, the dots stop fighting each other for pixels.
564
+ minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
457
565
  };
458
566
  }
459
567
 
568
+ // src/view3d/scene/nearField.ts
569
+ var SEATS_PER_CELL = 4;
570
+ var NearFieldIndex = class {
571
+ constructor(iPosition, count) {
572
+ this.minX = 0;
573
+ this.minZ = 0;
574
+ this.cell = 1;
575
+ this.cols = 1;
576
+ this.rows = 1;
577
+ /** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
578
+ this.cellStart = null;
579
+ this.cellItems = null;
580
+ this.iPosition = iPosition;
581
+ this.count = count;
582
+ }
583
+ /** Built on demand; safe to call repeatedly. */
584
+ ensureGrid() {
585
+ if (this.cellStart || this.count === 0) return;
586
+ const p = this.iPosition;
587
+ let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
588
+ for (let i = 0; i < this.count; i++) {
589
+ const x = p[i * 3], z = p[i * 3 + 2];
590
+ if (x < minX) minX = x;
591
+ if (x > maxX) maxX = x;
592
+ if (z < minZ) minZ = z;
593
+ if (z > maxZ) maxZ = z;
594
+ }
595
+ const w = Math.max(maxX - minX, 1e-3);
596
+ const h = Math.max(maxZ - minZ, 1e-3);
597
+ this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
598
+ this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
599
+ this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
600
+ this.minX = minX;
601
+ this.minZ = minZ;
602
+ const nCells = this.cols * this.rows;
603
+ const start = new Int32Array(nCells + 1);
604
+ const cellOf = (i) => {
605
+ const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
606
+ const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
607
+ return cz * this.cols + cx;
608
+ };
609
+ for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
610
+ for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
611
+ const items = new Int32Array(this.count);
612
+ const cursor = start.slice(0, nCells);
613
+ for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
614
+ this.cellStart = start;
615
+ this.cellItems = items;
616
+ }
617
+ /**
618
+ * Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
619
+ * nearest cell-ring first, and return how many were written.
620
+ *
621
+ * Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
622
+ * bites it drops the OUTERMOST seats, which are the ones already past the fade
623
+ * band and drawing nothing. A cap that truncated in index order would instead
624
+ * punch holes in the row you are sitting in.
625
+ *
626
+ * Note this is a horizontal (XZ) query and ignores height. A stacked venue's
627
+ * upper tier is therefore gathered along with the stalls beneath it — which is
628
+ * correct, because the fade weight is re-derived from true view depth in the
629
+ * shader anyway. The grid's only job is to bound the candidate set.
630
+ */
631
+ gather(camX, camZ, radius, out) {
632
+ this.ensureGrid();
633
+ const start = this.cellStart;
634
+ const items = this.cellItems;
635
+ if (!start || !items) return 0;
636
+ const cap = out.length;
637
+ const r2 = radius * radius;
638
+ const cx = Math.floor((camX - this.minX) / this.cell);
639
+ const cz = Math.floor((camZ - this.minZ) / this.cell);
640
+ const maxRing = Math.ceil(radius / this.cell) + 1;
641
+ const p = this.iPosition;
642
+ let n = 0;
643
+ for (let ring = 0; ring <= maxRing && n < cap; ring++) {
644
+ const z0 = cz - ring, z1 = cz + ring;
645
+ const x0 = cx - ring, x1 = cx + ring;
646
+ for (let gz = z0; gz <= z1 && n < cap; gz++) {
647
+ if (gz < 0 || gz >= this.rows) continue;
648
+ const edge = gz === z0 || gz === z1;
649
+ for (let gx = x0; gx <= x1 && n < cap; gx++) {
650
+ if (!edge && gx !== x0 && gx !== x1) {
651
+ gx = x1 - 1;
652
+ continue;
653
+ }
654
+ if (gx < 0 || gx >= this.cols) continue;
655
+ const c = gz * this.cols + gx;
656
+ for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
657
+ const i = items[k];
658
+ const dx = p[i * 3] - camX;
659
+ const dz = p[i * 3 + 2] - camZ;
660
+ if (dx * dx + dz * dz <= r2) out[n++] = i;
661
+ }
662
+ }
663
+ }
664
+ }
665
+ return n;
666
+ }
667
+ };
668
+
460
669
  // src/core/types.ts
461
670
  var ACCESSIBILITY_TYPES = [
462
671
  { key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
@@ -483,6 +692,12 @@ function accessibilityRingColor(types) {
483
692
  const primary = types?.[0];
484
693
  return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
485
694
  }
695
+ var SEAT_COMMERCIAL_MARKS = [
696
+ { key: "obstructedView", label: "Obstructed view", short: "Obstructed", icon: "\u26D4" },
697
+ { key: "restrictedView", label: "Restricted view", short: "Restricted", icon: "\u{1F441}" },
698
+ { key: "premium", label: "Premium seat", short: "Premium", icon: "\u2605" }
699
+ ];
700
+ var SEAT_COMMERCIAL_LABEL = new Map(SEAT_COMMERCIAL_MARKS.map((mark) => [mark.key, mark]));
486
701
  var SURROUNDINGS_SHAPE_ROLES = [
487
702
  "reference-focal",
488
703
  "bar",
@@ -959,6 +1174,152 @@ function rectPolygon(x, y, w, h) {
959
1174
  ];
960
1175
  }
961
1176
 
1177
+ // src/view3d/scene/seatChair.ts
1178
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
1179
+ var CHAIR_PITCH_FRACTION = 0.44;
1180
+ var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1181
+ var CHAIR_HALF_WIDTH_MAX_M = 0.3;
1182
+ var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
1183
+ function chairHalfWidth(pitchM) {
1184
+ if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
1185
+ return CHAIR_HALF_WIDTH_DEFAULT_M;
1186
+ }
1187
+ return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
1188
+ }
1189
+ var BACK_RAKE_SLOPE = 0.21;
1190
+ var PAD_BACK_GAP_M = 0.05;
1191
+ var PAD_TOP_M = 0.45;
1192
+ var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1193
+ var BOXES = [
1194
+ // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1195
+ // over the deck and the row reads as hovering trays.
1196
+ { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
1197
+ // Seat pad — a full seat width across and about as deep, which is what a real
1198
+ // one is. Its depth is bounded by the same pitch as its width, because the
1199
+ // pitch measure is the tighter of the in-row and row-to-row spacings, so a
1200
+ // tightly-raked tier cannot drive a pad into the back of the row in front.
1201
+ { part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
1202
+ // Back panel — thin, raked, and the tallest thing in the row, so it is what
1203
+ // carries the state colour when you look along a row from behind.
1204
+ {
1205
+ part: CHAIR_PART.back,
1206
+ min: [-1, BACK_BASE_M, -1],
1207
+ max: [1, 0.92, -0.72]
1208
+ }
1209
+ ];
1210
+ var FACES = [
1211
+ // +X
1212
+ { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
1213
+ // -X
1214
+ { n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
1215
+ // +Y
1216
+ { n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
1217
+ // -Y
1218
+ { n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
1219
+ // +Z
1220
+ { n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
1221
+ // -Z
1222
+ { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1223
+ ];
1224
+ function buildChairMesh() {
1225
+ const vertexCount = BOXES.length * FACES.length * 4;
1226
+ const indexCount = BOXES.length * FACES.length * 6;
1227
+ const position = new Float32Array(vertexCount * 3);
1228
+ const normal = new Float32Array(vertexCount * 3);
1229
+ const part = new Float32Array(vertexCount);
1230
+ const index = new Uint16Array(indexCount);
1231
+ let v = 0;
1232
+ let t = 0;
1233
+ for (const box of BOXES) {
1234
+ for (const face of FACES) {
1235
+ const base = v;
1236
+ const corner3 = new Float32Array(12);
1237
+ for (let ci = 0; ci < 4; ci++) {
1238
+ const corner = face.c[ci];
1239
+ for (let a = 0; a < 3; a++) {
1240
+ corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1241
+ }
1242
+ }
1243
+ const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1244
+ const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1245
+ let nx = ay * bz - az * by;
1246
+ let ny = az * bx - ax * bz;
1247
+ let nz = ax * by - ay * bx;
1248
+ const nl = Math.hypot(nx, ny, nz);
1249
+ if (nl > 1e-9) {
1250
+ nx /= nl;
1251
+ ny /= nl;
1252
+ nz /= nl;
1253
+ } else {
1254
+ nx = face.n[0];
1255
+ ny = face.n[1];
1256
+ nz = face.n[2];
1257
+ }
1258
+ if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1259
+ nx = -nx;
1260
+ ny = -ny;
1261
+ nz = -nz;
1262
+ }
1263
+ for (let ci = 0; ci < 4; ci++) {
1264
+ position[v * 3] = corner3[ci * 3];
1265
+ position[v * 3 + 1] = corner3[ci * 3 + 1];
1266
+ position[v * 3 + 2] = corner3[ci * 3 + 2];
1267
+ normal[v * 3] = nx;
1268
+ normal[v * 3 + 1] = ny;
1269
+ normal[v * 3 + 2] = nz;
1270
+ part[v] = box.part;
1271
+ v++;
1272
+ }
1273
+ index[t++] = base;
1274
+ index[t++] = base + 1;
1275
+ index[t++] = base + 2;
1276
+ index[t++] = base;
1277
+ index[t++] = base + 2;
1278
+ index[t++] = base + 3;
1279
+ }
1280
+ }
1281
+ return { position, normal, part, index, vertexCount, indexCount };
1282
+ }
1283
+ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1284
+ const yaw = new Float32Array(count);
1285
+ const px = (i) => iPosition[i * 3];
1286
+ const pz = (i) => iPosition[i * 3 + 2];
1287
+ let runStart = 0;
1288
+ const flushRun = (start, end) => {
1289
+ const n = end - start;
1290
+ for (let i = start; i < end; i++) {
1291
+ const [fx, fz] = focal(i);
1292
+ let dx = fx - px(i);
1293
+ let dz = fz - pz(i);
1294
+ if (n >= 2) {
1295
+ const a = Math.max(start, i - 1);
1296
+ const b = Math.min(end - 1, i + 1);
1297
+ const tx = px(b) - px(a);
1298
+ const tz = pz(b) - pz(a);
1299
+ const tl = Math.hypot(tx, tz);
1300
+ if (tl > 1e-6) {
1301
+ let nx = -tz / tl;
1302
+ let nz = tx / tl;
1303
+ if (nx * dx + nz * dz < 0) {
1304
+ nx = -nx;
1305
+ nz = -nz;
1306
+ }
1307
+ dx = nx;
1308
+ dz = nz;
1309
+ }
1310
+ }
1311
+ yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
1312
+ }
1313
+ };
1314
+ for (let i = 1; i <= count; i++) {
1315
+ if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
1316
+ flushRun(runStart, i);
1317
+ runStart = i;
1318
+ }
1319
+ }
1320
+ return yaw;
1321
+ }
1322
+
962
1323
  // src/view3d/scene/seatInstances.ts
963
1324
  var SEAT_DOT_RADIUS_M = 0.22;
964
1325
  var SEAT_PITCH_FRACTION = 0.42;
@@ -1028,6 +1389,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1028
1389
  const iPosition = new Float32Array(count * 3);
1029
1390
  const iState = new Float32Array(count);
1030
1391
  const iMaxRadius = new Float32Array(count);
1392
+ const iChairWidth = new Float32Array(count);
1031
1393
  const iRing = new Float32Array(count * 3);
1032
1394
  const idToIndex = /* @__PURE__ */ new Map();
1033
1395
  const spacing = nearestNeighbourSpacing(seats);
@@ -1036,6 +1398,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1036
1398
  const resolved = surfaces?.seatPitchU(i);
1037
1399
  const pitchM = (resolved ?? spacing[i]) * M;
1038
1400
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1401
+ iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
1039
1402
  iPosition[i * 3] = seat.x * M;
1040
1403
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
1041
1404
  iPosition[i * 3 + 2] = seat.y * M;
@@ -1057,7 +1420,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1057
1420
  iMaxRadius,
1058
1421
  iRing,
1059
1422
  idToIndex,
1060
- iFloor: seatFloor ?? new Float32Array(count)
1423
+ iChairWidth,
1424
+ iFloor: seatFloor ?? new Float32Array(count),
1425
+ iYaw: new Float32Array(count)
1061
1426
  };
1062
1427
  }
1063
1428
  var RUN_MERGE_GAP = 64;
@@ -1192,6 +1557,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
1192
1557
  }
1193
1558
  return kept;
1194
1559
  }
1560
+ var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
1561
+ function focusScore(screen, worldDistance, width, height) {
1562
+ const half = Math.max(1, Math.min(width, height) * 0.5);
1563
+ const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
1564
+ return worldDistance * (1 + 1.5 * off);
1565
+ }
1566
+ function pickDenseLabels(items, separationX, separationY, budget) {
1567
+ const ordered = [...items].sort((a, b) => a.focus - b.focus);
1568
+ const kept = [];
1569
+ for (const item of ordered) {
1570
+ if (kept.length >= budget) break;
1571
+ let clash = false;
1572
+ for (const k of kept) {
1573
+ if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
1574
+ clash = true;
1575
+ break;
1576
+ }
1577
+ }
1578
+ if (!clash) kept.push(item);
1579
+ }
1580
+ return kept;
1581
+ }
1195
1582
  function centroidOf(points) {
1196
1583
  if (!points.length) return null;
1197
1584
  let x = 0, y = 0;
@@ -1732,195 +2119,6 @@ function buildSectionRake(rows, focal) {
1732
2119
  return rowsRake(fits);
1733
2120
  }
1734
2121
 
1735
- // src/view3d/scene/surface.ts
1736
- var FLAT_SLAB_TOP_M = 0.05;
1737
- var CAP_MAX_ERROR_M = 0.05;
1738
- var SEAT_CLEARANCE_M = 0.15;
1739
- var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
1740
- function bboxOf(pts) {
1741
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1742
- for (const p of pts) {
1743
- if (p.x < minX) minX = p.x;
1744
- if (p.y < minY) minY = p.y;
1745
- if (p.x > maxX) maxX = p.x;
1746
- if (p.y > maxY) maxY = p.y;
1747
- }
1748
- return { minX, minY, maxX, maxY };
1749
- }
1750
- var MAX_TIER_RISE_M = 25;
1751
- function buildVenueSurfaces(units, seats) {
1752
- const bySection = /* @__PURE__ */ new Map();
1753
- const seatOwner = new Array(seats.length).fill(null);
1754
- const seatDeck = new Float64Array(seats.length);
1755
- const seatRowLevel = new Array(seats.length).fill(void 0);
1756
- const seatPitch = new Array(seats.length).fill(void 0);
1757
- const acc = /* @__PURE__ */ new Map();
1758
- const boxes = [];
1759
- for (const unit of units) {
1760
- for (const o of unit.objects) {
1761
- if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1762
- const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
1763
- acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
1764
- boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
1765
- }
1766
- }
1767
- for (let i = 0; i < seats.length; i++) {
1768
- const s = seats[i];
1769
- for (const b of boxes) {
1770
- if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
1771
- if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
1772
- seatOwner[i] = b.id;
1773
- const a = acc.get(b.id);
1774
- a.hasSeats = true;
1775
- const f = s.focalPoint ?? b.unit.focal;
1776
- const d = Math.hypot(s.x - f.x, s.y - f.y);
1777
- if (d < a.frontU) a.frontU = d;
1778
- a.seatIndices.push(i);
1779
- const rowKey = s.rowId || `__seat-${i}`;
1780
- const arr = a.rows.get(rowKey);
1781
- if (arr) arr.push({ x: s.x, y: s.y });
1782
- else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
1783
- break;
1784
- }
1785
- }
1786
- for (const [id, a] of acc) {
1787
- const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
1788
- const bottomY = a.unit.baseHeightM;
1789
- const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
1790
- const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
1791
- const kind = a.section.surfaceKind;
1792
- const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
1793
- const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
1794
- const needsRake = structure.rows.length < 2;
1795
- const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
1796
- let frontU = Infinity;
1797
- if (rake) {
1798
- if (a.hasSeats) {
1799
- for (const pts of a.rows.values()) {
1800
- for (const p of pts) {
1801
- const d = rake.depthAt(p.x, p.y);
1802
- if (d < frontU) frontU = d;
1803
- }
1804
- }
1805
- }
1806
- if (!a.hasSeats || !Number.isFinite(frontU)) {
1807
- frontU = Infinity;
1808
- for (const p of a.section.outline) {
1809
- const d = rake.depthAt(p.x, p.y);
1810
- if (d < frontU) frontU = d;
1811
- }
1812
- }
1813
- }
1814
- const flatTop = bottomY + FLAT_SLAB_TOP_M;
1815
- const baseFloor = bottomY + FLAT_SLAB_TOP_M;
1816
- const levelFor = (depthU) => {
1817
- const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
1818
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1819
- return Math.max(baseFloor, geo.height + rise);
1820
- };
1821
- const levelForBlockDepth = (blockDepthU) => {
1822
- const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
1823
- const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
1824
- return Math.max(baseFloor, geo.height + rise);
1825
- };
1826
- const rowLevels = flat ? [] : structure.rows.map((r) => ({
1827
- pts: r.pts,
1828
- y: levelForBlockDepth(r.blockDepth),
1829
- depth: r.blockDepth,
1830
- blockId: r.blockId
1831
- }));
1832
- const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
1833
- const rowBounds = rowLevels.map((r) => {
1834
- let cx = 0, cy = 0;
1835
- for (const p of r.pts) {
1836
- cx += p.x;
1837
- cy += p.y;
1838
- }
1839
- const n = r.pts.length || 1;
1840
- cx /= n;
1841
- cy /= n;
1842
- let rad = 0;
1843
- for (const p of r.pts) {
1844
- const d = Math.hypot(p.x - cx, p.y - cy);
1845
- if (d > rad) rad = d;
1846
- }
1847
- return { cx, cy, rad };
1848
- });
1849
- const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
1850
- let best = Infinity, bestY = landingY;
1851
- for (let i = 0; i < rowLevels.length; i++) {
1852
- const b = rowBounds[i];
1853
- if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
1854
- const d = distanceToPolyline(rowLevels[i].pts, x, y);
1855
- if (d < best) {
1856
- best = d;
1857
- bestY = rowLevels[i].y;
1858
- }
1859
- }
1860
- return bestY;
1861
- } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
1862
- const UP = [0, 1, 0];
1863
- const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
1864
- if (!rake) return UP;
1865
- const d = rake.depthAt(x, y);
1866
- if (d <= frontU) return UP;
1867
- const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
1868
- if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
1869
- if (geo.height + depthM * rakeTan <= baseFloor) return UP;
1870
- const [gx, gy] = rake.gradientAt(x, y);
1871
- if (gx === 0 && gy === 0) return UP;
1872
- const inv = 1 / Math.hypot(rakeTan, 1);
1873
- return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
1874
- };
1875
- if (!flat) {
1876
- for (const r of structure.rows) {
1877
- const y = levelForBlockDepth(r.blockDepth);
1878
- for (const si of r.seatIndices) seatRowLevel[si] = y;
1879
- }
1880
- }
1881
- for (const r of structure.rows) {
1882
- const gaps = [];
1883
- for (let k = 1; k < r.pts.length; k++) {
1884
- const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
1885
- if (d > 1e-6) gaps.push(d);
1886
- }
1887
- gaps.sort((x, y) => x - y);
1888
- const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
1889
- let across = Infinity;
1890
- const probe = r.pts[Math.floor(r.pts.length / 2)];
1891
- if (probe) {
1892
- for (const other of structure.rows) {
1893
- if (other === r || other.blockId !== r.blockId) continue;
1894
- const d = distanceToPolyline(other.pts, probe.x, probe.y);
1895
- if (d > 1e-6 && d < across) across = d;
1896
- }
1897
- }
1898
- const pitch = Math.min(along, Math.max(across, along * 0.5));
1899
- if (Number.isFinite(pitch) && pitch > 0) {
1900
- for (const si of r.seatIndices) seatPitch[si] = pitch;
1901
- }
1902
- }
1903
- bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
1904
- }
1905
- for (let i = 0; i < seats.length; i++) {
1906
- const ownerId = seatOwner[i];
1907
- const s = seats[i];
1908
- if (ownerId) {
1909
- const own = seatRowLevel[i];
1910
- seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
1911
- continue;
1912
- }
1913
- const eye = s.eyeHeightM;
1914
- seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1915
- }
1916
- return {
1917
- bySection,
1918
- seatOwner,
1919
- seatDeckY: (i) => seatDeck[i],
1920
- seatPitchU: (i) => seatPitch[i]
1921
- };
1922
- }
1923
-
1924
2122
  // src/view3d/scene/deckBands.ts
1925
2123
  var import_polygon_clipping2 = __toESM(require("polygon-clipping"), 1);
1926
2124
  var import_earcut2 = __toESM(require("earcut"), 1);
@@ -1969,6 +2167,68 @@ function extendEnds(pts, by) {
1969
2167
  });
1970
2168
  return out;
1971
2169
  }
2170
+ var CORNER_STEP_RAD = Math.PI / 12;
2171
+ function convexHull(pts) {
2172
+ if (pts.length < 3) return [...pts];
2173
+ const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
2174
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
2175
+ const half = (src) => {
2176
+ const out = [];
2177
+ for (const p of src) {
2178
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
2179
+ out.push(p);
2180
+ }
2181
+ out.pop();
2182
+ return out;
2183
+ };
2184
+ const hull = [...half(s), ...half([...s].reverse())];
2185
+ return hull.length >= 3 ? hull : [...pts];
2186
+ }
2187
+ function seatClusterPatch(pts, pad) {
2188
+ if (!pts.length || !(pad > 0)) return [];
2189
+ const hull = convexHull(pts);
2190
+ const arc = (v, from, to, out2) => {
2191
+ let sweep = to - from;
2192
+ while (sweep < 0) sweep += Math.PI * 2;
2193
+ while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
2194
+ const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
2195
+ const r = pad / Math.cos(sweep / steps / 2);
2196
+ for (let k = 0; k <= steps; k++) {
2197
+ const a = from + sweep * k / steps;
2198
+ out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
2199
+ }
2200
+ };
2201
+ if (hull.length < 3) {
2202
+ let cx = 0, cy = 0;
2203
+ for (const p of hull) {
2204
+ cx += p.x;
2205
+ cy += p.y;
2206
+ }
2207
+ cx /= hull.length || 1;
2208
+ cy /= hull.length || 1;
2209
+ let far = 0;
2210
+ for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
2211
+ const out2 = [];
2212
+ const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
2213
+ const r = (far + pad) / Math.cos(Math.PI / steps);
2214
+ for (let k = 0; k < steps; k++) {
2215
+ const a = k / steps * Math.PI * 2;
2216
+ out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
2217
+ }
2218
+ return out2;
2219
+ }
2220
+ const n = hull.length;
2221
+ const edgeAngle = [];
2222
+ for (let i = 0; i < n; i++) {
2223
+ const a = hull[i], b = hull[(i + 1) % n];
2224
+ edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
2225
+ }
2226
+ const out = [];
2227
+ for (let i = 0; i < n; i++) {
2228
+ arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
2229
+ }
2230
+ return dedupeAdjacent(out);
2231
+ }
1972
2232
  function distToPolyline(pts, x, y) {
1973
2233
  if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1974
2234
  let best = Infinity;
@@ -2099,7 +2359,14 @@ function deckFootprints(rows, focal, shared) {
2099
2359
  else byBlock.set(rows[i].blockId, [i]);
2100
2360
  }
2101
2361
  const out = [];
2102
- for (const [, indices] of byBlock) {
2362
+ for (const [, allIndices] of byBlock) {
2363
+ const indices = [];
2364
+ for (const i of allIndices) {
2365
+ const patch = rows[i].patch;
2366
+ if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
2367
+ else indices.push(i);
2368
+ }
2369
+ if (!indices.length) continue;
2103
2370
  indices.sort((a, b) => rows[a].depth - rows[b].depth);
2104
2371
  const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
2105
2372
  const usable = ribbons.filter((r) => r !== null);
@@ -2208,106 +2475,351 @@ var ClipTest = class {
2208
2475
  if (p.y > this.maxY) this.maxY = p.y;
2209
2476
  }
2210
2477
  }
2211
- }
2212
- /** True when every point lies strictly inside ONE ring of the clip region. */
2213
- containsAll(pts) {
2214
- for (const p of pts) {
2215
- if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
2216
- }
2217
- for (const ring of this.rings) {
2218
- let all = true;
2219
- for (const p of pts) {
2220
- if (!pointInRing(ring, p.x, p.y)) {
2221
- all = false;
2222
- break;
2478
+ }
2479
+ /** True when every point lies strictly inside ONE ring of the clip region. */
2480
+ containsAll(pts) {
2481
+ for (const p of pts) {
2482
+ if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
2483
+ }
2484
+ for (const ring of this.rings) {
2485
+ let all = true;
2486
+ for (const p of pts) {
2487
+ if (!pointInRing(ring, p.x, p.y)) {
2488
+ all = false;
2489
+ break;
2490
+ }
2491
+ }
2492
+ if (all) return true;
2493
+ }
2494
+ return false;
2495
+ }
2496
+ };
2497
+ function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
2498
+ if (poly.length < 3) return;
2499
+ if (clipTest.containsAll(poly)) {
2500
+ const UPF = [0, 1, 0];
2501
+ const a = poly[0];
2502
+ for (let i = 1; i + 1 < poly.length; i++) {
2503
+ const b = poly[i], c = poly[i + 1];
2504
+ builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
2505
+ }
2506
+ return;
2507
+ }
2508
+ const ring = poly.map((p) => [p.x, p.y]);
2509
+ ring.push(ring[0]);
2510
+ let pieces;
2511
+ try {
2512
+ pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
2513
+ } catch {
2514
+ return;
2515
+ }
2516
+ const UP = [0, 1, 0];
2517
+ for (const poly2 of pieces) {
2518
+ if (!poly2.length || poly2[0].length < 4) continue;
2519
+ const outer = poly2[0];
2520
+ const flat = [];
2521
+ const pts = [];
2522
+ for (let i = 0; i < outer.length - 1; i++) {
2523
+ flat.push(outer[i][0], outer[i][1]);
2524
+ pts.push([outer[i][0], outer[i][1]]);
2525
+ }
2526
+ if (pts.length < 3) continue;
2527
+ const tris = (0, import_earcut2.default)(flat, void 0, 2);
2528
+ for (let i = 0; i < tris.length; i += 3) {
2529
+ const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
2530
+ builder.tri(
2531
+ [a[0] * M, y, a[1] * M],
2532
+ [b[0] * M, y, b[1] * M],
2533
+ [c[0] * M, y, c[1] * M],
2534
+ UP,
2535
+ color
2536
+ );
2537
+ }
2538
+ }
2539
+ }
2540
+ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2541
+ if (rows.length < 2) return;
2542
+ const nbrs = shared ?? rowNeighbourhoods(rows);
2543
+ const UP = [0, 1, 0];
2544
+ const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2545
+ const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2546
+ for (let i = 0; i < rows.length; i++) {
2547
+ const row = rows[i];
2548
+ if (row.patch && row.patch.length >= 3) {
2549
+ const patch = [...row.patch];
2550
+ const belowY2 = nbrs[i].belowY ?? landingY;
2551
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2552
+ else {
2553
+ const a = patch[0];
2554
+ for (let k = 1; k + 1 < patch.length; k++) {
2555
+ const b = patch[k], c = patch[k + 1];
2556
+ builder.tri([a.x * M, row.y, a.y * M], [b.x * M, row.y, b.y * M], [c.x * M, row.y, c.y * M], UP, colors.tread);
2557
+ }
2558
+ }
2559
+ if (row.y > belowY2 + MIN_RISER_M) {
2560
+ let cx = 0, cy = 0;
2561
+ for (const p of patch) {
2562
+ cx += p.x;
2563
+ cy += p.y;
2564
+ }
2565
+ cx /= patch.length;
2566
+ cy /= patch.length;
2567
+ for (let k = 0; k < patch.length; k++) {
2568
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2569
+ const dx = q.x - p.x, dy = q.y - p.y;
2570
+ const len = Math.hypot(dx, dy);
2571
+ if (len < 1e-6) continue;
2572
+ let nx = dy / len, ny = -dx / len;
2573
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2574
+ nx = -nx;
2575
+ ny = -ny;
2576
+ }
2577
+ const rn = [nx, 0, ny];
2578
+ const pt = [p.x * M, row.y, p.y * M];
2579
+ const qt = [q.x * M, row.y, q.y * M];
2580
+ const pb = [p.x * M, belowY2, p.y * M];
2581
+ const qb = [q.x * M, belowY2, q.y * M];
2582
+ builder.tri(pt, qt, qb, rn, colors.riser);
2583
+ builder.tri(pt, qb, pb, rn, colors.riser);
2584
+ }
2585
+ }
2586
+ continue;
2587
+ }
2588
+ const rib = ribbonOf(rows, i, nbrs, focal);
2589
+ if (!rib) continue;
2590
+ const { pts, nrm, front, back } = rib;
2591
+ const belowY = nbrs[i].belowY ?? landingY;
2592
+ for (let k = 0; k + 1 < pts.length; k++) {
2593
+ const p = pts[k], q = pts[k + 1];
2594
+ const np = nrm[k], nq = nrm[k + 1];
2595
+ if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
2596
+ if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
2597
+ const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
2598
+ const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2599
+ const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2600
+ const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2601
+ if (clipRing && clipTest) {
2602
+ emitClippedPoly(builder, clipRing, clipTest, [
2603
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
2604
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2605
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2606
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
2607
+ ], row.y, colors.tread);
2608
+ } else {
2609
+ builder.tri(pF, qF, qB, UP, colors.tread);
2610
+ builder.tri(pF, qB, pB, UP, colors.tread);
2611
+ }
2612
+ if (row.y > belowY + MIN_RISER_M) {
2613
+ const pFd = [pF[0], belowY, pF[2]];
2614
+ const qFd = [qF[0], belowY, qF[2]];
2615
+ const rn = [-np[0], 0, -np[1]];
2616
+ builder.tri(pF, qF, qFd, rn, colors.riser);
2617
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
2618
+ }
2619
+ }
2620
+ }
2621
+ }
2622
+
2623
+ // src/view3d/scene/surface.ts
2624
+ var FLAT_SLAB_TOP_M = 0.05;
2625
+ var CAP_MAX_ERROR_M = 0.05;
2626
+ var SEAT_CLEARANCE_M = 0.15;
2627
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
2628
+ function bboxOf(pts) {
2629
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2630
+ for (const p of pts) {
2631
+ if (p.x < minX) minX = p.x;
2632
+ if (p.y < minY) minY = p.y;
2633
+ if (p.x > maxX) maxX = p.x;
2634
+ if (p.y > maxY) maxY = p.y;
2635
+ }
2636
+ return { minX, minY, maxX, maxY };
2637
+ }
2638
+ function pointInRing2(ring, x, y) {
2639
+ let inside = false;
2640
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2641
+ const a = ring[i], b = ring[j];
2642
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
2643
+ }
2644
+ return inside;
2645
+ }
2646
+ var MAX_TIER_RISE_M = 25;
2647
+ function buildVenueSurfaces(units, seats) {
2648
+ const bySection = /* @__PURE__ */ new Map();
2649
+ const seatOwner = new Array(seats.length).fill(null);
2650
+ const seatDeck = new Float64Array(seats.length);
2651
+ const seatRowLevel = new Array(seats.length).fill(void 0);
2652
+ const seatPitch = new Array(seats.length).fill(void 0);
2653
+ const acc = /* @__PURE__ */ new Map();
2654
+ const boxes = [];
2655
+ const tableRowIds = /* @__PURE__ */ new Set();
2656
+ for (const unit of units) {
2657
+ for (const o of unit.objects) {
2658
+ if (o.type === "table") tableRowIds.add(o.id);
2659
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2660
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2661
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2662
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2663
+ }
2664
+ }
2665
+ for (let i = 0; i < seats.length; i++) {
2666
+ const s = seats[i];
2667
+ for (const b of boxes) {
2668
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2669
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2670
+ seatOwner[i] = b.id;
2671
+ const a = acc.get(b.id);
2672
+ a.hasSeats = true;
2673
+ const f = s.focalPoint ?? b.unit.focal;
2674
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
2675
+ if (d < a.frontU) a.frontU = d;
2676
+ a.seatIndices.push(i);
2677
+ const rowKey = s.rowId || `__seat-${i}`;
2678
+ const arr = a.rows.get(rowKey);
2679
+ if (arr) arr.push({ x: s.x, y: s.y });
2680
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
2681
+ break;
2682
+ }
2683
+ }
2684
+ for (const [id, a] of acc) {
2685
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
2686
+ const bottomY = a.unit.baseHeightM;
2687
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
2688
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
2689
+ const kind = a.section.surfaceKind;
2690
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
2691
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
2692
+ const needsRake = structure.rows.length < 2;
2693
+ const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
2694
+ let frontU = Infinity;
2695
+ if (rake) {
2696
+ if (a.hasSeats) {
2697
+ for (const pts of a.rows.values()) {
2698
+ for (const p of pts) {
2699
+ const d = rake.depthAt(p.x, p.y);
2700
+ if (d < frontU) frontU = d;
2701
+ }
2702
+ }
2703
+ }
2704
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
2705
+ frontU = Infinity;
2706
+ for (const p of a.section.outline) {
2707
+ const d = rake.depthAt(p.x, p.y);
2708
+ if (d < frontU) frontU = d;
2709
+ }
2710
+ }
2711
+ }
2712
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
2713
+ const flatTop = Math.max(baseFloor, geo.height);
2714
+ const levelFor = (depthU) => {
2715
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
2716
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2717
+ return Math.max(baseFloor, geo.height + rise);
2718
+ };
2719
+ const levelForBlockDepth = (blockDepthU) => {
2720
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
2721
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2722
+ return Math.max(baseFloor, geo.height + rise);
2723
+ };
2724
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
2725
+ pts: r.pts,
2726
+ y: levelForBlockDepth(r.blockDepth),
2727
+ depth: r.blockDepth,
2728
+ blockId: r.blockId,
2729
+ ...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
2730
+ }));
2731
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
2732
+ const rowBounds = rowLevels.map((r) => {
2733
+ const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
2734
+ let cx = 0, cy = 0;
2735
+ for (const p of ext) {
2736
+ cx += p.x;
2737
+ cy += p.y;
2738
+ }
2739
+ const n = ext.length || 1;
2740
+ cx /= n;
2741
+ cy /= n;
2742
+ let rad = 0;
2743
+ for (const p of ext) {
2744
+ const d = Math.hypot(p.x - cx, p.y - cy);
2745
+ if (d > rad) rad = d;
2746
+ }
2747
+ return { cx, cy, rad };
2748
+ });
2749
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
2750
+ let best = Infinity, bestY = landingY;
2751
+ for (let i = 0; i < rowLevels.length; i++) {
2752
+ const b = rowBounds[i];
2753
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
2754
+ const patch = rowLevels[i].patch;
2755
+ const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
2756
+ if (d < best) {
2757
+ best = d;
2758
+ bestY = rowLevels[i].y;
2223
2759
  }
2224
2760
  }
2225
- if (all) return true;
2226
- }
2227
- return false;
2228
- }
2229
- };
2230
- function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
2231
- if (clipTest.containsAll(quad)) {
2232
- const UPF = [0, 1, 0];
2233
- const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
2234
- 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);
2235
- 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);
2236
- return;
2237
- }
2238
- const ring = quad.map((p) => [p.x, p.y]);
2239
- ring.push(ring[0]);
2240
- let pieces;
2241
- try {
2242
- pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
2243
- } catch {
2244
- return;
2245
- }
2246
- const UP = [0, 1, 0];
2247
- for (const poly of pieces) {
2248
- if (!poly.length || poly[0].length < 4) continue;
2249
- const outer = poly[0];
2250
- const flat = [];
2251
- const pts = [];
2252
- for (let i = 0; i < outer.length - 1; i++) {
2253
- flat.push(outer[i][0], outer[i][1]);
2254
- pts.push([outer[i][0], outer[i][1]]);
2255
- }
2256
- if (pts.length < 3) continue;
2257
- const tris = (0, import_earcut2.default)(flat, void 0, 2);
2258
- for (let i = 0; i < tris.length; i += 3) {
2259
- const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
2260
- builder.tri(
2261
- [a[0] * M, y, a[1] * M],
2262
- [b[0] * M, y, b[1] * M],
2263
- [c[0] * M, y, c[1] * M],
2264
- UP,
2265
- color
2266
- );
2761
+ return bestY;
2762
+ } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
2763
+ const UP = [0, 1, 0];
2764
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
2765
+ if (!rake) return UP;
2766
+ const d = rake.depthAt(x, y);
2767
+ if (d <= frontU) return UP;
2768
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
2769
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
2770
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
2771
+ const [gx, gy] = rake.gradientAt(x, y);
2772
+ if (gx === 0 && gy === 0) return UP;
2773
+ const inv = 1 / Math.hypot(rakeTan, 1);
2774
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
2775
+ };
2776
+ if (!flat) {
2777
+ for (const r of structure.rows) {
2778
+ const y = levelForBlockDepth(r.blockDepth);
2779
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
2780
+ }
2267
2781
  }
2268
- }
2269
- }
2270
- function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2271
- if (rows.length < 2) return;
2272
- const nbrs = shared ?? rowNeighbourhoods(rows);
2273
- const UP = [0, 1, 0];
2274
- const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2275
- const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2276
- for (let i = 0; i < rows.length; i++) {
2277
- const row = rows[i];
2278
- const rib = ribbonOf(rows, i, nbrs, focal);
2279
- if (!rib) continue;
2280
- const { pts, nrm, front, back } = rib;
2281
- const belowY = nbrs[i].belowY ?? landingY;
2282
- for (let k = 0; k + 1 < pts.length; k++) {
2283
- const p = pts[k], q = pts[k + 1];
2284
- const np = nrm[k], nq = nrm[k + 1];
2285
- if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
2286
- if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
2287
- const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
2288
- const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2289
- const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2290
- const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2291
- if (clipRing && clipTest) {
2292
- emitClippedQuad(builder, clipRing, clipTest, [
2293
- { x: p.x - np[0] * front, y: p.y - np[1] * front },
2294
- { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2295
- { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2296
- { x: p.x + np[0] * back, y: p.y + np[1] * back }
2297
- ], row.y, colors.tread);
2298
- } else {
2299
- builder.tri(pF, qF, qB, UP, colors.tread);
2300
- builder.tri(pF, qB, pB, UP, colors.tread);
2782
+ for (const r of structure.rows) {
2783
+ const gaps = [];
2784
+ for (let k = 1; k < r.pts.length; k++) {
2785
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
2786
+ if (d > 1e-6) gaps.push(d);
2301
2787
  }
2302
- if (row.y > belowY + MIN_RISER_M) {
2303
- const pFd = [pF[0], belowY, pF[2]];
2304
- const qFd = [qF[0], belowY, qF[2]];
2305
- const rn = [-np[0], 0, -np[1]];
2306
- builder.tri(pF, qF, qFd, rn, colors.riser);
2307
- builder.tri(pF, qFd, pFd, rn, colors.riser);
2788
+ gaps.sort((x, y) => x - y);
2789
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
2790
+ let across = Infinity;
2791
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
2792
+ if (probe) {
2793
+ for (const other of structure.rows) {
2794
+ if (other === r || other.blockId !== r.blockId) continue;
2795
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
2796
+ if (d > 1e-6 && d < across) across = d;
2797
+ }
2798
+ }
2799
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
2800
+ if (Number.isFinite(pitch) && pitch > 0) {
2801
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
2308
2802
  }
2309
2803
  }
2804
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
2805
+ }
2806
+ for (let i = 0; i < seats.length; i++) {
2807
+ const ownerId = seatOwner[i];
2808
+ const s = seats[i];
2809
+ if (ownerId) {
2810
+ const own = seatRowLevel[i];
2811
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
2812
+ continue;
2813
+ }
2814
+ const eye = s.eyeHeightM;
2815
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
2310
2816
  }
2817
+ return {
2818
+ bySection,
2819
+ seatOwner,
2820
+ seatDeckY: (i) => seatDeck[i],
2821
+ seatPitchU: (i) => seatPitch[i]
2822
+ };
2311
2823
  }
2312
2824
 
2313
2825
  // src/view3d/scene/sceneModel.ts
@@ -2475,22 +2987,39 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2475
2987
  }
2476
2988
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
2477
2989
  const topN = (p) => surface.normalAt(p.x, p.y);
2478
- for (const ring of claimed.subtract(outline)) {
2990
+ const level = surface.flat ? surface.landingY : void 0;
2991
+ for (const ring of claimed.subtract(outline, level)) {
2479
2992
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
2480
2993
  }
2481
2994
  }
2995
+ function coplanarLevels(a, b) {
2996
+ if (a === void 0 || b === void 0) return true;
2997
+ return Math.abs(a - b) < 1e-3;
2998
+ }
2482
2999
  var ClaimedArea = class {
2483
3000
  constructor() {
2484
3001
  this.rings = [];
2485
3002
  this.boxes = [];
3003
+ /** Constant deck height of each claim, or undefined when it is not level. */
3004
+ this.levels = [];
2486
3005
  }
2487
- /** `ring` minus everything claimed so far; then claim what is returned. */
2488
- subtract(ring) {
3006
+ /**
3007
+ * `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
3008
+ *
3009
+ * `level` is the claim's constant deck height when it has one. Two decks only
3010
+ * z-fight when they are coplanar, so only a coplanar claim may take ground
3011
+ * away — an elevated box hanging over a ground-level section overlaps it in
3012
+ * plan and must still draw its whole floor, or the box loses the part of its
3013
+ * deck that shares a footprint with whatever is underneath it. Passing
3014
+ * undefined (a surface with no single height) keeps the original
3015
+ * clip-against-everything behaviour.
3016
+ */
3017
+ subtract(ring, level) {
2489
3018
  const closed = ring.map((p) => [p.x, p.y]);
2490
3019
  if (closed.length < 3) return [];
2491
3020
  closed.push(closed[0]);
2492
3021
  const box = bboxOfRing(ring);
2493
- const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
3022
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
2494
3023
  let pieces = [[closed]];
2495
3024
  if (overlapping.length) {
2496
3025
  try {
@@ -2502,6 +3031,7 @@ var ClaimedArea = class {
2502
3031
  }
2503
3032
  this.rings.push([closed]);
2504
3033
  this.boxes.push(box);
3034
+ this.levels.push(level);
2505
3035
  const out = [];
2506
3036
  for (const poly of pieces) {
2507
3037
  if (!poly.length) continue;
@@ -2957,6 +3487,15 @@ function buildSceneModel(input) {
2957
3487
  });
2958
3488
  }
2959
3489
  }
3490
+ seatData.iYaw = computeSeatYaw(
3491
+ seatData.iPosition,
3492
+ seats.length,
3493
+ (i) => seats[i].rowId,
3494
+ (i) => {
3495
+ const f = units[seatFloor[i]]?.focal ?? focal;
3496
+ return [f.x * M, f.y * M];
3497
+ }
3498
+ );
2960
3499
  const cx = (fp.minX + fp.maxX) / 2 * M;
2961
3500
  const cz = (fp.minY + fp.maxY) / 2 * M;
2962
3501
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
@@ -2994,7 +3533,11 @@ var KIND_STYLE = {
2994
3533
  var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
2995
3534
  var DENSE_SEPARATION = {
2996
3535
  row: { x: 62, y: 16 },
2997
- seat: { x: 24, y: 13 }
3536
+ // Widened from 24: at close range seat labels stopped overlapping at all, so
3537
+ // the separation had no work left to do and the budget was carrying the whole
3538
+ // load. A wider box means the few labels that ARE kept are spread across the
3539
+ // seating instead of clustering into one stack.
3540
+ seat: { x: 46, y: 18 }
2998
3541
  };
2999
3542
  var LabelOverlay = class {
3000
3543
  constructor(container, opts = {}) {
@@ -3025,7 +3568,7 @@ var LabelOverlay = class {
3025
3568
  *
3026
3569
  * `viewProjection` is column-major, as OGL supplies it.
3027
3570
  */
3028
- update(viewProjection, width, height, cameraDistance, venueRadius) {
3571
+ update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
3029
3572
  if (!this.labels.length) return;
3030
3573
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
3031
3574
  const candidates = [];
@@ -3033,15 +3576,24 @@ var LabelOverlay = class {
3033
3576
  if (!kinds.has(label.kind)) continue;
3034
3577
  const screen = projectToScreen(viewProjection, label.anchor, width, height);
3035
3578
  if (!screen.visible) continue;
3036
- candidates.push({ label, screen });
3579
+ const world = cameraWorld ? Math.hypot(
3580
+ label.anchor[0] - cameraWorld[0],
3581
+ label.anchor[1] - cameraWorld[1],
3582
+ label.anchor[2] - cameraWorld[2]
3583
+ ) : 1;
3584
+ candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
3037
3585
  }
3038
3586
  const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
3039
3587
  const kept = [
3040
3588
  ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
3041
- ...["row", "seat"].flatMap((kind) => cullOverlapping(
3589
+ // The dense rungs are BUDGETED, not merely deduplicated — see
3590
+ // DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
3591
+ // the camera gets.
3592
+ ...["row", "seat"].flatMap((kind) => pickDenseLabels(
3042
3593
  candidates.filter((c) => c.label.kind === kind),
3043
3594
  DENSE_SEPARATION[kind].x,
3044
- DENSE_SEPARATION[kind].y
3595
+ DENSE_SEPARATION[kind].y,
3596
+ DENSE_LABEL_BUDGET[kind]
3045
3597
  ))
3046
3598
  ];
3047
3599
  const keptIds = new Set(kept.map((k) => k.label.id));
@@ -3090,6 +3642,13 @@ var import_ogl4 = require("ogl");
3090
3642
 
3091
3643
  // src/view3d/scene/materials.ts
3092
3644
  var import_ogl3 = require("ogl");
3645
+ var CHAIR_WEIGHT_GLSL = (
3646
+ /* glsl */
3647
+ `
3648
+ float chairWeight(float depth) {
3649
+ return 1.0 - smoothstep(uChairFull, uChairNone, depth);
3650
+ }`
3651
+ );
3093
3652
  var SOLID_VERT = (
3094
3653
  /* glsl */
3095
3654
  `#version 300 es
@@ -3169,14 +3728,23 @@ uniform float uSeatScale;
3169
3728
  uniform float uMinPixels;
3170
3729
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
3171
3730
  uniform float uFocusFloor; // -1 = show every floor
3731
+ uniform float uChairFull; // view depth at which the chair mesh is full size
3732
+ uniform float uChairNone; // ...and at which it has scaled away entirely
3172
3733
  out vec2 vUv;
3173
3734
  out vec3 vColor;
3174
3735
  out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
3175
3736
  out vec3 vRing;
3176
3737
  out float vDim;
3738
+ out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3739
+ ${CHAIR_WEIGHT_GLSL}
3177
3740
  void main() {
3178
3741
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
3179
3742
  float depth = max(-mv.z, 0.001);
3743
+ // Hand the seat over to the chair mesh as it comes into range. Derived from
3744
+ // this instance's OWN depth rather than from a global uniform, so a row two
3745
+ // metres away and the far side of the bowl resolve differently in the same
3746
+ // frame \u2014 which is the entire point of a ladder over a switch.
3747
+ vDotWeight = 1.0 - chairWeight(depth);
3180
3748
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
3181
3749
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
3182
3750
  // unbounded growth is what merges neighbouring rows into one mass at range.
@@ -3210,6 +3778,7 @@ in vec3 vColor;
3210
3778
  in float vBudget;
3211
3779
  in vec3 vRing;
3212
3780
  in float vDim;
3781
+ in float vDotWeight;
3213
3782
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
3214
3783
  uniform vec3 uFadeColor;
3215
3784
  out vec4 fragColor;
@@ -3234,9 +3803,131 @@ void main() {
3234
3803
  // Seats on an unfocused floor recede with their structure.
3235
3804
  c = mix(c, uFadeColor, vDim * 0.75);
3236
3805
  alpha *= mix(1.0, 0.30, vDim);
3806
+ // Yield to the chair. The chair grows out of this exact point, so through the
3807
+ // band the dot is always at least as big as the chair inside it and the seat
3808
+ // never thins out to nothing in between.
3809
+ alpha *= vDotWeight;
3810
+ if (alpha <= 0.0) discard;
3237
3811
  fragColor = vec4(c, alpha);
3238
3812
  }`
3239
3813
  );
3814
+ var CHAIR_VERT = (
3815
+ /* glsl */
3816
+ `#version 300 es
3817
+ precision highp float;
3818
+ in vec3 position; // local: x/z in units of the seat radius, y in METRES
3819
+ in vec3 normal;
3820
+ in float part; // 0 = pedestal, 1 = pad, 2 = back
3821
+ in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3822
+ in vec3 iColor; // per-instance state colour
3823
+ in float iRadius; // per-instance horizontal half-width, world metres
3824
+ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3825
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3826
+ in float iFloor;
3827
+ uniform mat4 modelViewMatrix;
3828
+ uniform mat4 projectionMatrix;
3829
+ uniform float uChairFull;
3830
+ uniform float uChairNone;
3831
+ uniform float uFocusFloor;
3832
+ uniform float uBackRake; // metres of z per metre of rise, above uBackBase
3833
+ uniform float uBackBase; // local height at which the back starts
3834
+ out vec3 vColor;
3835
+ out vec3 vNormalWorld;
3836
+ out vec3 vNormalView;
3837
+ out vec3 vPosView;
3838
+ out float vPart;
3839
+ out float vHeight; // local height in metres, for the vertical occlusion ramp
3840
+ out vec3 vRing;
3841
+ out float vDim;
3842
+ ${CHAIR_WEIGHT_GLSL}
3843
+ void main() {
3844
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
3845
+ float w = chairWeight(max(-anchor.z, 0.001));
3846
+ // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
3847
+ // chairs, but nobody gets a short one (people are the same height at every
3848
+ // seat pitch).
3849
+ vec3 p = position;
3850
+ p.xz *= iRadius;
3851
+ // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3852
+ // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3853
+ // width and the same chair would lean 20 degrees on a wide stadium row and
3854
+ // 6 on a tight theatre one.
3855
+ float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3856
+ p.z -= rake;
3857
+ // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3858
+ // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
3859
+ // the growth so the chair is already near full size while the dot is still
3860
+ // half there; see chairScale() in lod.ts.
3861
+ p *= sqrt(w);
3862
+ float c = cos(iYaw), s = sin(iYaw);
3863
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
3864
+ // Normals under the same two transforms, in reverse and inverted-transposed.
3865
+ // The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
3866
+ // unchanged; and the rake is a shear, whose normal transform adds a y term.
3867
+ // Skipping either lights the raked back as though it were still vertical.
3868
+ vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3869
+ if (part > 1.5) n.y += uBackRake * n.z;
3870
+ n = normalize(n);
3871
+ vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
3872
+ vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
3873
+ vPosView = mv.xyz;
3874
+ vNormalWorld = rn;
3875
+ vNormalView = normalize(mat3(modelViewMatrix) * rn);
3876
+ vColor = iColor;
3877
+ vPart = part;
3878
+ vHeight = position.y;
3879
+ vRing = iRing;
3880
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3881
+ gl_Position = projectionMatrix * mv;
3882
+ }`
3883
+ );
3884
+ var CHAIR_FRAG = (
3885
+ /* glsl */
3886
+ `#version 300 es
3887
+ precision highp float;
3888
+ in vec3 vColor;
3889
+ in vec3 vNormalWorld;
3890
+ in vec3 vNormalView;
3891
+ in vec3 vPosView;
3892
+ in float vPart;
3893
+ in float vHeight;
3894
+ in vec3 vRing;
3895
+ in float vDim;
3896
+ uniform vec3 uKeyDir;
3897
+ uniform vec3 uFadeColor;
3898
+ out vec4 fragColor;
3899
+ void main() {
3900
+ vec3 N = normalize(vNormalWorld);
3901
+ vec3 V = normalize(-vPosView);
3902
+ // The solids' rig, unchanged, so the chairs sit in the venue's light.
3903
+ float hemi = 0.5 + 0.5 * N.y;
3904
+ float key = max(dot(N, uKeyDir), 0.0);
3905
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
3906
+ float fill = max(dot(N, fillDir), 0.0);
3907
+ vec3 tint = vColor;
3908
+ // The accommodation ring, kept legible once the dot (which drew it) is gone:
3909
+ // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
3910
+ // survives to close range instead of vanishing exactly when the buyer arrives.
3911
+ float ringMask = step(0.001, dot(vRing, vRing));
3912
+ if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
3913
+ // Pad brightest, back a step below it, pedestal darkest. Three untextured
3914
+ // boxes only read as one object if they are separated tonally \u2014 with a single
3915
+ // flat colour the chair silhouettes as a crate.
3916
+ float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
3917
+ // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
3918
+ // sits to the deck the less sky it can actually see. This is the depth cue \u2014
3919
+ // without it the pad top, the back and the deck all resolve to the same flat
3920
+ // value and the row loses its form entirely.
3921
+ float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
3922
+ vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
3923
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3924
+ // A brighter rim than the solids get: it picks out every chair's own edge,
3925
+ // which is what stops a block of them merging into one mass up close.
3926
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
3927
+ base = mix(base, uFadeColor, vDim * 0.75);
3928
+ fragColor = vec4(base, 1.0);
3929
+ }`
3930
+ );
3240
3931
  var BG_VERT = (
3241
3932
  /* glsl */
3242
3933
  `#version 300 es
@@ -3333,7 +4024,7 @@ function createSeatPickProgram(gl) {
3333
4024
  uniforms: {
3334
4025
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
3335
4026
  uSeatScale: { value: 1 },
3336
- uMinPixels: { value: 2.5 },
4027
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
3337
4028
  uPixelToWorld: { value: 2e-3 }
3338
4029
  }
3339
4030
  });
@@ -3378,11 +4069,32 @@ function createSeatProgram(gl) {
3378
4069
  uniforms: {
3379
4070
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
3380
4071
  uSeatScale: { value: 1 },
3381
- uMinPixels: { value: 2.5 },
4072
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
3382
4073
  uPixelToWorld: { value: 2e-3 },
3383
4074
  uSeatFade: { value: 0 },
3384
4075
  uFocusFloor: { value: -1 },
3385
- uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
4076
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4077
+ uChairFull: { value: CHAIR_FULL_M },
4078
+ uChairNone: { value: CHAIR_NONE_M }
4079
+ }
4080
+ });
4081
+ }
4082
+ function createChairProgram(gl) {
4083
+ return new import_ogl3.Program(gl, {
4084
+ vertex: CHAIR_VERT,
4085
+ fragment: CHAIR_FRAG,
4086
+ transparent: false,
4087
+ depthTest: true,
4088
+ depthWrite: true,
4089
+ cullFace: false,
4090
+ uniforms: {
4091
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
4092
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4093
+ uChairFull: { value: CHAIR_FULL_M },
4094
+ uChairNone: { value: CHAIR_NONE_M },
4095
+ uBackRake: { value: BACK_RAKE_SLOPE },
4096
+ uBackBase: { value: BACK_BASE_M },
4097
+ uFocusFloor: { value: -1 }
3386
4098
  }
3387
4099
  });
3388
4100
  }
@@ -3448,16 +4160,90 @@ function buildGpuScene(gl, model) {
3448
4160
  seatMesh.frustumCulled = false;
3449
4161
  if (model.seats.count > 0) seatMesh.setParent(main);
3450
4162
  const colorAttr = seatGeo.attributes.iColor;
3451
- return {
4163
+ const chairBase = buildChairMesh();
4164
+ const chairProg = createChairProgram(gl);
4165
+ const CAP = CHAIR_MAX_INSTANCES;
4166
+ const cOffset = new Float32Array(CAP * 3);
4167
+ const cColor = new Float32Array(CAP * 3);
4168
+ const cRadius = new Float32Array(CAP);
4169
+ const cYaw = new Float32Array(CAP);
4170
+ const cRing = new Float32Array(CAP * 3);
4171
+ const cFloor = new Float32Array(CAP);
4172
+ const chairGeo = new import_ogl4.Geometry(gl, {
4173
+ position: { size: 3, data: chairBase.position },
4174
+ normal: { size: 3, data: chairBase.normal },
4175
+ part: { size: 1, data: chairBase.part },
4176
+ index: { data: chairBase.index },
4177
+ iOffset: { size: 3, data: cOffset, instanced: 1 },
4178
+ iColor: { size: 3, data: cColor, instanced: 1 },
4179
+ iRadius: { size: 1, data: cRadius, instanced: 1 },
4180
+ iYaw: { size: 1, data: cYaw, instanced: 1 },
4181
+ iRing: { size: 3, data: cRing, instanced: 1 },
4182
+ iFloor: { size: 1, data: cFloor, instanced: 1 }
4183
+ });
4184
+ const chairMesh = new import_ogl4.Mesh(gl, { geometry: chairGeo, program: chairProg });
4185
+ chairMesh.frustumCulled = false;
4186
+ let nearCount = 0;
4187
+ let nearIndices = new Int32Array(0);
4188
+ const writeChairColors = () => {
4189
+ const src = model.seats;
4190
+ for (let k = 0; k < nearCount; k++) {
4191
+ const i = nearIndices[k];
4192
+ const c = stateColors[src.iState[i]] ?? stateColors[0];
4193
+ cColor[k * 3] = c[0];
4194
+ cColor[k * 3 + 1] = c[1];
4195
+ cColor[k * 3 + 2] = c[2];
4196
+ }
4197
+ chairGeo.attributes.iColor.needsUpdate = true;
4198
+ };
4199
+ const scene = {
3452
4200
  main,
3453
4201
  background,
3454
4202
  seatProgram: seatProg,
3455
4203
  solidProgram: solidProg,
4204
+ chairProgram: chairProg,
3456
4205
  seatGeometry: seatGeo,
3457
4206
  solidGeometry: solidGeo,
3458
4207
  drawCalls: 3,
4208
+ setNearSeats(indices, count) {
4209
+ const n = Math.min(count, CAP);
4210
+ nearIndices = indices;
4211
+ nearCount = n;
4212
+ if (n === 0) {
4213
+ if (chairMesh.parent) chairMesh.setParent(null);
4214
+ chairGeo.instancedCount = 0;
4215
+ scene.drawCalls = 3;
4216
+ return;
4217
+ }
4218
+ const src = model.seats;
4219
+ for (let k = 0; k < n; k++) {
4220
+ const i = indices[k];
4221
+ cOffset[k * 3] = src.iPosition[i * 3];
4222
+ cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
4223
+ cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
4224
+ cRadius[k] = src.iChairWidth[i];
4225
+ cYaw[k] = src.iYaw[i];
4226
+ cRing[k * 3] = src.iRing[i * 3];
4227
+ cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
4228
+ cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
4229
+ cFloor[k] = src.iFloor[i];
4230
+ }
4231
+ writeChairColors();
4232
+ chairGeo.attributes.iOffset.needsUpdate = true;
4233
+ chairGeo.attributes.iRadius.needsUpdate = true;
4234
+ chairGeo.attributes.iYaw.needsUpdate = true;
4235
+ chairGeo.attributes.iRing.needsUpdate = true;
4236
+ chairGeo.attributes.iFloor.needsUpdate = true;
4237
+ chairGeo.instancedCount = n;
4238
+ if (!chairMesh.parent) chairMesh.setParent(main);
4239
+ scene.drawCalls = 4;
4240
+ },
4241
+ nearSeatCount() {
4242
+ return nearCount;
4243
+ },
3459
4244
  uploadSeatStateRuns(runs) {
3460
4245
  if (!runs.length) return;
4246
+ if (nearCount) writeChairColors();
3461
4247
  for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
3462
4248
  const buffer = colorAttr.buffer;
3463
4249
  if (!buffer) {
@@ -3477,8 +4263,11 @@ function buildGpuScene(gl, model) {
3477
4263
  solidProg.remove();
3478
4264
  seatGeo.remove();
3479
4265
  seatProg.remove();
4266
+ chairGeo.remove();
4267
+ chairProg.remove();
3480
4268
  }
3481
4269
  };
4270
+ return scene;
3482
4271
  }
3483
4272
 
3484
4273
  // src/view3d/pick/pickPipeline.ts
@@ -4046,6 +4835,28 @@ function mountVenue3D(container, input, opts = {}) {
4046
4835
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
4047
4836
  gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
4048
4837
  gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
4838
+ gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
4839
+ lastGatherX = Infinity;
4840
+ };
4841
+ const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
4842
+ const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
4843
+ let lastGatherX = Infinity;
4844
+ let lastGatherZ = Infinity;
4845
+ const updateNearField = () => {
4846
+ if (!gpu) return;
4847
+ const cam = orbit.camera.position;
4848
+ const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
4849
+ if (moved2 < CHAIR_REBUILD_M) return;
4850
+ lastGatherX = cam.x;
4851
+ lastGatherZ = cam.z;
4852
+ const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
4853
+ if (outside > CHAIR_GATHER_M) {
4854
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
4855
+ return;
4856
+ }
4857
+ const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
4858
+ if (n === 0 && gpu.nearSeatCount() === 0) return;
4859
+ gpu.setNearSeats(nearBuf, n);
4049
4860
  };
4050
4861
  const glctx = new GLContext(container, {
4051
4862
  onContextLost: () => {
@@ -4092,7 +4903,9 @@ function mountVenue3D(container, input, opts = {}) {
4092
4903
  const u = gpu.seatProgram.uniforms;
4093
4904
  u.uSeatScale.value = lod.scale;
4094
4905
  u.uSeatFade.value = lod.fade;
4906
+ u.uMinPixels.value = lod.minPixels;
4095
4907
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
4908
+ updateNearField();
4096
4909
  glctx.renderer.render({ scene: gpu.background, clear: true });
4097
4910
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
4098
4911
  labelOverlay.update(
@@ -4100,7 +4913,10 @@ function mountVenue3D(container, input, opts = {}) {
4100
4913
  glctx.canvas.clientWidth || 1,
4101
4914
  glctx.canvas.clientHeight || 1,
4102
4915
  orbit.currentDistance,
4103
- model.bounds.radius
4916
+ model.bounds.radius,
4917
+ // The dense label rungs rank by real distance from the eye, not by the
4918
+ // orbit radius — at the arrival pose those are wildly different numbers.
4919
+ [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
4104
4920
  );
4105
4921
  return moving;
4106
4922
  });
@@ -4373,6 +5189,7 @@ function mountVenue3D(container, input, opts = {}) {
4373
5189
  if (gpu) {
4374
5190
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
4375
5191
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
5192
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
4376
5193
  }
4377
5194
  focusedFloor = value;
4378
5195
  if (index !== null) {