@seatlayer/core 0.48.2 → 0.50.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.
@@ -81,8 +81,8 @@ var STRUCTURE = {
81
81
  * so an organizer's own stage colour survives — it is just no longer lit like
82
82
  * a basement.
83
83
  */
84
- stageTop: [0.86, 0.64, 0.36],
85
- stageWall: [0.34, 0.26, 0.18],
84
+ stageTop: [0.94, 0.7, 0.38],
85
+ stageWall: [0.48, 0.33, 0.18],
86
86
  decorTop: [0.22, 0.25, 0.29],
87
87
  decorWall: [0.15, 0.17, 0.2],
88
88
  gaTop: [0.24, 0.28, 0.33],
@@ -236,6 +236,10 @@ var OrbitCamera = class {
236
236
  this.minDist = 1;
237
237
  this.maxDist = 100;
238
238
  this.gestureFired = false;
239
+ /** Seat-eye arrival is a fixed physical origin. The buyer may opt into the
240
+ * dedicated look-around viewer, but venue orbit/dolly/pan must not pull the
241
+ * camera outside the selected seat. */
242
+ this.interactionEnabled = true;
239
243
  this.dragging = false;
240
244
  this.panning = false;
241
245
  this.lastX = 0;
@@ -258,6 +262,7 @@ var OrbitCamera = class {
258
262
  this.requestRender = requestRender;
259
263
  this.onGesture = onGesture;
260
264
  this.onPointerDown = (e) => {
265
+ if (!this.interactionEnabled) return;
261
266
  try {
262
267
  this.canvas.setPointerCapture?.(e.pointerId);
263
268
  } catch {
@@ -280,6 +285,7 @@ var OrbitCamera = class {
280
285
  }
281
286
  };
282
287
  this.onPointerMove = (e) => {
288
+ if (!this.interactionEnabled) return;
283
289
  if (!this.activePointers.has(e.pointerId)) return;
284
290
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
285
291
  if (this.activePointers.size >= 2) {
@@ -331,12 +337,14 @@ var OrbitCamera = class {
331
337
  };
332
338
  this.onWheel = (e) => {
333
339
  e.preventDefault();
340
+ if (!this.interactionEnabled) return;
334
341
  const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
335
342
  const norm2 = Math.max(-2, Math.min(2, e.deltaY * unit / 100));
336
343
  this.dollyBy(Math.exp(norm2 * 0.22));
337
344
  this.fireGesture();
338
345
  };
339
346
  this.onKeyDown = (e) => {
347
+ if (!this.interactionEnabled) return;
340
348
  const step = 7 * DEG;
341
349
  if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown")) {
342
350
  this.panBy(
@@ -434,6 +442,7 @@ var OrbitCamera = class {
434
442
  /** Programmatic zoom used by the visible camera controls. Keeping it on the
435
443
  * same dolly path as wheel, pinch and keyboard preserves all distance limits. */
436
444
  zoomBy(factor) {
445
+ if (!this.interactionEnabled) return;
437
446
  this.dollyBy(factor);
438
447
  this.fireGesture();
439
448
  }
@@ -443,6 +452,15 @@ var OrbitCamera = class {
443
452
  setPrimaryDragMode(mode) {
444
453
  this.primaryDragMode = mode;
445
454
  }
455
+ /** Enable venue navigation or pin the current camera pose for seat-eye mode. */
456
+ setInteractionEnabled(enabled) {
457
+ this.interactionEnabled = enabled;
458
+ if (enabled) return;
459
+ this.activePointers.clear();
460
+ this.dragging = false;
461
+ this.panning = false;
462
+ this.pinchDist = 0;
463
+ }
446
464
  /**
447
465
  * Fit a flattering 3/4 view to the bounds sphere. With `intro`, the camera
448
466
  * STARTS nearly top-down (matching the 2D map's orientation) and further out,
@@ -499,6 +517,26 @@ var OrbitCamera = class {
499
517
  this.polT = 55 * DEG;
500
518
  this.distT = fit * FRAME_MARGIN;
501
519
  }
520
+ /**
521
+ * Tighten the depth range around the actual venue bounds for the current pose.
522
+ *
523
+ * A fixed 0.1..5000 m frustum gives an ordinary arena centimetre-scale depth
524
+ * precision at overview distance—coarser than the separation between a row
525
+ * tread and its structural cap. The resulting z quantisation is the black
526
+ * radial comb previously seen while zooming. A padded bounds sphere contains
527
+ * every venue solid while preserving useful precision outside the bowl; when
528
+ * the camera enters that sphere, the near plane safely returns to 5 cm.
529
+ */
530
+ updateClipping(bounds) {
531
+ const dx = this.camera.position.x - bounds.center[0];
532
+ const dy = this.camera.position.y - bounds.center[1];
533
+ const dz = this.camera.position.z - bounds.center[2];
534
+ const distance = Math.hypot(dx, dy, dz);
535
+ const paddedRadius = Math.max(1, bounds.radius) * 1.18;
536
+ const near = Math.max(0.05, distance - paddedRadius);
537
+ const far = Math.max(near + 10, distance + paddedRadius);
538
+ this.camera.perspective({ near, far, aspect: this.camera.aspect });
539
+ }
502
540
  /** Damp toward targets; returns true while still moving. */
503
541
  update() {
504
542
  const da = this.azT - this.azimuth;
@@ -617,8 +655,12 @@ var RenderLoop = class {
617
655
  // src/view3d/lod.ts
618
656
  var SEAT_MIN_PIXELS_NEAR = 2.5;
619
657
  var SEAT_MIN_PIXELS_FAR = 1.15;
620
- var CHAIR_FULL_M = 12;
621
- var CHAIR_NONE_M = 22;
658
+ var CHAIR_FULL_M = 6;
659
+ var CHAIR_NONE_M = 10;
660
+ var OCCUPANT_FULL_M = 6;
661
+ var OCCUPANT_NONE_M = 9.5;
662
+ var OCCUPANT_NEAR_NONE_M = 0.9;
663
+ var OCCUPANT_NEAR_FULL_M = 2.2;
622
664
  var CHAIR_GATHER_M = 30;
623
665
  var CHAIR_REBUILD_M = 4;
624
666
  var CHAIR_MAX_INSTANCES = 8192;
@@ -874,13 +916,19 @@ var MeshBuilder = class {
874
916
  this.nor = new F32Buffer();
875
917
  this.col = new F32Buffer();
876
918
  this.flr = new F32Buffer();
919
+ this.mat = new F32Buffer();
877
920
  /** Floor index stamped onto every triangle emitted from now on. */
878
921
  this.currentFloor = 0;
922
+ this.currentMaterial = 0;
879
923
  }
880
924
  /** Stamp subsequent triangles as belonging to `index`. */
881
925
  setFloor(index) {
882
926
  this.currentFloor = index;
883
927
  }
928
+ /** Stamp subsequent triangles with a small renderer material class. */
929
+ setMaterial(index) {
930
+ this.currentMaterial = index;
931
+ }
884
932
  /** One triangle with a shared (flat) normal and per-vertex colours. */
885
933
  tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
886
934
  if (isDegenerate(p0, p1, p2)) return;
@@ -896,10 +944,13 @@ var MeshBuilder = class {
896
944
  this.flr.push1(this.currentFloor);
897
945
  this.flr.push1(this.currentFloor);
898
946
  this.flr.push1(this.currentFloor);
947
+ this.mat.push1(this.currentMaterial);
948
+ this.mat.push1(this.currentMaterial);
949
+ this.mat.push1(this.currentMaterial);
899
950
  }
900
951
  /** One triangle with independent per-vertex normals (smooth shading). */
901
952
  triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
902
- if (isDegenerate(p0, p1, p2)) return;
953
+ if (![...p0, ...p1, ...p2].every(Number.isFinite)) return;
903
954
  this.pos.push3(p0[0], p0[1], p0[2]);
904
955
  this.pos.push3(p1[0], p1[1], p1[2]);
905
956
  this.pos.push3(p2[0], p2[1], p2[2]);
@@ -912,6 +963,9 @@ var MeshBuilder = class {
912
963
  this.flr.push1(this.currentFloor);
913
964
  this.flr.push1(this.currentFloor);
914
965
  this.flr.push1(this.currentFloor);
966
+ this.mat.push1(this.currentMaterial);
967
+ this.mat.push1(this.currentMaterial);
968
+ this.mat.push1(this.currentMaterial);
915
969
  }
916
970
  get vertexCount() {
917
971
  return this.pos.length / 3;
@@ -922,6 +976,7 @@ var MeshBuilder = class {
922
976
  normal: this.nor.toArray(),
923
977
  color: this.col.toArray(),
924
978
  floor: this.flr.toArray(),
979
+ material: this.mat.toArray(),
925
980
  count: this.pos.length / 3
926
981
  };
927
982
  }
@@ -1164,15 +1219,17 @@ function mergeMeshData(parts) {
1164
1219
  const normal = new Float32Array(total * 3);
1165
1220
  const color = new Float32Array(total * 3);
1166
1221
  const floor = new Float32Array(total);
1222
+ const material = new Float32Array(total);
1167
1223
  let off = 0;
1168
1224
  for (const p of parts) {
1169
1225
  position.set(p.position, off * 3);
1170
1226
  normal.set(p.normal, off * 3);
1171
1227
  color.set(p.color, off * 3);
1172
1228
  floor.set(p.floor, off);
1229
+ material.set(p.material, off);
1173
1230
  off += p.count;
1174
1231
  }
1175
- return { position, normal, color, floor, count: total };
1232
+ return { position, normal, color, floor, material, count: total };
1176
1233
  }
1177
1234
  function outsetRing(ring, d, miterLimit = 2.5) {
1178
1235
  if (d <= 0 || ring.length < 3) return ring;
@@ -1266,7 +1323,7 @@ var BACK_RAKE_SLOPE = 0.21;
1266
1323
  var PAD_BACK_GAP_M = 0.05;
1267
1324
  var PAD_TOP_M = 0.45;
1268
1325
  var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1269
- var BOXES = [
1326
+ var CHAIR_BOXES = [
1270
1327
  // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1271
1328
  // over the deck and the row reads as hovering trays.
1272
1329
  { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
@@ -1281,39 +1338,22 @@ var BOXES = [
1281
1338
  part: CHAIR_PART.back,
1282
1339
  min: [-1, BACK_BASE_M, -1],
1283
1340
  max: [1, 0.92, -0.72]
1284
- },
1285
- // --- the occupant ---------------------------------------------------------
1286
- //
1287
- // A hall with every seat empty reads as an architectural model, not a venue.
1288
- // The 2048-px panorama this replaced drew a crowd; losing it was the price of
1289
- // sharpness, and this is how it is bought back — in geometry, where there is
1290
- // no resolution ceiling.
1291
- //
1292
- // Deliberately two blocks and no limbs. At the range these are visible a
1293
- // person is a torso and a head, and every extra part multiplies by the number
1294
- // of occupied seats in view. The silhouette is what carries it, exactly as it
1295
- // does in the generated panorama's head-and-shoulder figures.
1296
- //
1297
- // Sized as a seated adult against the 0.92 m chair back: hips at the pad top,
1298
- // shoulders just above the back panel, head clear of it. Torso is narrower
1299
- // than the chair so neighbours never interpenetrate at any pitch, and it sits
1300
- // forward of the back panel rather than inside it.
1301
- {
1302
- part: CHAIR_PART.body,
1303
- min: [-0.66, PAD_TOP_M, -0.58],
1304
- max: [0.66, 1, 0.26]
1305
- },
1306
- // The head is deliberately SMALL. Sized by eye against the chair it came out
1307
- // near-cubic and read as Lego; a real head is about 0.16 m across and 0.22 m
1308
- // tall, which against a 0.24 m chair half-width is roughly a third of the
1309
- // chair's width and clearly taller than it is wide. Getting this ratio wrong
1310
- // is what makes a crowd look like toys rather than people.
1311
- {
1312
- part: CHAIR_PART.head,
1313
- min: [-0.34, 1.03, -0.4],
1314
- max: [0.34, 1.27, 0.02]
1315
1341
  }
1316
1342
  ];
1343
+ var OCCUPANT_BOXES = [
1344
+ // Thighs project forward from the pad. A single lap slab brought back the
1345
+ // same toy-block problem as the old torso, so each leg keeps its own outline.
1346
+ { part: CHAIR_PART.body, min: [-0.52, 0.39, -0.08], max: [-0.08, 0.55, 0.82] },
1347
+ { part: CHAIR_PART.body, min: [0.08, 0.39, -0.08], max: [0.52, 0.55, 0.82] },
1348
+ // Lower legs drop from the knees, making the pose unmistakably seated from
1349
+ // the side and from the elevated arena camera.
1350
+ { part: CHAIR_PART.body, min: [-0.47, 0.05, 0.58], max: [-0.13, 0.43, 0.82] },
1351
+ { part: CHAIR_PART.body, min: [0.13, 0.05, 0.58], max: [0.47, 0.43, 0.82] },
1352
+ // A short neck keeps the head connected to the shoulders from behind. Most
1353
+ // of it sits inside the torso/head overlap: a long exposed cuboid made the
1354
+ // close-range figure read as a mannequin on a post.
1355
+ { part: CHAIR_PART.head, min: [-0.16, 1.04, -0.33], max: [0.16, 1.1, -0.13] }
1356
+ ];
1317
1357
  var FACES = [
1318
1358
  // +X
1319
1359
  { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
@@ -1329,63 +1369,111 @@ var FACES = [
1329
1369
  { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1330
1370
  ];
1331
1371
  function buildChairMesh() {
1332
- const vertexCount = BOXES.length * FACES.length * 4;
1333
- const indexCount = BOXES.length * FACES.length * 6;
1334
- const position = new Float32Array(vertexCount * 3);
1335
- const normal = new Float32Array(vertexCount * 3);
1336
- const part = new Float32Array(vertexCount);
1337
- const index = new Uint16Array(indexCount);
1338
- let v = 0;
1339
- let t = 0;
1340
- for (const box of BOXES) {
1372
+ const positions = [];
1373
+ const normals = [];
1374
+ const parts = [];
1375
+ const indices = [];
1376
+ const addQuad = (corners, outward, partId) => {
1377
+ const base = parts.length;
1378
+ const [a, b, c] = corners;
1379
+ const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1380
+ const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
1381
+ let nx = ab[1] * ac[2] - ab[2] * ac[1];
1382
+ let ny = ab[2] * ac[0] - ab[0] * ac[2];
1383
+ let nz = ab[0] * ac[1] - ab[1] * ac[0];
1384
+ const length = Math.hypot(nx, ny, nz);
1385
+ if (length > 1e-9) {
1386
+ nx /= length;
1387
+ ny /= length;
1388
+ nz /= length;
1389
+ } else {
1390
+ [nx, ny, nz] = outward;
1391
+ }
1392
+ if (nx * outward[0] + ny * outward[1] + nz * outward[2] < 0) {
1393
+ nx = -nx;
1394
+ ny = -ny;
1395
+ nz = -nz;
1396
+ }
1397
+ for (const corner of corners) {
1398
+ positions.push(...corner);
1399
+ normals.push(nx, ny, nz);
1400
+ parts.push(partId);
1401
+ }
1402
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
1403
+ };
1404
+ const addBox = (box) => {
1341
1405
  for (const face of FACES) {
1342
- const base = v;
1343
- const corner3 = new Float32Array(12);
1344
- for (let ci = 0; ci < 4; ci++) {
1345
- const corner = face.c[ci];
1346
- for (let a = 0; a < 3; a++) {
1347
- corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1348
- }
1349
- }
1350
- const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1351
- const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1352
- let nx = ay * bz - az * by;
1353
- let ny = az * bx - ax * bz;
1354
- let nz = ax * by - ay * bx;
1355
- const nl = Math.hypot(nx, ny, nz);
1356
- if (nl > 1e-9) {
1357
- nx /= nl;
1358
- ny /= nl;
1359
- nz /= nl;
1360
- } else {
1361
- nx = face.n[0];
1362
- ny = face.n[1];
1363
- nz = face.n[2];
1364
- }
1365
- if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1366
- nx = -nx;
1367
- ny = -ny;
1368
- nz = -nz;
1369
- }
1370
- for (let ci = 0; ci < 4; ci++) {
1371
- position[v * 3] = corner3[ci * 3];
1372
- position[v * 3 + 1] = corner3[ci * 3 + 1];
1373
- position[v * 3 + 2] = corner3[ci * 3 + 2];
1374
- normal[v * 3] = nx;
1375
- normal[v * 3 + 1] = ny;
1376
- normal[v * 3 + 2] = nz;
1377
- part[v] = box.part;
1378
- v++;
1379
- }
1380
- index[t++] = base;
1381
- index[t++] = base + 1;
1382
- index[t++] = base + 2;
1383
- index[t++] = base;
1384
- index[t++] = base + 2;
1385
- index[t++] = base + 3;
1406
+ const corners = face.c.map((corner) => corner.map(
1407
+ (side, axis) => side ? box.max[axis] : box.min[axis]
1408
+ ));
1409
+ addQuad(corners, face.n, box.part);
1386
1410
  }
1387
- }
1388
- return { position, normal, part, index, vertexCount, indexCount };
1411
+ };
1412
+ for (const box of CHAIR_BOXES) addBox(box);
1413
+ for (const box of OCCUPANT_BOXES) addBox(box);
1414
+ const waistY = PAD_TOP_M;
1415
+ const shoulderY = 1.08;
1416
+ const wb = 0.47, wt = 0.69;
1417
+ const backBottom = -0.55, frontBottom = 0.08;
1418
+ const backTop = -0.48, frontTop = 0.01;
1419
+ const blb = [-wb, waistY, backBottom], brb = [wb, waistY, backBottom];
1420
+ const blf = [-wb, waistY, frontBottom], brf = [wb, waistY, frontBottom];
1421
+ const tlb = [-wt, shoulderY, backTop], trb = [wt, shoulderY, backTop];
1422
+ const tlf = [-wt, shoulderY, frontTop], trf = [wt, shoulderY, frontTop];
1423
+ addQuad([brb, trb, trf, brf], [1, 0, 0], CHAIR_PART.body);
1424
+ addQuad([blf, tlf, tlb, blb], [-1, 0, 0], CHAIR_PART.body);
1425
+ addQuad([tlf, trf, trb, tlb], [0, 1, 0], CHAIR_PART.body);
1426
+ addQuad([blb, brb, brf, blf], [0, -1, 0], CHAIR_PART.body);
1427
+ addQuad([brf, trf, tlf, blf], [0, 0, 1], CHAIR_PART.body);
1428
+ addQuad([blb, tlb, trb, brb], [0, 0, -1], CHAIR_PART.body);
1429
+ const lonSegments = 10;
1430
+ const latSegments = 8;
1431
+ const centre = [0, 1.205, -0.23];
1432
+ const radii = [0.42, 0.115, 0.38];
1433
+ const headBase = parts.length;
1434
+ for (let lat = 0; lat <= latSegments; lat++) {
1435
+ const theta = Math.PI * lat / latSegments;
1436
+ const sy = Math.cos(theta);
1437
+ const ring = Math.sin(theta);
1438
+ for (let lon = 0; lon <= lonSegments; lon++) {
1439
+ const phi = Math.PI * 2 * lon / lonSegments;
1440
+ const dx = ring * Math.cos(phi);
1441
+ const dz = ring * Math.sin(phi);
1442
+ positions.push(
1443
+ centre[0] + radii[0] * dx,
1444
+ centre[1] + radii[1] * sy,
1445
+ centre[2] + radii[2] * dz
1446
+ );
1447
+ let nx = dx / radii[0];
1448
+ let ny = sy / radii[1];
1449
+ let nz = dz / radii[2];
1450
+ const length = Math.hypot(nx, ny, nz) || 1;
1451
+ nx /= length;
1452
+ ny /= length;
1453
+ nz /= length;
1454
+ normals.push(nx, ny, nz);
1455
+ parts.push(CHAIR_PART.head);
1456
+ }
1457
+ }
1458
+ for (let lat = 0; lat < latSegments; lat++) {
1459
+ for (let lon = 0; lon < lonSegments; lon++) {
1460
+ const a = headBase + lat * (lonSegments + 1) + lon;
1461
+ const b = a + lonSegments + 1;
1462
+ indices.push(a, b, a + 1, a + 1, b, b + 1);
1463
+ }
1464
+ }
1465
+ const position = new Float32Array(positions);
1466
+ const normal = new Float32Array(normals);
1467
+ const part = new Float32Array(parts);
1468
+ const index = new Uint16Array(indices);
1469
+ return {
1470
+ position,
1471
+ normal,
1472
+ part,
1473
+ index,
1474
+ vertexCount: parts.length,
1475
+ indexCount: indices.length
1476
+ };
1389
1477
  }
1390
1478
  function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1391
1479
  const yaw = new Float32Array(count);
@@ -1689,7 +1777,7 @@ function focusScore(screen, worldDistance, width, height) {
1689
1777
  return worldDistance * (1 + 1.5 * off);
1690
1778
  }
1691
1779
  function pickDenseLabels(items, separationX, separationY, budget) {
1692
- const ordered = [...items].sort((a, b) => a.focus - b.focus);
1780
+ const ordered = [...items].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.focus - b.focus);
1693
1781
  const kept = [];
1694
1782
  for (const item of ordered) {
1695
1783
  if (kept.length >= budget) break;
@@ -2668,19 +2756,31 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2668
2756
  const UP = [0, 1, 0];
2669
2757
  const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2670
2758
  const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2759
+ const emitRiser = (draw) => {
2760
+ builder.setMaterial(2);
2761
+ draw();
2762
+ builder.setMaterial(0);
2763
+ };
2764
+ const emitTread = (draw) => {
2765
+ builder.setMaterial(3);
2766
+ draw();
2767
+ builder.setMaterial(0);
2768
+ };
2671
2769
  for (let i = 0; i < rows.length; i++) {
2672
2770
  const row = rows[i];
2673
2771
  if (row.patch && row.patch.length >= 3) {
2674
2772
  const patch = [...row.patch];
2675
2773
  const belowY2 = nbrs[i].belowY ?? landingY;
2676
- if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2677
- else {
2678
- const a = patch[0];
2679
- for (let k = 1; k + 1 < patch.length; k++) {
2680
- const b = patch[k], c = patch[k + 1];
2681
- 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);
2774
+ emitTread(() => {
2775
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2776
+ else {
2777
+ const a = patch[0];
2778
+ for (let k = 1; k + 1 < patch.length; k++) {
2779
+ const b = patch[k], c = patch[k + 1];
2780
+ 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);
2781
+ }
2682
2782
  }
2683
- }
2783
+ });
2684
2784
  if (row.y > belowY2 + MIN_RISER_M) {
2685
2785
  let cx = 0, cy = 0;
2686
2786
  for (const p of patch) {
@@ -2689,24 +2789,26 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2689
2789
  }
2690
2790
  cx /= patch.length;
2691
2791
  cy /= patch.length;
2692
- for (let k = 0; k < patch.length; k++) {
2693
- const p = patch[k], q = patch[(k + 1) % patch.length];
2694
- const dx = q.x - p.x, dy = q.y - p.y;
2695
- const len = Math.hypot(dx, dy);
2696
- if (len < 1e-6) continue;
2697
- let nx = dy / len, ny = -dx / len;
2698
- if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2699
- nx = -nx;
2700
- ny = -ny;
2792
+ emitRiser(() => {
2793
+ for (let k = 0; k < patch.length; k++) {
2794
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2795
+ const dx = q.x - p.x, dy = q.y - p.y;
2796
+ const len = Math.hypot(dx, dy);
2797
+ if (len < 1e-6) continue;
2798
+ let nx = dy / len, ny = -dx / len;
2799
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2800
+ nx = -nx;
2801
+ ny = -ny;
2802
+ }
2803
+ const rn = [nx, 0, ny];
2804
+ const pt = [p.x * M, row.y, p.y * M];
2805
+ const qt = [q.x * M, row.y, q.y * M];
2806
+ const pb = [p.x * M, belowY2, p.y * M];
2807
+ const qb = [q.x * M, belowY2, q.y * M];
2808
+ builder.tri(pt, qt, qb, rn, colors.riser);
2809
+ builder.tri(pt, qb, pb, rn, colors.riser);
2701
2810
  }
2702
- const rn = [nx, 0, ny];
2703
- const pt = [p.x * M, row.y, p.y * M];
2704
- const qt = [q.x * M, row.y, q.y * M];
2705
- const pb = [p.x * M, belowY2, p.y * M];
2706
- const qb = [q.x * M, belowY2, q.y * M];
2707
- builder.tri(pt, qt, qb, rn, colors.riser);
2708
- builder.tri(pt, qb, pb, rn, colors.riser);
2709
- }
2811
+ });
2710
2812
  }
2711
2813
  continue;
2712
2814
  }
@@ -2723,23 +2825,27 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2723
2825
  const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2724
2826
  const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2725
2827
  const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2726
- if (clipRing && clipTest) {
2727
- emitClippedPoly(builder, clipRing, clipTest, [
2728
- { x: p.x - np[0] * front, y: p.y - np[1] * front },
2729
- { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2730
- { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2731
- { x: p.x + np[0] * back, y: p.y + np[1] * back }
2732
- ], row.y, colors.tread);
2733
- } else {
2734
- builder.tri(pF, qF, qB, UP, colors.tread);
2735
- builder.tri(pF, qB, pB, UP, colors.tread);
2736
- }
2828
+ emitTread(() => {
2829
+ if (clipRing && clipTest) {
2830
+ emitClippedPoly(builder, clipRing, clipTest, [
2831
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
2832
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2833
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2834
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
2835
+ ], row.y, colors.tread);
2836
+ } else {
2837
+ builder.tri(pF, qF, qB, UP, colors.tread);
2838
+ builder.tri(pF, qB, pB, UP, colors.tread);
2839
+ }
2840
+ });
2737
2841
  if (row.y > belowY + MIN_RISER_M) {
2738
2842
  const pFd = [pF[0], belowY, pF[2]];
2739
2843
  const qFd = [qF[0], belowY, qF[2]];
2740
2844
  const rn = [-np[0], 0, -np[1]];
2741
- builder.tri(pF, qF, qFd, rn, colors.riser);
2742
- builder.tri(pF, qFd, pFd, rn, colors.riser);
2845
+ emitRiser(() => {
2846
+ builder.tri(pF, qF, qFd, rn, colors.riser);
2847
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
2848
+ });
2743
2849
  }
2744
2850
  }
2745
2851
  }
@@ -3084,18 +3190,21 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
3084
3190
  const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal, nbrs), footprint);
3085
3191
  const capUp = () => [0, 1, 0];
3086
3192
  for (const b of blocks) {
3087
- extrudePrism(
3088
- builder,
3089
- b.outline,
3090
- b.holes,
3091
- () => b.topY,
3092
- bottomY,
3093
- colTop,
3094
- S.tierWall,
3095
- AO,
3096
- void 0,
3097
- capUp
3098
- );
3193
+ const baseCapY = Math.max(bottomY, b.topY - 0.02);
3194
+ for (const ring of claimed.subtract(b.outline, baseCapY)) {
3195
+ extrudePrism(
3196
+ builder,
3197
+ ring,
3198
+ b.holes,
3199
+ () => baseCapY,
3200
+ bottomY,
3201
+ colTop,
3202
+ S.tierWall,
3203
+ AO,
3204
+ void 0,
3205
+ capUp
3206
+ );
3207
+ }
3099
3208
  }
3100
3209
  if (!blocks.length) {
3101
3210
  extrudePrism(
@@ -3109,12 +3218,23 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
3109
3218
  AO
3110
3219
  );
3111
3220
  }
3112
- emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
3221
+ const bandColors = {
3113
3222
  tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
3114
3223
  // Risers read as the structure they are, a shade below their tread, which
3115
3224
  // is what makes the stepping legible from a low angle.
3116
3225
  riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
3117
- }, footprint.length === 1 ? outline : footprint.flat(), nbrs);
3226
+ };
3227
+ for (const allowed of footprint.length ? footprint : [outline]) {
3228
+ emitDeckBands(
3229
+ builder,
3230
+ surface.rowLevels,
3231
+ unit.focal,
3232
+ surface.landingY,
3233
+ bandColors,
3234
+ allowed,
3235
+ nbrs
3236
+ );
3237
+ }
3118
3238
  return;
3119
3239
  }
3120
3240
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
@@ -3368,8 +3488,10 @@ function buildShape(builder, shape, base, topOffsetM, S, focal, stages) {
3368
3488
  halfZ: Math.max((maxZ - minZ) / 2 * M, 0.5)
3369
3489
  });
3370
3490
  }
3371
- const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
3372
- const colWall = isStage ? S.stageWall : shape.role === "screen" ? [0.24, 0.46, 0.82] : tintTop(hexToRgb(shape.fill), S.decorWall);
3491
+ const authoredFill = hexToRgb(shape.fill);
3492
+ const colTop = isStage && authoredFill ? mix(S.stageTop, scaleRgb(desaturate(authoredFill, 0.18), 0.9), 0.9) : tintTop(authoredFill, isStage ? S.stageTop : S.decorTop);
3493
+ const screenFill = shape.role === "screen" ? authoredFill : null;
3494
+ const colWall = isStage ? authoredFill ? mix(S.stageWall, scaleRgb(authoredFill, 0.58), 0.82) : S.stageWall : shape.role === "screen" ? screenFill ? mix(scaleRgb(screenFill, 1.42), [0.24, 0.46, 0.82], 0.08) : [0.24, 0.46, 0.82] : tintTop(hexToRgb(shape.fill), S.decorWall);
3373
3495
  if (architecture.kind === "portal" && poly.length === 4) {
3374
3496
  const quad = orientQuadLong(poly);
3375
3497
  emitArchitecturalPrism(builder, quadWidthSlice(quad, 0, 0.16), height, base, colTop, colWall);
@@ -3509,16 +3631,19 @@ function buildSceneModel(input) {
3509
3631
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
3510
3632
  for (const o of unit.objects) {
3511
3633
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
3512
- else if (o.type === "shape") buildShape(
3513
- builder,
3514
- o,
3515
- unit.baseHeightM,
3516
- claimShapeTopOffset(o, claimedShapeLayers),
3517
- S,
3518
- unit.focal,
3519
- stageBounds
3520
- );
3521
- else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3634
+ else if (o.type === "shape") {
3635
+ builder.setMaterial(o.role === "screen" ? 1 : 0);
3636
+ buildShape(
3637
+ builder,
3638
+ o,
3639
+ unit.baseHeightM,
3640
+ claimShapeTopOffset(o, claimedShapeLayers),
3641
+ S,
3642
+ unit.focal,
3643
+ stageBounds
3644
+ );
3645
+ builder.setMaterial(0);
3646
+ } else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3522
3647
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3523
3648
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3524
3649
  else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
@@ -3824,10 +3949,49 @@ function buildSceneModel(input) {
3824
3949
  const cx = (fp.minX + fp.maxX) / 2 * M;
3825
3950
  const cz = (fp.minY + fp.maxY) / 2 * M;
3826
3951
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
3952
+ let renderMinX = Infinity, renderMinY = Infinity, renderMinZ = Infinity;
3953
+ let renderMaxX = -Infinity, renderMaxY = -Infinity, renderMaxZ = -Infinity;
3954
+ const includeRenderPoint = (x, y, z) => {
3955
+ renderMinX = Math.min(renderMinX, x);
3956
+ renderMaxX = Math.max(renderMaxX, x);
3957
+ renderMinY = Math.min(renderMinY, y);
3958
+ renderMaxY = Math.max(renderMaxY, y);
3959
+ renderMinZ = Math.min(renderMinZ, z);
3960
+ renderMaxZ = Math.max(renderMaxZ, z);
3961
+ };
3962
+ for (let i = 0; i < solids.position.length; i += 3) {
3963
+ includeRenderPoint(solids.position[i], solids.position[i + 1], solids.position[i + 2]);
3964
+ }
3965
+ for (let i = 0; i < seatData.count; i++) {
3966
+ const o = i * 3;
3967
+ const x = seatData.iPosition[o], y = seatData.iPosition[o + 1], z = seatData.iPosition[o + 2];
3968
+ const r = Math.max(0.35, seatData.iMaxRadius[i] ?? 0.35);
3969
+ includeRenderPoint(x - r, y, z - r);
3970
+ includeRenderPoint(x + r, y + 1.6, z + r);
3971
+ }
3972
+ if (!Number.isFinite(renderMinX)) {
3973
+ renderMinX = cx - radius;
3974
+ renderMaxX = cx + radius;
3975
+ renderMinY = 0;
3976
+ renderMaxY = radius * 0.16;
3977
+ renderMinZ = cz - radius;
3978
+ renderMaxZ = cz + radius;
3979
+ }
3980
+ const renderCenter = [
3981
+ (renderMinX + renderMaxX) / 2,
3982
+ (renderMinY + renderMaxY) / 2,
3983
+ (renderMinZ + renderMaxZ) / 2
3984
+ ];
3985
+ const renderRadius = Math.max(1, 0.5 * Math.hypot(
3986
+ renderMaxX - renderMinX,
3987
+ renderMaxY - renderMinY,
3988
+ renderMaxZ - renderMinZ
3989
+ ));
3827
3990
  return {
3828
3991
  solids,
3829
3992
  seats: seatData,
3830
3993
  bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
3994
+ renderBounds: { center: renderCenter, radius: renderRadius },
3831
3995
  stateColorLUT: themeSeatColorLUT(theme, SEAT_STATES),
3832
3996
  theme,
3833
3997
  seatCount: seats.length,
@@ -3871,6 +4035,7 @@ var LabelOverlay = class {
3871
4035
  this.nodes = /* @__PURE__ */ new Map();
3872
4036
  this.labels = [];
3873
4037
  this.forcedDense = false;
4038
+ this.selectedSeatLabelIds = /* @__PURE__ */ new Set();
3874
4039
  this.opts = opts;
3875
4040
  this.root = document.createElement("div");
3876
4041
  this.root.setAttribute("data-view3d-labels", "");
@@ -3910,6 +4075,12 @@ var LabelOverlay = class {
3910
4075
  setForcedDense(enabled) {
3911
4076
  this.forcedDense = enabled;
3912
4077
  }
4078
+ /** Keep the authoritative selected identities pinned above nearby dense
4079
+ * labels. The seat mesh and its DOM label must never describe different
4080
+ * rows merely because collision suppression preferred the neighbour. */
4081
+ setSelectedSeatIds(ids) {
4082
+ this.selectedSeatLabelIds = new Set(Array.from(ids, (id) => `seat:${id}`));
4083
+ }
3913
4084
  /**
3914
4085
  * Reposition every label for the current camera.
3915
4086
  *
@@ -3932,7 +4103,12 @@ var LabelOverlay = class {
3932
4103
  label.anchor[1] - cameraWorld[1],
3933
4104
  label.anchor[2] - cameraWorld[2]
3934
4105
  ) : 1;
3935
- candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
4106
+ candidates.push({
4107
+ label,
4108
+ screen,
4109
+ focus: focusScore(screen, world, width, height),
4110
+ ...this.selectedSeatLabelIds.has(label.id) ? { priority: 1 } : {}
4111
+ });
3936
4112
  }
3937
4113
  const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
3938
4114
  const kept = [
@@ -3960,26 +4136,35 @@ var LabelOverlay = class {
3960
4136
  }
3961
4137
  nodeFor(label) {
3962
4138
  let node = this.nodes.get(label.id);
3963
- if (node) return node;
3964
- node = document.createElement("div");
4139
+ if (!node) {
4140
+ node = document.createElement("div");
4141
+ const s2 = node.style;
4142
+ s2.position = "absolute";
4143
+ s2.left = "0";
4144
+ s2.top = "0";
4145
+ s2.whiteSpace = "nowrap";
4146
+ s2.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
4147
+ s2.display = "none";
4148
+ this.root.appendChild(node);
4149
+ this.nodes.set(label.id, node);
4150
+ }
3965
4151
  node.textContent = label.text;
3966
4152
  node.setAttribute("data-label-kind", label.kind);
4153
+ const selected = this.selectedSeatLabelIds.has(label.id);
4154
+ node.toggleAttribute("data-selected-seat", selected);
3967
4155
  const style = KIND_STYLE[label.kind];
3968
4156
  const s = node.style;
3969
- s.position = "absolute";
3970
- s.left = "0";
3971
- s.top = "0";
3972
- s.whiteSpace = "nowrap";
3973
4157
  s.fontSize = `${style.size}px`;
3974
- s.fontWeight = style.weight;
3975
- s.opacity = String(style.opacity);
3976
- s.color = label.color ?? this.opts.ink ?? "#e8edf5";
3977
- s.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
4158
+ s.fontWeight = selected ? "800" : style.weight;
4159
+ s.opacity = selected ? "1" : String(style.opacity);
4160
+ s.color = selected ? "#ecf8ff" : label.color ?? this.opts.ink ?? "#e8edf5";
4161
+ s.background = selected ? "rgba(10,28,45,.9)" : "";
4162
+ s.border = selected ? "1px solid rgba(89,199,255,.92)" : "";
4163
+ s.borderRadius = selected ? "999px" : "";
4164
+ s.padding = selected ? "3px 7px" : "";
4165
+ s.boxShadow = selected ? "0 0 0 2px rgba(41,176,255,.18), 0 5px 14px rgba(0,0,0,.35)" : "";
3978
4166
  s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
3979
4167
  if (label.kind === "zone") s.textTransform = "uppercase";
3980
- s.display = "none";
3981
- this.root.appendChild(node);
3982
- this.nodes.set(label.id, node);
3983
4168
  return node;
3984
4169
  }
3985
4170
  dispose() {
@@ -4000,6 +4185,15 @@ float chairWeight(float depth) {
4000
4185
  return 1.0 - smoothstep(uChairFull, uChairNone, depth);
4001
4186
  }`
4002
4187
  );
4188
+ var OCCUPANT_WEIGHT_GLSL = (
4189
+ /* glsl */
4190
+ `
4191
+ float occupantWeight(float depth) {
4192
+ float nearWeight = smoothstep(${OCCUPANT_NEAR_NONE_M.toFixed(1)}, ${OCCUPANT_NEAR_FULL_M.toFixed(1)}, depth);
4193
+ float farWeight = 1.0 - smoothstep(${OCCUPANT_FULL_M.toFixed(1)}, ${OCCUPANT_NONE_M.toFixed(1)}, depth);
4194
+ return nearWeight * farWeight;
4195
+ }`
4196
+ );
4003
4197
  var SOLID_VERT = (
4004
4198
  /* glsl */
4005
4199
  `#version 300 es
@@ -4008,8 +4202,10 @@ in vec3 position;
4008
4202
  in vec3 normal;
4009
4203
  in vec3 color;
4010
4204
  in float floorIndex;
4205
+ in float materialIndex;
4011
4206
  uniform mat4 modelMatrix;
4012
4207
  uniform float uFocusFloor; // -1 = show every floor
4208
+ uniform float uStructureDetail; // 0 = venue overview, 1 = section/row/seat
4013
4209
  uniform mat4 modelViewMatrix;
4014
4210
  uniform mat4 projectionMatrix;
4015
4211
  uniform mat3 normalMatrix;
@@ -4017,14 +4213,19 @@ out vec3 vColor;
4017
4213
  out vec3 vNormalWorld;
4018
4214
  out vec3 vNormalView;
4019
4215
  out vec3 vPosView;
4216
+ out vec3 vPosWorld;
4020
4217
  out float vDim;
4218
+ out float vMaterial;
4021
4219
  void main() {
4022
4220
  // Per-floor isolation without splitting the merged mesh into a draw call per
4023
4221
  // floor: a floor that is not the focused one is dimmed, not hidden, so the
4024
4222
  // buyer keeps the whole venue as context while looking at one level.
4025
4223
  vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
4224
+ vMaterial = materialIndex;
4225
+ vec4 world = modelMatrix * vec4(position, 1.0);
4026
4226
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
4027
4227
  vPosView = mv.xyz;
4228
+ vPosWorld = world.xyz;
4028
4229
  vNormalView = normalize(normalMatrix * normal);
4029
4230
  // World normal drives the key + hemisphere so the lighting stays welded to the
4030
4231
  // venue as the camera orbits (the scene has no non-uniform scale, so mat3 of
@@ -4042,10 +4243,20 @@ in vec3 vColor;
4042
4243
  in vec3 vNormalWorld;
4043
4244
  in vec3 vNormalView;
4044
4245
  in vec3 vPosView;
4246
+ in vec3 vPosWorld;
4045
4247
  in float vDim;
4248
+ in float vMaterial;
4046
4249
  uniform vec3 uKeyDir; // WORLD space, unit, points at the light
4250
+ uniform float uStructureDetail;
4047
4251
  out vec4 fragColor;
4048
4252
  void main() {
4253
+ // Row treads/risers are honest and useful when the buyer is inside a stand.
4254
+ // At whole-venue scale their overlapping centimetre-scale surfaces quantise
4255
+ // and alias into camera-dependent radial combs. The clean structural block
4256
+ // cap remains, so overview keeps every stand without pretending to show row
4257
+ // detail below a useful pixel size.
4258
+ float rowDetail = step(1.5, vMaterial);
4259
+ if (rowDetail > 0.5 && uStructureDetail < 0.5) discard;
4049
4260
  vec3 N = normalize(vNormalWorld);
4050
4261
  vec3 V = normalize(-vPosView);
4051
4262
  float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
@@ -4057,6 +4268,30 @@ void main() {
4057
4268
  vec3 base = vColor * (0.52 + 0.34 * hemi) + vColor * key * 0.34 + vColor * fill * 0.10;
4058
4269
  float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
4059
4270
  base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained (view-dependent by design)
4271
+ // Bright, saturated authored screen faces should read as powered displays,
4272
+ // not painted slabs. Vertex colour is deliberately the material signal here:
4273
+ // muted tiers and structure never cross these thresholds. The broad waves
4274
+ // supply a restrained procedural LED treatment without textures, downloads,
4275
+ // solid fake-light geometry or post-processing.
4276
+ float maxChannel = max(max(vColor.r, vColor.g), vColor.b);
4277
+ float minChannel = min(min(vColor.r, vColor.g), vColor.b);
4278
+ float poweredDisplay = (1.0 - step(0.5, abs(vMaterial - 1.0)))
4279
+ * smoothstep(0.58, 0.72, maxChannel)
4280
+ * smoothstep(0.28, 0.48, maxChannel - minChannel);
4281
+ float verticalFace = 1.0 - smoothstep(0.20, 0.62, abs(N.y));
4282
+ float wave = 0.5 + 0.5 * sin(vPosWorld.x * 0.44 + vPosWorld.y * 0.28);
4283
+ float localX = vPosWorld.x;
4284
+ float localY = vPosWorld.y;
4285
+ float beamWest = exp(-pow((localX + 2.6 - localY * 0.22) * 0.44, 2.0));
4286
+ float beamEast = exp(-pow((localX - 2.6 + localY * 0.22) * 0.44, 2.0));
4287
+ float band = smoothstep(0.02, 0.16, 0.18 - abs(localY - 3.9));
4288
+ float focalHalo = exp(-pow(localX * 0.26, 2.0) - pow((localY - 3.9) * 0.38, 2.0));
4289
+ vec3 display = vColor * (0.74 + wave * 0.26)
4290
+ + vec3(0.10, 0.30, 0.52) * beamWest
4291
+ + vec3(0.34, 0.13, 0.42) * beamEast
4292
+ + vec3(0.62, 0.46, 0.20) * band * 0.28
4293
+ + vec3(0.20, 0.10, 0.34) * focalHalo;
4294
+ base = mix(base, display, poweredDisplay * verticalFace * 0.92);
4060
4295
  // Unfocused floors fall back toward the background rather than vanishing.
4061
4296
  base = mix(base, base * 0.45, vDim);
4062
4297
  fragColor = vec4(base, 1.0);
@@ -4089,6 +4324,7 @@ out vec3 vRing;
4089
4324
  out float vDim;
4090
4325
  out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
4091
4326
  out float vPhysicalSeat;
4327
+ out float vPixelRadius;
4092
4328
  ${CHAIR_WEIGHT_GLSL}
4093
4329
  void main() {
4094
4330
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
@@ -4102,6 +4338,7 @@ void main() {
4102
4338
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
4103
4339
  // unbounded growth is what merges neighbouring rows into one mass at range.
4104
4340
  float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
4341
+ vPixelRadius = r / max(depth * uPixelToWorld, 0.000001);
4105
4342
  // How much of the requested pixel floor the dot could actually afford. Below 1
4106
4343
  // it is losing legibility to distance, and the fragment stage dissolves it
4107
4344
  // toward the tier top rather than letting a sub-pixel dot alias and shimmer.
@@ -4134,10 +4371,13 @@ in vec3 vRing;
4134
4371
  in float vDim;
4135
4372
  in float vDotWeight;
4136
4373
  in float vPhysicalSeat;
4374
+ in float vPixelRadius;
4137
4375
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
4376
+ uniform float uSeatDetail; // 0 for venue/area overview; 1 in section/row/seat
4138
4377
  uniform vec3 uFadeColor;
4139
4378
  out vec4 fragColor;
4140
4379
  void main() {
4380
+ if (uSeatDetail < 0.5) discard;
4141
4381
  // Empty wheelchair provision is a square bay, never a round chair marker.
4142
4382
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
4143
4383
  if (d > 1.0) discard;
@@ -4156,6 +4396,20 @@ void main() {
4156
4396
  // aliasing; the tier cap underneath already carries the section's category
4157
4397
  // tint, so the block reads as coloured seating rather than empty concrete.
4158
4398
  alpha *= smoothstep(0.35, 1.0, vBudget);
4399
+ // Do not rasterise inventory states until a dot is large enough to read as a
4400
+ // seat rather than a sample in a dense screen-space pattern. This is based on
4401
+ // actual projected pixels, so it remains correct for unusually large or small
4402
+ // venues where a radius-normalised camera distance is misleading.
4403
+ alpha *= smoothstep(2.4, 3.2, vPixelRadius);
4404
+ // Inventory states are a section-level decision aid. At whole-venue/area
4405
+ // scale the category-tinted structural surface is the honest, stable signal;
4406
+ // thousands of enlarged status dots only form a moir\xE9 pattern. The journey
4407
+ // state enables them when the buyer enters a section, row or exact seat.
4408
+ // Whole-venue status detail is below a useful pixel size. Drawing every dot
4409
+ // there turns concentric rows into camera-dependent moir\xE9 streaks, especially
4410
+ // while zooming. uSeatFade already measures this exact LOD transition: let
4411
+ // the category-tinted tier carry the overview, then reveal individual live
4412
+ // availability as the buyer approaches a section.
4159
4413
  // Seats on an unfocused floor recede with their structure.
4160
4414
  c = mix(c, uFadeColor, vDim * 0.75);
4161
4415
  alpha *= mix(1.0, 0.30, vDim);
@@ -4200,9 +4454,15 @@ out float vDim;
4200
4454
  out float vOccupant; // 1 = this vertex belongs to a person, not to the chair
4201
4455
  out vec3 vOccupantTint;
4202
4456
  ${CHAIR_WEIGHT_GLSL}
4457
+ ${OCCUPANT_WEIGHT_GLSL}
4203
4458
  void main() {
4204
4459
  vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
4205
- float w = chairWeight(max(-anchor.z, 0.001));
4460
+ float viewDepth = -anchor.z;
4461
+ float depth = max(viewDepth, 0.001);
4462
+ // A stale near-field gather can contain seats now behind the camera after a
4463
+ // row\u2192overview move. Treating negative depth as 0.001 made those chairs full
4464
+ // size across the near plane, producing the long black zoom streaks.
4465
+ float w = viewDepth > 0.05 ? chairWeight(depth) : 0.0;
4206
4466
  // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
4207
4467
  // chairs, but nobody gets a short one (people are the same height at every
4208
4468
  // seat pitch).
@@ -4227,6 +4487,10 @@ void main() {
4227
4487
  // identical mannequins: +/-6% height and +/-8% width off the hash.
4228
4488
  p.y *= 0.94 + 0.12 * iSeed;
4229
4489
  p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
4490
+ // People only appear once they are large enough to read as people. At
4491
+ // section/overview distance their tiny dark geometry aliases into radial
4492
+ // streaks; seat dots already communicate held/booked state there.
4493
+ p *= occupantWeight(depth);
4230
4494
  }
4231
4495
  }
4232
4496
  p.xz *= iRadius;
@@ -4265,7 +4529,7 @@ void main() {
4265
4529
  // not an audience. Hair/clothing for the body, a warm tone for the head,
4266
4530
  // both varied by the same per-person hash.
4267
4531
  float t = fract(iSeed * 3.71);
4268
- vec3 clothes = mix(vec3(0.13, 0.15, 0.20), vec3(0.34, 0.30, 0.36), t);
4532
+ vec3 clothes = mix(vec3(0.18, 0.22, 0.30), vec3(0.42, 0.29, 0.34), t);
4269
4533
  vec3 skin = mix(vec3(0.52, 0.38, 0.29), vec3(0.86, 0.70, 0.58), fract(iSeed * 11.3));
4270
4534
  vOccupantTint = (part > 3.5) ? skin : clothes;
4271
4535
  vPart = part;
@@ -4369,14 +4633,19 @@ uniform float uSeatRadius;
4369
4633
  uniform float uSeatScale;
4370
4634
  uniform float uMinPixels;
4371
4635
  uniform float uPixelToWorld;
4636
+ uniform float uChairFull;
4637
+ uniform float uChairNone;
4372
4638
  out vec2 vUv;
4373
4639
  out float vPhysicalSeat;
4640
+ out float vDotWeight;
4374
4641
  flat out vec3 vPick;
4642
+ ${CHAIR_WEIGHT_GLSL}
4375
4643
  void main() {
4376
4644
  int id = gl_InstanceID + 1; // 0 reserved for no-hit
4377
4645
  vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
4378
4646
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
4379
4647
  float depth = max(-mv.z, 0.001);
4648
+ vDotWeight = 1.0 - chairWeight(depth);
4380
4649
  float minR = uMinPixels * depth * uPixelToWorld;
4381
4650
  float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
4382
4651
  mv.xy += position * r;
@@ -4393,14 +4662,75 @@ var SEAT_PICK_FRAG = (
4393
4662
  precision highp float;
4394
4663
  in vec2 vUv;
4395
4664
  in float vPhysicalSeat;
4665
+ in float vDotWeight;
4396
4666
  flat in vec3 vPick;
4397
4667
  out vec4 fragColor;
4398
4668
  void main() {
4669
+ if (vDotWeight <= 0.02) discard; // invisible dot cannot steal a chair tap
4399
4670
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
4400
4671
  if (d > 1.0) discard; // hit-mask matches chair/bay shape
4401
4672
  fragColor = vec4(vPick, 1.0);
4402
4673
  }`
4403
4674
  );
4675
+ var CHAIR_PICK_VERT = (
4676
+ /* glsl */
4677
+ `#version 300 es
4678
+ precision highp float;
4679
+ in vec3 position;
4680
+ in float part;
4681
+ in vec3 iOffset;
4682
+ in float iRadius;
4683
+ in float iYaw;
4684
+ in float iSeed;
4685
+ in float iPhysicalSeat;
4686
+ in float iPickIndex;
4687
+ uniform mat4 modelViewMatrix;
4688
+ uniform mat4 projectionMatrix;
4689
+ uniform float uChairFull;
4690
+ uniform float uChairNone;
4691
+ uniform float uBackRake;
4692
+ uniform float uBackBase;
4693
+ flat out vec3 vPick;
4694
+ ${CHAIR_WEIGHT_GLSL}
4695
+ ${OCCUPANT_WEIGHT_GLSL}
4696
+ void main() {
4697
+ int id = int(iPickIndex + 0.5); // already global index + 1
4698
+ vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
4699
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
4700
+ float viewDepth = -anchor.z;
4701
+ float depth = max(viewDepth, 0.001);
4702
+ float w = viewDepth > 0.05 ? chairWeight(depth) : 0.0;
4703
+ vec3 p = position;
4704
+ float occupant = step(2.5, part);
4705
+ float taken = step(0.0, iSeed);
4706
+ if (iPhysicalSeat < 0.5) {
4707
+ p = vec3(0.0);
4708
+ } else if (occupant > 0.5) {
4709
+ if (taken < 0.5) {
4710
+ p = vec3(0.0);
4711
+ } else {
4712
+ p.y *= 0.94 + 0.12 * iSeed;
4713
+ p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
4714
+ p *= occupantWeight(depth);
4715
+ }
4716
+ }
4717
+ p.xz *= iRadius;
4718
+ float rake = (part > 1.5 && part < 2.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
4719
+ p.z -= rake;
4720
+ p *= sqrt(w);
4721
+ float c = cos(iYaw), s = sin(iYaw);
4722
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
4723
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(iOffset + rp, 1.0);
4724
+ }`
4725
+ );
4726
+ var CHAIR_PICK_FRAG = (
4727
+ /* glsl */
4728
+ `#version 300 es
4729
+ precision highp float;
4730
+ flat in vec3 vPick;
4731
+ out vec4 fragColor;
4732
+ void main() { fragColor = vec4(vPick, 1.0); }`
4733
+ );
4404
4734
  var PICK_DEPTH_VERT = (
4405
4735
  /* glsl */
4406
4736
  `#version 300 es
@@ -4431,7 +4761,25 @@ function createSeatPickProgram(gl) {
4431
4761
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
4432
4762
  uSeatScale: { value: 1 },
4433
4763
  uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
4434
- uPixelToWorld: { value: 2e-3 }
4764
+ uPixelToWorld: { value: 2e-3 },
4765
+ uChairFull: { value: CHAIR_FULL_M },
4766
+ uChairNone: { value: CHAIR_NONE_M }
4767
+ }
4768
+ });
4769
+ }
4770
+ function createChairPickProgram(gl) {
4771
+ return new import_ogl3.Program(gl, {
4772
+ vertex: CHAIR_PICK_VERT,
4773
+ fragment: CHAIR_PICK_FRAG,
4774
+ transparent: false,
4775
+ depthTest: true,
4776
+ depthWrite: true,
4777
+ cullFace: false,
4778
+ uniforms: {
4779
+ uChairFull: { value: CHAIR_FULL_M },
4780
+ uChairNone: { value: CHAIR_NONE_M },
4781
+ uBackRake: { value: BACK_RAKE_SLOPE },
4782
+ uBackBase: { value: BACK_BASE_M }
4435
4783
  }
4436
4784
  });
4437
4785
  }
@@ -4460,7 +4808,8 @@ function createSolidProgram(gl) {
4460
4808
  // High and off-axis, in world space: reads as a house rig rather than a
4461
4809
  // headlamp welded to the camera.
4462
4810
  uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
4463
- uFocusFloor: { value: -1 }
4811
+ uFocusFloor: { value: -1 },
4812
+ uStructureDetail: { value: 0 }
4464
4813
  }
4465
4814
  });
4466
4815
  }
@@ -4478,6 +4827,7 @@ function createSeatProgram(gl) {
4478
4827
  uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
4479
4828
  uPixelToWorld: { value: 2e-3 },
4480
4829
  uSeatFade: { value: 0 },
4830
+ uSeatDetail: { value: 0 },
4481
4831
  uFocusFloor: { value: -1 },
4482
4832
  uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4483
4833
  uChairFull: { value: CHAIR_FULL_M },
@@ -4542,7 +4892,8 @@ function buildGpuScene(gl, model) {
4542
4892
  position: { size: 3, data: model.solids.position },
4543
4893
  normal: { size: 3, data: model.solids.normal },
4544
4894
  color: { size: 3, data: model.solids.color },
4545
- floorIndex: { size: 1, data: model.solids.floor }
4895
+ floorIndex: { size: 1, data: model.solids.floor },
4896
+ materialIndex: { size: 1, data: model.solids.material }
4546
4897
  });
4547
4898
  const solidProg = createSolidProgram(gl);
4548
4899
  const solidMesh = new import_ogl4.Mesh(gl, { geometry: solidGeo, program: solidProg });
@@ -4578,6 +4929,7 @@ function buildGpuScene(gl, model) {
4578
4929
  const cRing = new Float32Array(CAP * 3);
4579
4930
  const cFloor = new Float32Array(CAP);
4580
4931
  const cPhysicalSeat = new Float32Array(CAP);
4932
+ const cPickIndex = new Float32Array(CAP);
4581
4933
  const cSeed = new Float32Array(CAP);
4582
4934
  const chairGeo = new import_ogl4.Geometry(gl, {
4583
4935
  position: { size: 3, data: chairBase.position },
@@ -4591,7 +4943,8 @@ function buildGpuScene(gl, model) {
4591
4943
  iRing: { size: 3, data: cRing, instanced: 1 },
4592
4944
  iFloor: { size: 1, data: cFloor, instanced: 1 },
4593
4945
  iPhysicalSeat: { size: 1, data: cPhysicalSeat, instanced: 1 },
4594
- iSeed: { size: 1, data: cSeed, instanced: 1 }
4946
+ iSeed: { size: 1, data: cSeed, instanced: 1 },
4947
+ iPickIndex: { size: 1, data: cPickIndex, instanced: 1 }
4595
4948
  });
4596
4949
  const chairMesh = new import_ogl4.Mesh(gl, { geometry: chairGeo, program: chairProg });
4597
4950
  chairMesh.frustumCulled = false;
@@ -4601,6 +4954,7 @@ function buildGpuScene(gl, model) {
4601
4954
  const src = model.seats;
4602
4955
  for (let k = 0; k < nearCount; k++) {
4603
4956
  const i = nearIndices[k];
4957
+ cSeed[k] = seatOccupantSeed(src.iState[i], i);
4604
4958
  const useCategory = src.iCategory && src.iState[i] === 0;
4605
4959
  const c = useCategory ? null : stateColors[src.iState[i]] ?? stateColors[0];
4606
4960
  if (c) {
@@ -4615,6 +4969,7 @@ function buildGpuScene(gl, model) {
4615
4969
  }
4616
4970
  }
4617
4971
  chairGeo.attributes.iColor.needsUpdate = true;
4972
+ chairGeo.attributes.iSeed.needsUpdate = true;
4618
4973
  };
4619
4974
  const scene = {
4620
4975
  main,
@@ -4623,6 +4978,7 @@ function buildGpuScene(gl, model) {
4623
4978
  solidProgram: solidProg,
4624
4979
  chairProgram: chairProg,
4625
4980
  seatGeometry: seatGeo,
4981
+ chairGeometry: chairGeo,
4626
4982
  solidGeometry: solidGeo,
4627
4983
  drawCalls: 3,
4628
4984
  setNearSeats(indices, count) {
@@ -4648,7 +5004,7 @@ function buildGpuScene(gl, model) {
4648
5004
  cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
4649
5005
  cFloor[k] = src.iFloor[i];
4650
5006
  cPhysicalSeat[k] = src.iPhysicalSeat[i];
4651
- cSeed[k] = seatOccupantSeed(src.iState[i], i);
5007
+ cPickIndex[k] = i + 1;
4652
5008
  }
4653
5009
  writeChairColors();
4654
5010
  chairGeo.attributes.iOffset.needsUpdate = true;
@@ -4658,6 +5014,7 @@ function buildGpuScene(gl, model) {
4658
5014
  chairGeo.attributes.iFloor.needsUpdate = true;
4659
5015
  chairGeo.attributes.iPhysicalSeat.needsUpdate = true;
4660
5016
  chairGeo.attributes.iSeed.needsUpdate = true;
5017
+ chairGeo.attributes.iPickIndex.needsUpdate = true;
4661
5018
  chairGeo.instancedCount = n;
4662
5019
  if (!chairMesh.parent) chairMesh.setParent(main);
4663
5020
  scene.drawCalls = 4;
@@ -4737,8 +5094,9 @@ function pickPixelCoords(clientX, clientY, rect, dpr, bufferWidth, bufferHeight)
4737
5094
  // src/view3d/pick/pickPipeline.ts
4738
5095
  var SYNC_KEYS = ["uSeatRadius", "uSeatScale", "uMinPixels", "uPixelToWorld"];
4739
5096
  var PickPipeline = class {
4740
- constructor(renderer, seatGeo, solidGeo, seatCount) {
5097
+ constructor(renderer, seatGeo, chairGeo, solidGeo, seatCount) {
4741
5098
  this.seatScene = new import_ogl5.Transform();
5099
+ this.chairScene = new import_ogl5.Transform();
4742
5100
  this.solidScene = new import_ogl5.Transform();
4743
5101
  this.target = null;
4744
5102
  /** Display clear colour to restore after the pick pass (theme-dependent). */
@@ -4747,10 +5105,14 @@ var PickPipeline = class {
4747
5105
  this.gl = renderer.gl;
4748
5106
  this.maxIndex = seatCount;
4749
5107
  this.seatProg = createSeatPickProgram(this.gl);
5108
+ this.chairProg = createChairPickProgram(this.gl);
4750
5109
  this.depthProg = createPickDepthProgram(this.gl);
4751
5110
  const seatMesh = new import_ogl5.Mesh(this.gl, { geometry: seatGeo, program: this.seatProg });
4752
5111
  seatMesh.frustumCulled = false;
4753
5112
  seatMesh.setParent(this.seatScene);
5113
+ const chairMesh = new import_ogl5.Mesh(this.gl, { geometry: chairGeo, program: this.chairProg });
5114
+ chairMesh.frustumCulled = false;
5115
+ chairMesh.setParent(this.chairScene);
4754
5116
  const solidMesh = new import_ogl5.Mesh(this.gl, { geometry: solidGeo, program: this.depthProg });
4755
5117
  solidMesh.frustumCulled = false;
4756
5118
  solidMesh.setParent(this.solidScene);
@@ -4803,6 +5165,7 @@ var PickPipeline = class {
4803
5165
  gl.clearColor(0, 0, 0, 1);
4804
5166
  this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
4805
5167
  this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
5168
+ this.renderer.render({ scene: this.chairScene, camera, target, clear: false });
4806
5169
  gl.clearColor(br, bg, bb, 1);
4807
5170
  gl.disable(gl.SCISSOR_TEST);
4808
5171
  const buf = new Uint8Array(boxW * boxH * 4);
@@ -4814,6 +5177,7 @@ var PickPipeline = class {
4814
5177
  dispose() {
4815
5178
  this.destroyTarget();
4816
5179
  this.seatProg.remove();
5180
+ this.chairProg.remove();
4817
5181
  this.depthProg.remove();
4818
5182
  }
4819
5183
  };
@@ -4943,16 +5307,18 @@ var Cinematic = class {
4943
5307
  this.outQuat = new import_ogl6.Quat();
4944
5308
  this.startTime = 0;
4945
5309
  this.duration = FLIGHT_DURATION_MS;
5310
+ this.endFov = FOV_END;
4946
5311
  this.resolveFn = null;
4947
5312
  this.camera = camera;
4948
5313
  }
4949
5314
  /** Begin (or retarget) a flight. Resolves when it lands or is cancelled. */
4950
- start(waypoints, startQuat, endQuat, duration = FLIGHT_DURATION_MS) {
5315
+ start(waypoints, startQuat, endQuat, duration = FLIGHT_DURATION_MS, endFov = FOV_END) {
4951
5316
  this.settle();
4952
5317
  this.waypoints = waypoints;
4953
5318
  this.startQuat.copy(startQuat);
4954
5319
  this.endQuat.copy(endQuat);
4955
5320
  this.duration = duration;
5321
+ this.endFov = endFov;
4956
5322
  this.startTime = performance.now();
4957
5323
  this.active = true;
4958
5324
  return new Promise((res) => {
@@ -4963,7 +5329,7 @@ var Cinematic = class {
4963
5329
  update(now2) {
4964
5330
  if (!this.active) return false;
4965
5331
  const u = Math.min(1, (now2 - this.startTime) / this.duration);
4966
- const { pos, fov, eased } = sampleFlight(this.waypoints, u);
5332
+ const { pos, fov, eased } = sampleFlight(this.waypoints, u, FOV_START, this.endFov);
4967
5333
  this.camera.position.set(pos[0], pos[1], pos[2]);
4968
5334
  this.outQuat.copy(this.startQuat).slerp(this.endQuat, orientationLeadT(eased, ORIENTATION_LEAD));
4969
5335
  this.camera.quaternion.copy(this.outQuat);
@@ -4990,9 +5356,10 @@ var Cinematic = class {
4990
5356
  // src/view3d/camera/seatViewPose.ts
4991
5357
  var SEAT_EYE_ABOVE_DECK_M = 1.02;
4992
5358
  var FOCAL_LOOK_HEIGHT_M = 1.5;
4993
- function seatViewPose(seatDeckWorld, focalPoint, floorBaseHeightM = 0) {
5359
+ function seatViewPose(seatDeckWorld, focalPoint, floorBaseHeightM = 0, authoredEyeHeightM) {
5360
+ const eyeY = Number.isFinite(authoredEyeHeightM) ? floorBaseHeightM + authoredEyeHeightM : seatDeckWorld[1] + SEAT_EYE_ABOVE_DECK_M;
4994
5361
  return {
4995
- eye: [seatDeckWorld[0], seatDeckWorld[1] + SEAT_EYE_ABOVE_DECK_M, seatDeckWorld[2]],
5362
+ eye: [seatDeckWorld[0], eyeY, seatDeckWorld[2]],
4996
5363
  focal: [
4997
5364
  focalPoint.x * M,
4998
5365
  floorBaseHeightM + FOCAL_LOOK_HEIGHT_M,
@@ -5091,9 +5458,10 @@ function schedulePanoramaUpgrade(work) {
5091
5458
 
5092
5459
  // src/view3d/crossfade/panorama.ts
5093
5460
  function seatViewDisclosure(view) {
5094
- const coverage = view.generated ? "Live 3D \xB7 exact seat-eye" : view.coverage === "exact-seat" ? "Exact seat photo" : view.coverage === "row-representative" ? "Representative row view" : view.coverage === "section-representative" ? "Representative section view" : view.coverage === "venue-representative" ? "Representative venue view" : "Venue photo";
5461
+ const demo = view.mediaKind === "demo-render";
5462
+ const coverage = view.generated || view.mediaKind === "model" ? "Live 3D \xB7 chart-derived seat-eye" : demo ? view.coverage === "exact-seat" ? "AI-generated exact-seat demo" : view.coverage === "row-representative" ? "AI-generated representative row demo" : view.coverage === "section-representative" ? "AI-generated representative section demo" : "AI-generated illustrative venue demo" : view.coverage === "exact-seat" ? "Exact seat photo" : view.coverage === "row-representative" ? "Representative row view" : view.coverage === "section-representative" ? "Representative section view" : view.coverage === "venue-representative" ? "Representative venue view" : "Venue photo";
5095
5463
  const year = view.capturedAt && /^\d{4}/.test(view.capturedAt) ? view.capturedAt.slice(0, 4) : "";
5096
- return [coverage, year ? `captured ${year}` : "", view.sourceLabel ?? ""].filter(Boolean).join(" \xB7 ");
5464
+ return [coverage, year ? `${demo ? "created" : "captured"} ${year}` : "", view.sourceLabel ?? ""].filter(Boolean).join(" \xB7 ");
5097
5465
  }
5098
5466
  var VFOV_DEG = 70;
5099
5467
  var MIN_VFOV_DEG = 35;
@@ -5170,7 +5538,7 @@ function mountPanorama(container, view, opts = {}) {
5170
5538
  root.appendChild(closeBtn);
5171
5539
  if (opts.disclosure) {
5172
5540
  const disclosure = document.createElement("div");
5173
- disclosure.textContent = `360\xB0 panorama \xB7 ${opts.disclosure}`;
5541
+ disclosure.textContent = `${opts.disclosurePrefix ?? "360\xB0 panorama"} \xB7 ${opts.disclosure}`;
5174
5542
  Object.assign(disclosure.style, {
5175
5543
  position: "absolute",
5176
5544
  top: "12px",
@@ -5656,7 +6024,7 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5656
6024
  root.appendChild(closeBtn);
5657
6025
  if (opts.disclosure) {
5658
6026
  const disclosure = document.createElement("div");
5659
- disclosure.textContent = `360\xB0 panorama \xB7 ${opts.disclosure}`;
6027
+ disclosure.textContent = `${opts.disclosurePrefix ?? "360\xB0 panorama"} \xB7 ${opts.disclosure}`;
5660
6028
  Object.assign(disclosure.style, {
5661
6029
  position: "absolute",
5662
6030
  top: "12px",
@@ -6000,7 +6368,13 @@ function mountVenue3D(container, input, opts = {}) {
6000
6368
  };
6001
6369
  const rebuildGpu = () => {
6002
6370
  gpu = buildGpuScene(glctx.gl, model);
6003
- pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
6371
+ pick = new PickPipeline(
6372
+ glctx.renderer,
6373
+ gpu.seatGeometry,
6374
+ gpu.chairGeometry,
6375
+ gpu.solidGeometry,
6376
+ model.seats.count
6377
+ );
6004
6378
  glctx.setClearColor(model.theme.background.top);
6005
6379
  pick.setRestoreClear(model.theme.background.top);
6006
6380
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
@@ -6013,8 +6387,22 @@ function mountVenue3D(container, input, opts = {}) {
6013
6387
  const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
6014
6388
  let lastGatherX = Infinity;
6015
6389
  let lastGatherZ = Infinity;
6016
- const updateNearField = () => {
6390
+ let nearFieldDetailEnabled = false;
6391
+ let cameraDetailVisible = false;
6392
+ let panoramaSphereVisible = false;
6393
+ const setNearFieldDetailEnabled = (enabled) => {
6394
+ nearFieldDetailEnabled = enabled;
6395
+ if (!enabled) cameraDetailVisible = false;
6396
+ lastGatherX = Infinity;
6397
+ lastGatherZ = Infinity;
6398
+ if (!enabled && gpu?.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
6399
+ };
6400
+ const updateNearField = (detailVisible) => {
6017
6401
  if (!gpu) return;
6402
+ if (!detailVisible) {
6403
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
6404
+ return;
6405
+ }
6018
6406
  const cam = orbit.camera.position;
6019
6407
  const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
6020
6408
  if (moved2 < CHAIR_REBUILD_M) return;
@@ -6057,7 +6445,8 @@ function mountVenue3D(container, input, opts = {}) {
6057
6445
  const dz = model.focalWorld[2] - model.bounds.center[2];
6058
6446
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
6059
6447
  })();
6060
- orbit.frame(model.bounds, true, stageAzimuth, opts.portraitOverviewCrop === true);
6448
+ const reduceMotionOnMount = typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
6449
+ orbit.frame(model.bounds, !reduceMotionOnMount, stageAzimuth, opts.portraitOverviewCrop === true);
6061
6450
  const cinematic = new Cinematic(orbit.camera);
6062
6451
  const restoreContainerPosition = establishPositioningContext(container);
6063
6452
  const labelOverlay = new LabelOverlay(container, {
@@ -6077,12 +6466,16 @@ function mountVenue3D(container, input, opts = {}) {
6077
6466
  const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
6078
6467
  const seatIds = new Set(sectionSeats.map((seat) => seat.id));
6079
6468
  const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
6080
- const focusedBase = baseLabels.filter((label) => label.kind !== "row" && label.kind !== "seat" || (label.kind === "row" ? rowIds.has(label.id.slice("row:".length)) : seatIds.has(label.id.slice("seat:".length))));
6081
- if (model.seatCount <= 6e3) {
6469
+ const selectedIds = new Set(selection.keys());
6470
+ const selectedInSection = new Set(sectionSeats.filter((seat) => selectedIds.has(seat.id)).map((seat) => seat.id));
6471
+ const focusedBase = baseLabels.filter((label) => label.kind !== "row" && label.kind !== "seat" || (label.kind === "row" ? rowIds.has(label.id.slice("row:".length)) : seatIds.has(label.id.slice("seat:".length)) && (selectedInSection.size === 0 || selectedInSection.has(label.id.slice("seat:".length)))));
6472
+ if (model.seatCount <= 6e3 && selectedInSection.size === 0) {
6082
6473
  labelOverlay.setLabels(focusedBase);
6083
6474
  return;
6084
6475
  }
6085
- const labels = sectionSeats.flatMap((seat) => {
6476
+ const labelSeats = selectedInSection.size ? sectionSeats.filter((seat) => selectedInSection.has(seat.id)) : sectionSeats;
6477
+ const generatedIds = new Set(labelSeats.map((seat) => `seat:${seat.id}`));
6478
+ const labels = labelSeats.flatMap((seat) => {
6086
6479
  const index = model.seats.idToIndex.get(seat.id);
6087
6480
  if (index === void 0) return [];
6088
6481
  const offset = index * 3;
@@ -6092,12 +6485,18 @@ function mountVenue3D(container, input, opts = {}) {
6092
6485
  text: seat.displayLabel || seat.label,
6093
6486
  anchor: [
6094
6487
  model.seats.iPosition[offset],
6095
- model.seats.iPosition[offset + 1] + 0.55,
6488
+ // Put the active identity on the chair back. A deck-level label can
6489
+ // sit a full row below a foreground chair in perspective, while DOM
6490
+ // labels for rows behind remain visible through the WebGL geometry.
6491
+ model.seats.iPosition[offset + 1] + (selectedIds.has(seat.id) ? 1.05 : 0.55),
6096
6492
  model.seats.iPosition[offset + 2]
6097
6493
  ]
6098
6494
  }];
6099
6495
  });
6100
- labelOverlay.setLabels([...focusedBase, ...labels]);
6496
+ labelOverlay.setLabels([
6497
+ ...focusedBase.filter((label) => !generatedIds.has(label.id)),
6498
+ ...labels
6499
+ ]);
6101
6500
  };
6102
6501
  rebuildGpu();
6103
6502
  const loop = new RenderLoop(() => {
@@ -6105,12 +6504,25 @@ function mountVenue3D(container, input, opts = {}) {
6105
6504
  const flying = cinematic.active;
6106
6505
  const moving = flying ? cinematic.update(performance.now()) : orbit.update();
6107
6506
  const lod = computeSeatLod(orbit.currentDistance, model.bounds.radius);
6507
+ const clippingBounds = panoramaSphereVisible ? { center: [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z], radius: 520 } : model.renderBounds;
6508
+ orbit.updateClipping(clippingBounds);
6509
+ const cameraDx = orbit.camera.position.x - model.renderBounds.center[0];
6510
+ const cameraDy = orbit.camera.position.y - model.renderBounds.center[1];
6511
+ const cameraDz = orbit.camera.position.z - model.renderBounds.center[2];
6512
+ const cameraVenueDistance = Math.hypot(cameraDx, cameraDy, cameraDz);
6513
+ const detailEnter = model.renderBounds.radius * 1.55;
6514
+ const detailExit = model.renderBounds.radius * 1.8;
6515
+ if (!nearFieldDetailEnabled) cameraDetailVisible = false;
6516
+ else if (cameraDetailVisible) cameraDetailVisible = cameraVenueDistance <= detailExit;
6517
+ else cameraDetailVisible = cameraVenueDistance <= detailEnter;
6108
6518
  const u = gpu.seatProgram.uniforms;
6109
6519
  u.uSeatScale.value = lod.scale;
6110
6520
  u.uSeatFade.value = lod.fade;
6521
+ u.uSeatDetail.value = cameraDetailVisible ? 1 : 0;
6522
+ gpu.solidProgram.uniforms.uStructureDetail.value = cameraDetailVisible ? 1 : 0;
6111
6523
  u.uMinPixels.value = lod.minPixels;
6112
6524
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
6113
- updateNearField();
6525
+ updateNearField(cameraDetailVisible);
6114
6526
  glctx.renderer.render({ scene: gpu.background, clear: true });
6115
6527
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
6116
6528
  labelOverlay.update(
@@ -6134,6 +6546,8 @@ function mountVenue3D(container, input, opts = {}) {
6134
6546
  };
6135
6547
  const { updates, next } = diffSelection(selection, ids, baseStateIndex);
6136
6548
  selection = next;
6549
+ labelOverlay.setSelectedSeatIds(selection.keys());
6550
+ if (focusedSectionId) showSectionSeatLabels(focusedSectionId);
6137
6551
  if (updates.length) {
6138
6552
  const runs = applySeatStates(model.seats, updates);
6139
6553
  if (gpu) gpu.uploadSeatStateRuns(runs);
@@ -6172,12 +6586,12 @@ function mountVenue3D(container, input, opts = {}) {
6172
6586
  model.seats.iPosition[idx * 3 + 1],
6173
6587
  model.seats.iPosition[idx * 3 + 2]
6174
6588
  ];
6175
- return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0);
6589
+ return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0, seat?.eyeHeightM);
6176
6590
  };
6177
- const placeCameraFinal = (finalPos, focal) => {
6591
+ const placeCameraFinal = (finalPos, focal, fov = FOV_END) => {
6178
6592
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
6179
6593
  orbit.camera.lookAt(new import_ogl8.Vec3(focal[0], focal[1], focal[2]));
6180
- orbit.camera.fov = FOV_END;
6594
+ orbit.camera.fov = fov;
6181
6595
  orbit.camera.updateProjectionMatrix();
6182
6596
  };
6183
6597
  const isNarrow = () => (container.clientWidth || glctx.canvas.clientWidth) <= 520;
@@ -6275,7 +6689,8 @@ function mountVenue3D(container, input, opts = {}) {
6275
6689
  });
6276
6690
  }
6277
6691
  if (opts.getSeatView) {
6278
- addButton(retry ? "\u21BB Retry 360\xB0 panorama" : "\u25C9 Open 360\xB0 panorama", `${retry ? "Retry" : "Open"} the 360\xB0 panorama from seat ${seatLabel}`, () => {
6692
+ const sourceAction = opts.seatViewActionLabel?.(seatId) ?? "Open 360\xB0 panorama";
6693
+ addButton(retry ? `\u21BB Retry ${sourceAction}` : `\u25C9 ${sourceAction}`, `${retry ? "Retry" : sourceAction} from seat ${seatLabel}`, () => {
6279
6694
  if (disposed || gen !== flightGen) {
6280
6695
  removeArriveChip();
6281
6696
  return;
@@ -6297,121 +6712,158 @@ function mountVenue3D(container, input, opts = {}) {
6297
6712
  return controls;
6298
6713
  };
6299
6714
  const navigationModeChip = document.createElement("div");
6300
- navigationModeChip.setAttribute("role", "group");
6301
- navigationModeChip.setAttribute("aria-label", "3D navigation mode");
6715
+ let seatViewLocked = false;
6716
+ navigationModeChip.className = "sl-3d-navigation-controls";
6302
6717
  Object.assign(navigationModeChip.style, {
6303
6718
  position: "absolute",
6304
- left: "14px",
6305
- top: "62px",
6719
+ right: "14px",
6720
+ bottom: "14px",
6306
6721
  display: "flex",
6307
- gap: "3px",
6308
- padding: "3px",
6309
- borderRadius: "999px",
6310
6722
  zIndex: "4",
6311
- background: "rgba(12,18,32,0.72)",
6312
- border: "1px solid rgba(150,165,205,0.35)",
6313
- backdropFilter: "blur(6px)"
6723
+ flexDirection: "column",
6724
+ alignItems: "flex-end",
6725
+ gap: "7px"
6726
+ });
6727
+ const modeControls = document.createElement("div");
6728
+ modeControls.setAttribute("role", "group");
6729
+ modeControls.setAttribute("aria-label", "3D drag mode");
6730
+ Object.assign(modeControls.style, {
6731
+ display: "flex",
6732
+ flexDirection: "column",
6733
+ gap: "6px",
6734
+ alignItems: "flex-end"
6735
+ });
6736
+ const utilityControls = document.createElement("div");
6737
+ utilityControls.setAttribute("role", "group");
6738
+ utilityControls.setAttribute("aria-label", "3D view controls");
6739
+ Object.assign(utilityControls.style, {
6740
+ display: "flex",
6741
+ flexDirection: "column",
6742
+ gap: "6px",
6743
+ alignItems: "flex-end"
6314
6744
  });
6315
6745
  const rotateModeButton = document.createElement("button");
6316
6746
  rotateModeButton.type = "button";
6317
- rotateModeButton.textContent = "\u21BB Rotate";
6747
+ rotateModeButton.textContent = "\u21BB";
6318
6748
  rotateModeButton.setAttribute("aria-label", "Drag to rotate the 3D venue");
6749
+ rotateModeButton.dataset.tooltip = "Rotate venue";
6319
6750
  const moveModeButton = document.createElement("button");
6320
6751
  moveModeButton.type = "button";
6321
- moveModeButton.textContent = "\u2725 Move";
6752
+ moveModeButton.textContent = "\u2725";
6322
6753
  moveModeButton.setAttribute("aria-label", "Drag to move the 3D venue left, right, up or down");
6754
+ moveModeButton.dataset.tooltip = "Move venue";
6323
6755
  const zoomOutButton = document.createElement("button");
6324
6756
  zoomOutButton.type = "button";
6325
6757
  zoomOutButton.textContent = "\u2212";
6326
6758
  zoomOutButton.setAttribute("aria-label", "Zoom out of the 3D venue");
6327
- zoomOutButton.title = "Zoom out";
6759
+ zoomOutButton.dataset.tooltip = "Zoom out";
6328
6760
  const zoomInButton = document.createElement("button");
6329
6761
  zoomInButton.type = "button";
6330
6762
  zoomInButton.textContent = "+";
6331
6763
  zoomInButton.setAttribute("aria-label", "Zoom into the 3D venue");
6332
- zoomInButton.title = "Zoom in";
6764
+ zoomInButton.dataset.tooltip = "Zoom in";
6333
6765
  for (const button of [rotateModeButton, moveModeButton, zoomOutButton, zoomInButton]) {
6766
+ button.className = "sl-3d-icon-control";
6334
6767
  Object.assign(button.style, {
6335
- minHeight: "34px",
6336
- padding: "6px 10px",
6337
- border: "0",
6768
+ width: "44px",
6769
+ minWidth: "44px",
6770
+ minHeight: "44px",
6771
+ padding: "9px",
6772
+ border: "1px solid rgba(150,165,205,0.35)",
6338
6773
  borderRadius: "999px",
6339
6774
  color: "#c9d4ea",
6340
- background: "transparent",
6341
- font: "600 11px/1 inherit",
6775
+ background: "rgba(12,18,32,0.82)",
6776
+ font: "600 17px/1 inherit",
6777
+ backdropFilter: "blur(6px)",
6342
6778
  cursor: "pointer",
6343
6779
  whiteSpace: "nowrap"
6344
6780
  });
6345
- if (button === zoomOutButton || button === zoomInButton) {
6346
- button.style.minWidth = "34px";
6347
- button.style.padding = "6px";
6348
- button.style.fontSize = "17px";
6349
- }
6350
- navigationModeChip.appendChild(button);
6351
6781
  }
6782
+ modeControls.append(rotateModeButton, moveModeButton);
6783
+ utilityControls.append(zoomInButton, zoomOutButton);
6352
6784
  const setNavigationMode = (mode) => {
6353
6785
  orbit.setPrimaryDragMode(mode);
6354
6786
  const rotateActive = mode === "orbit";
6355
6787
  rotateModeButton.setAttribute("aria-pressed", String(rotateActive));
6356
6788
  moveModeButton.setAttribute("aria-pressed", String(!rotateActive));
6357
- rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.48)" : "transparent";
6358
- moveModeButton.style.background = rotateActive ? "transparent" : "rgba(96,110,150,0.48)";
6789
+ rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.68)" : "rgba(12,18,32,0.82)";
6790
+ moveModeButton.style.background = rotateActive ? "rgba(12,18,32,0.82)" : "rgba(96,110,150,0.68)";
6359
6791
  navigationModeChip.title = rotateActive ? "Drag to rotate \xB7 Shift-drag to move" : "Drag to move \xB7 Shift-drag to rotate";
6360
6792
  };
6361
6793
  rotateModeButton.addEventListener("click", () => setNavigationMode("orbit"));
6362
6794
  moveModeButton.addEventListener("click", () => setNavigationMode("pan"));
6363
6795
  zoomOutButton.addEventListener("click", () => orbit.zoomBy(1.22));
6364
6796
  zoomInButton.addEventListener("click", () => orbit.zoomBy(0.82));
6797
+ navigationModeChip.append(modeControls, utilityControls);
6365
6798
  setNavigationMode("orbit");
6366
6799
  container.appendChild(navigationModeChip);
6367
6800
  const layoutNavigationChip = () => {
6368
- navigationModeChip.style.display = isNarrow() ? "none" : "flex";
6801
+ const narrow = isNarrow();
6802
+ navigationModeChip.style.display = seatViewLocked ? "none" : "flex";
6803
+ navigationModeChip.style.right = narrow ? "12px" : "14px";
6804
+ navigationModeChip.style.bottom = narrow ? "calc(env(safe-area-inset-bottom, 0px) + 76px)" : "14px";
6805
+ modeControls.style.display = narrow ? "none" : "flex";
6806
+ zoomInButton.style.display = narrow ? "none" : "";
6807
+ zoomOutButton.style.display = narrow ? "none" : "";
6369
6808
  };
6370
6809
  layoutNavigationChip();
6810
+ const setSeatViewLocked = (locked) => {
6811
+ seatViewLocked = locked;
6812
+ orbit.setInteractionEnabled(!locked);
6813
+ layoutNavigationChip();
6814
+ };
6371
6815
  const overviewChip = document.createElement("button");
6816
+ overviewChip.className = "sl-3d-overview-control sl-3d-icon-control";
6372
6817
  overviewChip.type = "button";
6373
- overviewChip.textContent = "\u2302 Overview";
6374
- overviewChip.setAttribute("aria-label", "Return to the venue overview");
6818
+ overviewChip.textContent = "\u2302";
6819
+ overviewChip.setAttribute("aria-label", "Fit the whole venue in view");
6820
+ overviewChip.dataset.tooltip = "Fit venue";
6375
6821
  Object.assign(overviewChip.style, {
6376
- position: "absolute",
6377
- right: "14px",
6378
- bottom: "18px",
6379
- minHeight: "40px",
6380
- padding: "8px 14px",
6822
+ width: "44px",
6823
+ minWidth: "44px",
6824
+ minHeight: "44px",
6825
+ padding: "9px",
6381
6826
  borderRadius: "999px",
6382
6827
  background: "rgba(12,18,32,0.72)",
6383
6828
  color: "#c9d4ea",
6384
6829
  border: "1px solid rgba(150,165,205,0.35)",
6385
6830
  backdropFilter: "blur(6px)",
6386
- font: "600 12.5px/1 inherit",
6831
+ font: "600 11px/1 inherit",
6387
6832
  cursor: "pointer",
6388
- zIndex: "4"
6833
+ whiteSpace: "nowrap"
6389
6834
  });
6835
+ const layoutOverviewChip = () => {
6836
+ overviewChip.textContent = "\u2302";
6837
+ };
6838
+ layoutOverviewChip();
6390
6839
  const focusOverview = () => {
6391
6840
  if (disposed) return;
6841
+ opts.onViewTargetChange?.(null);
6842
+ opts.onSectionFocusChange?.(null);
6392
6843
  if (panorama) {
6393
6844
  panorama.dispose();
6394
6845
  panorama = null;
6395
6846
  analytics.panoramaClosed();
6396
6847
  }
6848
+ panoramaSphereVisible = false;
6397
6849
  panoramaLoadAbort?.abort();
6398
6850
  panoramaLoadAbort = null;
6399
6851
  restorePanoramaLayer();
6400
6852
  overviewChip.style.display = "";
6401
6853
  labelOverlay.setVisible(true);
6402
6854
  frozen = false;
6855
+ setNearFieldDetailEnabled(false);
6856
+ setSeatViewLocked(false);
6403
6857
  cancelFlight();
6404
6858
  removeArriveChip();
6405
6859
  setNavigationMode("orbit");
6406
6860
  if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
6407
6861
  else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
6408
- opts.onViewTargetChange?.(null);
6409
6862
  showSectionSeatLabels(null);
6410
- opts.onSectionFocusChange?.(null);
6411
6863
  loop.requestRender();
6412
6864
  };
6413
6865
  overviewChip.addEventListener("click", focusOverview);
6414
- container.appendChild(overviewChip);
6866
+ utilityControls.appendChild(overviewChip);
6415
6867
  const openPanorama = async (seatId, fadeMs, gen) => {
6416
6868
  panoramaLoadAbort?.abort();
6417
6869
  const loadAbort = new AbortController();
@@ -6457,9 +6909,10 @@ function mountVenue3D(container, input, opts = {}) {
6457
6909
  loadAbort.abort();
6458
6910
  if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6459
6911
  panorama = null;
6912
+ panoramaSphereVisible = false;
6460
6913
  restorePanoramaLayer();
6461
6914
  overviewChip.style.display = "";
6462
- labelOverlay.setVisible(true);
6915
+ labelOverlay.setVisible(!opts.arriveAtSeatEye);
6463
6916
  frozen = false;
6464
6917
  analytics.panoramaClosed();
6465
6918
  orbit.resumeAfterFlight(seatPose.focal);
@@ -6470,6 +6923,7 @@ function mountVenue3D(container, input, opts = {}) {
6470
6923
  const sceneMode = view.generated === true;
6471
6924
  const disclosure = seatViewDisclosure(view);
6472
6925
  raisePanoramaLayer();
6926
+ panoramaSphereVisible = !sceneMode;
6473
6927
  overviewChip.style.display = "none";
6474
6928
  const effectiveFadeMs = reducedMotion() ? 0 : fadeMs;
6475
6929
  const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
@@ -6479,9 +6933,17 @@ function mountVenue3D(container, input, opts = {}) {
6479
6933
  requestRender: () => loop.requestRender(),
6480
6934
  cameraOriginWorld: sceneMode ? seatPose.eye : void 0,
6481
6935
  focalWorld: seatPose.focal
6482
- }, { fadeMs: effectiveFadeMs, seatLabel, disclosure, onClose, signal: loadAbort.signal }) : null;
6936
+ }, {
6937
+ fadeMs: effectiveFadeMs,
6938
+ seatLabel,
6939
+ disclosure,
6940
+ disclosurePrefix: sceneMode ? "Live seat-eye view" : "360\xB0 panorama",
6941
+ onClose,
6942
+ signal: loadAbort.signal
6943
+ }) : null;
6483
6944
  if (disposed || gen !== flightGen) {
6484
6945
  loadAbort.abort();
6946
+ panoramaSphereVisible = false;
6485
6947
  if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6486
6948
  spherical?.dispose();
6487
6949
  overviewChip.style.display = "";
@@ -6494,7 +6956,21 @@ function mountVenue3D(container, input, opts = {}) {
6494
6956
  frozen = false;
6495
6957
  loop.requestRender();
6496
6958
  panorama = spherical;
6959
+ } else if (sceneMode) {
6960
+ restorePanoramaLayer();
6961
+ overviewChip.style.display = "";
6962
+ frozen = false;
6963
+ orbit.resumeAfterFlight(seatPose.focal);
6964
+ loop.requestRender();
6965
+ panorama = null;
6966
+ panoramaSphereVisible = false;
6967
+ analytics.panoramaFailed(seatId, "mount");
6968
+ const controls = showArriveChip(seatId, flightGen, true);
6969
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
6970
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6971
+ return;
6497
6972
  } else {
6973
+ panoramaSphereVisible = false;
6498
6974
  analytics.panoramaFallback(seatId);
6499
6975
  panorama = mountPanorama(container, view, {
6500
6976
  fadeMs: effectiveFadeMs,
@@ -6519,36 +6995,45 @@ function mountVenue3D(container, input, opts = {}) {
6519
6995
  if (disposed || !gpu) return Promise.resolve();
6520
6996
  const idx = model.seats.idToIndex.get(seatId);
6521
6997
  if (idx === void 0) return Promise.resolve();
6998
+ setNearFieldDetailEnabled(true);
6522
6999
  opts.onViewTargetChange?.(seatId);
6523
7000
  if (panorama) {
6524
7001
  panorama.dispose();
6525
7002
  panorama = null;
7003
+ panoramaSphereVisible = false;
6526
7004
  overviewChip.style.display = "";
6527
7005
  restorePanoramaLayer();
6528
7006
  }
6529
7007
  panoramaLoadAbort?.abort();
6530
7008
  panoramaLoadAbort = null;
6531
7009
  frozen = false;
7010
+ setSeatViewLocked(false);
6532
7011
  const gen = ++flightGen;
6533
7012
  removeArriveChip();
6534
7013
  const { eye: seatEye, focal } = resolvedSeatViewPose(seatId, idx);
6535
7014
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
6536
- const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
7015
+ const flight = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
7016
+ const finalPos = opts.arriveAtSeatEye ? seatEye : flight.finalPos;
7017
+ const arrivalFov = opts.arriveAtSeatEye ? container.clientWidth / Math.max(1, container.clientHeight) < 0.7 ? 90 : 65 : FOV_END;
7018
+ const waypoints = [flight.waypoints[0], flight.waypoints[1], finalPos];
7019
+ labelOverlay.setVisible(false);
6537
7020
  if (reducedMotion()) {
6538
- placeCameraFinal(finalPos, focal);
7021
+ placeCameraFinal(finalPos, focal, arrivalFov);
6539
7022
  loop.requestRender();
6540
7023
  analytics.cinematicSkipped();
6541
7024
  if (!disposed) orbit.syncFromCamera();
7025
+ setSeatViewLocked(opts.arriveAtSeatEye === true);
6542
7026
  showArriveChip(seatId, gen);
6543
7027
  return Promise.resolve();
6544
7028
  }
6545
7029
  const startQuat = new import_ogl8.Quat().copy(orbit.camera.quaternion);
6546
7030
  const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
6547
7031
  loop.requestRender();
6548
- return cinematic.start(waypoints, startQuat, endQuat).then(() => {
7032
+ return cinematic.start(waypoints, startQuat, endQuat, FLIGHT_DURATION_MS, arrivalFov).then(() => {
6549
7033
  if (disposed || gen !== flightGen) return;
6550
7034
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
6551
7035
  orbit.resumeAfterFlight(focal);
7036
+ setSeatViewLocked(opts.arriveAtSeatEye === true);
6552
7037
  loop.requestRender();
6553
7038
  showArriveChip(seatId, gen);
6554
7039
  });
@@ -6556,6 +7041,8 @@ function mountVenue3D(container, input, opts = {}) {
6556
7041
  const focusSectionCamera = (sectionId) => {
6557
7042
  const sec = model.sections.find((candidate) => candidate.id === sectionId);
6558
7043
  if (!sec || sec.seatCount === 0) return false;
7044
+ setNearFieldDetailEnabled(true);
7045
+ setSeatViewLocked(false);
6559
7046
  cinematic.cancel();
6560
7047
  removeArriveChip();
6561
7048
  setNavigationMode("pan");
@@ -6570,6 +7057,7 @@ function mountVenue3D(container, input, opts = {}) {
6570
7057
  };
6571
7058
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
6572
7059
  const onDown = (e) => {
7060
+ if (seatViewLocked) return;
6573
7061
  if (downId !== -1) return;
6574
7062
  downId = e.pointerId;
6575
7063
  downX = e.clientX;
@@ -6580,6 +7068,7 @@ function mountVenue3D(container, input, opts = {}) {
6580
7068
  if (cinematic.active) {
6581
7069
  analytics.cinematicCancelled();
6582
7070
  cancelFlight();
7071
+ opts.onViewTargetChange?.(null);
6583
7072
  }
6584
7073
  };
6585
7074
  const onMove = (e) => {
@@ -6630,6 +7119,22 @@ function mountVenue3D(container, input, opts = {}) {
6630
7119
  }
6631
7120
  return bestIndex;
6632
7121
  };
7122
+ const pickVisibleGeometry = (clientX, clientY) => {
7123
+ if (!gpu || !pick) return -1;
7124
+ pick.syncFromSeatProgram(gpu.seatProgram);
7125
+ const rect = glctx.canvas.getBoundingClientRect();
7126
+ const dpr = glctx.renderer.dpr;
7127
+ const { x, y } = pickPixelCoords(
7128
+ clientX,
7129
+ clientY,
7130
+ rect,
7131
+ dpr,
7132
+ glctx.gl.drawingBufferWidth,
7133
+ glctx.gl.drawingBufferHeight
7134
+ );
7135
+ const radius = Math.max(2, Math.round(8 * dpr));
7136
+ return pick.pick(orbit.camera, x, y, radius);
7137
+ };
6633
7138
  const onUp = (e) => {
6634
7139
  if (e.pointerId !== downId) return;
6635
7140
  const isTap = !moved && performance.now() - downT < TAP_MS;
@@ -6639,7 +7144,15 @@ function mountVenue3D(container, input, opts = {}) {
6639
7144
  return;
6640
7145
  }
6641
7146
  if (!isTap || !gpu || !pick) return;
6642
- let idx = focusedSectionId ? pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, true) : -1;
7147
+ let idx = pickVisibleGeometry(e.clientX, e.clientY);
7148
+ if (focusedSectionId && idx >= 0) {
7149
+ const visibleSeatId = seatIdByIndex[idx];
7150
+ const visibleSectionId = visibleSeatId ? sectionIdBySeatId.get(visibleSeatId) : void 0;
7151
+ if (visibleSectionId && visibleSectionId !== focusedSectionId && focusSectionCamera(visibleSectionId)) return;
7152
+ }
7153
+ if (focusedSectionId && idx < 0) {
7154
+ idx = pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, false);
7155
+ }
6643
7156
  if (focusedSectionId && idx < 0) {
6644
7157
  const sectionIndex = pickNearestProjectedSeat(e.clientX, e.clientY, null, 72, false);
6645
7158
  const sectionSeatId = sectionIndex >= 0 ? seatIdByIndex[sectionIndex] : void 0;
@@ -6647,24 +7160,30 @@ function mountVenue3D(container, input, opts = {}) {
6647
7160
  if (nextSectionId && nextSectionId !== focusedSectionId && focusSectionCamera(nextSectionId)) return;
6648
7161
  }
6649
7162
  if (!focusedSectionId) {
6650
- pick.syncFromSeatProgram(gpu.seatProgram);
6651
- const rect = glctx.canvas.getBoundingClientRect();
6652
- const dpr = glctx.renderer.dpr;
6653
- const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
6654
- const radius = Math.max(2, Math.round(8 * dpr));
6655
- idx = pick.pick(orbit.camera, x, y, radius);
6656
7163
  if (idx < 0) idx = pickNearestProjectedSeat(e.clientX, e.clientY, null, 42, false);
6657
7164
  const overviewSeatId = idx >= 0 ? seatIdByIndex[idx] : void 0;
6658
7165
  const sectionId = overviewSeatId ? sectionIdBySeatId.get(overviewSeatId) : void 0;
6659
7166
  if (sectionId && focusSectionCamera(sectionId)) return;
6660
7167
  }
6661
7168
  if (idx < 0 || idx >= seatIdByIndex.length) {
6662
- if (selection.size) setSelection([]);
7169
+ if (selection.size) {
7170
+ setSelection([]);
7171
+ opts.onViewTargetChange?.(null);
7172
+ }
6663
7173
  return;
6664
7174
  }
6665
7175
  const seatId = seatIdByIndex[idx];
6666
- if (selection.has(seatId) && selection.size === 1) setSelection([]);
6667
- else setSelection([seatId]);
7176
+ const seatState = SEAT_STATES[Math.round(model.seats.iState[idx] ?? 0)] ?? "available";
7177
+ if (seatState !== "available" && seatState !== "selected") {
7178
+ opts.onSeatInspect?.(seatId, seatState);
7179
+ return;
7180
+ }
7181
+ if (selection.has(seatId) && selection.size === 1) {
7182
+ setSelection([]);
7183
+ opts.onViewTargetChange?.(null);
7184
+ return;
7185
+ }
7186
+ setSelection([seatId]);
6668
7187
  ensureSeatView(seatId);
6669
7188
  analytics.seatPicked(seatId, sectionIdBySeatId.get(seatId));
6670
7189
  opts.onSeatPick?.(seatId);
@@ -6689,6 +7208,7 @@ function mountVenue3D(container, input, opts = {}) {
6689
7208
  orbit.setAspect(width / Math.max(1, height));
6690
7209
  layoutArriveChip();
6691
7210
  layoutNavigationChip();
7211
+ layoutOverviewChip();
6692
7212
  loop.requestRender();
6693
7213
  },
6694
7214
  stats() {
@@ -6707,6 +7227,8 @@ function mountVenue3D(container, input, opts = {}) {
6707
7227
  focusFloor(index) {
6708
7228
  if (index !== null && !model.floors.some((f) => f.index === index)) return false;
6709
7229
  const value = index ?? -1;
7230
+ setNearFieldDetailEnabled(false);
7231
+ setSeatViewLocked(false);
6710
7232
  if (gpu) {
6711
7233
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
6712
7234
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
@@ -6730,6 +7252,8 @@ function mountVenue3D(container, input, opts = {}) {
6730
7252
  focusZone(zoneId) {
6731
7253
  const zone = model.zones.find((z) => z.id === zoneId);
6732
7254
  if (!zone || zone.seatCount === 0) return false;
7255
+ setNearFieldDetailEnabled(false);
7256
+ setSeatViewLocked(false);
6733
7257
  cinematic.cancel();
6734
7258
  setNavigationMode("orbit");
6735
7259
  showSectionSeatLabels(null);
@@ -6756,6 +7280,8 @@ function mountVenue3D(container, input, opts = {}) {
6756
7280
  focusRow(rowId) {
6757
7281
  const row = model.rows.find((candidate) => candidate.id === rowId);
6758
7282
  if (!row || row.seatCount === 0) return false;
7283
+ setNearFieldDetailEnabled(true);
7284
+ setSeatViewLocked(false);
6759
7285
  cinematic.cancel();
6760
7286
  setNavigationMode("pan");
6761
7287
  if (row.sectionId) {