@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.
@@ -7,7 +7,7 @@ import {
7
7
  pointInPolygonWithHoles,
8
8
  resolveSection,
9
9
  sectionGeometry
10
- } from "../chunk-FGS67GT3.js";
10
+ } from "../chunk-MGC5BVAD.js";
11
11
 
12
12
  // src/view3d/index.ts
13
13
  import { Quat as Quat2, Vec3 as Vec33 } from "ogl";
@@ -183,10 +183,18 @@ var OrbitCamera = class {
183
183
  this.maxDist = 100;
184
184
  this.gestureFired = false;
185
185
  this.dragging = false;
186
+ this.panning = false;
186
187
  this.lastX = 0;
187
188
  this.lastY = 0;
188
189
  this.activePointers = /* @__PURE__ */ new Map();
189
190
  this.pinchDist = 0;
191
+ this.pinchCx = 0;
192
+ this.pinchCy = 0;
193
+ /** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
194
+ this.targetT = new Vec3();
195
+ /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
196
+ this.panAnchor = new Vec3();
197
+ this.panLimit = 0;
190
198
  this.camera = new Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
191
199
  this.canvas = canvas;
192
200
  this.requestRender = requestRender;
@@ -198,12 +206,17 @@ var OrbitCamera = class {
198
206
  }
199
207
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
200
208
  if (this.activePointers.size === 1) {
201
- this.dragging = true;
209
+ this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
210
+ this.dragging = !this.panning;
202
211
  this.lastX = e.clientX;
203
212
  this.lastY = e.clientY;
204
213
  } else if (this.activePointers.size === 2) {
205
214
  this.dragging = false;
215
+ this.panning = false;
206
216
  this.pinchDist = this.currentPinchDistance();
217
+ const c = this.pinchCentroid();
218
+ this.pinchCx = c.x;
219
+ this.pinchCy = c.y;
207
220
  }
208
221
  };
209
222
  this.onPointerMove = (e) => {
@@ -216,11 +229,24 @@ var OrbitCamera = class {
216
229
  this.fireGesture();
217
230
  }
218
231
  this.pinchDist = d;
232
+ const c = this.pinchCentroid();
233
+ this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
234
+ this.pinchCx = c.x;
235
+ this.pinchCy = c.y;
219
236
  return;
220
237
  }
221
- if (!this.dragging) return;
222
238
  const dx = e.clientX - this.lastX;
223
239
  const dy = e.clientY - this.lastY;
240
+ if (this.panning) {
241
+ this.lastX = e.clientX;
242
+ this.lastY = e.clientY;
243
+ if (dx !== 0 || dy !== 0) {
244
+ this.panBy(dx, dy);
245
+ this.fireGesture();
246
+ }
247
+ return;
248
+ }
249
+ if (!this.dragging) return;
224
250
  this.lastX = e.clientX;
225
251
  this.lastY = e.clientY;
226
252
  if (dx !== 0 || dy !== 0) this.fireGesture();
@@ -235,7 +261,13 @@ var OrbitCamera = class {
235
261
  } catch {
236
262
  }
237
263
  if (this.activePointers.size < 2) this.pinchDist = 0;
238
- if (this.activePointers.size === 0) this.dragging = false;
264
+ if (this.activePointers.size === 0) {
265
+ this.dragging = false;
266
+ this.panning = false;
267
+ }
268
+ };
269
+ this.onContextMenu = (e) => {
270
+ e.preventDefault();
239
271
  };
240
272
  this.onWheel = (e) => {
241
273
  e.preventDefault();
@@ -249,6 +281,55 @@ var OrbitCamera = class {
249
281
  canvas.addEventListener("pointerup", this.onPointerUp);
250
282
  canvas.addEventListener("pointercancel", this.onPointerUp);
251
283
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
284
+ canvas.addEventListener("contextmenu", this.onContextMenu);
285
+ }
286
+ pinchCentroid() {
287
+ const pts = [...this.activePointers.values()];
288
+ if (!pts.length) return { x: 0, y: 0 };
289
+ let x = 0;
290
+ let y = 0;
291
+ for (const p of pts) {
292
+ x += p.x;
293
+ y += p.y;
294
+ }
295
+ return { x: x / pts.length, y: y / pts.length };
296
+ }
297
+ /**
298
+ * Slide the orbit pivot across the camera's own screen plane.
299
+ *
300
+ * Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
301
+ * under the cursor whatever the zoom: at the overview a drag sweeps the whole
302
+ * bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
303
+ * would be unusable at one end or the other.
304
+ */
305
+ panBy(dxPx, dyPx) {
306
+ const h = this.canvas.clientHeight || 1;
307
+ const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
308
+ const sinA = Math.sin(this.azimuth);
309
+ const cosA = Math.cos(this.azimuth);
310
+ const rightX = cosA;
311
+ const rightZ = -sinA;
312
+ const cp = Math.cos(this.polar);
313
+ const sp = Math.sin(this.polar);
314
+ const fwdX = -sinA * cp;
315
+ const fwdZ = -cosA * cp;
316
+ this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
317
+ this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
318
+ this.targetT.y += dyPx * sp * perPx;
319
+ this.clampPan();
320
+ this.requestRender();
321
+ }
322
+ /** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
323
+ * venue entirely — the Overview chip should never be the only way back. */
324
+ clampPan() {
325
+ if (this.panLimit <= 0) return;
326
+ const lim = this.panLimit;
327
+ const cx = this.panAnchor.x;
328
+ const cy = this.panAnchor.y;
329
+ const cz = this.panAnchor.z;
330
+ this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
331
+ this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
332
+ this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
252
333
  }
253
334
  /** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
254
335
  fireGesture() {
@@ -275,6 +356,9 @@ var OrbitCamera = class {
275
356
  */
276
357
  frame(bounds, intro = false, stageAzimuth) {
277
358
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
359
+ this.targetT.copy(this.target);
360
+ this.panAnchor.copy(this.target);
361
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
278
362
  const r = Math.max(1, bounds.radius);
279
363
  const halfV = this.fovY * DEG / 2;
280
364
  const aspect = this.camera.aspect || 1;
@@ -306,6 +390,9 @@ var OrbitCamera = class {
306
390
  */
307
391
  frameSoft(bounds, stageAzimuth) {
308
392
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
393
+ this.targetT.copy(this.target);
394
+ this.panAnchor.copy(this.target);
395
+ this.panLimit = Math.max(1, bounds.radius) * 1.5;
309
396
  this.syncFromCamera();
310
397
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
311
398
  const r = Math.max(1, bounds.radius);
@@ -322,10 +409,17 @@ var OrbitCamera = class {
322
409
  const da = this.azT - this.azimuth;
323
410
  const dp = this.polT - this.polar;
324
411
  const dd = this.distT - this.distance;
325
- const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
412
+ const tx = this.targetT.x - this.target.x;
413
+ const ty = this.targetT.y - this.target.y;
414
+ const tz = this.targetT.z - this.target.z;
415
+ const panEps = Math.max(1e-4, this.distance * 1e-4);
416
+ const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4 || Math.abs(tx) > panEps || Math.abs(ty) > panEps || Math.abs(tz) > panEps;
326
417
  this.azimuth += da * DAMP;
327
418
  this.polar += dp * DAMP;
328
419
  this.distance += dd * DAMP;
420
+ this.target.x += tx * DAMP;
421
+ this.target.y += ty * DAMP;
422
+ this.target.z += tz * DAMP;
329
423
  if (moving) this.applyPosition();
330
424
  return moving;
331
425
  }
@@ -351,6 +445,7 @@ var OrbitCamera = class {
351
445
  /** Point the orbit pivot at a new world target without moving the camera. */
352
446
  setTarget(target) {
353
447
  this.target.set(target[0], target[1], target[2]);
448
+ this.targetT.copy(this.target);
354
449
  }
355
450
  /** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
356
451
  * flight ends looking at `target` (the venue focal), so re-pivot there first —
@@ -358,6 +453,7 @@ var OrbitCamera = class {
358
453
  resumeAfterFlight(target) {
359
454
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
360
455
  if (target) this.target.set(target[0], target[1], target[2]);
456
+ this.targetT.copy(this.target);
361
457
  this.syncFromCamera();
362
458
  }
363
459
  applyPosition() {
@@ -374,6 +470,7 @@ var OrbitCamera = class {
374
470
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
375
471
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
376
472
  this.canvas.removeEventListener("wheel", this.onWheel);
473
+ this.canvas.removeEventListener("contextmenu", this.onContextMenu);
377
474
  this.activePointers.clear();
378
475
  }
379
476
  };
@@ -422,17 +519,129 @@ var RenderLoop = class {
422
519
  };
423
520
 
424
521
  // src/view3d/lod.ts
522
+ var SEAT_MIN_PIXELS_NEAR = 2.5;
523
+ var SEAT_MIN_PIXELS_FAR = 1.15;
524
+ var CHAIR_FULL_M = 12;
525
+ var CHAIR_NONE_M = 22;
526
+ var CHAIR_GATHER_M = 30;
527
+ var CHAIR_REBUILD_M = 4;
528
+ var CHAIR_MAX_INSTANCES = 8192;
529
+ var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
425
530
  function computeSeatLod(distance, radius) {
426
531
  const near = radius * 1.4;
427
532
  const far = radius * 3.2;
428
- if (distance <= near) return { scale: 1, fade: 0 };
533
+ if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
429
534
  const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
430
535
  return {
431
536
  scale: 1 - t * 0.4,
432
- fade: t * 0.55
537
+ fade: t * 0.55,
538
+ // Tapered on the same ramp as the fade: as the block starts reading by its
539
+ // tier tint, the dots stop fighting each other for pixels.
540
+ minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
433
541
  };
434
542
  }
435
543
 
544
+ // src/view3d/scene/nearField.ts
545
+ var SEATS_PER_CELL = 4;
546
+ var NearFieldIndex = class {
547
+ constructor(iPosition, count) {
548
+ this.minX = 0;
549
+ this.minZ = 0;
550
+ this.cell = 1;
551
+ this.cols = 1;
552
+ this.rows = 1;
553
+ /** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
554
+ this.cellStart = null;
555
+ this.cellItems = null;
556
+ this.iPosition = iPosition;
557
+ this.count = count;
558
+ }
559
+ /** Built on demand; safe to call repeatedly. */
560
+ ensureGrid() {
561
+ if (this.cellStart || this.count === 0) return;
562
+ const p = this.iPosition;
563
+ let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
564
+ for (let i = 0; i < this.count; i++) {
565
+ const x = p[i * 3], z = p[i * 3 + 2];
566
+ if (x < minX) minX = x;
567
+ if (x > maxX) maxX = x;
568
+ if (z < minZ) minZ = z;
569
+ if (z > maxZ) maxZ = z;
570
+ }
571
+ const w = Math.max(maxX - minX, 1e-3);
572
+ const h = Math.max(maxZ - minZ, 1e-3);
573
+ this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
574
+ this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
575
+ this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
576
+ this.minX = minX;
577
+ this.minZ = minZ;
578
+ const nCells = this.cols * this.rows;
579
+ const start = new Int32Array(nCells + 1);
580
+ const cellOf = (i) => {
581
+ const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
582
+ const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
583
+ return cz * this.cols + cx;
584
+ };
585
+ for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
586
+ for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
587
+ const items = new Int32Array(this.count);
588
+ const cursor = start.slice(0, nCells);
589
+ for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
590
+ this.cellStart = start;
591
+ this.cellItems = items;
592
+ }
593
+ /**
594
+ * Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
595
+ * nearest cell-ring first, and return how many were written.
596
+ *
597
+ * Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
598
+ * bites it drops the OUTERMOST seats, which are the ones already past the fade
599
+ * band and drawing nothing. A cap that truncated in index order would instead
600
+ * punch holes in the row you are sitting in.
601
+ *
602
+ * Note this is a horizontal (XZ) query and ignores height. A stacked venue's
603
+ * upper tier is therefore gathered along with the stalls beneath it — which is
604
+ * correct, because the fade weight is re-derived from true view depth in the
605
+ * shader anyway. The grid's only job is to bound the candidate set.
606
+ */
607
+ gather(camX, camZ, radius, out) {
608
+ this.ensureGrid();
609
+ const start = this.cellStart;
610
+ const items = this.cellItems;
611
+ if (!start || !items) return 0;
612
+ const cap = out.length;
613
+ const r2 = radius * radius;
614
+ const cx = Math.floor((camX - this.minX) / this.cell);
615
+ const cz = Math.floor((camZ - this.minZ) / this.cell);
616
+ const maxRing = Math.ceil(radius / this.cell) + 1;
617
+ const p = this.iPosition;
618
+ let n = 0;
619
+ for (let ring = 0; ring <= maxRing && n < cap; ring++) {
620
+ const z0 = cz - ring, z1 = cz + ring;
621
+ const x0 = cx - ring, x1 = cx + ring;
622
+ for (let gz = z0; gz <= z1 && n < cap; gz++) {
623
+ if (gz < 0 || gz >= this.rows) continue;
624
+ const edge = gz === z0 || gz === z1;
625
+ for (let gx = x0; gx <= x1 && n < cap; gx++) {
626
+ if (!edge && gx !== x0 && gx !== x1) {
627
+ gx = x1 - 1;
628
+ continue;
629
+ }
630
+ if (gx < 0 || gx >= this.cols) continue;
631
+ const c = gz * this.cols + gx;
632
+ for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
633
+ const i = items[k];
634
+ const dx = p[i * 3] - camX;
635
+ const dz = p[i * 3 + 2] - camZ;
636
+ if (dx * dx + dz * dz <= r2) out[n++] = i;
637
+ }
638
+ }
639
+ }
640
+ }
641
+ return n;
642
+ }
643
+ };
644
+
436
645
  // src/view3d/scene/geometry.ts
437
646
  import earcut from "earcut";
438
647
  import polygonClipping from "polygon-clipping";
@@ -859,6 +1068,152 @@ function rectPolygon(x, y, w, h) {
859
1068
  ];
860
1069
  }
861
1070
 
1071
+ // src/view3d/scene/seatChair.ts
1072
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
1073
+ var CHAIR_PITCH_FRACTION = 0.44;
1074
+ var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1075
+ var CHAIR_HALF_WIDTH_MAX_M = 0.3;
1076
+ var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
1077
+ function chairHalfWidth(pitchM) {
1078
+ if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
1079
+ return CHAIR_HALF_WIDTH_DEFAULT_M;
1080
+ }
1081
+ return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
1082
+ }
1083
+ var BACK_RAKE_SLOPE = 0.21;
1084
+ var PAD_BACK_GAP_M = 0.05;
1085
+ var PAD_TOP_M = 0.45;
1086
+ var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1087
+ var BOXES = [
1088
+ // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1089
+ // over the deck and the row reads as hovering trays.
1090
+ { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
1091
+ // Seat pad — a full seat width across and about as deep, which is what a real
1092
+ // one is. Its depth is bounded by the same pitch as its width, because the
1093
+ // pitch measure is the tighter of the in-row and row-to-row spacings, so a
1094
+ // tightly-raked tier cannot drive a pad into the back of the row in front.
1095
+ { part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
1096
+ // Back panel — thin, raked, and the tallest thing in the row, so it is what
1097
+ // carries the state colour when you look along a row from behind.
1098
+ {
1099
+ part: CHAIR_PART.back,
1100
+ min: [-1, BACK_BASE_M, -1],
1101
+ max: [1, 0.92, -0.72]
1102
+ }
1103
+ ];
1104
+ var FACES = [
1105
+ // +X
1106
+ { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
1107
+ // -X
1108
+ { n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
1109
+ // +Y
1110
+ { n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
1111
+ // -Y
1112
+ { n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
1113
+ // +Z
1114
+ { n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
1115
+ // -Z
1116
+ { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1117
+ ];
1118
+ function buildChairMesh() {
1119
+ const vertexCount = BOXES.length * FACES.length * 4;
1120
+ const indexCount = BOXES.length * FACES.length * 6;
1121
+ const position = new Float32Array(vertexCount * 3);
1122
+ const normal = new Float32Array(vertexCount * 3);
1123
+ const part = new Float32Array(vertexCount);
1124
+ const index = new Uint16Array(indexCount);
1125
+ let v = 0;
1126
+ let t = 0;
1127
+ for (const box of BOXES) {
1128
+ for (const face of FACES) {
1129
+ const base = v;
1130
+ const corner3 = new Float32Array(12);
1131
+ for (let ci = 0; ci < 4; ci++) {
1132
+ const corner = face.c[ci];
1133
+ for (let a = 0; a < 3; a++) {
1134
+ corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1135
+ }
1136
+ }
1137
+ const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1138
+ const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1139
+ let nx = ay * bz - az * by;
1140
+ let ny = az * bx - ax * bz;
1141
+ let nz = ax * by - ay * bx;
1142
+ const nl = Math.hypot(nx, ny, nz);
1143
+ if (nl > 1e-9) {
1144
+ nx /= nl;
1145
+ ny /= nl;
1146
+ nz /= nl;
1147
+ } else {
1148
+ nx = face.n[0];
1149
+ ny = face.n[1];
1150
+ nz = face.n[2];
1151
+ }
1152
+ if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1153
+ nx = -nx;
1154
+ ny = -ny;
1155
+ nz = -nz;
1156
+ }
1157
+ for (let ci = 0; ci < 4; ci++) {
1158
+ position[v * 3] = corner3[ci * 3];
1159
+ position[v * 3 + 1] = corner3[ci * 3 + 1];
1160
+ position[v * 3 + 2] = corner3[ci * 3 + 2];
1161
+ normal[v * 3] = nx;
1162
+ normal[v * 3 + 1] = ny;
1163
+ normal[v * 3 + 2] = nz;
1164
+ part[v] = box.part;
1165
+ v++;
1166
+ }
1167
+ index[t++] = base;
1168
+ index[t++] = base + 1;
1169
+ index[t++] = base + 2;
1170
+ index[t++] = base;
1171
+ index[t++] = base + 2;
1172
+ index[t++] = base + 3;
1173
+ }
1174
+ }
1175
+ return { position, normal, part, index, vertexCount, indexCount };
1176
+ }
1177
+ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1178
+ const yaw = new Float32Array(count);
1179
+ const px = (i) => iPosition[i * 3];
1180
+ const pz = (i) => iPosition[i * 3 + 2];
1181
+ let runStart = 0;
1182
+ const flushRun = (start, end) => {
1183
+ const n = end - start;
1184
+ for (let i = start; i < end; i++) {
1185
+ const [fx, fz] = focal(i);
1186
+ let dx = fx - px(i);
1187
+ let dz = fz - pz(i);
1188
+ if (n >= 2) {
1189
+ const a = Math.max(start, i - 1);
1190
+ const b = Math.min(end - 1, i + 1);
1191
+ const tx = px(b) - px(a);
1192
+ const tz = pz(b) - pz(a);
1193
+ const tl = Math.hypot(tx, tz);
1194
+ if (tl > 1e-6) {
1195
+ let nx = -tz / tl;
1196
+ let nz = tx / tl;
1197
+ if (nx * dx + nz * dz < 0) {
1198
+ nx = -nx;
1199
+ nz = -nz;
1200
+ }
1201
+ dx = nx;
1202
+ dz = nz;
1203
+ }
1204
+ }
1205
+ yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
1206
+ }
1207
+ };
1208
+ for (let i = 1; i <= count; i++) {
1209
+ if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
1210
+ flushRun(runStart, i);
1211
+ runStart = i;
1212
+ }
1213
+ }
1214
+ return yaw;
1215
+ }
1216
+
862
1217
  // src/view3d/scene/seatInstances.ts
863
1218
  var SEAT_DOT_RADIUS_M = 0.22;
864
1219
  var SEAT_PITCH_FRACTION = 0.42;
@@ -928,6 +1283,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
928
1283
  const iPosition = new Float32Array(count * 3);
929
1284
  const iState = new Float32Array(count);
930
1285
  const iMaxRadius = new Float32Array(count);
1286
+ const iChairWidth = new Float32Array(count);
931
1287
  const iRing = new Float32Array(count * 3);
932
1288
  const idToIndex = /* @__PURE__ */ new Map();
933
1289
  const spacing = nearestNeighbourSpacing(seats);
@@ -936,6 +1292,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
936
1292
  const resolved = surfaces?.seatPitchU(i);
937
1293
  const pitchM = (resolved ?? spacing[i]) * M;
938
1294
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1295
+ iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
939
1296
  iPosition[i * 3] = seat.x * M;
940
1297
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
941
1298
  iPosition[i * 3 + 2] = seat.y * M;
@@ -957,7 +1314,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
957
1314
  iMaxRadius,
958
1315
  iRing,
959
1316
  idToIndex,
960
- iFloor: seatFloor ?? new Float32Array(count)
1317
+ iChairWidth,
1318
+ iFloor: seatFloor ?? new Float32Array(count),
1319
+ iYaw: new Float32Array(count)
961
1320
  };
962
1321
  }
963
1322
  var RUN_MERGE_GAP = 64;
@@ -1092,6 +1451,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
1092
1451
  }
1093
1452
  return kept;
1094
1453
  }
1454
+ var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
1455
+ function focusScore(screen, worldDistance, width, height) {
1456
+ const half = Math.max(1, Math.min(width, height) * 0.5);
1457
+ const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
1458
+ return worldDistance * (1 + 1.5 * off);
1459
+ }
1460
+ function pickDenseLabels(items, separationX, separationY, budget) {
1461
+ const ordered = [...items].sort((a, b) => a.focus - b.focus);
1462
+ const kept = [];
1463
+ for (const item of ordered) {
1464
+ if (kept.length >= budget) break;
1465
+ let clash = false;
1466
+ for (const k of kept) {
1467
+ if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
1468
+ clash = true;
1469
+ break;
1470
+ }
1471
+ }
1472
+ if (!clash) kept.push(item);
1473
+ }
1474
+ return kept;
1475
+ }
1095
1476
  function centroidOf(points) {
1096
1477
  if (!points.length) return null;
1097
1478
  let x = 0, y = 0;
@@ -1265,195 +1646,6 @@ function buildSectionRake(rows, focal) {
1265
1646
  return rowsRake(fits);
1266
1647
  }
1267
1648
 
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
1649
  // src/view3d/scene/deckBands.ts
1458
1650
  import polygonClipping2 from "polygon-clipping";
1459
1651
  import earcut2 from "earcut";
@@ -1502,6 +1694,68 @@ function extendEnds(pts, by) {
1502
1694
  });
1503
1695
  return out;
1504
1696
  }
1697
+ var CORNER_STEP_RAD = Math.PI / 12;
1698
+ function convexHull(pts) {
1699
+ if (pts.length < 3) return [...pts];
1700
+ const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
1701
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
1702
+ const half = (src) => {
1703
+ const out = [];
1704
+ for (const p of src) {
1705
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
1706
+ out.push(p);
1707
+ }
1708
+ out.pop();
1709
+ return out;
1710
+ };
1711
+ const hull = [...half(s), ...half([...s].reverse())];
1712
+ return hull.length >= 3 ? hull : [...pts];
1713
+ }
1714
+ function seatClusterPatch(pts, pad) {
1715
+ if (!pts.length || !(pad > 0)) return [];
1716
+ const hull = convexHull(pts);
1717
+ const arc = (v, from, to, out2) => {
1718
+ let sweep = to - from;
1719
+ while (sweep < 0) sweep += Math.PI * 2;
1720
+ while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
1721
+ const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
1722
+ const r = pad / Math.cos(sweep / steps / 2);
1723
+ for (let k = 0; k <= steps; k++) {
1724
+ const a = from + sweep * k / steps;
1725
+ out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
1726
+ }
1727
+ };
1728
+ if (hull.length < 3) {
1729
+ let cx = 0, cy = 0;
1730
+ for (const p of hull) {
1731
+ cx += p.x;
1732
+ cy += p.y;
1733
+ }
1734
+ cx /= hull.length || 1;
1735
+ cy /= hull.length || 1;
1736
+ let far = 0;
1737
+ for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
1738
+ const out2 = [];
1739
+ const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
1740
+ const r = (far + pad) / Math.cos(Math.PI / steps);
1741
+ for (let k = 0; k < steps; k++) {
1742
+ const a = k / steps * Math.PI * 2;
1743
+ out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
1744
+ }
1745
+ return out2;
1746
+ }
1747
+ const n = hull.length;
1748
+ const edgeAngle = [];
1749
+ for (let i = 0; i < n; i++) {
1750
+ const a = hull[i], b = hull[(i + 1) % n];
1751
+ edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
1752
+ }
1753
+ const out = [];
1754
+ for (let i = 0; i < n; i++) {
1755
+ arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
1756
+ }
1757
+ return dedupeAdjacent(out);
1758
+ }
1505
1759
  function distToPolyline(pts, x, y) {
1506
1760
  if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
1507
1761
  let best = Infinity;
@@ -1632,7 +1886,14 @@ function deckFootprints(rows, focal, shared) {
1632
1886
  else byBlock.set(rows[i].blockId, [i]);
1633
1887
  }
1634
1888
  const out = [];
1635
- for (const [, indices] of byBlock) {
1889
+ for (const [, allIndices] of byBlock) {
1890
+ const indices = [];
1891
+ for (const i of allIndices) {
1892
+ const patch = rows[i].patch;
1893
+ if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
1894
+ else indices.push(i);
1895
+ }
1896
+ if (!indices.length) continue;
1636
1897
  indices.sort((a, b) => rows[a].depth - rows[b].depth);
1637
1898
  const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
1638
1899
  const usable = ribbons.filter((r) => r !== null);
@@ -1741,106 +2002,351 @@ var ClipTest = class {
1741
2002
  if (p.y > this.maxY) this.maxY = p.y;
1742
2003
  }
1743
2004
  }
1744
- }
1745
- /** True when every point lies strictly inside ONE ring of the clip region. */
1746
- containsAll(pts) {
1747
- for (const p of pts) {
1748
- if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
1749
- }
1750
- for (const ring of this.rings) {
1751
- let all = true;
1752
- for (const p of pts) {
1753
- if (!pointInRing(ring, p.x, p.y)) {
1754
- all = false;
1755
- break;
2005
+ }
2006
+ /** True when every point lies strictly inside ONE ring of the clip region. */
2007
+ containsAll(pts) {
2008
+ for (const p of pts) {
2009
+ if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
2010
+ }
2011
+ for (const ring of this.rings) {
2012
+ let all = true;
2013
+ for (const p of pts) {
2014
+ if (!pointInRing(ring, p.x, p.y)) {
2015
+ all = false;
2016
+ break;
2017
+ }
2018
+ }
2019
+ if (all) return true;
2020
+ }
2021
+ return false;
2022
+ }
2023
+ };
2024
+ function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
2025
+ if (poly.length < 3) return;
2026
+ if (clipTest.containsAll(poly)) {
2027
+ const UPF = [0, 1, 0];
2028
+ const a = poly[0];
2029
+ for (let i = 1; i + 1 < poly.length; i++) {
2030
+ const b = poly[i], c = poly[i + 1];
2031
+ builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
2032
+ }
2033
+ return;
2034
+ }
2035
+ const ring = poly.map((p) => [p.x, p.y]);
2036
+ ring.push(ring[0]);
2037
+ let pieces;
2038
+ try {
2039
+ pieces = polygonClipping2.intersection([ring], clipRing);
2040
+ } catch {
2041
+ return;
2042
+ }
2043
+ const UP = [0, 1, 0];
2044
+ for (const poly2 of pieces) {
2045
+ if (!poly2.length || poly2[0].length < 4) continue;
2046
+ const outer = poly2[0];
2047
+ const flat = [];
2048
+ const pts = [];
2049
+ for (let i = 0; i < outer.length - 1; i++) {
2050
+ flat.push(outer[i][0], outer[i][1]);
2051
+ pts.push([outer[i][0], outer[i][1]]);
2052
+ }
2053
+ if (pts.length < 3) continue;
2054
+ const tris = earcut2(flat, void 0, 2);
2055
+ for (let i = 0; i < tris.length; i += 3) {
2056
+ const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
2057
+ builder.tri(
2058
+ [a[0] * M, y, a[1] * M],
2059
+ [b[0] * M, y, b[1] * M],
2060
+ [c[0] * M, y, c[1] * M],
2061
+ UP,
2062
+ color
2063
+ );
2064
+ }
2065
+ }
2066
+ }
2067
+ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2068
+ if (rows.length < 2) return;
2069
+ const nbrs = shared ?? rowNeighbourhoods(rows);
2070
+ const UP = [0, 1, 0];
2071
+ const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2072
+ const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2073
+ for (let i = 0; i < rows.length; i++) {
2074
+ const row = rows[i];
2075
+ if (row.patch && row.patch.length >= 3) {
2076
+ const patch = [...row.patch];
2077
+ const belowY2 = nbrs[i].belowY ?? landingY;
2078
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2079
+ else {
2080
+ const a = patch[0];
2081
+ for (let k = 1; k + 1 < patch.length; k++) {
2082
+ const b = patch[k], c = patch[k + 1];
2083
+ builder.tri([a.x * M, row.y, a.y * M], [b.x * M, row.y, b.y * M], [c.x * M, row.y, c.y * M], UP, colors.tread);
2084
+ }
2085
+ }
2086
+ if (row.y > belowY2 + MIN_RISER_M) {
2087
+ let cx = 0, cy = 0;
2088
+ for (const p of patch) {
2089
+ cx += p.x;
2090
+ cy += p.y;
2091
+ }
2092
+ cx /= patch.length;
2093
+ cy /= patch.length;
2094
+ for (let k = 0; k < patch.length; k++) {
2095
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2096
+ const dx = q.x - p.x, dy = q.y - p.y;
2097
+ const len = Math.hypot(dx, dy);
2098
+ if (len < 1e-6) continue;
2099
+ let nx = dy / len, ny = -dx / len;
2100
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2101
+ nx = -nx;
2102
+ ny = -ny;
2103
+ }
2104
+ const rn = [nx, 0, ny];
2105
+ const pt = [p.x * M, row.y, p.y * M];
2106
+ const qt = [q.x * M, row.y, q.y * M];
2107
+ const pb = [p.x * M, belowY2, p.y * M];
2108
+ const qb = [q.x * M, belowY2, q.y * M];
2109
+ builder.tri(pt, qt, qb, rn, colors.riser);
2110
+ builder.tri(pt, qb, pb, rn, colors.riser);
2111
+ }
2112
+ }
2113
+ continue;
2114
+ }
2115
+ const rib = ribbonOf(rows, i, nbrs, focal);
2116
+ if (!rib) continue;
2117
+ const { pts, nrm, front, back } = rib;
2118
+ const belowY = nbrs[i].belowY ?? landingY;
2119
+ for (let k = 0; k + 1 < pts.length; k++) {
2120
+ const p = pts[k], q = pts[k + 1];
2121
+ const np = nrm[k], nq = nrm[k + 1];
2122
+ if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
2123
+ if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
2124
+ const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
2125
+ const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2126
+ const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2127
+ const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2128
+ if (clipRing && clipTest) {
2129
+ emitClippedPoly(builder, clipRing, clipTest, [
2130
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
2131
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2132
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2133
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
2134
+ ], row.y, colors.tread);
2135
+ } else {
2136
+ builder.tri(pF, qF, qB, UP, colors.tread);
2137
+ builder.tri(pF, qB, pB, UP, colors.tread);
2138
+ }
2139
+ if (row.y > belowY + MIN_RISER_M) {
2140
+ const pFd = [pF[0], belowY, pF[2]];
2141
+ const qFd = [qF[0], belowY, qF[2]];
2142
+ const rn = [-np[0], 0, -np[1]];
2143
+ builder.tri(pF, qF, qFd, rn, colors.riser);
2144
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ // src/view3d/scene/surface.ts
2151
+ var FLAT_SLAB_TOP_M = 0.05;
2152
+ var CAP_MAX_ERROR_M = 0.05;
2153
+ var SEAT_CLEARANCE_M = 0.15;
2154
+ var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
2155
+ function bboxOf(pts) {
2156
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2157
+ for (const p of pts) {
2158
+ if (p.x < minX) minX = p.x;
2159
+ if (p.y < minY) minY = p.y;
2160
+ if (p.x > maxX) maxX = p.x;
2161
+ if (p.y > maxY) maxY = p.y;
2162
+ }
2163
+ return { minX, minY, maxX, maxY };
2164
+ }
2165
+ function pointInRing2(ring, x, y) {
2166
+ let inside = false;
2167
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2168
+ const a = ring[i], b = ring[j];
2169
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
2170
+ }
2171
+ return inside;
2172
+ }
2173
+ var MAX_TIER_RISE_M = 25;
2174
+ function buildVenueSurfaces(units, seats) {
2175
+ const bySection = /* @__PURE__ */ new Map();
2176
+ const seatOwner = new Array(seats.length).fill(null);
2177
+ const seatDeck = new Float64Array(seats.length);
2178
+ const seatRowLevel = new Array(seats.length).fill(void 0);
2179
+ const seatPitch = new Array(seats.length).fill(void 0);
2180
+ const acc = /* @__PURE__ */ new Map();
2181
+ const boxes = [];
2182
+ const tableRowIds = /* @__PURE__ */ new Set();
2183
+ for (const unit of units) {
2184
+ for (const o of unit.objects) {
2185
+ if (o.type === "table") tableRowIds.add(o.id);
2186
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
2187
+ const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
2188
+ acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
2189
+ boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
2190
+ }
2191
+ }
2192
+ for (let i = 0; i < seats.length; i++) {
2193
+ const s = seats[i];
2194
+ for (const b of boxes) {
2195
+ if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
2196
+ if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
2197
+ seatOwner[i] = b.id;
2198
+ const a = acc.get(b.id);
2199
+ a.hasSeats = true;
2200
+ const f = s.focalPoint ?? b.unit.focal;
2201
+ const d = Math.hypot(s.x - f.x, s.y - f.y);
2202
+ if (d < a.frontU) a.frontU = d;
2203
+ a.seatIndices.push(i);
2204
+ const rowKey = s.rowId || `__seat-${i}`;
2205
+ const arr = a.rows.get(rowKey);
2206
+ if (arr) arr.push({ x: s.x, y: s.y });
2207
+ else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
2208
+ break;
2209
+ }
2210
+ }
2211
+ for (const [id, a] of acc) {
2212
+ const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
2213
+ const bottomY = a.unit.baseHeightM;
2214
+ const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
2215
+ const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
2216
+ const kind = a.section.surfaceKind;
2217
+ const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
2218
+ const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
2219
+ const needsRake = structure.rows.length < 2;
2220
+ const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
2221
+ let frontU = Infinity;
2222
+ if (rake) {
2223
+ if (a.hasSeats) {
2224
+ for (const pts of a.rows.values()) {
2225
+ for (const p of pts) {
2226
+ const d = rake.depthAt(p.x, p.y);
2227
+ if (d < frontU) frontU = d;
2228
+ }
2229
+ }
2230
+ }
2231
+ if (!a.hasSeats || !Number.isFinite(frontU)) {
2232
+ frontU = Infinity;
2233
+ for (const p of a.section.outline) {
2234
+ const d = rake.depthAt(p.x, p.y);
2235
+ if (d < frontU) frontU = d;
2236
+ }
2237
+ }
2238
+ }
2239
+ const baseFloor = bottomY + FLAT_SLAB_TOP_M;
2240
+ const flatTop = Math.max(baseFloor, geo.height);
2241
+ const levelFor = (depthU) => {
2242
+ const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
2243
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2244
+ return Math.max(baseFloor, geo.height + rise);
2245
+ };
2246
+ const levelForBlockDepth = (blockDepthU) => {
2247
+ const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
2248
+ const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
2249
+ return Math.max(baseFloor, geo.height + rise);
2250
+ };
2251
+ const rowLevels = flat ? [] : structure.rows.map((r) => ({
2252
+ pts: r.pts,
2253
+ y: levelForBlockDepth(r.blockDepth),
2254
+ depth: r.blockDepth,
2255
+ blockId: r.blockId,
2256
+ ...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
2257
+ }));
2258
+ const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
2259
+ const rowBounds = rowLevels.map((r) => {
2260
+ const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
2261
+ let cx = 0, cy = 0;
2262
+ for (const p of ext) {
2263
+ cx += p.x;
2264
+ cy += p.y;
2265
+ }
2266
+ const n = ext.length || 1;
2267
+ cx /= n;
2268
+ cy /= n;
2269
+ let rad = 0;
2270
+ for (const p of ext) {
2271
+ const d = Math.hypot(p.x - cx, p.y - cy);
2272
+ if (d > rad) rad = d;
2273
+ }
2274
+ return { cx, cy, rad };
2275
+ });
2276
+ const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
2277
+ let best = Infinity, bestY = landingY;
2278
+ for (let i = 0; i < rowLevels.length; i++) {
2279
+ const b = rowBounds[i];
2280
+ if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
2281
+ const patch = rowLevels[i].patch;
2282
+ const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
2283
+ if (d < best) {
2284
+ best = d;
2285
+ bestY = rowLevels[i].y;
1756
2286
  }
1757
2287
  }
1758
- if (all) return true;
1759
- }
1760
- return false;
1761
- }
1762
- };
1763
- function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
1764
- if (clipTest.containsAll(quad)) {
1765
- 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);
1769
- return;
1770
- }
1771
- const ring = quad.map((p) => [p.x, p.y]);
1772
- ring.push(ring[0]);
1773
- let pieces;
1774
- try {
1775
- pieces = polygonClipping2.intersection([ring], clipRing);
1776
- } catch {
1777
- return;
1778
- }
1779
- 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];
1783
- const flat = [];
1784
- const pts = [];
1785
- for (let i = 0; i < outer.length - 1; i++) {
1786
- flat.push(outer[i][0], outer[i][1]);
1787
- pts.push([outer[i][0], outer[i][1]]);
1788
- }
1789
- if (pts.length < 3) continue;
1790
- const tris = earcut2(flat, void 0, 2);
1791
- for (let i = 0; i < tris.length; i += 3) {
1792
- const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
1793
- builder.tri(
1794
- [a[0] * M, y, a[1] * M],
1795
- [b[0] * M, y, b[1] * M],
1796
- [c[0] * M, y, c[1] * M],
1797
- UP,
1798
- color
1799
- );
2288
+ return bestY;
2289
+ } : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
2290
+ const UP = [0, 1, 0];
2291
+ const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
2292
+ if (!rake) return UP;
2293
+ const d = rake.depthAt(x, y);
2294
+ if (d <= frontU) return UP;
2295
+ const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
2296
+ if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
2297
+ if (geo.height + depthM * rakeTan <= baseFloor) return UP;
2298
+ const [gx, gy] = rake.gradientAt(x, y);
2299
+ if (gx === 0 && gy === 0) return UP;
2300
+ const inv = 1 / Math.hypot(rakeTan, 1);
2301
+ return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
2302
+ };
2303
+ if (!flat) {
2304
+ for (const r of structure.rows) {
2305
+ const y = levelForBlockDepth(r.blockDepth);
2306
+ for (const si of r.seatIndices) seatRowLevel[si] = y;
2307
+ }
1800
2308
  }
1801
- }
1802
- }
1803
- function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
1804
- if (rows.length < 2) return;
1805
- const nbrs = shared ?? rowNeighbourhoods(rows);
1806
- const UP = [0, 1, 0];
1807
- const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
1808
- const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
1809
- for (let i = 0; i < rows.length; i++) {
1810
- const row = rows[i];
1811
- const rib = ribbonOf(rows, i, nbrs, focal);
1812
- if (!rib) continue;
1813
- const { pts, nrm, front, back } = rib;
1814
- const belowY = nbrs[i].belowY ?? landingY;
1815
- for (let k = 0; k + 1 < pts.length; k++) {
1816
- const p = pts[k], q = pts[k + 1];
1817
- const np = nrm[k], nq = nrm[k + 1];
1818
- if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
1819
- if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
1820
- const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
1821
- const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
1822
- const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
1823
- const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
1824
- if (clipRing && clipTest) {
1825
- emitClippedQuad(builder, clipRing, clipTest, [
1826
- { x: p.x - np[0] * front, y: p.y - np[1] * front },
1827
- { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
1828
- { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
1829
- { x: p.x + np[0] * back, y: p.y + np[1] * back }
1830
- ], row.y, colors.tread);
1831
- } else {
1832
- builder.tri(pF, qF, qB, UP, colors.tread);
1833
- builder.tri(pF, qB, pB, UP, colors.tread);
2309
+ for (const r of structure.rows) {
2310
+ const gaps = [];
2311
+ for (let k = 1; k < r.pts.length; k++) {
2312
+ const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
2313
+ if (d > 1e-6) gaps.push(d);
1834
2314
  }
1835
- if (row.y > belowY + MIN_RISER_M) {
1836
- const pFd = [pF[0], belowY, pF[2]];
1837
- const qFd = [qF[0], belowY, qF[2]];
1838
- const rn = [-np[0], 0, -np[1]];
1839
- builder.tri(pF, qF, qFd, rn, colors.riser);
1840
- builder.tri(pF, qFd, pFd, rn, colors.riser);
2315
+ gaps.sort((x, y) => x - y);
2316
+ const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
2317
+ let across = Infinity;
2318
+ const probe = r.pts[Math.floor(r.pts.length / 2)];
2319
+ if (probe) {
2320
+ for (const other of structure.rows) {
2321
+ if (other === r || other.blockId !== r.blockId) continue;
2322
+ const d = distanceToPolyline(other.pts, probe.x, probe.y);
2323
+ if (d > 1e-6 && d < across) across = d;
2324
+ }
2325
+ }
2326
+ const pitch = Math.min(along, Math.max(across, along * 0.5));
2327
+ if (Number.isFinite(pitch) && pitch > 0) {
2328
+ for (const si of r.seatIndices) seatPitch[si] = pitch;
1841
2329
  }
1842
2330
  }
2331
+ bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
2332
+ }
2333
+ for (let i = 0; i < seats.length; i++) {
2334
+ const ownerId = seatOwner[i];
2335
+ const s = seats[i];
2336
+ if (ownerId) {
2337
+ const own = seatRowLevel[i];
2338
+ seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
2339
+ continue;
2340
+ }
2341
+ const eye = s.eyeHeightM;
2342
+ seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
1843
2343
  }
2344
+ return {
2345
+ bySection,
2346
+ seatOwner,
2347
+ seatDeckY: (i) => seatDeck[i],
2348
+ seatPitchU: (i) => seatPitch[i]
2349
+ };
1844
2350
  }
1845
2351
 
1846
2352
  // src/view3d/scene/sceneModel.ts
@@ -2008,22 +2514,39 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2008
2514
  }
2009
2515
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
2010
2516
  const topN = (p) => surface.normalAt(p.x, p.y);
2011
- for (const ring of claimed.subtract(outline)) {
2517
+ const level = surface.flat ? surface.landingY : void 0;
2518
+ for (const ring of claimed.subtract(outline, level)) {
2012
2519
  extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
2013
2520
  }
2014
2521
  }
2522
+ function coplanarLevels(a, b) {
2523
+ if (a === void 0 || b === void 0) return true;
2524
+ return Math.abs(a - b) < 1e-3;
2525
+ }
2015
2526
  var ClaimedArea = class {
2016
2527
  constructor() {
2017
2528
  this.rings = [];
2018
2529
  this.boxes = [];
2530
+ /** Constant deck height of each claim, or undefined when it is not level. */
2531
+ this.levels = [];
2019
2532
  }
2020
- /** `ring` minus everything claimed so far; then claim what is returned. */
2021
- subtract(ring) {
2533
+ /**
2534
+ * `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
2535
+ *
2536
+ * `level` is the claim's constant deck height when it has one. Two decks only
2537
+ * z-fight when they are coplanar, so only a coplanar claim may take ground
2538
+ * away — an elevated box hanging over a ground-level section overlaps it in
2539
+ * plan and must still draw its whole floor, or the box loses the part of its
2540
+ * deck that shares a footprint with whatever is underneath it. Passing
2541
+ * undefined (a surface with no single height) keeps the original
2542
+ * clip-against-everything behaviour.
2543
+ */
2544
+ subtract(ring, level) {
2022
2545
  const closed = ring.map((p) => [p.x, p.y]);
2023
2546
  if (closed.length < 3) return [];
2024
2547
  closed.push(closed[0]);
2025
2548
  const box = bboxOfRing(ring);
2026
- const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
2549
+ const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
2027
2550
  let pieces = [[closed]];
2028
2551
  if (overlapping.length) {
2029
2552
  try {
@@ -2035,6 +2558,7 @@ var ClaimedArea = class {
2035
2558
  }
2036
2559
  this.rings.push([closed]);
2037
2560
  this.boxes.push(box);
2561
+ this.levels.push(level);
2038
2562
  const out = [];
2039
2563
  for (const poly of pieces) {
2040
2564
  if (!poly.length) continue;
@@ -2490,6 +3014,15 @@ function buildSceneModel(input) {
2490
3014
  });
2491
3015
  }
2492
3016
  }
3017
+ seatData.iYaw = computeSeatYaw(
3018
+ seatData.iPosition,
3019
+ seats.length,
3020
+ (i) => seats[i].rowId,
3021
+ (i) => {
3022
+ const f = units[seatFloor[i]]?.focal ?? focal;
3023
+ return [f.x * M, f.y * M];
3024
+ }
3025
+ );
2493
3026
  const cx = (fp.minX + fp.maxX) / 2 * M;
2494
3027
  const cz = (fp.minY + fp.maxY) / 2 * M;
2495
3028
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
@@ -2527,7 +3060,11 @@ var KIND_STYLE = {
2527
3060
  var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
2528
3061
  var DENSE_SEPARATION = {
2529
3062
  row: { x: 62, y: 16 },
2530
- seat: { x: 24, y: 13 }
3063
+ // Widened from 24: at close range seat labels stopped overlapping at all, so
3064
+ // the separation had no work left to do and the budget was carrying the whole
3065
+ // load. A wider box means the few labels that ARE kept are spread across the
3066
+ // seating instead of clustering into one stack.
3067
+ seat: { x: 46, y: 18 }
2531
3068
  };
2532
3069
  var LabelOverlay = class {
2533
3070
  constructor(container, opts = {}) {
@@ -2558,7 +3095,7 @@ var LabelOverlay = class {
2558
3095
  *
2559
3096
  * `viewProjection` is column-major, as OGL supplies it.
2560
3097
  */
2561
- update(viewProjection, width, height, cameraDistance, venueRadius) {
3098
+ update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
2562
3099
  if (!this.labels.length) return;
2563
3100
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
2564
3101
  const candidates = [];
@@ -2566,15 +3103,24 @@ var LabelOverlay = class {
2566
3103
  if (!kinds.has(label.kind)) continue;
2567
3104
  const screen = projectToScreen(viewProjection, label.anchor, width, height);
2568
3105
  if (!screen.visible) continue;
2569
- candidates.push({ label, screen });
3106
+ const world = cameraWorld ? Math.hypot(
3107
+ label.anchor[0] - cameraWorld[0],
3108
+ label.anchor[1] - cameraWorld[1],
3109
+ label.anchor[2] - cameraWorld[2]
3110
+ ) : 1;
3111
+ candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
2570
3112
  }
2571
3113
  const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
2572
3114
  const kept = [
2573
3115
  ...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
2574
- ...["row", "seat"].flatMap((kind) => cullOverlapping(
3116
+ // The dense rungs are BUDGETED, not merely deduplicated — see
3117
+ // DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
3118
+ // the camera gets.
3119
+ ...["row", "seat"].flatMap((kind) => pickDenseLabels(
2575
3120
  candidates.filter((c) => c.label.kind === kind),
2576
3121
  DENSE_SEPARATION[kind].x,
2577
- DENSE_SEPARATION[kind].y
3122
+ DENSE_SEPARATION[kind].y,
3123
+ DENSE_LABEL_BUDGET[kind]
2578
3124
  ))
2579
3125
  ];
2580
3126
  const keptIds = new Set(kept.map((k) => k.label.id));
@@ -2623,6 +3169,13 @@ import { Geometry, Mesh, Transform } from "ogl";
2623
3169
 
2624
3170
  // src/view3d/scene/materials.ts
2625
3171
  import { Program } from "ogl";
3172
+ var CHAIR_WEIGHT_GLSL = (
3173
+ /* glsl */
3174
+ `
3175
+ float chairWeight(float depth) {
3176
+ return 1.0 - smoothstep(uChairFull, uChairNone, depth);
3177
+ }`
3178
+ );
2626
3179
  var SOLID_VERT = (
2627
3180
  /* glsl */
2628
3181
  `#version 300 es
@@ -2702,14 +3255,23 @@ uniform float uSeatScale;
2702
3255
  uniform float uMinPixels;
2703
3256
  uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
2704
3257
  uniform float uFocusFloor; // -1 = show every floor
3258
+ uniform float uChairFull; // view depth at which the chair mesh is full size
3259
+ uniform float uChairNone; // ...and at which it has scaled away entirely
2705
3260
  out vec2 vUv;
2706
3261
  out vec3 vColor;
2707
3262
  out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
2708
3263
  out vec3 vRing;
2709
3264
  out float vDim;
3265
+ out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3266
+ ${CHAIR_WEIGHT_GLSL}
2710
3267
  void main() {
2711
3268
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
2712
3269
  float depth = max(-mv.z, 0.001);
3270
+ // Hand the seat over to the chair mesh as it comes into range. Derived from
3271
+ // this instance's OWN depth rather than from a global uniform, so a row two
3272
+ // metres away and the far side of the bowl resolve differently in the same
3273
+ // frame \u2014 which is the entire point of a ladder over a switch.
3274
+ vDotWeight = 1.0 - chairWeight(depth);
2713
3275
  float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
2714
3276
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
2715
3277
  // unbounded growth is what merges neighbouring rows into one mass at range.
@@ -2743,6 +3305,7 @@ in vec3 vColor;
2743
3305
  in float vBudget;
2744
3306
  in vec3 vRing;
2745
3307
  in float vDim;
3308
+ in float vDotWeight;
2746
3309
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
2747
3310
  uniform vec3 uFadeColor;
2748
3311
  out vec4 fragColor;
@@ -2767,9 +3330,131 @@ void main() {
2767
3330
  // Seats on an unfocused floor recede with their structure.
2768
3331
  c = mix(c, uFadeColor, vDim * 0.75);
2769
3332
  alpha *= mix(1.0, 0.30, vDim);
3333
+ // Yield to the chair. The chair grows out of this exact point, so through the
3334
+ // band the dot is always at least as big as the chair inside it and the seat
3335
+ // never thins out to nothing in between.
3336
+ alpha *= vDotWeight;
3337
+ if (alpha <= 0.0) discard;
2770
3338
  fragColor = vec4(c, alpha);
2771
3339
  }`
2772
3340
  );
3341
+ var CHAIR_VERT = (
3342
+ /* glsl */
3343
+ `#version 300 es
3344
+ precision highp float;
3345
+ in vec3 position; // local: x/z in units of the seat radius, y in METRES
3346
+ in vec3 normal;
3347
+ in float part; // 0 = pedestal, 1 = pad, 2 = back
3348
+ in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3349
+ in vec3 iColor; // per-instance state colour
3350
+ in float iRadius; // per-instance horizontal half-width, world metres
3351
+ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3352
+ in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3353
+ in float iFloor;
3354
+ uniform mat4 modelViewMatrix;
3355
+ uniform mat4 projectionMatrix;
3356
+ uniform float uChairFull;
3357
+ uniform float uChairNone;
3358
+ uniform float uFocusFloor;
3359
+ uniform float uBackRake; // metres of z per metre of rise, above uBackBase
3360
+ uniform float uBackBase; // local height at which the back starts
3361
+ out vec3 vColor;
3362
+ out vec3 vNormalWorld;
3363
+ out vec3 vNormalView;
3364
+ out vec3 vPosView;
3365
+ out float vPart;
3366
+ out float vHeight; // local height in metres, for the vertical occlusion ramp
3367
+ out vec3 vRing;
3368
+ out float vDim;
3369
+ ${CHAIR_WEIGHT_GLSL}
3370
+ void main() {
3371
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
3372
+ float w = chairWeight(max(-anchor.z, 0.001));
3373
+ // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
3374
+ // chairs, but nobody gets a short one (people are the same height at every
3375
+ // seat pitch).
3376
+ vec3 p = position;
3377
+ p.xz *= iRadius;
3378
+ // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3379
+ // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3380
+ // width and the same chair would lean 20 degrees on a wide stadium row and
3381
+ // 6 on a tight theatre one.
3382
+ float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3383
+ p.z -= rake;
3384
+ // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3385
+ // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
3386
+ // the growth so the chair is already near full size while the dot is still
3387
+ // half there; see chairScale() in lod.ts.
3388
+ p *= sqrt(w);
3389
+ float c = cos(iYaw), s = sin(iYaw);
3390
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
3391
+ // Normals under the same two transforms, in reverse and inverted-transposed.
3392
+ // The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
3393
+ // unchanged; and the rake is a shear, whose normal transform adds a y term.
3394
+ // Skipping either lights the raked back as though it were still vertical.
3395
+ vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3396
+ if (part > 1.5) n.y += uBackRake * n.z;
3397
+ n = normalize(n);
3398
+ vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
3399
+ vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
3400
+ vPosView = mv.xyz;
3401
+ vNormalWorld = rn;
3402
+ vNormalView = normalize(mat3(modelViewMatrix) * rn);
3403
+ vColor = iColor;
3404
+ vPart = part;
3405
+ vHeight = position.y;
3406
+ vRing = iRing;
3407
+ vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3408
+ gl_Position = projectionMatrix * mv;
3409
+ }`
3410
+ );
3411
+ var CHAIR_FRAG = (
3412
+ /* glsl */
3413
+ `#version 300 es
3414
+ precision highp float;
3415
+ in vec3 vColor;
3416
+ in vec3 vNormalWorld;
3417
+ in vec3 vNormalView;
3418
+ in vec3 vPosView;
3419
+ in float vPart;
3420
+ in float vHeight;
3421
+ in vec3 vRing;
3422
+ in float vDim;
3423
+ uniform vec3 uKeyDir;
3424
+ uniform vec3 uFadeColor;
3425
+ out vec4 fragColor;
3426
+ void main() {
3427
+ vec3 N = normalize(vNormalWorld);
3428
+ vec3 V = normalize(-vPosView);
3429
+ // The solids' rig, unchanged, so the chairs sit in the venue's light.
3430
+ float hemi = 0.5 + 0.5 * N.y;
3431
+ float key = max(dot(N, uKeyDir), 0.0);
3432
+ vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
3433
+ float fill = max(dot(N, fillDir), 0.0);
3434
+ vec3 tint = vColor;
3435
+ // The accommodation ring, kept legible once the dot (which drew it) is gone:
3436
+ // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
3437
+ // survives to close range instead of vanishing exactly when the buyer arrives.
3438
+ float ringMask = step(0.001, dot(vRing, vRing));
3439
+ if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
3440
+ // Pad brightest, back a step below it, pedestal darkest. Three untextured
3441
+ // boxes only read as one object if they are separated tonally \u2014 with a single
3442
+ // flat colour the chair silhouettes as a crate.
3443
+ float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
3444
+ // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
3445
+ // sits to the deck the less sky it can actually see. This is the depth cue \u2014
3446
+ // without it the pad top, the back and the deck all resolve to the same flat
3447
+ // value and the row loses its form entirely.
3448
+ float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
3449
+ vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
3450
+ float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3451
+ // A brighter rim than the solids get: it picks out every chair's own edge,
3452
+ // which is what stops a block of them merging into one mass up close.
3453
+ base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
3454
+ base = mix(base, uFadeColor, vDim * 0.75);
3455
+ fragColor = vec4(base, 1.0);
3456
+ }`
3457
+ );
2773
3458
  var BG_VERT = (
2774
3459
  /* glsl */
2775
3460
  `#version 300 es
@@ -2866,7 +3551,7 @@ function createSeatPickProgram(gl) {
2866
3551
  uniforms: {
2867
3552
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
2868
3553
  uSeatScale: { value: 1 },
2869
- uMinPixels: { value: 2.5 },
3554
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
2870
3555
  uPixelToWorld: { value: 2e-3 }
2871
3556
  }
2872
3557
  });
@@ -2911,11 +3596,32 @@ function createSeatProgram(gl) {
2911
3596
  uniforms: {
2912
3597
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
2913
3598
  uSeatScale: { value: 1 },
2914
- uMinPixels: { value: 2.5 },
3599
+ uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
2915
3600
  uPixelToWorld: { value: 2e-3 },
2916
3601
  uSeatFade: { value: 0 },
2917
3602
  uFocusFloor: { value: -1 },
2918
- uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
3603
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
3604
+ uChairFull: { value: CHAIR_FULL_M },
3605
+ uChairNone: { value: CHAIR_NONE_M }
3606
+ }
3607
+ });
3608
+ }
3609
+ function createChairProgram(gl) {
3610
+ return new Program(gl, {
3611
+ vertex: CHAIR_VERT,
3612
+ fragment: CHAIR_FRAG,
3613
+ transparent: false,
3614
+ depthTest: true,
3615
+ depthWrite: true,
3616
+ cullFace: false,
3617
+ uniforms: {
3618
+ uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
3619
+ uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
3620
+ uChairFull: { value: CHAIR_FULL_M },
3621
+ uChairNone: { value: CHAIR_NONE_M },
3622
+ uBackRake: { value: BACK_RAKE_SLOPE },
3623
+ uBackBase: { value: BACK_BASE_M },
3624
+ uFocusFloor: { value: -1 }
2919
3625
  }
2920
3626
  });
2921
3627
  }
@@ -2981,16 +3687,90 @@ function buildGpuScene(gl, model) {
2981
3687
  seatMesh.frustumCulled = false;
2982
3688
  if (model.seats.count > 0) seatMesh.setParent(main);
2983
3689
  const colorAttr = seatGeo.attributes.iColor;
2984
- return {
3690
+ const chairBase = buildChairMesh();
3691
+ const chairProg = createChairProgram(gl);
3692
+ const CAP = CHAIR_MAX_INSTANCES;
3693
+ const cOffset = new Float32Array(CAP * 3);
3694
+ const cColor = new Float32Array(CAP * 3);
3695
+ const cRadius = new Float32Array(CAP);
3696
+ const cYaw = new Float32Array(CAP);
3697
+ const cRing = new Float32Array(CAP * 3);
3698
+ const cFloor = new Float32Array(CAP);
3699
+ const chairGeo = new Geometry(gl, {
3700
+ position: { size: 3, data: chairBase.position },
3701
+ normal: { size: 3, data: chairBase.normal },
3702
+ part: { size: 1, data: chairBase.part },
3703
+ index: { data: chairBase.index },
3704
+ iOffset: { size: 3, data: cOffset, instanced: 1 },
3705
+ iColor: { size: 3, data: cColor, instanced: 1 },
3706
+ iRadius: { size: 1, data: cRadius, instanced: 1 },
3707
+ iYaw: { size: 1, data: cYaw, instanced: 1 },
3708
+ iRing: { size: 3, data: cRing, instanced: 1 },
3709
+ iFloor: { size: 1, data: cFloor, instanced: 1 }
3710
+ });
3711
+ const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
3712
+ chairMesh.frustumCulled = false;
3713
+ let nearCount = 0;
3714
+ let nearIndices = new Int32Array(0);
3715
+ const writeChairColors = () => {
3716
+ const src = model.seats;
3717
+ for (let k = 0; k < nearCount; k++) {
3718
+ const i = nearIndices[k];
3719
+ const c = stateColors[src.iState[i]] ?? stateColors[0];
3720
+ cColor[k * 3] = c[0];
3721
+ cColor[k * 3 + 1] = c[1];
3722
+ cColor[k * 3 + 2] = c[2];
3723
+ }
3724
+ chairGeo.attributes.iColor.needsUpdate = true;
3725
+ };
3726
+ const scene = {
2985
3727
  main,
2986
3728
  background,
2987
3729
  seatProgram: seatProg,
2988
3730
  solidProgram: solidProg,
3731
+ chairProgram: chairProg,
2989
3732
  seatGeometry: seatGeo,
2990
3733
  solidGeometry: solidGeo,
2991
3734
  drawCalls: 3,
3735
+ setNearSeats(indices, count) {
3736
+ const n = Math.min(count, CAP);
3737
+ nearIndices = indices;
3738
+ nearCount = n;
3739
+ if (n === 0) {
3740
+ if (chairMesh.parent) chairMesh.setParent(null);
3741
+ chairGeo.instancedCount = 0;
3742
+ scene.drawCalls = 3;
3743
+ return;
3744
+ }
3745
+ const src = model.seats;
3746
+ for (let k = 0; k < n; k++) {
3747
+ const i = indices[k];
3748
+ cOffset[k * 3] = src.iPosition[i * 3];
3749
+ cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
3750
+ cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
3751
+ cRadius[k] = src.iChairWidth[i];
3752
+ cYaw[k] = src.iYaw[i];
3753
+ cRing[k * 3] = src.iRing[i * 3];
3754
+ cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
3755
+ cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
3756
+ cFloor[k] = src.iFloor[i];
3757
+ }
3758
+ writeChairColors();
3759
+ chairGeo.attributes.iOffset.needsUpdate = true;
3760
+ chairGeo.attributes.iRadius.needsUpdate = true;
3761
+ chairGeo.attributes.iYaw.needsUpdate = true;
3762
+ chairGeo.attributes.iRing.needsUpdate = true;
3763
+ chairGeo.attributes.iFloor.needsUpdate = true;
3764
+ chairGeo.instancedCount = n;
3765
+ if (!chairMesh.parent) chairMesh.setParent(main);
3766
+ scene.drawCalls = 4;
3767
+ },
3768
+ nearSeatCount() {
3769
+ return nearCount;
3770
+ },
2992
3771
  uploadSeatStateRuns(runs) {
2993
3772
  if (!runs.length) return;
3773
+ if (nearCount) writeChairColors();
2994
3774
  for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
2995
3775
  const buffer = colorAttr.buffer;
2996
3776
  if (!buffer) {
@@ -3010,8 +3790,11 @@ function buildGpuScene(gl, model) {
3010
3790
  solidProg.remove();
3011
3791
  seatGeo.remove();
3012
3792
  seatProg.remove();
3793
+ chairGeo.remove();
3794
+ chairProg.remove();
3013
3795
  }
3014
3796
  };
3797
+ return scene;
3015
3798
  }
3016
3799
 
3017
3800
  // src/view3d/pick/pickPipeline.ts
@@ -3579,6 +4362,28 @@ function mountVenue3D(container, input, opts = {}) {
3579
4362
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
3580
4363
  gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
3581
4364
  gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
4365
+ gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
4366
+ lastGatherX = Infinity;
4367
+ };
4368
+ const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
4369
+ const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
4370
+ let lastGatherX = Infinity;
4371
+ let lastGatherZ = Infinity;
4372
+ const updateNearField = () => {
4373
+ if (!gpu) return;
4374
+ const cam = orbit.camera.position;
4375
+ const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
4376
+ if (moved2 < CHAIR_REBUILD_M) return;
4377
+ lastGatherX = cam.x;
4378
+ lastGatherZ = cam.z;
4379
+ const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
4380
+ if (outside > CHAIR_GATHER_M) {
4381
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
4382
+ return;
4383
+ }
4384
+ const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
4385
+ if (n === 0 && gpu.nearSeatCount() === 0) return;
4386
+ gpu.setNearSeats(nearBuf, n);
3582
4387
  };
3583
4388
  const glctx = new GLContext(container, {
3584
4389
  onContextLost: () => {
@@ -3625,7 +4430,9 @@ function mountVenue3D(container, input, opts = {}) {
3625
4430
  const u = gpu.seatProgram.uniforms;
3626
4431
  u.uSeatScale.value = lod.scale;
3627
4432
  u.uSeatFade.value = lod.fade;
4433
+ u.uMinPixels.value = lod.minPixels;
3628
4434
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
4435
+ updateNearField();
3629
4436
  glctx.renderer.render({ scene: gpu.background, clear: true });
3630
4437
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
3631
4438
  labelOverlay.update(
@@ -3633,7 +4440,10 @@ function mountVenue3D(container, input, opts = {}) {
3633
4440
  glctx.canvas.clientWidth || 1,
3634
4441
  glctx.canvas.clientHeight || 1,
3635
4442
  orbit.currentDistance,
3636
- model.bounds.radius
4443
+ model.bounds.radius,
4444
+ // The dense label rungs rank by real distance from the eye, not by the
4445
+ // orbit radius — at the arrival pose those are wildly different numbers.
4446
+ [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
3637
4447
  );
3638
4448
  return moving;
3639
4449
  });
@@ -3906,6 +4716,7 @@ function mountVenue3D(container, input, opts = {}) {
3906
4716
  if (gpu) {
3907
4717
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
3908
4718
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
4719
+ gpu.chairProgram.uniforms.uFocusFloor.value = value;
3909
4720
  }
3910
4721
  focusedFloor = value;
3911
4722
  if (index !== null) {