@seatlayer/core 0.48.1 → 0.49.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,14 +7,14 @@ import {
7
7
  pointInPolygonWithHoles,
8
8
  resolveSection,
9
9
  sectionGeometry
10
- } from "../chunk-TCTAS4Z2.js";
10
+ } from "../chunk-GD267BFB.js";
11
11
  import {
12
12
  MAX_PITCH_DEG,
13
13
  VFOV_DEG,
14
14
  clampPanoramaFov,
15
15
  mountPanorama,
16
16
  seatViewDisclosure
17
- } from "../chunk-I6DPX3EM.js";
17
+ } from "../chunk-BE3F7CT3.js";
18
18
  import {
19
19
  browserPanoramaConstraints,
20
20
  loadPanoramaImage,
@@ -69,8 +69,8 @@ var STRUCTURE = {
69
69
  * so an organizer's own stage colour survives — it is just no longer lit like
70
70
  * a basement.
71
71
  */
72
- stageTop: [0.86, 0.64, 0.36],
73
- stageWall: [0.34, 0.26, 0.18],
72
+ stageTop: [0.94, 0.7, 0.38],
73
+ stageWall: [0.48, 0.33, 0.18],
74
74
  decorTop: [0.22, 0.25, 0.29],
75
75
  decorWall: [0.15, 0.17, 0.2],
76
76
  gaTop: [0.24, 0.28, 0.33],
@@ -224,6 +224,10 @@ var OrbitCamera = class {
224
224
  this.minDist = 1;
225
225
  this.maxDist = 100;
226
226
  this.gestureFired = false;
227
+ /** Seat-eye arrival is a fixed physical origin. The buyer may opt into the
228
+ * dedicated look-around viewer, but venue orbit/dolly/pan must not pull the
229
+ * camera outside the selected seat. */
230
+ this.interactionEnabled = true;
227
231
  this.dragging = false;
228
232
  this.panning = false;
229
233
  this.lastX = 0;
@@ -246,6 +250,7 @@ var OrbitCamera = class {
246
250
  this.requestRender = requestRender;
247
251
  this.onGesture = onGesture;
248
252
  this.onPointerDown = (e) => {
253
+ if (!this.interactionEnabled) return;
249
254
  try {
250
255
  this.canvas.setPointerCapture?.(e.pointerId);
251
256
  } catch {
@@ -268,6 +273,7 @@ var OrbitCamera = class {
268
273
  }
269
274
  };
270
275
  this.onPointerMove = (e) => {
276
+ if (!this.interactionEnabled) return;
271
277
  if (!this.activePointers.has(e.pointerId)) return;
272
278
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
273
279
  if (this.activePointers.size >= 2) {
@@ -319,12 +325,14 @@ var OrbitCamera = class {
319
325
  };
320
326
  this.onWheel = (e) => {
321
327
  e.preventDefault();
328
+ if (!this.interactionEnabled) return;
322
329
  const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
323
330
  const norm2 = Math.max(-2, Math.min(2, e.deltaY * unit / 100));
324
331
  this.dollyBy(Math.exp(norm2 * 0.22));
325
332
  this.fireGesture();
326
333
  };
327
334
  this.onKeyDown = (e) => {
335
+ if (!this.interactionEnabled) return;
328
336
  const step = 7 * DEG;
329
337
  if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown")) {
330
338
  this.panBy(
@@ -422,6 +430,7 @@ var OrbitCamera = class {
422
430
  /** Programmatic zoom used by the visible camera controls. Keeping it on the
423
431
  * same dolly path as wheel, pinch and keyboard preserves all distance limits. */
424
432
  zoomBy(factor) {
433
+ if (!this.interactionEnabled) return;
425
434
  this.dollyBy(factor);
426
435
  this.fireGesture();
427
436
  }
@@ -431,6 +440,15 @@ var OrbitCamera = class {
431
440
  setPrimaryDragMode(mode) {
432
441
  this.primaryDragMode = mode;
433
442
  }
443
+ /** Enable venue navigation or pin the current camera pose for seat-eye mode. */
444
+ setInteractionEnabled(enabled) {
445
+ this.interactionEnabled = enabled;
446
+ if (enabled) return;
447
+ this.activePointers.clear();
448
+ this.dragging = false;
449
+ this.panning = false;
450
+ this.pinchDist = 0;
451
+ }
434
452
  /**
435
453
  * Fit a flattering 3/4 view to the bounds sphere. With `intro`, the camera
436
454
  * STARTS nearly top-down (matching the 2D map's orientation) and further out,
@@ -487,6 +505,26 @@ var OrbitCamera = class {
487
505
  this.polT = 55 * DEG;
488
506
  this.distT = fit * FRAME_MARGIN;
489
507
  }
508
+ /**
509
+ * Tighten the depth range around the actual venue bounds for the current pose.
510
+ *
511
+ * A fixed 0.1..5000 m frustum gives an ordinary arena centimetre-scale depth
512
+ * precision at overview distance—coarser than the separation between a row
513
+ * tread and its structural cap. The resulting z quantisation is the black
514
+ * radial comb previously seen while zooming. A padded bounds sphere contains
515
+ * every venue solid while preserving useful precision outside the bowl; when
516
+ * the camera enters that sphere, the near plane safely returns to 5 cm.
517
+ */
518
+ updateClipping(bounds) {
519
+ const dx = this.camera.position.x - bounds.center[0];
520
+ const dy = this.camera.position.y - bounds.center[1];
521
+ const dz = this.camera.position.z - bounds.center[2];
522
+ const distance = Math.hypot(dx, dy, dz);
523
+ const paddedRadius = Math.max(1, bounds.radius) * 1.18;
524
+ const near = Math.max(0.05, distance - paddedRadius);
525
+ const far = Math.max(near + 10, distance + paddedRadius);
526
+ this.camera.perspective({ near, far, aspect: this.camera.aspect });
527
+ }
490
528
  /** Damp toward targets; returns true while still moving. */
491
529
  update() {
492
530
  const da = this.azT - this.azimuth;
@@ -605,8 +643,12 @@ var RenderLoop = class {
605
643
  // src/view3d/lod.ts
606
644
  var SEAT_MIN_PIXELS_NEAR = 2.5;
607
645
  var SEAT_MIN_PIXELS_FAR = 1.15;
608
- var CHAIR_FULL_M = 12;
609
- var CHAIR_NONE_M = 22;
646
+ var CHAIR_FULL_M = 6;
647
+ var CHAIR_NONE_M = 10;
648
+ var OCCUPANT_FULL_M = 6;
649
+ var OCCUPANT_NONE_M = 9.5;
650
+ var OCCUPANT_NEAR_NONE_M = 0.9;
651
+ var OCCUPANT_NEAR_FULL_M = 2.2;
610
652
  var CHAIR_GATHER_M = 30;
611
653
  var CHAIR_REBUILD_M = 4;
612
654
  var CHAIR_MAX_INSTANCES = 8192;
@@ -777,13 +819,19 @@ var MeshBuilder = class {
777
819
  this.nor = new F32Buffer();
778
820
  this.col = new F32Buffer();
779
821
  this.flr = new F32Buffer();
822
+ this.mat = new F32Buffer();
780
823
  /** Floor index stamped onto every triangle emitted from now on. */
781
824
  this.currentFloor = 0;
825
+ this.currentMaterial = 0;
782
826
  }
783
827
  /** Stamp subsequent triangles as belonging to `index`. */
784
828
  setFloor(index) {
785
829
  this.currentFloor = index;
786
830
  }
831
+ /** Stamp subsequent triangles with a small renderer material class. */
832
+ setMaterial(index) {
833
+ this.currentMaterial = index;
834
+ }
787
835
  /** One triangle with a shared (flat) normal and per-vertex colours. */
788
836
  tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
789
837
  if (isDegenerate(p0, p1, p2)) return;
@@ -799,10 +847,13 @@ var MeshBuilder = class {
799
847
  this.flr.push1(this.currentFloor);
800
848
  this.flr.push1(this.currentFloor);
801
849
  this.flr.push1(this.currentFloor);
850
+ this.mat.push1(this.currentMaterial);
851
+ this.mat.push1(this.currentMaterial);
852
+ this.mat.push1(this.currentMaterial);
802
853
  }
803
854
  /** One triangle with independent per-vertex normals (smooth shading). */
804
855
  triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
805
- if (isDegenerate(p0, p1, p2)) return;
856
+ if (![...p0, ...p1, ...p2].every(Number.isFinite)) return;
806
857
  this.pos.push3(p0[0], p0[1], p0[2]);
807
858
  this.pos.push3(p1[0], p1[1], p1[2]);
808
859
  this.pos.push3(p2[0], p2[1], p2[2]);
@@ -815,6 +866,9 @@ var MeshBuilder = class {
815
866
  this.flr.push1(this.currentFloor);
816
867
  this.flr.push1(this.currentFloor);
817
868
  this.flr.push1(this.currentFloor);
869
+ this.mat.push1(this.currentMaterial);
870
+ this.mat.push1(this.currentMaterial);
871
+ this.mat.push1(this.currentMaterial);
818
872
  }
819
873
  get vertexCount() {
820
874
  return this.pos.length / 3;
@@ -825,6 +879,7 @@ var MeshBuilder = class {
825
879
  normal: this.nor.toArray(),
826
880
  color: this.col.toArray(),
827
881
  floor: this.flr.toArray(),
882
+ material: this.mat.toArray(),
828
883
  count: this.pos.length / 3
829
884
  };
830
885
  }
@@ -1067,15 +1122,17 @@ function mergeMeshData(parts) {
1067
1122
  const normal = new Float32Array(total * 3);
1068
1123
  const color = new Float32Array(total * 3);
1069
1124
  const floor = new Float32Array(total);
1125
+ const material = new Float32Array(total);
1070
1126
  let off = 0;
1071
1127
  for (const p of parts) {
1072
1128
  position.set(p.position, off * 3);
1073
1129
  normal.set(p.normal, off * 3);
1074
1130
  color.set(p.color, off * 3);
1075
1131
  floor.set(p.floor, off);
1132
+ material.set(p.material, off);
1076
1133
  off += p.count;
1077
1134
  }
1078
- return { position, normal, color, floor, count: total };
1135
+ return { position, normal, color, floor, material, count: total };
1079
1136
  }
1080
1137
  function outsetRing(ring, d, miterLimit = 2.5) {
1081
1138
  if (d <= 0 || ring.length < 3) return ring;
@@ -1169,7 +1226,7 @@ var BACK_RAKE_SLOPE = 0.21;
1169
1226
  var PAD_BACK_GAP_M = 0.05;
1170
1227
  var PAD_TOP_M = 0.45;
1171
1228
  var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
1172
- var BOXES = [
1229
+ var CHAIR_BOXES = [
1173
1230
  // Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
1174
1231
  // over the deck and the row reads as hovering trays.
1175
1232
  { part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
@@ -1184,39 +1241,22 @@ var BOXES = [
1184
1241
  part: CHAIR_PART.back,
1185
1242
  min: [-1, BACK_BASE_M, -1],
1186
1243
  max: [1, 0.92, -0.72]
1187
- },
1188
- // --- the occupant ---------------------------------------------------------
1189
- //
1190
- // A hall with every seat empty reads as an architectural model, not a venue.
1191
- // The 2048-px panorama this replaced drew a crowd; losing it was the price of
1192
- // sharpness, and this is how it is bought back — in geometry, where there is
1193
- // no resolution ceiling.
1194
- //
1195
- // Deliberately two blocks and no limbs. At the range these are visible a
1196
- // person is a torso and a head, and every extra part multiplies by the number
1197
- // of occupied seats in view. The silhouette is what carries it, exactly as it
1198
- // does in the generated panorama's head-and-shoulder figures.
1199
- //
1200
- // Sized as a seated adult against the 0.92 m chair back: hips at the pad top,
1201
- // shoulders just above the back panel, head clear of it. Torso is narrower
1202
- // than the chair so neighbours never interpenetrate at any pitch, and it sits
1203
- // forward of the back panel rather than inside it.
1204
- {
1205
- part: CHAIR_PART.body,
1206
- min: [-0.66, PAD_TOP_M, -0.58],
1207
- max: [0.66, 1, 0.26]
1208
- },
1209
- // The head is deliberately SMALL. Sized by eye against the chair it came out
1210
- // near-cubic and read as Lego; a real head is about 0.16 m across and 0.22 m
1211
- // tall, which against a 0.24 m chair half-width is roughly a third of the
1212
- // chair's width and clearly taller than it is wide. Getting this ratio wrong
1213
- // is what makes a crowd look like toys rather than people.
1214
- {
1215
- part: CHAIR_PART.head,
1216
- min: [-0.34, 1.03, -0.4],
1217
- max: [0.34, 1.27, 0.02]
1218
1244
  }
1219
1245
  ];
1246
+ var OCCUPANT_BOXES = [
1247
+ // Thighs project forward from the pad. A single lap slab brought back the
1248
+ // same toy-block problem as the old torso, so each leg keeps its own outline.
1249
+ { part: CHAIR_PART.body, min: [-0.52, 0.39, -0.08], max: [-0.08, 0.55, 0.82] },
1250
+ { part: CHAIR_PART.body, min: [0.08, 0.39, -0.08], max: [0.52, 0.55, 0.82] },
1251
+ // Lower legs drop from the knees, making the pose unmistakably seated from
1252
+ // the side and from the elevated arena camera.
1253
+ { part: CHAIR_PART.body, min: [-0.47, 0.05, 0.58], max: [-0.13, 0.43, 0.82] },
1254
+ { part: CHAIR_PART.body, min: [0.13, 0.05, 0.58], max: [0.47, 0.43, 0.82] },
1255
+ // A short neck keeps the head connected to the shoulders from behind. Most
1256
+ // of it sits inside the torso/head overlap: a long exposed cuboid made the
1257
+ // close-range figure read as a mannequin on a post.
1258
+ { part: CHAIR_PART.head, min: [-0.16, 1.04, -0.33], max: [0.16, 1.1, -0.13] }
1259
+ ];
1220
1260
  var FACES = [
1221
1261
  // +X
1222
1262
  { n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
@@ -1232,63 +1272,111 @@ var FACES = [
1232
1272
  { n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
1233
1273
  ];
1234
1274
  function buildChairMesh() {
1235
- const vertexCount = BOXES.length * FACES.length * 4;
1236
- const indexCount = BOXES.length * FACES.length * 6;
1237
- const position = new Float32Array(vertexCount * 3);
1238
- const normal = new Float32Array(vertexCount * 3);
1239
- const part = new Float32Array(vertexCount);
1240
- const index = new Uint16Array(indexCount);
1241
- let v = 0;
1242
- let t = 0;
1243
- for (const box of BOXES) {
1275
+ const positions = [];
1276
+ const normals = [];
1277
+ const parts = [];
1278
+ const indices = [];
1279
+ const addQuad = (corners, outward, partId) => {
1280
+ const base = parts.length;
1281
+ const [a, b, c] = corners;
1282
+ const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1283
+ const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
1284
+ let nx = ab[1] * ac[2] - ab[2] * ac[1];
1285
+ let ny = ab[2] * ac[0] - ab[0] * ac[2];
1286
+ let nz = ab[0] * ac[1] - ab[1] * ac[0];
1287
+ const length = Math.hypot(nx, ny, nz);
1288
+ if (length > 1e-9) {
1289
+ nx /= length;
1290
+ ny /= length;
1291
+ nz /= length;
1292
+ } else {
1293
+ [nx, ny, nz] = outward;
1294
+ }
1295
+ if (nx * outward[0] + ny * outward[1] + nz * outward[2] < 0) {
1296
+ nx = -nx;
1297
+ ny = -ny;
1298
+ nz = -nz;
1299
+ }
1300
+ for (const corner of corners) {
1301
+ positions.push(...corner);
1302
+ normals.push(nx, ny, nz);
1303
+ parts.push(partId);
1304
+ }
1305
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
1306
+ };
1307
+ const addBox = (box) => {
1244
1308
  for (const face of FACES) {
1245
- const base = v;
1246
- const corner3 = new Float32Array(12);
1247
- for (let ci = 0; ci < 4; ci++) {
1248
- const corner = face.c[ci];
1249
- for (let a = 0; a < 3; a++) {
1250
- corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
1251
- }
1252
- }
1253
- const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
1254
- const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
1255
- let nx = ay * bz - az * by;
1256
- let ny = az * bx - ax * bz;
1257
- let nz = ax * by - ay * bx;
1258
- const nl = Math.hypot(nx, ny, nz);
1259
- if (nl > 1e-9) {
1260
- nx /= nl;
1261
- ny /= nl;
1262
- nz /= nl;
1263
- } else {
1264
- nx = face.n[0];
1265
- ny = face.n[1];
1266
- nz = face.n[2];
1267
- }
1268
- if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
1269
- nx = -nx;
1270
- ny = -ny;
1271
- nz = -nz;
1272
- }
1273
- for (let ci = 0; ci < 4; ci++) {
1274
- position[v * 3] = corner3[ci * 3];
1275
- position[v * 3 + 1] = corner3[ci * 3 + 1];
1276
- position[v * 3 + 2] = corner3[ci * 3 + 2];
1277
- normal[v * 3] = nx;
1278
- normal[v * 3 + 1] = ny;
1279
- normal[v * 3 + 2] = nz;
1280
- part[v] = box.part;
1281
- v++;
1282
- }
1283
- index[t++] = base;
1284
- index[t++] = base + 1;
1285
- index[t++] = base + 2;
1286
- index[t++] = base;
1287
- index[t++] = base + 2;
1288
- index[t++] = base + 3;
1309
+ const corners = face.c.map((corner) => corner.map(
1310
+ (side, axis) => side ? box.max[axis] : box.min[axis]
1311
+ ));
1312
+ addQuad(corners, face.n, box.part);
1289
1313
  }
1290
- }
1291
- return { position, normal, part, index, vertexCount, indexCount };
1314
+ };
1315
+ for (const box of CHAIR_BOXES) addBox(box);
1316
+ for (const box of OCCUPANT_BOXES) addBox(box);
1317
+ const waistY = PAD_TOP_M;
1318
+ const shoulderY = 1.08;
1319
+ const wb = 0.47, wt = 0.69;
1320
+ const backBottom = -0.55, frontBottom = 0.08;
1321
+ const backTop = -0.48, frontTop = 0.01;
1322
+ const blb = [-wb, waistY, backBottom], brb = [wb, waistY, backBottom];
1323
+ const blf = [-wb, waistY, frontBottom], brf = [wb, waistY, frontBottom];
1324
+ const tlb = [-wt, shoulderY, backTop], trb = [wt, shoulderY, backTop];
1325
+ const tlf = [-wt, shoulderY, frontTop], trf = [wt, shoulderY, frontTop];
1326
+ addQuad([brb, trb, trf, brf], [1, 0, 0], CHAIR_PART.body);
1327
+ addQuad([blf, tlf, tlb, blb], [-1, 0, 0], CHAIR_PART.body);
1328
+ addQuad([tlf, trf, trb, tlb], [0, 1, 0], CHAIR_PART.body);
1329
+ addQuad([blb, brb, brf, blf], [0, -1, 0], CHAIR_PART.body);
1330
+ addQuad([brf, trf, tlf, blf], [0, 0, 1], CHAIR_PART.body);
1331
+ addQuad([blb, tlb, trb, brb], [0, 0, -1], CHAIR_PART.body);
1332
+ const lonSegments = 10;
1333
+ const latSegments = 8;
1334
+ const centre = [0, 1.205, -0.23];
1335
+ const radii = [0.42, 0.115, 0.38];
1336
+ const headBase = parts.length;
1337
+ for (let lat = 0; lat <= latSegments; lat++) {
1338
+ const theta = Math.PI * lat / latSegments;
1339
+ const sy = Math.cos(theta);
1340
+ const ring = Math.sin(theta);
1341
+ for (let lon = 0; lon <= lonSegments; lon++) {
1342
+ const phi = Math.PI * 2 * lon / lonSegments;
1343
+ const dx = ring * Math.cos(phi);
1344
+ const dz = ring * Math.sin(phi);
1345
+ positions.push(
1346
+ centre[0] + radii[0] * dx,
1347
+ centre[1] + radii[1] * sy,
1348
+ centre[2] + radii[2] * dz
1349
+ );
1350
+ let nx = dx / radii[0];
1351
+ let ny = sy / radii[1];
1352
+ let nz = dz / radii[2];
1353
+ const length = Math.hypot(nx, ny, nz) || 1;
1354
+ nx /= length;
1355
+ ny /= length;
1356
+ nz /= length;
1357
+ normals.push(nx, ny, nz);
1358
+ parts.push(CHAIR_PART.head);
1359
+ }
1360
+ }
1361
+ for (let lat = 0; lat < latSegments; lat++) {
1362
+ for (let lon = 0; lon < lonSegments; lon++) {
1363
+ const a = headBase + lat * (lonSegments + 1) + lon;
1364
+ const b = a + lonSegments + 1;
1365
+ indices.push(a, b, a + 1, a + 1, b, b + 1);
1366
+ }
1367
+ }
1368
+ const position = new Float32Array(positions);
1369
+ const normal = new Float32Array(normals);
1370
+ const part = new Float32Array(parts);
1371
+ const index = new Uint16Array(indices);
1372
+ return {
1373
+ position,
1374
+ normal,
1375
+ part,
1376
+ index,
1377
+ vertexCount: parts.length,
1378
+ indexCount: indices.length
1379
+ };
1292
1380
  }
1293
1381
  function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1294
1382
  const yaw = new Float32Array(count);
@@ -1592,7 +1680,7 @@ function focusScore(screen, worldDistance, width, height) {
1592
1680
  return worldDistance * (1 + 1.5 * off);
1593
1681
  }
1594
1682
  function pickDenseLabels(items, separationX, separationY, budget) {
1595
- const ordered = [...items].sort((a, b) => a.focus - b.focus);
1683
+ const ordered = [...items].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.focus - b.focus);
1596
1684
  const kept = [];
1597
1685
  for (const item of ordered) {
1598
1686
  if (kept.length >= budget) break;
@@ -2204,19 +2292,31 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2204
2292
  const UP = [0, 1, 0];
2205
2293
  const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
2206
2294
  const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
2295
+ const emitRiser = (draw) => {
2296
+ builder.setMaterial(2);
2297
+ draw();
2298
+ builder.setMaterial(0);
2299
+ };
2300
+ const emitTread = (draw) => {
2301
+ builder.setMaterial(3);
2302
+ draw();
2303
+ builder.setMaterial(0);
2304
+ };
2207
2305
  for (let i = 0; i < rows.length; i++) {
2208
2306
  const row = rows[i];
2209
2307
  if (row.patch && row.patch.length >= 3) {
2210
2308
  const patch = [...row.patch];
2211
2309
  const belowY2 = nbrs[i].belowY ?? landingY;
2212
- if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2213
- else {
2214
- const a = patch[0];
2215
- for (let k = 1; k + 1 < patch.length; k++) {
2216
- const b = patch[k], c = patch[k + 1];
2217
- 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);
2310
+ emitTread(() => {
2311
+ if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
2312
+ else {
2313
+ const a = patch[0];
2314
+ for (let k = 1; k + 1 < patch.length; k++) {
2315
+ const b = patch[k], c = patch[k + 1];
2316
+ 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);
2317
+ }
2218
2318
  }
2219
- }
2319
+ });
2220
2320
  if (row.y > belowY2 + MIN_RISER_M) {
2221
2321
  let cx = 0, cy = 0;
2222
2322
  for (const p of patch) {
@@ -2225,24 +2325,26 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2225
2325
  }
2226
2326
  cx /= patch.length;
2227
2327
  cy /= patch.length;
2228
- for (let k = 0; k < patch.length; k++) {
2229
- const p = patch[k], q = patch[(k + 1) % patch.length];
2230
- const dx = q.x - p.x, dy = q.y - p.y;
2231
- const len = Math.hypot(dx, dy);
2232
- if (len < 1e-6) continue;
2233
- let nx = dy / len, ny = -dx / len;
2234
- if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2235
- nx = -nx;
2236
- ny = -ny;
2328
+ emitRiser(() => {
2329
+ for (let k = 0; k < patch.length; k++) {
2330
+ const p = patch[k], q = patch[(k + 1) % patch.length];
2331
+ const dx = q.x - p.x, dy = q.y - p.y;
2332
+ const len = Math.hypot(dx, dy);
2333
+ if (len < 1e-6) continue;
2334
+ let nx = dy / len, ny = -dx / len;
2335
+ if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
2336
+ nx = -nx;
2337
+ ny = -ny;
2338
+ }
2339
+ const rn = [nx, 0, ny];
2340
+ const pt = [p.x * M, row.y, p.y * M];
2341
+ const qt = [q.x * M, row.y, q.y * M];
2342
+ const pb = [p.x * M, belowY2, p.y * M];
2343
+ const qb = [q.x * M, belowY2, q.y * M];
2344
+ builder.tri(pt, qt, qb, rn, colors.riser);
2345
+ builder.tri(pt, qb, pb, rn, colors.riser);
2237
2346
  }
2238
- const rn = [nx, 0, ny];
2239
- const pt = [p.x * M, row.y, p.y * M];
2240
- const qt = [q.x * M, row.y, q.y * M];
2241
- const pb = [p.x * M, belowY2, p.y * M];
2242
- const qb = [q.x * M, belowY2, q.y * M];
2243
- builder.tri(pt, qt, qb, rn, colors.riser);
2244
- builder.tri(pt, qb, pb, rn, colors.riser);
2245
- }
2347
+ });
2246
2348
  }
2247
2349
  continue;
2248
2350
  }
@@ -2259,23 +2361,27 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
2259
2361
  const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
2260
2362
  const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
2261
2363
  const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
2262
- if (clipRing && clipTest) {
2263
- emitClippedPoly(builder, clipRing, clipTest, [
2264
- { x: p.x - np[0] * front, y: p.y - np[1] * front },
2265
- { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2266
- { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2267
- { x: p.x + np[0] * back, y: p.y + np[1] * back }
2268
- ], row.y, colors.tread);
2269
- } else {
2270
- builder.tri(pF, qF, qB, UP, colors.tread);
2271
- builder.tri(pF, qB, pB, UP, colors.tread);
2272
- }
2364
+ emitTread(() => {
2365
+ if (clipRing && clipTest) {
2366
+ emitClippedPoly(builder, clipRing, clipTest, [
2367
+ { x: p.x - np[0] * front, y: p.y - np[1] * front },
2368
+ { x: q.x - nq[0] * front, y: q.y - nq[1] * front },
2369
+ { x: q.x + nq[0] * back, y: q.y + nq[1] * back },
2370
+ { x: p.x + np[0] * back, y: p.y + np[1] * back }
2371
+ ], row.y, colors.tread);
2372
+ } else {
2373
+ builder.tri(pF, qF, qB, UP, colors.tread);
2374
+ builder.tri(pF, qB, pB, UP, colors.tread);
2375
+ }
2376
+ });
2273
2377
  if (row.y > belowY + MIN_RISER_M) {
2274
2378
  const pFd = [pF[0], belowY, pF[2]];
2275
2379
  const qFd = [qF[0], belowY, qF[2]];
2276
2380
  const rn = [-np[0], 0, -np[1]];
2277
- builder.tri(pF, qF, qFd, rn, colors.riser);
2278
- builder.tri(pF, qFd, pFd, rn, colors.riser);
2381
+ emitRiser(() => {
2382
+ builder.tri(pF, qF, qFd, rn, colors.riser);
2383
+ builder.tri(pF, qFd, pFd, rn, colors.riser);
2384
+ });
2279
2385
  }
2280
2386
  }
2281
2387
  }
@@ -2620,18 +2726,21 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2620
2726
  const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal, nbrs), footprint);
2621
2727
  const capUp = () => [0, 1, 0];
2622
2728
  for (const b of blocks) {
2623
- extrudePrism(
2624
- builder,
2625
- b.outline,
2626
- b.holes,
2627
- () => b.topY,
2628
- bottomY,
2629
- colTop,
2630
- S.tierWall,
2631
- AO,
2632
- void 0,
2633
- capUp
2634
- );
2729
+ const baseCapY = Math.max(bottomY, b.topY - 0.02);
2730
+ for (const ring of claimed.subtract(b.outline, baseCapY)) {
2731
+ extrudePrism(
2732
+ builder,
2733
+ ring,
2734
+ b.holes,
2735
+ () => baseCapY,
2736
+ bottomY,
2737
+ colTop,
2738
+ S.tierWall,
2739
+ AO,
2740
+ void 0,
2741
+ capUp
2742
+ );
2743
+ }
2635
2744
  }
2636
2745
  if (!blocks.length) {
2637
2746
  extrudePrism(
@@ -2645,12 +2754,23 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
2645
2754
  AO
2646
2755
  );
2647
2756
  }
2648
- emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
2757
+ const bandColors = {
2649
2758
  tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
2650
2759
  // Risers read as the structure they are, a shade below their tread, which
2651
2760
  // is what makes the stepping legible from a low angle.
2652
2761
  riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
2653
- }, footprint.length === 1 ? outline : footprint.flat(), nbrs);
2762
+ };
2763
+ for (const allowed of footprint.length ? footprint : [outline]) {
2764
+ emitDeckBands(
2765
+ builder,
2766
+ surface.rowLevels,
2767
+ unit.focal,
2768
+ surface.landingY,
2769
+ bandColors,
2770
+ allowed,
2771
+ nbrs
2772
+ );
2773
+ }
2654
2774
  return;
2655
2775
  }
2656
2776
  const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
@@ -2904,8 +3024,10 @@ function buildShape(builder, shape, base, topOffsetM, S, focal, stages) {
2904
3024
  halfZ: Math.max((maxZ - minZ) / 2 * M, 0.5)
2905
3025
  });
2906
3026
  }
2907
- const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
2908
- const colWall = isStage ? S.stageWall : shape.role === "screen" ? [0.24, 0.46, 0.82] : tintTop(hexToRgb(shape.fill), S.decorWall);
3027
+ const authoredFill = hexToRgb(shape.fill);
3028
+ const colTop = isStage && authoredFill ? mix(S.stageTop, scaleRgb(desaturate(authoredFill, 0.18), 0.9), 0.9) : tintTop(authoredFill, isStage ? S.stageTop : S.decorTop);
3029
+ const screenFill = shape.role === "screen" ? authoredFill : null;
3030
+ 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);
2909
3031
  if (architecture.kind === "portal" && poly.length === 4) {
2910
3032
  const quad = orientQuadLong(poly);
2911
3033
  emitArchitecturalPrism(builder, quadWidthSlice(quad, 0, 0.16), height, base, colTop, colWall);
@@ -3045,16 +3167,19 @@ function buildSceneModel(input) {
3045
3167
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
3046
3168
  for (const o of unit.objects) {
3047
3169
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
3048
- else if (o.type === "shape") buildShape(
3049
- builder,
3050
- o,
3051
- unit.baseHeightM,
3052
- claimShapeTopOffset(o, claimedShapeLayers),
3053
- S,
3054
- unit.focal,
3055
- stageBounds
3056
- );
3057
- else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3170
+ else if (o.type === "shape") {
3171
+ builder.setMaterial(o.role === "screen" ? 1 : 0);
3172
+ buildShape(
3173
+ builder,
3174
+ o,
3175
+ unit.baseHeightM,
3176
+ claimShapeTopOffset(o, claimedShapeLayers),
3177
+ S,
3178
+ unit.focal,
3179
+ stageBounds
3180
+ );
3181
+ builder.setMaterial(0);
3182
+ } else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3058
3183
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3059
3184
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3060
3185
  else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
@@ -3360,10 +3485,49 @@ function buildSceneModel(input) {
3360
3485
  const cx = (fp.minX + fp.maxX) / 2 * M;
3361
3486
  const cz = (fp.minY + fp.maxY) / 2 * M;
3362
3487
  const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
3488
+ let renderMinX = Infinity, renderMinY = Infinity, renderMinZ = Infinity;
3489
+ let renderMaxX = -Infinity, renderMaxY = -Infinity, renderMaxZ = -Infinity;
3490
+ const includeRenderPoint = (x, y, z) => {
3491
+ renderMinX = Math.min(renderMinX, x);
3492
+ renderMaxX = Math.max(renderMaxX, x);
3493
+ renderMinY = Math.min(renderMinY, y);
3494
+ renderMaxY = Math.max(renderMaxY, y);
3495
+ renderMinZ = Math.min(renderMinZ, z);
3496
+ renderMaxZ = Math.max(renderMaxZ, z);
3497
+ };
3498
+ for (let i = 0; i < solids.position.length; i += 3) {
3499
+ includeRenderPoint(solids.position[i], solids.position[i + 1], solids.position[i + 2]);
3500
+ }
3501
+ for (let i = 0; i < seatData.count; i++) {
3502
+ const o = i * 3;
3503
+ const x = seatData.iPosition[o], y = seatData.iPosition[o + 1], z = seatData.iPosition[o + 2];
3504
+ const r = Math.max(0.35, seatData.iMaxRadius[i] ?? 0.35);
3505
+ includeRenderPoint(x - r, y, z - r);
3506
+ includeRenderPoint(x + r, y + 1.6, z + r);
3507
+ }
3508
+ if (!Number.isFinite(renderMinX)) {
3509
+ renderMinX = cx - radius;
3510
+ renderMaxX = cx + radius;
3511
+ renderMinY = 0;
3512
+ renderMaxY = radius * 0.16;
3513
+ renderMinZ = cz - radius;
3514
+ renderMaxZ = cz + radius;
3515
+ }
3516
+ const renderCenter = [
3517
+ (renderMinX + renderMaxX) / 2,
3518
+ (renderMinY + renderMaxY) / 2,
3519
+ (renderMinZ + renderMaxZ) / 2
3520
+ ];
3521
+ const renderRadius = Math.max(1, 0.5 * Math.hypot(
3522
+ renderMaxX - renderMinX,
3523
+ renderMaxY - renderMinY,
3524
+ renderMaxZ - renderMinZ
3525
+ ));
3363
3526
  return {
3364
3527
  solids,
3365
3528
  seats: seatData,
3366
3529
  bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
3530
+ renderBounds: { center: renderCenter, radius: renderRadius },
3367
3531
  stateColorLUT: themeSeatColorLUT(theme, SEAT_STATES),
3368
3532
  theme,
3369
3533
  seatCount: seats.length,
@@ -3407,6 +3571,7 @@ var LabelOverlay = class {
3407
3571
  this.nodes = /* @__PURE__ */ new Map();
3408
3572
  this.labels = [];
3409
3573
  this.forcedDense = false;
3574
+ this.selectedSeatLabelIds = /* @__PURE__ */ new Set();
3410
3575
  this.opts = opts;
3411
3576
  this.root = document.createElement("div");
3412
3577
  this.root.setAttribute("data-view3d-labels", "");
@@ -3446,6 +3611,12 @@ var LabelOverlay = class {
3446
3611
  setForcedDense(enabled) {
3447
3612
  this.forcedDense = enabled;
3448
3613
  }
3614
+ /** Keep the authoritative selected identities pinned above nearby dense
3615
+ * labels. The seat mesh and its DOM label must never describe different
3616
+ * rows merely because collision suppression preferred the neighbour. */
3617
+ setSelectedSeatIds(ids) {
3618
+ this.selectedSeatLabelIds = new Set(Array.from(ids, (id) => `seat:${id}`));
3619
+ }
3449
3620
  /**
3450
3621
  * Reposition every label for the current camera.
3451
3622
  *
@@ -3468,7 +3639,12 @@ var LabelOverlay = class {
3468
3639
  label.anchor[1] - cameraWorld[1],
3469
3640
  label.anchor[2] - cameraWorld[2]
3470
3641
  ) : 1;
3471
- candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
3642
+ candidates.push({
3643
+ label,
3644
+ screen,
3645
+ focus: focusScore(screen, world, width, height),
3646
+ ...this.selectedSeatLabelIds.has(label.id) ? { priority: 1 } : {}
3647
+ });
3472
3648
  }
3473
3649
  const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
3474
3650
  const kept = [
@@ -3496,26 +3672,35 @@ var LabelOverlay = class {
3496
3672
  }
3497
3673
  nodeFor(label) {
3498
3674
  let node = this.nodes.get(label.id);
3499
- if (node) return node;
3500
- node = document.createElement("div");
3675
+ if (!node) {
3676
+ node = document.createElement("div");
3677
+ const s2 = node.style;
3678
+ s2.position = "absolute";
3679
+ s2.left = "0";
3680
+ s2.top = "0";
3681
+ s2.whiteSpace = "nowrap";
3682
+ s2.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
3683
+ s2.display = "none";
3684
+ this.root.appendChild(node);
3685
+ this.nodes.set(label.id, node);
3686
+ }
3501
3687
  node.textContent = label.text;
3502
3688
  node.setAttribute("data-label-kind", label.kind);
3689
+ const selected = this.selectedSeatLabelIds.has(label.id);
3690
+ node.toggleAttribute("data-selected-seat", selected);
3503
3691
  const style = KIND_STYLE[label.kind];
3504
3692
  const s = node.style;
3505
- s.position = "absolute";
3506
- s.left = "0";
3507
- s.top = "0";
3508
- s.whiteSpace = "nowrap";
3509
3693
  s.fontSize = `${style.size}px`;
3510
- s.fontWeight = style.weight;
3511
- s.opacity = String(style.opacity);
3512
- s.color = label.color ?? this.opts.ink ?? "#e8edf5";
3513
- s.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
3694
+ s.fontWeight = selected ? "800" : style.weight;
3695
+ s.opacity = selected ? "1" : String(style.opacity);
3696
+ s.color = selected ? "#ecf8ff" : label.color ?? this.opts.ink ?? "#e8edf5";
3697
+ s.background = selected ? "rgba(10,28,45,.9)" : "";
3698
+ s.border = selected ? "1px solid rgba(89,199,255,.92)" : "";
3699
+ s.borderRadius = selected ? "999px" : "";
3700
+ s.padding = selected ? "3px 7px" : "";
3701
+ s.boxShadow = selected ? "0 0 0 2px rgba(41,176,255,.18), 0 5px 14px rgba(0,0,0,.35)" : "";
3514
3702
  s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
3515
3703
  if (label.kind === "zone") s.textTransform = "uppercase";
3516
- s.display = "none";
3517
- this.root.appendChild(node);
3518
- this.nodes.set(label.id, node);
3519
3704
  return node;
3520
3705
  }
3521
3706
  dispose() {
@@ -3536,6 +3721,15 @@ float chairWeight(float depth) {
3536
3721
  return 1.0 - smoothstep(uChairFull, uChairNone, depth);
3537
3722
  }`
3538
3723
  );
3724
+ var OCCUPANT_WEIGHT_GLSL = (
3725
+ /* glsl */
3726
+ `
3727
+ float occupantWeight(float depth) {
3728
+ float nearWeight = smoothstep(${OCCUPANT_NEAR_NONE_M.toFixed(1)}, ${OCCUPANT_NEAR_FULL_M.toFixed(1)}, depth);
3729
+ float farWeight = 1.0 - smoothstep(${OCCUPANT_FULL_M.toFixed(1)}, ${OCCUPANT_NONE_M.toFixed(1)}, depth);
3730
+ return nearWeight * farWeight;
3731
+ }`
3732
+ );
3539
3733
  var SOLID_VERT = (
3540
3734
  /* glsl */
3541
3735
  `#version 300 es
@@ -3544,8 +3738,10 @@ in vec3 position;
3544
3738
  in vec3 normal;
3545
3739
  in vec3 color;
3546
3740
  in float floorIndex;
3741
+ in float materialIndex;
3547
3742
  uniform mat4 modelMatrix;
3548
3743
  uniform float uFocusFloor; // -1 = show every floor
3744
+ uniform float uStructureDetail; // 0 = venue overview, 1 = section/row/seat
3549
3745
  uniform mat4 modelViewMatrix;
3550
3746
  uniform mat4 projectionMatrix;
3551
3747
  uniform mat3 normalMatrix;
@@ -3553,14 +3749,19 @@ out vec3 vColor;
3553
3749
  out vec3 vNormalWorld;
3554
3750
  out vec3 vNormalView;
3555
3751
  out vec3 vPosView;
3752
+ out vec3 vPosWorld;
3556
3753
  out float vDim;
3754
+ out float vMaterial;
3557
3755
  void main() {
3558
3756
  // Per-floor isolation without splitting the merged mesh into a draw call per
3559
3757
  // floor: a floor that is not the focused one is dimmed, not hidden, so the
3560
3758
  // buyer keeps the whole venue as context while looking at one level.
3561
3759
  vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3760
+ vMaterial = materialIndex;
3761
+ vec4 world = modelMatrix * vec4(position, 1.0);
3562
3762
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
3563
3763
  vPosView = mv.xyz;
3764
+ vPosWorld = world.xyz;
3564
3765
  vNormalView = normalize(normalMatrix * normal);
3565
3766
  // World normal drives the key + hemisphere so the lighting stays welded to the
3566
3767
  // venue as the camera orbits (the scene has no non-uniform scale, so mat3 of
@@ -3578,10 +3779,20 @@ in vec3 vColor;
3578
3779
  in vec3 vNormalWorld;
3579
3780
  in vec3 vNormalView;
3580
3781
  in vec3 vPosView;
3782
+ in vec3 vPosWorld;
3581
3783
  in float vDim;
3784
+ in float vMaterial;
3582
3785
  uniform vec3 uKeyDir; // WORLD space, unit, points at the light
3786
+ uniform float uStructureDetail;
3583
3787
  out vec4 fragColor;
3584
3788
  void main() {
3789
+ // Row treads/risers are honest and useful when the buyer is inside a stand.
3790
+ // At whole-venue scale their overlapping centimetre-scale surfaces quantise
3791
+ // and alias into camera-dependent radial combs. The clean structural block
3792
+ // cap remains, so overview keeps every stand without pretending to show row
3793
+ // detail below a useful pixel size.
3794
+ float rowDetail = step(1.5, vMaterial);
3795
+ if (rowDetail > 0.5 && uStructureDetail < 0.5) discard;
3585
3796
  vec3 N = normalize(vNormalWorld);
3586
3797
  vec3 V = normalize(-vPosView);
3587
3798
  float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
@@ -3593,6 +3804,30 @@ void main() {
3593
3804
  vec3 base = vColor * (0.52 + 0.34 * hemi) + vColor * key * 0.34 + vColor * fill * 0.10;
3594
3805
  float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3595
3806
  base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained (view-dependent by design)
3807
+ // Bright, saturated authored screen faces should read as powered displays,
3808
+ // not painted slabs. Vertex colour is deliberately the material signal here:
3809
+ // muted tiers and structure never cross these thresholds. The broad waves
3810
+ // supply a restrained procedural LED treatment without textures, downloads,
3811
+ // solid fake-light geometry or post-processing.
3812
+ float maxChannel = max(max(vColor.r, vColor.g), vColor.b);
3813
+ float minChannel = min(min(vColor.r, vColor.g), vColor.b);
3814
+ float poweredDisplay = (1.0 - step(0.5, abs(vMaterial - 1.0)))
3815
+ * smoothstep(0.58, 0.72, maxChannel)
3816
+ * smoothstep(0.28, 0.48, maxChannel - minChannel);
3817
+ float verticalFace = 1.0 - smoothstep(0.20, 0.62, abs(N.y));
3818
+ float wave = 0.5 + 0.5 * sin(vPosWorld.x * 0.44 + vPosWorld.y * 0.28);
3819
+ float localX = vPosWorld.x;
3820
+ float localY = vPosWorld.y;
3821
+ float beamWest = exp(-pow((localX + 2.6 - localY * 0.22) * 0.44, 2.0));
3822
+ float beamEast = exp(-pow((localX - 2.6 + localY * 0.22) * 0.44, 2.0));
3823
+ float band = smoothstep(0.02, 0.16, 0.18 - abs(localY - 3.9));
3824
+ float focalHalo = exp(-pow(localX * 0.26, 2.0) - pow((localY - 3.9) * 0.38, 2.0));
3825
+ vec3 display = vColor * (0.74 + wave * 0.26)
3826
+ + vec3(0.10, 0.30, 0.52) * beamWest
3827
+ + vec3(0.34, 0.13, 0.42) * beamEast
3828
+ + vec3(0.62, 0.46, 0.20) * band * 0.28
3829
+ + vec3(0.20, 0.10, 0.34) * focalHalo;
3830
+ base = mix(base, display, poweredDisplay * verticalFace * 0.92);
3596
3831
  // Unfocused floors fall back toward the background rather than vanishing.
3597
3832
  base = mix(base, base * 0.45, vDim);
3598
3833
  fragColor = vec4(base, 1.0);
@@ -3625,6 +3860,7 @@ out vec3 vRing;
3625
3860
  out float vDim;
3626
3861
  out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3627
3862
  out float vPhysicalSeat;
3863
+ out float vPixelRadius;
3628
3864
  ${CHAIR_WEIGHT_GLSL}
3629
3865
  void main() {
3630
3866
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
@@ -3638,6 +3874,7 @@ void main() {
3638
3874
  // Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
3639
3875
  // unbounded growth is what merges neighbouring rows into one mass at range.
3640
3876
  float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
3877
+ vPixelRadius = r / max(depth * uPixelToWorld, 0.000001);
3641
3878
  // How much of the requested pixel floor the dot could actually afford. Below 1
3642
3879
  // it is losing legibility to distance, and the fragment stage dissolves it
3643
3880
  // toward the tier top rather than letting a sub-pixel dot alias and shimmer.
@@ -3670,10 +3907,13 @@ in vec3 vRing;
3670
3907
  in float vDim;
3671
3908
  in float vDotWeight;
3672
3909
  in float vPhysicalSeat;
3910
+ in float vPixelRadius;
3673
3911
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
3912
+ uniform float uSeatDetail; // 0 for venue/area overview; 1 in section/row/seat
3674
3913
  uniform vec3 uFadeColor;
3675
3914
  out vec4 fragColor;
3676
3915
  void main() {
3916
+ if (uSeatDetail < 0.5) discard;
3677
3917
  // Empty wheelchair provision is a square bay, never a round chair marker.
3678
3918
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
3679
3919
  if (d > 1.0) discard;
@@ -3692,6 +3932,20 @@ void main() {
3692
3932
  // aliasing; the tier cap underneath already carries the section's category
3693
3933
  // tint, so the block reads as coloured seating rather than empty concrete.
3694
3934
  alpha *= smoothstep(0.35, 1.0, vBudget);
3935
+ // Do not rasterise inventory states until a dot is large enough to read as a
3936
+ // seat rather than a sample in a dense screen-space pattern. This is based on
3937
+ // actual projected pixels, so it remains correct for unusually large or small
3938
+ // venues where a radius-normalised camera distance is misleading.
3939
+ alpha *= smoothstep(2.4, 3.2, vPixelRadius);
3940
+ // Inventory states are a section-level decision aid. At whole-venue/area
3941
+ // scale the category-tinted structural surface is the honest, stable signal;
3942
+ // thousands of enlarged status dots only form a moir\xE9 pattern. The journey
3943
+ // state enables them when the buyer enters a section, row or exact seat.
3944
+ // Whole-venue status detail is below a useful pixel size. Drawing every dot
3945
+ // there turns concentric rows into camera-dependent moir\xE9 streaks, especially
3946
+ // while zooming. uSeatFade already measures this exact LOD transition: let
3947
+ // the category-tinted tier carry the overview, then reveal individual live
3948
+ // availability as the buyer approaches a section.
3695
3949
  // Seats on an unfocused floor recede with their structure.
3696
3950
  c = mix(c, uFadeColor, vDim * 0.75);
3697
3951
  alpha *= mix(1.0, 0.30, vDim);
@@ -3736,9 +3990,15 @@ out float vDim;
3736
3990
  out float vOccupant; // 1 = this vertex belongs to a person, not to the chair
3737
3991
  out vec3 vOccupantTint;
3738
3992
  ${CHAIR_WEIGHT_GLSL}
3993
+ ${OCCUPANT_WEIGHT_GLSL}
3739
3994
  void main() {
3740
3995
  vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
3741
- float w = chairWeight(max(-anchor.z, 0.001));
3996
+ float viewDepth = -anchor.z;
3997
+ float depth = max(viewDepth, 0.001);
3998
+ // A stale near-field gather can contain seats now behind the camera after a
3999
+ // row\u2192overview move. Treating negative depth as 0.001 made those chairs full
4000
+ // size across the near plane, producing the long black zoom streaks.
4001
+ float w = viewDepth > 0.05 ? chairWeight(depth) : 0.0;
3742
4002
  // 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
3743
4003
  // chairs, but nobody gets a short one (people are the same height at every
3744
4004
  // seat pitch).
@@ -3763,6 +4023,10 @@ void main() {
3763
4023
  // identical mannequins: +/-6% height and +/-8% width off the hash.
3764
4024
  p.y *= 0.94 + 0.12 * iSeed;
3765
4025
  p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
4026
+ // People only appear once they are large enough to read as people. At
4027
+ // section/overview distance their tiny dark geometry aliases into radial
4028
+ // streaks; seat dots already communicate held/booked state there.
4029
+ p *= occupantWeight(depth);
3766
4030
  }
3767
4031
  }
3768
4032
  p.xz *= iRadius;
@@ -3801,7 +4065,7 @@ void main() {
3801
4065
  // not an audience. Hair/clothing for the body, a warm tone for the head,
3802
4066
  // both varied by the same per-person hash.
3803
4067
  float t = fract(iSeed * 3.71);
3804
- vec3 clothes = mix(vec3(0.13, 0.15, 0.20), vec3(0.34, 0.30, 0.36), t);
4068
+ vec3 clothes = mix(vec3(0.18, 0.22, 0.30), vec3(0.42, 0.29, 0.34), t);
3805
4069
  vec3 skin = mix(vec3(0.52, 0.38, 0.29), vec3(0.86, 0.70, 0.58), fract(iSeed * 11.3));
3806
4070
  vOccupantTint = (part > 3.5) ? skin : clothes;
3807
4071
  vPart = part;
@@ -3905,14 +4169,19 @@ uniform float uSeatRadius;
3905
4169
  uniform float uSeatScale;
3906
4170
  uniform float uMinPixels;
3907
4171
  uniform float uPixelToWorld;
4172
+ uniform float uChairFull;
4173
+ uniform float uChairNone;
3908
4174
  out vec2 vUv;
3909
4175
  out float vPhysicalSeat;
4176
+ out float vDotWeight;
3910
4177
  flat out vec3 vPick;
4178
+ ${CHAIR_WEIGHT_GLSL}
3911
4179
  void main() {
3912
4180
  int id = gl_InstanceID + 1; // 0 reserved for no-hit
3913
4181
  vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
3914
4182
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
3915
4183
  float depth = max(-mv.z, 0.001);
4184
+ vDotWeight = 1.0 - chairWeight(depth);
3916
4185
  float minR = uMinPixels * depth * uPixelToWorld;
3917
4186
  float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
3918
4187
  mv.xy += position * r;
@@ -3929,14 +4198,75 @@ var SEAT_PICK_FRAG = (
3929
4198
  precision highp float;
3930
4199
  in vec2 vUv;
3931
4200
  in float vPhysicalSeat;
4201
+ in float vDotWeight;
3932
4202
  flat in vec3 vPick;
3933
4203
  out vec4 fragColor;
3934
4204
  void main() {
4205
+ if (vDotWeight <= 0.02) discard; // invisible dot cannot steal a chair tap
3935
4206
  float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
3936
4207
  if (d > 1.0) discard; // hit-mask matches chair/bay shape
3937
4208
  fragColor = vec4(vPick, 1.0);
3938
4209
  }`
3939
4210
  );
4211
+ var CHAIR_PICK_VERT = (
4212
+ /* glsl */
4213
+ `#version 300 es
4214
+ precision highp float;
4215
+ in vec3 position;
4216
+ in float part;
4217
+ in vec3 iOffset;
4218
+ in float iRadius;
4219
+ in float iYaw;
4220
+ in float iSeed;
4221
+ in float iPhysicalSeat;
4222
+ in float iPickIndex;
4223
+ uniform mat4 modelViewMatrix;
4224
+ uniform mat4 projectionMatrix;
4225
+ uniform float uChairFull;
4226
+ uniform float uChairNone;
4227
+ uniform float uBackRake;
4228
+ uniform float uBackBase;
4229
+ flat out vec3 vPick;
4230
+ ${CHAIR_WEIGHT_GLSL}
4231
+ ${OCCUPANT_WEIGHT_GLSL}
4232
+ void main() {
4233
+ int id = int(iPickIndex + 0.5); // already global index + 1
4234
+ vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
4235
+ vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
4236
+ float viewDepth = -anchor.z;
4237
+ float depth = max(viewDepth, 0.001);
4238
+ float w = viewDepth > 0.05 ? chairWeight(depth) : 0.0;
4239
+ vec3 p = position;
4240
+ float occupant = step(2.5, part);
4241
+ float taken = step(0.0, iSeed);
4242
+ if (iPhysicalSeat < 0.5) {
4243
+ p = vec3(0.0);
4244
+ } else if (occupant > 0.5) {
4245
+ if (taken < 0.5) {
4246
+ p = vec3(0.0);
4247
+ } else {
4248
+ p.y *= 0.94 + 0.12 * iSeed;
4249
+ p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
4250
+ p *= occupantWeight(depth);
4251
+ }
4252
+ }
4253
+ p.xz *= iRadius;
4254
+ float rake = (part > 1.5 && part < 2.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
4255
+ p.z -= rake;
4256
+ p *= sqrt(w);
4257
+ float c = cos(iYaw), s = sin(iYaw);
4258
+ vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
4259
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(iOffset + rp, 1.0);
4260
+ }`
4261
+ );
4262
+ var CHAIR_PICK_FRAG = (
4263
+ /* glsl */
4264
+ `#version 300 es
4265
+ precision highp float;
4266
+ flat in vec3 vPick;
4267
+ out vec4 fragColor;
4268
+ void main() { fragColor = vec4(vPick, 1.0); }`
4269
+ );
3940
4270
  var PICK_DEPTH_VERT = (
3941
4271
  /* glsl */
3942
4272
  `#version 300 es
@@ -3967,7 +4297,25 @@ function createSeatPickProgram(gl) {
3967
4297
  uSeatRadius: { value: SEAT_DOT_RADIUS_M },
3968
4298
  uSeatScale: { value: 1 },
3969
4299
  uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
3970
- uPixelToWorld: { value: 2e-3 }
4300
+ uPixelToWorld: { value: 2e-3 },
4301
+ uChairFull: { value: CHAIR_FULL_M },
4302
+ uChairNone: { value: CHAIR_NONE_M }
4303
+ }
4304
+ });
4305
+ }
4306
+ function createChairPickProgram(gl) {
4307
+ return new Program(gl, {
4308
+ vertex: CHAIR_PICK_VERT,
4309
+ fragment: CHAIR_PICK_FRAG,
4310
+ transparent: false,
4311
+ depthTest: true,
4312
+ depthWrite: true,
4313
+ cullFace: false,
4314
+ uniforms: {
4315
+ uChairFull: { value: CHAIR_FULL_M },
4316
+ uChairNone: { value: CHAIR_NONE_M },
4317
+ uBackRake: { value: BACK_RAKE_SLOPE },
4318
+ uBackBase: { value: BACK_BASE_M }
3971
4319
  }
3972
4320
  });
3973
4321
  }
@@ -3996,7 +4344,8 @@ function createSolidProgram(gl) {
3996
4344
  // High and off-axis, in world space: reads as a house rig rather than a
3997
4345
  // headlamp welded to the camera.
3998
4346
  uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
3999
- uFocusFloor: { value: -1 }
4347
+ uFocusFloor: { value: -1 },
4348
+ uStructureDetail: { value: 0 }
4000
4349
  }
4001
4350
  });
4002
4351
  }
@@ -4014,6 +4363,7 @@ function createSeatProgram(gl) {
4014
4363
  uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
4015
4364
  uPixelToWorld: { value: 2e-3 },
4016
4365
  uSeatFade: { value: 0 },
4366
+ uSeatDetail: { value: 0 },
4017
4367
  uFocusFloor: { value: -1 },
4018
4368
  uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
4019
4369
  uChairFull: { value: CHAIR_FULL_M },
@@ -4078,7 +4428,8 @@ function buildGpuScene(gl, model) {
4078
4428
  position: { size: 3, data: model.solids.position },
4079
4429
  normal: { size: 3, data: model.solids.normal },
4080
4430
  color: { size: 3, data: model.solids.color },
4081
- floorIndex: { size: 1, data: model.solids.floor }
4431
+ floorIndex: { size: 1, data: model.solids.floor },
4432
+ materialIndex: { size: 1, data: model.solids.material }
4082
4433
  });
4083
4434
  const solidProg = createSolidProgram(gl);
4084
4435
  const solidMesh = new Mesh(gl, { geometry: solidGeo, program: solidProg });
@@ -4114,6 +4465,7 @@ function buildGpuScene(gl, model) {
4114
4465
  const cRing = new Float32Array(CAP * 3);
4115
4466
  const cFloor = new Float32Array(CAP);
4116
4467
  const cPhysicalSeat = new Float32Array(CAP);
4468
+ const cPickIndex = new Float32Array(CAP);
4117
4469
  const cSeed = new Float32Array(CAP);
4118
4470
  const chairGeo = new Geometry(gl, {
4119
4471
  position: { size: 3, data: chairBase.position },
@@ -4127,7 +4479,8 @@ function buildGpuScene(gl, model) {
4127
4479
  iRing: { size: 3, data: cRing, instanced: 1 },
4128
4480
  iFloor: { size: 1, data: cFloor, instanced: 1 },
4129
4481
  iPhysicalSeat: { size: 1, data: cPhysicalSeat, instanced: 1 },
4130
- iSeed: { size: 1, data: cSeed, instanced: 1 }
4482
+ iSeed: { size: 1, data: cSeed, instanced: 1 },
4483
+ iPickIndex: { size: 1, data: cPickIndex, instanced: 1 }
4131
4484
  });
4132
4485
  const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
4133
4486
  chairMesh.frustumCulled = false;
@@ -4137,6 +4490,7 @@ function buildGpuScene(gl, model) {
4137
4490
  const src = model.seats;
4138
4491
  for (let k = 0; k < nearCount; k++) {
4139
4492
  const i = nearIndices[k];
4493
+ cSeed[k] = seatOccupantSeed(src.iState[i], i);
4140
4494
  const useCategory = src.iCategory && src.iState[i] === 0;
4141
4495
  const c = useCategory ? null : stateColors[src.iState[i]] ?? stateColors[0];
4142
4496
  if (c) {
@@ -4151,6 +4505,7 @@ function buildGpuScene(gl, model) {
4151
4505
  }
4152
4506
  }
4153
4507
  chairGeo.attributes.iColor.needsUpdate = true;
4508
+ chairGeo.attributes.iSeed.needsUpdate = true;
4154
4509
  };
4155
4510
  const scene = {
4156
4511
  main,
@@ -4159,6 +4514,7 @@ function buildGpuScene(gl, model) {
4159
4514
  solidProgram: solidProg,
4160
4515
  chairProgram: chairProg,
4161
4516
  seatGeometry: seatGeo,
4517
+ chairGeometry: chairGeo,
4162
4518
  solidGeometry: solidGeo,
4163
4519
  drawCalls: 3,
4164
4520
  setNearSeats(indices, count) {
@@ -4184,7 +4540,7 @@ function buildGpuScene(gl, model) {
4184
4540
  cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
4185
4541
  cFloor[k] = src.iFloor[i];
4186
4542
  cPhysicalSeat[k] = src.iPhysicalSeat[i];
4187
- cSeed[k] = seatOccupantSeed(src.iState[i], i);
4543
+ cPickIndex[k] = i + 1;
4188
4544
  }
4189
4545
  writeChairColors();
4190
4546
  chairGeo.attributes.iOffset.needsUpdate = true;
@@ -4194,6 +4550,7 @@ function buildGpuScene(gl, model) {
4194
4550
  chairGeo.attributes.iFloor.needsUpdate = true;
4195
4551
  chairGeo.attributes.iPhysicalSeat.needsUpdate = true;
4196
4552
  chairGeo.attributes.iSeed.needsUpdate = true;
4553
+ chairGeo.attributes.iPickIndex.needsUpdate = true;
4197
4554
  chairGeo.instancedCount = n;
4198
4555
  if (!chairMesh.parent) chairMesh.setParent(main);
4199
4556
  scene.drawCalls = 4;
@@ -4273,8 +4630,9 @@ function pickPixelCoords(clientX, clientY, rect, dpr, bufferWidth, bufferHeight)
4273
4630
  // src/view3d/pick/pickPipeline.ts
4274
4631
  var SYNC_KEYS = ["uSeatRadius", "uSeatScale", "uMinPixels", "uPixelToWorld"];
4275
4632
  var PickPipeline = class {
4276
- constructor(renderer, seatGeo, solidGeo, seatCount) {
4633
+ constructor(renderer, seatGeo, chairGeo, solidGeo, seatCount) {
4277
4634
  this.seatScene = new Transform2();
4635
+ this.chairScene = new Transform2();
4278
4636
  this.solidScene = new Transform2();
4279
4637
  this.target = null;
4280
4638
  /** Display clear colour to restore after the pick pass (theme-dependent). */
@@ -4283,10 +4641,14 @@ var PickPipeline = class {
4283
4641
  this.gl = renderer.gl;
4284
4642
  this.maxIndex = seatCount;
4285
4643
  this.seatProg = createSeatPickProgram(this.gl);
4644
+ this.chairProg = createChairPickProgram(this.gl);
4286
4645
  this.depthProg = createPickDepthProgram(this.gl);
4287
4646
  const seatMesh = new Mesh2(this.gl, { geometry: seatGeo, program: this.seatProg });
4288
4647
  seatMesh.frustumCulled = false;
4289
4648
  seatMesh.setParent(this.seatScene);
4649
+ const chairMesh = new Mesh2(this.gl, { geometry: chairGeo, program: this.chairProg });
4650
+ chairMesh.frustumCulled = false;
4651
+ chairMesh.setParent(this.chairScene);
4290
4652
  const solidMesh = new Mesh2(this.gl, { geometry: solidGeo, program: this.depthProg });
4291
4653
  solidMesh.frustumCulled = false;
4292
4654
  solidMesh.setParent(this.solidScene);
@@ -4339,6 +4701,7 @@ var PickPipeline = class {
4339
4701
  gl.clearColor(0, 0, 0, 1);
4340
4702
  this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
4341
4703
  this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
4704
+ this.renderer.render({ scene: this.chairScene, camera, target, clear: false });
4342
4705
  gl.clearColor(br, bg, bb, 1);
4343
4706
  gl.disable(gl.SCISSOR_TEST);
4344
4707
  const buf = new Uint8Array(boxW * boxH * 4);
@@ -4350,6 +4713,7 @@ var PickPipeline = class {
4350
4713
  dispose() {
4351
4714
  this.destroyTarget();
4352
4715
  this.seatProg.remove();
4716
+ this.chairProg.remove();
4353
4717
  this.depthProg.remove();
4354
4718
  }
4355
4719
  };
@@ -4479,16 +4843,18 @@ var Cinematic = class {
4479
4843
  this.outQuat = new Quat();
4480
4844
  this.startTime = 0;
4481
4845
  this.duration = FLIGHT_DURATION_MS;
4846
+ this.endFov = FOV_END;
4482
4847
  this.resolveFn = null;
4483
4848
  this.camera = camera;
4484
4849
  }
4485
4850
  /** Begin (or retarget) a flight. Resolves when it lands or is cancelled. */
4486
- start(waypoints, startQuat, endQuat, duration = FLIGHT_DURATION_MS) {
4851
+ start(waypoints, startQuat, endQuat, duration = FLIGHT_DURATION_MS, endFov = FOV_END) {
4487
4852
  this.settle();
4488
4853
  this.waypoints = waypoints;
4489
4854
  this.startQuat.copy(startQuat);
4490
4855
  this.endQuat.copy(endQuat);
4491
4856
  this.duration = duration;
4857
+ this.endFov = endFov;
4492
4858
  this.startTime = performance.now();
4493
4859
  this.active = true;
4494
4860
  return new Promise((res) => {
@@ -4499,7 +4865,7 @@ var Cinematic = class {
4499
4865
  update(now2) {
4500
4866
  if (!this.active) return false;
4501
4867
  const u = Math.min(1, (now2 - this.startTime) / this.duration);
4502
- const { pos, fov, eased } = sampleFlight(this.waypoints, u);
4868
+ const { pos, fov, eased } = sampleFlight(this.waypoints, u, FOV_START, this.endFov);
4503
4869
  this.camera.position.set(pos[0], pos[1], pos[2]);
4504
4870
  this.outQuat.copy(this.startQuat).slerp(this.endQuat, orientationLeadT(eased, ORIENTATION_LEAD));
4505
4871
  this.camera.quaternion.copy(this.outQuat);
@@ -4526,9 +4892,10 @@ var Cinematic = class {
4526
4892
  // src/view3d/camera/seatViewPose.ts
4527
4893
  var SEAT_EYE_ABOVE_DECK_M = 1.02;
4528
4894
  var FOCAL_LOOK_HEIGHT_M = 1.5;
4529
- function seatViewPose(seatDeckWorld, focalPoint, floorBaseHeightM = 0) {
4895
+ function seatViewPose(seatDeckWorld, focalPoint, floorBaseHeightM = 0, authoredEyeHeightM) {
4896
+ const eyeY = Number.isFinite(authoredEyeHeightM) ? floorBaseHeightM + authoredEyeHeightM : seatDeckWorld[1] + SEAT_EYE_ABOVE_DECK_M;
4530
4897
  return {
4531
- eye: [seatDeckWorld[0], seatDeckWorld[1] + SEAT_EYE_ABOVE_DECK_M, seatDeckWorld[2]],
4898
+ eye: [seatDeckWorld[0], eyeY, seatDeckWorld[2]],
4532
4899
  focal: [
4533
4900
  focalPoint.x * M,
4534
4901
  floorBaseHeightM + FOCAL_LOOK_HEIGHT_M,
@@ -4791,7 +5158,7 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4791
5158
  root.appendChild(closeBtn);
4792
5159
  if (opts.disclosure) {
4793
5160
  const disclosure = document.createElement("div");
4794
- disclosure.textContent = `360\xB0 panorama \xB7 ${opts.disclosure}`;
5161
+ disclosure.textContent = `${opts.disclosurePrefix ?? "360\xB0 panorama"} \xB7 ${opts.disclosure}`;
4795
5162
  Object.assign(disclosure.style, {
4796
5163
  position: "absolute",
4797
5164
  top: "12px",
@@ -5134,7 +5501,13 @@ function mountVenue3D(container, input, opts = {}) {
5134
5501
  };
5135
5502
  const rebuildGpu = () => {
5136
5503
  gpu = buildGpuScene(glctx.gl, model);
5137
- pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
5504
+ pick = new PickPipeline(
5505
+ glctx.renderer,
5506
+ gpu.seatGeometry,
5507
+ gpu.chairGeometry,
5508
+ gpu.solidGeometry,
5509
+ model.seats.count
5510
+ );
5138
5511
  glctx.setClearColor(model.theme.background.top);
5139
5512
  pick.setRestoreClear(model.theme.background.top);
5140
5513
  gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
@@ -5147,8 +5520,22 @@ function mountVenue3D(container, input, opts = {}) {
5147
5520
  const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
5148
5521
  let lastGatherX = Infinity;
5149
5522
  let lastGatherZ = Infinity;
5150
- const updateNearField = () => {
5523
+ let nearFieldDetailEnabled = false;
5524
+ let cameraDetailVisible = false;
5525
+ let panoramaSphereVisible = false;
5526
+ const setNearFieldDetailEnabled = (enabled) => {
5527
+ nearFieldDetailEnabled = enabled;
5528
+ if (!enabled) cameraDetailVisible = false;
5529
+ lastGatherX = Infinity;
5530
+ lastGatherZ = Infinity;
5531
+ if (!enabled && gpu?.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
5532
+ };
5533
+ const updateNearField = (detailVisible) => {
5151
5534
  if (!gpu) return;
5535
+ if (!detailVisible) {
5536
+ if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
5537
+ return;
5538
+ }
5152
5539
  const cam = orbit.camera.position;
5153
5540
  const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
5154
5541
  if (moved2 < CHAIR_REBUILD_M) return;
@@ -5191,7 +5578,8 @@ function mountVenue3D(container, input, opts = {}) {
5191
5578
  const dz = model.focalWorld[2] - model.bounds.center[2];
5192
5579
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5193
5580
  })();
5194
- orbit.frame(model.bounds, true, stageAzimuth, opts.portraitOverviewCrop === true);
5581
+ const reduceMotionOnMount = typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
5582
+ orbit.frame(model.bounds, !reduceMotionOnMount, stageAzimuth, opts.portraitOverviewCrop === true);
5195
5583
  const cinematic = new Cinematic(orbit.camera);
5196
5584
  const restoreContainerPosition = establishPositioningContext(container);
5197
5585
  const labelOverlay = new LabelOverlay(container, {
@@ -5211,12 +5599,16 @@ function mountVenue3D(container, input, opts = {}) {
5211
5599
  const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
5212
5600
  const seatIds = new Set(sectionSeats.map((seat) => seat.id));
5213
5601
  const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
5214
- 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))));
5215
- if (model.seatCount <= 6e3) {
5602
+ const selectedIds = new Set(selection.keys());
5603
+ const selectedInSection = new Set(sectionSeats.filter((seat) => selectedIds.has(seat.id)).map((seat) => seat.id));
5604
+ 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)))));
5605
+ if (model.seatCount <= 6e3 && selectedInSection.size === 0) {
5216
5606
  labelOverlay.setLabels(focusedBase);
5217
5607
  return;
5218
5608
  }
5219
- const labels = sectionSeats.flatMap((seat) => {
5609
+ const labelSeats = selectedInSection.size ? sectionSeats.filter((seat) => selectedInSection.has(seat.id)) : sectionSeats;
5610
+ const generatedIds = new Set(labelSeats.map((seat) => `seat:${seat.id}`));
5611
+ const labels = labelSeats.flatMap((seat) => {
5220
5612
  const index = model.seats.idToIndex.get(seat.id);
5221
5613
  if (index === void 0) return [];
5222
5614
  const offset = index * 3;
@@ -5226,12 +5618,18 @@ function mountVenue3D(container, input, opts = {}) {
5226
5618
  text: seat.displayLabel || seat.label,
5227
5619
  anchor: [
5228
5620
  model.seats.iPosition[offset],
5229
- model.seats.iPosition[offset + 1] + 0.55,
5621
+ // Put the active identity on the chair back. A deck-level label can
5622
+ // sit a full row below a foreground chair in perspective, while DOM
5623
+ // labels for rows behind remain visible through the WebGL geometry.
5624
+ model.seats.iPosition[offset + 1] + (selectedIds.has(seat.id) ? 1.05 : 0.55),
5230
5625
  model.seats.iPosition[offset + 2]
5231
5626
  ]
5232
5627
  }];
5233
5628
  });
5234
- labelOverlay.setLabels([...focusedBase, ...labels]);
5629
+ labelOverlay.setLabels([
5630
+ ...focusedBase.filter((label) => !generatedIds.has(label.id)),
5631
+ ...labels
5632
+ ]);
5235
5633
  };
5236
5634
  rebuildGpu();
5237
5635
  const loop = new RenderLoop(() => {
@@ -5239,12 +5637,25 @@ function mountVenue3D(container, input, opts = {}) {
5239
5637
  const flying = cinematic.active;
5240
5638
  const moving = flying ? cinematic.update(performance.now()) : orbit.update();
5241
5639
  const lod = computeSeatLod(orbit.currentDistance, model.bounds.radius);
5640
+ const clippingBounds = panoramaSphereVisible ? { center: [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z], radius: 520 } : model.renderBounds;
5641
+ orbit.updateClipping(clippingBounds);
5642
+ const cameraDx = orbit.camera.position.x - model.renderBounds.center[0];
5643
+ const cameraDy = orbit.camera.position.y - model.renderBounds.center[1];
5644
+ const cameraDz = orbit.camera.position.z - model.renderBounds.center[2];
5645
+ const cameraVenueDistance = Math.hypot(cameraDx, cameraDy, cameraDz);
5646
+ const detailEnter = model.renderBounds.radius * 1.55;
5647
+ const detailExit = model.renderBounds.radius * 1.8;
5648
+ if (!nearFieldDetailEnabled) cameraDetailVisible = false;
5649
+ else if (cameraDetailVisible) cameraDetailVisible = cameraVenueDistance <= detailExit;
5650
+ else cameraDetailVisible = cameraVenueDistance <= detailEnter;
5242
5651
  const u = gpu.seatProgram.uniforms;
5243
5652
  u.uSeatScale.value = lod.scale;
5244
5653
  u.uSeatFade.value = lod.fade;
5654
+ u.uSeatDetail.value = cameraDetailVisible ? 1 : 0;
5655
+ gpu.solidProgram.uniforms.uStructureDetail.value = cameraDetailVisible ? 1 : 0;
5245
5656
  u.uMinPixels.value = lod.minPixels;
5246
5657
  u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
5247
- updateNearField();
5658
+ updateNearField(cameraDetailVisible);
5248
5659
  glctx.renderer.render({ scene: gpu.background, clear: true });
5249
5660
  glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
5250
5661
  labelOverlay.update(
@@ -5268,6 +5679,8 @@ function mountVenue3D(container, input, opts = {}) {
5268
5679
  };
5269
5680
  const { updates, next } = diffSelection(selection, ids, baseStateIndex);
5270
5681
  selection = next;
5682
+ labelOverlay.setSelectedSeatIds(selection.keys());
5683
+ if (focusedSectionId) showSectionSeatLabels(focusedSectionId);
5271
5684
  if (updates.length) {
5272
5685
  const runs = applySeatStates(model.seats, updates);
5273
5686
  if (gpu) gpu.uploadSeatStateRuns(runs);
@@ -5306,12 +5719,12 @@ function mountVenue3D(container, input, opts = {}) {
5306
5719
  model.seats.iPosition[idx * 3 + 1],
5307
5720
  model.seats.iPosition[idx * 3 + 2]
5308
5721
  ];
5309
- return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0);
5722
+ return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0, seat?.eyeHeightM);
5310
5723
  };
5311
- const placeCameraFinal = (finalPos, focal) => {
5724
+ const placeCameraFinal = (finalPos, focal, fov = FOV_END) => {
5312
5725
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
5313
5726
  orbit.camera.lookAt(new Vec33(focal[0], focal[1], focal[2]));
5314
- orbit.camera.fov = FOV_END;
5727
+ orbit.camera.fov = fov;
5315
5728
  orbit.camera.updateProjectionMatrix();
5316
5729
  };
5317
5730
  const isNarrow = () => (container.clientWidth || glctx.canvas.clientWidth) <= 520;
@@ -5409,7 +5822,8 @@ function mountVenue3D(container, input, opts = {}) {
5409
5822
  });
5410
5823
  }
5411
5824
  if (opts.getSeatView) {
5412
- addButton(retry ? "\u21BB Retry 360\xB0 panorama" : "\u25C9 Open 360\xB0 panorama", `${retry ? "Retry" : "Open"} the 360\xB0 panorama from seat ${seatLabel}`, () => {
5825
+ const sourceAction = opts.seatViewActionLabel?.(seatId) ?? "Open 360\xB0 panorama";
5826
+ addButton(retry ? `\u21BB Retry ${sourceAction}` : `\u25C9 ${sourceAction}`, `${retry ? "Retry" : sourceAction} from seat ${seatLabel}`, () => {
5413
5827
  if (disposed || gen !== flightGen) {
5414
5828
  removeArriveChip();
5415
5829
  return;
@@ -5431,121 +5845,158 @@ function mountVenue3D(container, input, opts = {}) {
5431
5845
  return controls;
5432
5846
  };
5433
5847
  const navigationModeChip = document.createElement("div");
5434
- navigationModeChip.setAttribute("role", "group");
5435
- navigationModeChip.setAttribute("aria-label", "3D navigation mode");
5848
+ let seatViewLocked = false;
5849
+ navigationModeChip.className = "sl-3d-navigation-controls";
5436
5850
  Object.assign(navigationModeChip.style, {
5437
5851
  position: "absolute",
5438
- left: "14px",
5439
- top: "62px",
5852
+ right: "14px",
5853
+ bottom: "14px",
5440
5854
  display: "flex",
5441
- gap: "3px",
5442
- padding: "3px",
5443
- borderRadius: "999px",
5444
5855
  zIndex: "4",
5445
- background: "rgba(12,18,32,0.72)",
5446
- border: "1px solid rgba(150,165,205,0.35)",
5447
- backdropFilter: "blur(6px)"
5856
+ flexDirection: "column",
5857
+ alignItems: "flex-end",
5858
+ gap: "7px"
5859
+ });
5860
+ const modeControls = document.createElement("div");
5861
+ modeControls.setAttribute("role", "group");
5862
+ modeControls.setAttribute("aria-label", "3D drag mode");
5863
+ Object.assign(modeControls.style, {
5864
+ display: "flex",
5865
+ flexDirection: "column",
5866
+ gap: "6px",
5867
+ alignItems: "flex-end"
5868
+ });
5869
+ const utilityControls = document.createElement("div");
5870
+ utilityControls.setAttribute("role", "group");
5871
+ utilityControls.setAttribute("aria-label", "3D view controls");
5872
+ Object.assign(utilityControls.style, {
5873
+ display: "flex",
5874
+ flexDirection: "column",
5875
+ gap: "6px",
5876
+ alignItems: "flex-end"
5448
5877
  });
5449
5878
  const rotateModeButton = document.createElement("button");
5450
5879
  rotateModeButton.type = "button";
5451
- rotateModeButton.textContent = "\u21BB Rotate";
5880
+ rotateModeButton.textContent = "\u21BB";
5452
5881
  rotateModeButton.setAttribute("aria-label", "Drag to rotate the 3D venue");
5882
+ rotateModeButton.dataset.tooltip = "Rotate venue";
5453
5883
  const moveModeButton = document.createElement("button");
5454
5884
  moveModeButton.type = "button";
5455
- moveModeButton.textContent = "\u2725 Move";
5885
+ moveModeButton.textContent = "\u2725";
5456
5886
  moveModeButton.setAttribute("aria-label", "Drag to move the 3D venue left, right, up or down");
5887
+ moveModeButton.dataset.tooltip = "Move venue";
5457
5888
  const zoomOutButton = document.createElement("button");
5458
5889
  zoomOutButton.type = "button";
5459
5890
  zoomOutButton.textContent = "\u2212";
5460
5891
  zoomOutButton.setAttribute("aria-label", "Zoom out of the 3D venue");
5461
- zoomOutButton.title = "Zoom out";
5892
+ zoomOutButton.dataset.tooltip = "Zoom out";
5462
5893
  const zoomInButton = document.createElement("button");
5463
5894
  zoomInButton.type = "button";
5464
5895
  zoomInButton.textContent = "+";
5465
5896
  zoomInButton.setAttribute("aria-label", "Zoom into the 3D venue");
5466
- zoomInButton.title = "Zoom in";
5897
+ zoomInButton.dataset.tooltip = "Zoom in";
5467
5898
  for (const button of [rotateModeButton, moveModeButton, zoomOutButton, zoomInButton]) {
5899
+ button.className = "sl-3d-icon-control";
5468
5900
  Object.assign(button.style, {
5469
- minHeight: "34px",
5470
- padding: "6px 10px",
5471
- border: "0",
5901
+ width: "44px",
5902
+ minWidth: "44px",
5903
+ minHeight: "44px",
5904
+ padding: "9px",
5905
+ border: "1px solid rgba(150,165,205,0.35)",
5472
5906
  borderRadius: "999px",
5473
5907
  color: "#c9d4ea",
5474
- background: "transparent",
5475
- font: "600 11px/1 inherit",
5908
+ background: "rgba(12,18,32,0.82)",
5909
+ font: "600 17px/1 inherit",
5910
+ backdropFilter: "blur(6px)",
5476
5911
  cursor: "pointer",
5477
5912
  whiteSpace: "nowrap"
5478
5913
  });
5479
- if (button === zoomOutButton || button === zoomInButton) {
5480
- button.style.minWidth = "34px";
5481
- button.style.padding = "6px";
5482
- button.style.fontSize = "17px";
5483
- }
5484
- navigationModeChip.appendChild(button);
5485
5914
  }
5915
+ modeControls.append(rotateModeButton, moveModeButton);
5916
+ utilityControls.append(zoomInButton, zoomOutButton);
5486
5917
  const setNavigationMode = (mode) => {
5487
5918
  orbit.setPrimaryDragMode(mode);
5488
5919
  const rotateActive = mode === "orbit";
5489
5920
  rotateModeButton.setAttribute("aria-pressed", String(rotateActive));
5490
5921
  moveModeButton.setAttribute("aria-pressed", String(!rotateActive));
5491
- rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.48)" : "transparent";
5492
- moveModeButton.style.background = rotateActive ? "transparent" : "rgba(96,110,150,0.48)";
5922
+ rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.68)" : "rgba(12,18,32,0.82)";
5923
+ moveModeButton.style.background = rotateActive ? "rgba(12,18,32,0.82)" : "rgba(96,110,150,0.68)";
5493
5924
  navigationModeChip.title = rotateActive ? "Drag to rotate \xB7 Shift-drag to move" : "Drag to move \xB7 Shift-drag to rotate";
5494
5925
  };
5495
5926
  rotateModeButton.addEventListener("click", () => setNavigationMode("orbit"));
5496
5927
  moveModeButton.addEventListener("click", () => setNavigationMode("pan"));
5497
5928
  zoomOutButton.addEventListener("click", () => orbit.zoomBy(1.22));
5498
5929
  zoomInButton.addEventListener("click", () => orbit.zoomBy(0.82));
5930
+ navigationModeChip.append(modeControls, utilityControls);
5499
5931
  setNavigationMode("orbit");
5500
5932
  container.appendChild(navigationModeChip);
5501
5933
  const layoutNavigationChip = () => {
5502
- navigationModeChip.style.display = isNarrow() ? "none" : "flex";
5934
+ const narrow = isNarrow();
5935
+ navigationModeChip.style.display = seatViewLocked ? "none" : "flex";
5936
+ navigationModeChip.style.right = narrow ? "12px" : "14px";
5937
+ navigationModeChip.style.bottom = narrow ? "calc(env(safe-area-inset-bottom, 0px) + 76px)" : "14px";
5938
+ modeControls.style.display = narrow ? "none" : "flex";
5939
+ zoomInButton.style.display = narrow ? "none" : "";
5940
+ zoomOutButton.style.display = narrow ? "none" : "";
5503
5941
  };
5504
5942
  layoutNavigationChip();
5943
+ const setSeatViewLocked = (locked) => {
5944
+ seatViewLocked = locked;
5945
+ orbit.setInteractionEnabled(!locked);
5946
+ layoutNavigationChip();
5947
+ };
5505
5948
  const overviewChip = document.createElement("button");
5949
+ overviewChip.className = "sl-3d-overview-control sl-3d-icon-control";
5506
5950
  overviewChip.type = "button";
5507
- overviewChip.textContent = "\u2302 Overview";
5508
- overviewChip.setAttribute("aria-label", "Return to the venue overview");
5951
+ overviewChip.textContent = "\u2302";
5952
+ overviewChip.setAttribute("aria-label", "Fit the whole venue in view");
5953
+ overviewChip.dataset.tooltip = "Fit venue";
5509
5954
  Object.assign(overviewChip.style, {
5510
- position: "absolute",
5511
- right: "14px",
5512
- bottom: "18px",
5513
- minHeight: "40px",
5514
- padding: "8px 14px",
5955
+ width: "44px",
5956
+ minWidth: "44px",
5957
+ minHeight: "44px",
5958
+ padding: "9px",
5515
5959
  borderRadius: "999px",
5516
5960
  background: "rgba(12,18,32,0.72)",
5517
5961
  color: "#c9d4ea",
5518
5962
  border: "1px solid rgba(150,165,205,0.35)",
5519
5963
  backdropFilter: "blur(6px)",
5520
- font: "600 12.5px/1 inherit",
5964
+ font: "600 11px/1 inherit",
5521
5965
  cursor: "pointer",
5522
- zIndex: "4"
5966
+ whiteSpace: "nowrap"
5523
5967
  });
5968
+ const layoutOverviewChip = () => {
5969
+ overviewChip.textContent = "\u2302";
5970
+ };
5971
+ layoutOverviewChip();
5524
5972
  const focusOverview = () => {
5525
5973
  if (disposed) return;
5974
+ opts.onViewTargetChange?.(null);
5975
+ opts.onSectionFocusChange?.(null);
5526
5976
  if (panorama) {
5527
5977
  panorama.dispose();
5528
5978
  panorama = null;
5529
5979
  analytics.panoramaClosed();
5530
5980
  }
5981
+ panoramaSphereVisible = false;
5531
5982
  panoramaLoadAbort?.abort();
5532
5983
  panoramaLoadAbort = null;
5533
5984
  restorePanoramaLayer();
5534
5985
  overviewChip.style.display = "";
5535
5986
  labelOverlay.setVisible(true);
5536
5987
  frozen = false;
5988
+ setNearFieldDetailEnabled(false);
5989
+ setSeatViewLocked(false);
5537
5990
  cancelFlight();
5538
5991
  removeArriveChip();
5539
5992
  setNavigationMode("orbit");
5540
5993
  if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
5541
5994
  else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
5542
- opts.onViewTargetChange?.(null);
5543
5995
  showSectionSeatLabels(null);
5544
- opts.onSectionFocusChange?.(null);
5545
5996
  loop.requestRender();
5546
5997
  };
5547
5998
  overviewChip.addEventListener("click", focusOverview);
5548
- container.appendChild(overviewChip);
5999
+ utilityControls.appendChild(overviewChip);
5549
6000
  const openPanorama = async (seatId, fadeMs, gen) => {
5550
6001
  panoramaLoadAbort?.abort();
5551
6002
  const loadAbort = new AbortController();
@@ -5591,9 +6042,10 @@ function mountVenue3D(container, input, opts = {}) {
5591
6042
  loadAbort.abort();
5592
6043
  if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5593
6044
  panorama = null;
6045
+ panoramaSphereVisible = false;
5594
6046
  restorePanoramaLayer();
5595
6047
  overviewChip.style.display = "";
5596
- labelOverlay.setVisible(true);
6048
+ labelOverlay.setVisible(!opts.arriveAtSeatEye);
5597
6049
  frozen = false;
5598
6050
  analytics.panoramaClosed();
5599
6051
  orbit.resumeAfterFlight(seatPose.focal);
@@ -5604,6 +6056,7 @@ function mountVenue3D(container, input, opts = {}) {
5604
6056
  const sceneMode = view.generated === true;
5605
6057
  const disclosure = seatViewDisclosure(view);
5606
6058
  raisePanoramaLayer();
6059
+ panoramaSphereVisible = !sceneMode;
5607
6060
  overviewChip.style.display = "none";
5608
6061
  const effectiveFadeMs = reducedMotion() ? 0 : fadeMs;
5609
6062
  const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
@@ -5613,9 +6066,17 @@ function mountVenue3D(container, input, opts = {}) {
5613
6066
  requestRender: () => loop.requestRender(),
5614
6067
  cameraOriginWorld: sceneMode ? seatPose.eye : void 0,
5615
6068
  focalWorld: seatPose.focal
5616
- }, { fadeMs: effectiveFadeMs, seatLabel, disclosure, onClose, signal: loadAbort.signal }) : null;
6069
+ }, {
6070
+ fadeMs: effectiveFadeMs,
6071
+ seatLabel,
6072
+ disclosure,
6073
+ disclosurePrefix: sceneMode ? "Live seat-eye view" : "360\xB0 panorama",
6074
+ onClose,
6075
+ signal: loadAbort.signal
6076
+ }) : null;
5617
6077
  if (disposed || gen !== flightGen) {
5618
6078
  loadAbort.abort();
6079
+ panoramaSphereVisible = false;
5619
6080
  if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5620
6081
  spherical?.dispose();
5621
6082
  overviewChip.style.display = "";
@@ -5628,7 +6089,21 @@ function mountVenue3D(container, input, opts = {}) {
5628
6089
  frozen = false;
5629
6090
  loop.requestRender();
5630
6091
  panorama = spherical;
6092
+ } else if (sceneMode) {
6093
+ restorePanoramaLayer();
6094
+ overviewChip.style.display = "";
6095
+ frozen = false;
6096
+ orbit.resumeAfterFlight(seatPose.focal);
6097
+ loop.requestRender();
6098
+ panorama = null;
6099
+ panoramaSphereVisible = false;
6100
+ analytics.panoramaFailed(seatId, "mount");
6101
+ const controls = showArriveChip(seatId, flightGen, true);
6102
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
6103
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6104
+ return;
5631
6105
  } else {
6106
+ panoramaSphereVisible = false;
5632
6107
  analytics.panoramaFallback(seatId);
5633
6108
  panorama = mountPanorama(container, view, {
5634
6109
  fadeMs: effectiveFadeMs,
@@ -5653,36 +6128,45 @@ function mountVenue3D(container, input, opts = {}) {
5653
6128
  if (disposed || !gpu) return Promise.resolve();
5654
6129
  const idx = model.seats.idToIndex.get(seatId);
5655
6130
  if (idx === void 0) return Promise.resolve();
6131
+ setNearFieldDetailEnabled(true);
5656
6132
  opts.onViewTargetChange?.(seatId);
5657
6133
  if (panorama) {
5658
6134
  panorama.dispose();
5659
6135
  panorama = null;
6136
+ panoramaSphereVisible = false;
5660
6137
  overviewChip.style.display = "";
5661
6138
  restorePanoramaLayer();
5662
6139
  }
5663
6140
  panoramaLoadAbort?.abort();
5664
6141
  panoramaLoadAbort = null;
5665
6142
  frozen = false;
6143
+ setSeatViewLocked(false);
5666
6144
  const gen = ++flightGen;
5667
6145
  removeArriveChip();
5668
6146
  const { eye: seatEye, focal } = resolvedSeatViewPose(seatId, idx);
5669
6147
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
5670
- const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
6148
+ const flight = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
6149
+ const finalPos = opts.arriveAtSeatEye ? seatEye : flight.finalPos;
6150
+ const arrivalFov = opts.arriveAtSeatEye ? container.clientWidth / Math.max(1, container.clientHeight) < 0.7 ? 90 : 65 : FOV_END;
6151
+ const waypoints = [flight.waypoints[0], flight.waypoints[1], finalPos];
6152
+ labelOverlay.setVisible(false);
5671
6153
  if (reducedMotion()) {
5672
- placeCameraFinal(finalPos, focal);
6154
+ placeCameraFinal(finalPos, focal, arrivalFov);
5673
6155
  loop.requestRender();
5674
6156
  analytics.cinematicSkipped();
5675
6157
  if (!disposed) orbit.syncFromCamera();
6158
+ setSeatViewLocked(opts.arriveAtSeatEye === true);
5676
6159
  showArriveChip(seatId, gen);
5677
6160
  return Promise.resolve();
5678
6161
  }
5679
6162
  const startQuat = new Quat2().copy(orbit.camera.quaternion);
5680
6163
  const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
5681
6164
  loop.requestRender();
5682
- return cinematic.start(waypoints, startQuat, endQuat).then(() => {
6165
+ return cinematic.start(waypoints, startQuat, endQuat, FLIGHT_DURATION_MS, arrivalFov).then(() => {
5683
6166
  if (disposed || gen !== flightGen) return;
5684
6167
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
5685
6168
  orbit.resumeAfterFlight(focal);
6169
+ setSeatViewLocked(opts.arriveAtSeatEye === true);
5686
6170
  loop.requestRender();
5687
6171
  showArriveChip(seatId, gen);
5688
6172
  });
@@ -5690,6 +6174,8 @@ function mountVenue3D(container, input, opts = {}) {
5690
6174
  const focusSectionCamera = (sectionId) => {
5691
6175
  const sec = model.sections.find((candidate) => candidate.id === sectionId);
5692
6176
  if (!sec || sec.seatCount === 0) return false;
6177
+ setNearFieldDetailEnabled(true);
6178
+ setSeatViewLocked(false);
5693
6179
  cinematic.cancel();
5694
6180
  removeArriveChip();
5695
6181
  setNavigationMode("pan");
@@ -5704,6 +6190,7 @@ function mountVenue3D(container, input, opts = {}) {
5704
6190
  };
5705
6191
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
5706
6192
  const onDown = (e) => {
6193
+ if (seatViewLocked) return;
5707
6194
  if (downId !== -1) return;
5708
6195
  downId = e.pointerId;
5709
6196
  downX = e.clientX;
@@ -5714,6 +6201,7 @@ function mountVenue3D(container, input, opts = {}) {
5714
6201
  if (cinematic.active) {
5715
6202
  analytics.cinematicCancelled();
5716
6203
  cancelFlight();
6204
+ opts.onViewTargetChange?.(null);
5717
6205
  }
5718
6206
  };
5719
6207
  const onMove = (e) => {
@@ -5764,6 +6252,22 @@ function mountVenue3D(container, input, opts = {}) {
5764
6252
  }
5765
6253
  return bestIndex;
5766
6254
  };
6255
+ const pickVisibleGeometry = (clientX, clientY) => {
6256
+ if (!gpu || !pick) return -1;
6257
+ pick.syncFromSeatProgram(gpu.seatProgram);
6258
+ const rect = glctx.canvas.getBoundingClientRect();
6259
+ const dpr = glctx.renderer.dpr;
6260
+ const { x, y } = pickPixelCoords(
6261
+ clientX,
6262
+ clientY,
6263
+ rect,
6264
+ dpr,
6265
+ glctx.gl.drawingBufferWidth,
6266
+ glctx.gl.drawingBufferHeight
6267
+ );
6268
+ const radius = Math.max(2, Math.round(8 * dpr));
6269
+ return pick.pick(orbit.camera, x, y, radius);
6270
+ };
5767
6271
  const onUp = (e) => {
5768
6272
  if (e.pointerId !== downId) return;
5769
6273
  const isTap = !moved && performance.now() - downT < TAP_MS;
@@ -5773,7 +6277,15 @@ function mountVenue3D(container, input, opts = {}) {
5773
6277
  return;
5774
6278
  }
5775
6279
  if (!isTap || !gpu || !pick) return;
5776
- let idx = focusedSectionId ? pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, true) : -1;
6280
+ let idx = pickVisibleGeometry(e.clientX, e.clientY);
6281
+ if (focusedSectionId && idx >= 0) {
6282
+ const visibleSeatId = seatIdByIndex[idx];
6283
+ const visibleSectionId = visibleSeatId ? sectionIdBySeatId.get(visibleSeatId) : void 0;
6284
+ if (visibleSectionId && visibleSectionId !== focusedSectionId && focusSectionCamera(visibleSectionId)) return;
6285
+ }
6286
+ if (focusedSectionId && idx < 0) {
6287
+ idx = pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, false);
6288
+ }
5777
6289
  if (focusedSectionId && idx < 0) {
5778
6290
  const sectionIndex = pickNearestProjectedSeat(e.clientX, e.clientY, null, 72, false);
5779
6291
  const sectionSeatId = sectionIndex >= 0 ? seatIdByIndex[sectionIndex] : void 0;
@@ -5781,24 +6293,30 @@ function mountVenue3D(container, input, opts = {}) {
5781
6293
  if (nextSectionId && nextSectionId !== focusedSectionId && focusSectionCamera(nextSectionId)) return;
5782
6294
  }
5783
6295
  if (!focusedSectionId) {
5784
- pick.syncFromSeatProgram(gpu.seatProgram);
5785
- const rect = glctx.canvas.getBoundingClientRect();
5786
- const dpr = glctx.renderer.dpr;
5787
- const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
5788
- const radius = Math.max(2, Math.round(8 * dpr));
5789
- idx = pick.pick(orbit.camera, x, y, radius);
5790
6296
  if (idx < 0) idx = pickNearestProjectedSeat(e.clientX, e.clientY, null, 42, false);
5791
6297
  const overviewSeatId = idx >= 0 ? seatIdByIndex[idx] : void 0;
5792
6298
  const sectionId = overviewSeatId ? sectionIdBySeatId.get(overviewSeatId) : void 0;
5793
6299
  if (sectionId && focusSectionCamera(sectionId)) return;
5794
6300
  }
5795
6301
  if (idx < 0 || idx >= seatIdByIndex.length) {
5796
- if (selection.size) setSelection([]);
6302
+ if (selection.size) {
6303
+ setSelection([]);
6304
+ opts.onViewTargetChange?.(null);
6305
+ }
5797
6306
  return;
5798
6307
  }
5799
6308
  const seatId = seatIdByIndex[idx];
5800
- if (selection.has(seatId) && selection.size === 1) setSelection([]);
5801
- else setSelection([seatId]);
6309
+ const seatState = SEAT_STATES[Math.round(model.seats.iState[idx] ?? 0)] ?? "available";
6310
+ if (seatState !== "available" && seatState !== "selected") {
6311
+ opts.onSeatInspect?.(seatId, seatState);
6312
+ return;
6313
+ }
6314
+ if (selection.has(seatId) && selection.size === 1) {
6315
+ setSelection([]);
6316
+ opts.onViewTargetChange?.(null);
6317
+ return;
6318
+ }
6319
+ setSelection([seatId]);
5802
6320
  ensureSeatView(seatId);
5803
6321
  analytics.seatPicked(seatId, sectionIdBySeatId.get(seatId));
5804
6322
  opts.onSeatPick?.(seatId);
@@ -5823,6 +6341,7 @@ function mountVenue3D(container, input, opts = {}) {
5823
6341
  orbit.setAspect(width / Math.max(1, height));
5824
6342
  layoutArriveChip();
5825
6343
  layoutNavigationChip();
6344
+ layoutOverviewChip();
5826
6345
  loop.requestRender();
5827
6346
  },
5828
6347
  stats() {
@@ -5841,6 +6360,8 @@ function mountVenue3D(container, input, opts = {}) {
5841
6360
  focusFloor(index) {
5842
6361
  if (index !== null && !model.floors.some((f) => f.index === index)) return false;
5843
6362
  const value = index ?? -1;
6363
+ setNearFieldDetailEnabled(false);
6364
+ setSeatViewLocked(false);
5844
6365
  if (gpu) {
5845
6366
  gpu.seatProgram.uniforms.uFocusFloor.value = value;
5846
6367
  gpu.solidProgram.uniforms.uFocusFloor.value = value;
@@ -5864,6 +6385,8 @@ function mountVenue3D(container, input, opts = {}) {
5864
6385
  focusZone(zoneId) {
5865
6386
  const zone = model.zones.find((z) => z.id === zoneId);
5866
6387
  if (!zone || zone.seatCount === 0) return false;
6388
+ setNearFieldDetailEnabled(false);
6389
+ setSeatViewLocked(false);
5867
6390
  cinematic.cancel();
5868
6391
  setNavigationMode("orbit");
5869
6392
  showSectionSeatLabels(null);
@@ -5890,6 +6413,8 @@ function mountVenue3D(container, input, opts = {}) {
5890
6413
  focusRow(rowId) {
5891
6414
  const row = model.rows.find((candidate) => candidate.id === rowId);
5892
6415
  if (!row || row.seatCount === 0) return false;
6416
+ setNearFieldDetailEnabled(true);
6417
+ setSeatViewLocked(false);
5893
6418
  cinematic.cancel();
5894
6419
  setNavigationMode("pan");
5895
6420
  if (row.sectionId) {