@seatlayer/core 0.32.0 → 0.34.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.
@@ -28,13 +28,36 @@ var SEAT_STATE_COLORS = {
28
28
  selected: [0.24, 0.74, 1],
29
29
  dimmed: [0.28, 0.32, 0.37]
30
30
  };
31
+ function upholsteryTone(rgb) {
32
+ const luma = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
33
+ const SAT = 0.42;
34
+ const VALUE = 0.62;
35
+ return [
36
+ (luma + (rgb[0] - luma) * SAT) * VALUE,
37
+ (luma + (rgb[1] - luma) * SAT) * VALUE,
38
+ (luma + (rgb[2] - luma) * SAT) * VALUE
39
+ ];
40
+ }
31
41
  var STRUCTURE = {
32
42
  ground: [0.07, 0.085, 0.11],
33
43
  tierTop: [0.24, 0.28, 0.34],
34
44
  tierWall: [0.17, 0.2, 0.25],
35
- stageTop: [0.42, 0.36, 0.26],
36
- // warm, slightly emissive read
37
- stageWall: [0.26, 0.22, 0.16],
45
+ /**
46
+ * The lit performance surface.
47
+ *
48
+ * This was [0.42, 0.36, 0.26] and commented "slightly emissive read", which it
49
+ * was not — under the scene's rig it resolved to a mid-brown slab, and from a
50
+ * seat the stage was the DIMMEST large surface in a dark hall. That is exactly
51
+ * backwards: the stage is the one place in a venue with light pointed at it,
52
+ * and it is what a buyer's eye should land on when they arrive at their seat.
53
+ *
54
+ * Bright and warm enough to hold that role against the surrounding structure,
55
+ * which sits around 0.24. An authored `fill` still tints it (see `buildShape`),
56
+ * so an organizer's own stage colour survives — it is just no longer lit like
57
+ * a basement.
58
+ */
59
+ stageTop: [0.86, 0.64, 0.36],
60
+ stageWall: [0.34, 0.26, 0.18],
38
61
  decorTop: [0.22, 0.25, 0.29],
39
62
  decorWall: [0.15, 0.17, 0.2],
40
63
  gaTop: [0.24, 0.28, 0.33],
@@ -1069,7 +1092,8 @@ function rectPolygon(x, y, w, h) {
1069
1092
  }
1070
1093
 
1071
1094
  // src/view3d/scene/seatChair.ts
1072
- var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
1095
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2, body: 3, head: 4 };
1096
+ var CHAIR_PART_OCCUPANT_MIN = CHAIR_PART.body;
1073
1097
  var CHAIR_PITCH_FRACTION = 0.44;
1074
1098
  var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1075
1099
  var CHAIR_HALF_WIDTH_MAX_M = 0.3;
@@ -1099,6 +1123,37 @@ var BOXES = [
1099
1123
  part: CHAIR_PART.back,
1100
1124
  min: [-1, BACK_BASE_M, -1],
1101
1125
  max: [1, 0.92, -0.72]
1126
+ },
1127
+ // --- the occupant ---------------------------------------------------------
1128
+ //
1129
+ // A hall with every seat empty reads as an architectural model, not a venue.
1130
+ // The 2048-px panorama this replaced drew a crowd; losing it was the price of
1131
+ // sharpness, and this is how it is bought back — in geometry, where there is
1132
+ // no resolution ceiling.
1133
+ //
1134
+ // Deliberately two blocks and no limbs. At the range these are visible a
1135
+ // person is a torso and a head, and every extra part multiplies by the number
1136
+ // of occupied seats in view. The silhouette is what carries it, exactly as it
1137
+ // does in the generated panorama's head-and-shoulder figures.
1138
+ //
1139
+ // Sized as a seated adult against the 0.92 m chair back: hips at the pad top,
1140
+ // shoulders just above the back panel, head clear of it. Torso is narrower
1141
+ // than the chair so neighbours never interpenetrate at any pitch, and it sits
1142
+ // forward of the back panel rather than inside it.
1143
+ {
1144
+ part: CHAIR_PART.body,
1145
+ min: [-0.66, PAD_TOP_M, -0.58],
1146
+ max: [0.66, 1, 0.26]
1147
+ },
1148
+ // The head is deliberately SMALL. Sized by eye against the chair it came out
1149
+ // near-cubic and read as Lego; a real head is about 0.16 m across and 0.22 m
1150
+ // tall, which against a 0.24 m chair half-width is roughly a third of the
1151
+ // chair's width and clearly taller than it is wide. Getting this ratio wrong
1152
+ // is what makes a crowd look like toys rather than people.
1153
+ {
1154
+ part: CHAIR_PART.head,
1155
+ min: [-0.34, 1.03, -0.4],
1156
+ max: [0.34, 1.27, 0.02]
1102
1157
  }
1103
1158
  ];
1104
1159
  var FACES = [
@@ -1216,6 +1271,15 @@ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1216
1271
 
1217
1272
  // src/view3d/scene/seatInstances.ts
1218
1273
  var SEAT_DOT_RADIUS_M = 0.22;
1274
+ function seatOccupantSeed(stateIndex, seatIndex) {
1275
+ const state = SEAT_STATES[stateIndex];
1276
+ if (state !== "sold" && state !== "held") return -1;
1277
+ let h = (seatIndex + 1) * 2654435761;
1278
+ h ^= h >>> 15;
1279
+ h = Math.imul(h, 2246822519);
1280
+ h ^= h >>> 13;
1281
+ return (h >>> 0) % 1e5 / 1e5;
1282
+ }
1219
1283
  var SEAT_PITCH_FRACTION = 0.42;
1220
1284
  function nearestNeighbourSpacing(seats) {
1221
1285
  const n = seats.length;
@@ -1278,10 +1342,11 @@ function seatSurfaceY(seat) {
1278
1342
  if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
1279
1343
  return 0;
1280
1344
  }
1281
- function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1345
+ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor) {
1282
1346
  const count = seats.length;
1283
1347
  const iPosition = new Float32Array(count * 3);
1284
1348
  const iState = new Float32Array(count);
1349
+ const iCategory = new Float32Array(count * 3);
1285
1350
  const iMaxRadius = new Float32Array(count);
1286
1351
  const iChairWidth = new Float32Array(count);
1287
1352
  const iRing = new Float32Array(count * 3);
@@ -1297,6 +1362,10 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1297
1362
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
1298
1363
  iPosition[i * 3 + 2] = seat.y * M;
1299
1364
  iState[i] = seatStateIndex(initial ? initial(seat) : "available");
1365
+ const cat = hexToRgb(categoryColor?.get(seat.categoryKey) ?? "#6e7bff") ?? [0.43, 0.48, 1];
1366
+ iCategory[i * 3] = cat[0];
1367
+ iCategory[i * 3 + 1] = cat[1];
1368
+ iCategory[i * 3 + 2] = cat[2];
1300
1369
  if (seat.accessibility?.length) {
1301
1370
  const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
1302
1371
  if (rgb) {
@@ -1311,6 +1380,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1311
1380
  count,
1312
1381
  iPosition,
1313
1382
  iState,
1383
+ iCategory,
1314
1384
  iMaxRadius,
1315
1385
  iRing,
1316
1386
  idToIndex,
@@ -2651,11 +2721,27 @@ function shapePolygon(shape) {
2651
2721
  }
2652
2722
  return null;
2653
2723
  }
2654
- function buildShape(builder, shape, base, S) {
2724
+ function buildShape(builder, shape, base, S, stages) {
2655
2725
  const poly = shapePolygon(shape);
2656
2726
  if (!poly) return;
2657
2727
  const isStage = shape.role === "stage";
2658
2728
  const height = isStage ? base + 1 : base + 0.25;
2729
+ if (isStage && stages) {
2730
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
2731
+ for (const pt of poly) {
2732
+ if (pt.x < minX) minX = pt.x;
2733
+ if (pt.x > maxX) maxX = pt.x;
2734
+ if (pt.y < minZ) minZ = pt.y;
2735
+ if (pt.y > maxZ) maxZ = pt.y;
2736
+ }
2737
+ stages.push({
2738
+ cx: (minX + maxX) / 2 * M,
2739
+ cz: (minZ + maxZ) / 2 * M,
2740
+ y: height,
2741
+ halfX: Math.max((maxX - minX) / 2 * M, 0.5),
2742
+ halfZ: Math.max((maxZ - minZ) / 2 * M, 0.5)
2743
+ });
2744
+ }
2659
2745
  const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
2660
2746
  const colWall = isStage ? S.stageWall : S.decorWall;
2661
2747
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
@@ -2765,6 +2851,7 @@ function buildSceneModel(input) {
2765
2851
  const sectionFills = resolveSectionFills(doc, seats);
2766
2852
  const catColor = /* @__PURE__ */ new Map();
2767
2853
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
2854
+ const stageBounds = [];
2768
2855
  const surfaces = buildVenueSurfaces(units, seats);
2769
2856
  const seatFloor = new Float32Array(seats.length);
2770
2857
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
@@ -2774,7 +2861,7 @@ function buildSceneModel(input) {
2774
2861
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
2775
2862
  for (const o of unit.objects) {
2776
2863
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2777
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
2864
+ else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S, stageBounds);
2778
2865
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2779
2866
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2780
2867
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
@@ -2783,7 +2870,7 @@ function buildSceneModel(input) {
2783
2870
  }
2784
2871
  const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
2785
2872
  const solids = mergeMeshData([builder.build()]);
2786
- const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
2873
+ const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor, catColor);
2787
2874
  const zoneDefs = doc.zones ?? [];
2788
2875
  const zones = [];
2789
2876
  if (zoneDefs.length) {
@@ -3036,6 +3123,7 @@ function buildSceneModel(input) {
3036
3123
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
3037
3124
  focalWorld: [focal.x * M, 1.5, focal.y * M],
3038
3125
  zones,
3126
+ stages: stageBounds,
3039
3127
  sections,
3040
3128
  labels,
3041
3129
  floors
@@ -3081,6 +3169,20 @@ var LabelOverlay = class {
3081
3169
  if (opts.fontFamily) s.fontFamily = opts.fontFamily;
3082
3170
  container.appendChild(this.root);
3083
3171
  }
3172
+ /**
3173
+ * Hide or show the whole overlay without disturbing which labels exist.
3174
+ *
3175
+ * The in-scene panorama sphere is GL, so it draws BEHIND every DOM label —
3176
+ * row and seat labels floated on top of a 360 photo until this existed. The
3177
+ * old DOM panorama never needed it because its opaque div covered them.
3178
+ *
3179
+ * Deliberately not `setLabels([])`: that destroys the nodes and the declutter
3180
+ * state, so closing the panorama would rebuild and re-rank the whole overlay
3181
+ * and flash. Visibility is a view concern, not a data one.
3182
+ */
3183
+ setVisible(visible) {
3184
+ this.root.style.visibility = visible ? "" : "hidden";
3185
+ }
3084
3186
  setLabels(labels) {
3085
3187
  this.labels = labels;
3086
3188
  for (const [id, node] of this.nodes) {
@@ -3344,13 +3446,14 @@ var CHAIR_VERT = (
3344
3446
  precision highp float;
3345
3447
  in vec3 position; // local: x/z in units of the seat radius, y in METRES
3346
3448
  in vec3 normal;
3347
- in float part; // 0 = pedestal, 1 = pad, 2 = back
3449
+ in float part; // 0 = pedestal, 1 = pad, 2 = back, 3 = body, 4 = head
3348
3450
  in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3349
3451
  in vec3 iColor; // per-instance state colour
3350
3452
  in float iRadius; // per-instance horizontal half-width, world metres
3351
3453
  in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3352
3454
  in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3353
3455
  in float iFloor;
3456
+ in float iSeed; // <0 = seat is empty; else per-person hash in [0,1)
3354
3457
  uniform mat4 modelViewMatrix;
3355
3458
  uniform mat4 projectionMatrix;
3356
3459
  uniform float uChairFull;
@@ -3366,6 +3469,8 @@ out float vPart;
3366
3469
  out float vHeight; // local height in metres, for the vertical occlusion ramp
3367
3470
  out vec3 vRing;
3368
3471
  out float vDim;
3472
+ out float vOccupant; // 1 = this vertex belongs to a person, not to the chair
3473
+ out vec3 vOccupantTint;
3369
3474
  ${CHAIR_WEIGHT_GLSL}
3370
3475
  void main() {
3371
3476
  vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
@@ -3374,12 +3479,35 @@ void main() {
3374
3479
  // chairs, but nobody gets a short one (people are the same height at every
3375
3480
  // seat pitch).
3376
3481
  vec3 p = position;
3482
+ // The occupant. Parts 3 and 4 are a person; everything below is furniture.
3483
+ //
3484
+ // One instanced mesh draws both an empty seat and a taken one, because the
3485
+ // alternative \u2014 a second geometry and a second draw call gathered per frame \u2014
3486
+ // would double the near-field cost to show what is already known per instance.
3487
+ // An unoccupied seat collapses its person to a point at the seat, which the
3488
+ // rasteriser discards, exactly as the chair itself collapses at w=0.
3489
+ float occupant = step(2.5, part);
3490
+ float taken = step(0.0, iSeed);
3491
+ vOccupant = occupant;
3492
+ if (occupant > 0.5) {
3493
+ if (taken < 0.5) {
3494
+ p = vec3(0.0); // empty seat: no person
3495
+ } else {
3496
+ // Vary the build so a sold-out row is people rather than a rank of
3497
+ // identical mannequins: +/-6% height and +/-8% width off the hash.
3498
+ p.y *= 0.94 + 0.12 * iSeed;
3499
+ p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
3500
+ }
3501
+ }
3377
3502
  p.xz *= iRadius;
3378
3503
  // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3379
3504
  // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3380
3505
  // width and the same chair would lean 20 degrees on a wide stadium row and
3381
3506
  // 6 on a tight theatre one.
3382
- float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3507
+ // Only the BACK panel rakes. The old test (part > 1.5) meant "the back", and
3508
+ // now also catches the occupant \u2014 leaning a person by the panel's rule would
3509
+ // translate their head backwards by a rake measured from the panel's base.
3510
+ float rake = (part > 1.5 && part < 2.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3383
3511
  p.z -= rake;
3384
3512
  // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3385
3513
  // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
@@ -3393,7 +3521,7 @@ void main() {
3393
3521
  // unchanged; and the rake is a shear, whose normal transform adds a y term.
3394
3522
  // Skipping either lights the raked back as though it were still vertical.
3395
3523
  vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3396
- if (part > 1.5) n.y += uBackRake * n.z;
3524
+ if (part > 1.5 && part < 2.5) n.y += uBackRake * n.z;
3397
3525
  n = normalize(n);
3398
3526
  vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
3399
3527
  vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
@@ -3401,6 +3529,15 @@ void main() {
3401
3529
  vNormalWorld = rn;
3402
3530
  vNormalView = normalize(mat3(modelViewMatrix) * rn);
3403
3531
  vColor = iColor;
3532
+ // Occupant colour, resolved here so the fragment stage needs no extra
3533
+ // varyings beyond this one. A person is NOT painted in the seat's state
3534
+ // colour: a sold seat is red, and a hall of red people reads as a warning,
3535
+ // not an audience. Hair/clothing for the body, a warm tone for the head,
3536
+ // both varied by the same per-person hash.
3537
+ float t = fract(iSeed * 3.71);
3538
+ vec3 clothes = mix(vec3(0.13, 0.15, 0.20), vec3(0.34, 0.30, 0.36), t);
3539
+ vec3 skin = mix(vec3(0.52, 0.38, 0.29), vec3(0.86, 0.70, 0.58), fract(iSeed * 11.3));
3540
+ vOccupantTint = (part > 3.5) ? skin : clothes;
3404
3541
  vPart = part;
3405
3542
  vHeight = position.y;
3406
3543
  vRing = iRing;
@@ -3420,6 +3557,8 @@ in float vPart;
3420
3557
  in float vHeight;
3421
3558
  in vec3 vRing;
3422
3559
  in float vDim;
3560
+ in float vOccupant;
3561
+ in vec3 vOccupantTint;
3423
3562
  uniform vec3 uKeyDir;
3424
3563
  uniform vec3 uFadeColor;
3425
3564
  out vec4 fragColor;
@@ -3432,6 +3571,7 @@ void main() {
3432
3571
  vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
3433
3572
  float fill = max(dot(N, fillDir), 0.0);
3434
3573
  vec3 tint = vColor;
3574
+ if (vOccupant > 0.5) tint = vOccupantTint;
3435
3575
  // The accommodation ring, kept legible once the dot (which drew it) is gone:
3436
3576
  // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
3437
3577
  // survives to close range instead of vanishing exactly when the buyer arrives.
@@ -3440,12 +3580,16 @@ void main() {
3440
3580
  // Pad brightest, back a step below it, pedestal darkest. Three untextured
3441
3581
  // boxes only read as one object if they are separated tonally \u2014 with a single
3442
3582
  // flat colour the chair silhouettes as a crate.
3443
- float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
3583
+ // A person is lit as a person, not as upholstery: no part shading, and less
3584
+ // of the pad's sheen, so a head does not read as a polished box.
3585
+ float partShade = vOccupant > 0.5 ? 0.95 : (vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80));
3444
3586
  // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
3445
3587
  // sits to the deck the less sky it can actually see. This is the depth cue \u2014
3446
3588
  // without it the pad top, the back and the deck all resolve to the same flat
3447
3589
  // value and the row loses its form entirely.
3448
- float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
3590
+ // Occupants rise above the 0.92 m chair back, so their ramp uses their own
3591
+ // height or every head would clamp to full brightness and float.
3592
+ float ao = mix(0.58, 1.0, clamp(vHeight / (vOccupant > 0.5 ? 1.30 : 0.92), 0.0, 1.0));
3449
3593
  vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
3450
3594
  float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3451
3595
  // A brighter rim than the solids get: it picks out every chair's own edge,
@@ -3640,12 +3784,13 @@ function createBackgroundProgram(gl, top, bottom) {
3640
3784
  }
3641
3785
 
3642
3786
  // src/view3d/scene/build.ts
3643
- function writeSeatColors(iColor, iState, start, count, states) {
3787
+ function writeSeatColors(iColor, iState, start, count, states, iCategory) {
3644
3788
  for (let i = start; i < start + count; i++) {
3645
- const c = states[iState[i]] ?? states[0];
3646
- iColor[i * 3] = c[0];
3647
- iColor[i * 3 + 1] = c[1];
3648
- iColor[i * 3 + 2] = c[2];
3789
+ const useCategory = iCategory && iState[i] === 0;
3790
+ const c = useCategory ? null : states[iState[i]] ?? states[0];
3791
+ iColor[i * 3] = c ? c[0] : iCategory[i * 3];
3792
+ iColor[i * 3 + 1] = c ? c[1] : iCategory[i * 3 + 1];
3793
+ iColor[i * 3 + 2] = c ? c[2] : iCategory[i * 3 + 2];
3649
3794
  }
3650
3795
  }
3651
3796
  var SEAT_QUAD = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
@@ -3671,7 +3816,7 @@ function buildGpuScene(gl, model) {
3671
3816
  const seatProg = createSeatProgram(gl);
3672
3817
  const iColor = new Float32Array(model.seats.count * 3);
3673
3818
  const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
3674
- writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
3819
+ writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors, model.seats.iCategory);
3675
3820
  const seatGeo = new Geometry(gl, {
3676
3821
  position: { size: 2, data: SEAT_QUAD },
3677
3822
  iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
@@ -3696,6 +3841,7 @@ function buildGpuScene(gl, model) {
3696
3841
  const cYaw = new Float32Array(CAP);
3697
3842
  const cRing = new Float32Array(CAP * 3);
3698
3843
  const cFloor = new Float32Array(CAP);
3844
+ const cSeed = new Float32Array(CAP);
3699
3845
  const chairGeo = new Geometry(gl, {
3700
3846
  position: { size: 3, data: chairBase.position },
3701
3847
  normal: { size: 3, data: chairBase.normal },
@@ -3706,7 +3852,8 @@ function buildGpuScene(gl, model) {
3706
3852
  iRadius: { size: 1, data: cRadius, instanced: 1 },
3707
3853
  iYaw: { size: 1, data: cYaw, instanced: 1 },
3708
3854
  iRing: { size: 3, data: cRing, instanced: 1 },
3709
- iFloor: { size: 1, data: cFloor, instanced: 1 }
3855
+ iFloor: { size: 1, data: cFloor, instanced: 1 },
3856
+ iSeed: { size: 1, data: cSeed, instanced: 1 }
3710
3857
  });
3711
3858
  const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
3712
3859
  chairMesh.frustumCulled = false;
@@ -3716,10 +3863,18 @@ function buildGpuScene(gl, model) {
3716
3863
  const src = model.seats;
3717
3864
  for (let k = 0; k < nearCount; k++) {
3718
3865
  const i = nearIndices[k];
3719
- const c = stateColors[src.iState[i]] ?? stateColors[0];
3720
- cColor[k * 3] = c[0];
3721
- cColor[k * 3 + 1] = c[1];
3722
- cColor[k * 3 + 2] = c[2];
3866
+ const useCategory = src.iCategory && src.iState[i] === 0;
3867
+ const c = useCategory ? null : stateColors[src.iState[i]] ?? stateColors[0];
3868
+ if (c) {
3869
+ cColor[k * 3] = c[0];
3870
+ cColor[k * 3 + 1] = c[1];
3871
+ cColor[k * 3 + 2] = c[2];
3872
+ } else {
3873
+ const u = upholsteryTone([src.iCategory[i * 3], src.iCategory[i * 3 + 1], src.iCategory[i * 3 + 2]]);
3874
+ cColor[k * 3] = u[0];
3875
+ cColor[k * 3 + 1] = u[1];
3876
+ cColor[k * 3 + 2] = u[2];
3877
+ }
3723
3878
  }
3724
3879
  chairGeo.attributes.iColor.needsUpdate = true;
3725
3880
  };
@@ -3754,6 +3909,7 @@ function buildGpuScene(gl, model) {
3754
3909
  cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
3755
3910
  cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
3756
3911
  cFloor[k] = src.iFloor[i];
3912
+ cSeed[k] = seatOccupantSeed(src.iState[i], i);
3757
3913
  }
3758
3914
  writeChairColors();
3759
3915
  chairGeo.attributes.iOffset.needsUpdate = true;
@@ -3761,6 +3917,7 @@ function buildGpuScene(gl, model) {
3761
3917
  chairGeo.attributes.iYaw.needsUpdate = true;
3762
3918
  chairGeo.attributes.iRing.needsUpdate = true;
3763
3919
  chairGeo.attributes.iFloor.needsUpdate = true;
3920
+ chairGeo.attributes.iSeed.needsUpdate = true;
3764
3921
  chairGeo.instancedCount = n;
3765
3922
  if (!chairMesh.parent) chairMesh.setParent(main);
3766
3923
  scene.drawCalls = 4;
@@ -3771,7 +3928,9 @@ function buildGpuScene(gl, model) {
3771
3928
  uploadSeatStateRuns(runs) {
3772
3929
  if (!runs.length) return;
3773
3930
  if (nearCount) writeChairColors();
3774
- for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
3931
+ for (const run of runs) {
3932
+ writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors, model.seats.iCategory);
3933
+ }
3775
3934
  const buffer = colorAttr.buffer;
3776
3935
  if (!buffer) {
3777
3936
  colorAttr.needsUpdate = true;
@@ -4277,6 +4436,317 @@ function mountPanorama(container, view, opts = {}) {
4277
4436
  };
4278
4437
  }
4279
4438
 
4439
+ // src/view3d/scene/panoSphere.ts
4440
+ import { Geometry as Geometry3, Mesh as Mesh3, Program as Program4, Texture } from "ogl";
4441
+ var PANO_SEGMENTS_LON = 64;
4442
+ var PANO_SEGMENTS_LAT = 32;
4443
+ var PANO_RADIUS_M = 500;
4444
+ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEGMENTS_LAT, radius = PANO_RADIUS_M) {
4445
+ const lonCount = segmentsLon + 1;
4446
+ const latCount = segmentsLat + 1;
4447
+ const vertexCount = lonCount * latCount;
4448
+ const position = new Float32Array(vertexCount * 3);
4449
+ const uv = new Float32Array(vertexCount * 2);
4450
+ for (let iLat = 0; iLat < latCount; iLat++) {
4451
+ const v = iLat / segmentsLat;
4452
+ const polar = v * Math.PI;
4453
+ const sinPolar = Math.sin(polar);
4454
+ const cosPolar = Math.cos(polar);
4455
+ for (let iLon = 0; iLon < lonCount; iLon++) {
4456
+ const u = iLon / segmentsLon;
4457
+ const azimuth = (u - 0.5) * Math.PI * 2;
4458
+ const i = iLat * lonCount + iLon;
4459
+ position[i * 3] = radius * sinPolar * Math.sin(azimuth);
4460
+ position[i * 3 + 1] = radius * cosPolar;
4461
+ position[i * 3 + 2] = -radius * sinPolar * Math.cos(azimuth);
4462
+ uv[i * 2] = u;
4463
+ uv[i * 2 + 1] = v;
4464
+ }
4465
+ }
4466
+ const index = new Uint16Array(segmentsLon * segmentsLat * 6);
4467
+ let w = 0;
4468
+ for (let iLat = 0; iLat < segmentsLat; iLat++) {
4469
+ for (let iLon = 0; iLon < segmentsLon; iLon++) {
4470
+ const a = iLat * lonCount + iLon;
4471
+ const b = a + lonCount;
4472
+ index[w++] = a;
4473
+ index[w++] = b;
4474
+ index[w++] = a + 1;
4475
+ index[w++] = a + 1;
4476
+ index[w++] = b;
4477
+ index[w++] = b + 1;
4478
+ }
4479
+ }
4480
+ return { position, uv, index };
4481
+ }
4482
+ function bearingPitchToDirection(bearingDeg, pitchDeg) {
4483
+ const yaw = bearingDeg * Math.PI / 180;
4484
+ const pitch = pitchDeg * Math.PI / 180;
4485
+ const cosPitch = Math.cos(pitch);
4486
+ return [cosPitch * Math.sin(yaw), Math.sin(pitch), -cosPitch * Math.cos(yaw)];
4487
+ }
4488
+ var PANO_VERT = (
4489
+ /* glsl */
4490
+ `#version 300 es
4491
+ precision highp float;
4492
+ in vec3 position;
4493
+ in vec2 uv;
4494
+ uniform mat4 modelViewMatrix;
4495
+ uniform mat4 projectionMatrix;
4496
+ out vec2 vUv;
4497
+ void main() {
4498
+ vUv = uv;
4499
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
4500
+ }`
4501
+ );
4502
+ var PANO_FRAG = (
4503
+ /* glsl */
4504
+ `#version 300 es
4505
+ precision highp float;
4506
+ in vec2 vUv;
4507
+ uniform sampler2D uPano;
4508
+ uniform float uOpacity;
4509
+ out vec4 fragColor;
4510
+ void main() {
4511
+ vec3 rgb = texture(uPano, vUv).rgb;
4512
+ fragColor = vec4(rgb, uOpacity);
4513
+ }`
4514
+ );
4515
+ function createPanoSphere(gl, image) {
4516
+ const { position, uv, index } = buildPanoSphere();
4517
+ const geometry = new Geometry3(gl, {
4518
+ position: { size: 3, data: position },
4519
+ uv: { size: 2, data: uv },
4520
+ index: { data: index }
4521
+ });
4522
+ const texture = new Texture(gl, {
4523
+ image,
4524
+ // The seam column is duplicated in geometry, so CLAMP is correct and avoids
4525
+ // a wrapped bilinear tap bleeding the far edge of the image into it.
4526
+ wrapS: gl.CLAMP_TO_EDGE,
4527
+ wrapT: gl.CLAMP_TO_EDGE,
4528
+ generateMipmaps: true,
4529
+ // MUST be false, and this is the one line that decides whether the whole
4530
+ // panorama is upside down.
4531
+ //
4532
+ // OGL defaults `flipY` to true for 2D textures, which is right for the usual
4533
+ // case: a UV of 0 means the BOTTOM of a quad, an image's first row is its
4534
+ // TOP, and flipping on upload reconciles the two. This sphere is the other
4535
+ // case. Its `v` is authored in IMAGE space — v=0 is the top of the equirect
4536
+ // and maps to the top of the sphere (`buildPanoSphere`, and the mapping
4537
+ // `generatePanorama` and the 2D viewer both use). Flipping on upload as well
4538
+ // applies the correction twice: the stadium pitch renders on the ceiling and
4539
+ // the audience sits overhead.
4540
+ //
4541
+ // The geometry tests state the v mapping and pass either way — they never
4542
+ // touch GL — so this is not something a unit test can defend. It was caught
4543
+ // by looking at a stadium.
4544
+ flipY: false
4545
+ });
4546
+ const program = new Program4(gl, {
4547
+ vertex: PANO_VERT,
4548
+ fragment: PANO_FRAG,
4549
+ uniforms: { uPano: { value: texture }, uOpacity: { value: 0 } },
4550
+ transparent: true,
4551
+ depthTest: false,
4552
+ depthWrite: false
4553
+ });
4554
+ const mesh = new Mesh3(gl, { geometry, program });
4555
+ return {
4556
+ mesh,
4557
+ setOpacity(value) {
4558
+ program.uniforms.uOpacity.value = Math.max(0, Math.min(1, value));
4559
+ },
4560
+ dispose() {
4561
+ mesh.setParent(null);
4562
+ geometry.remove();
4563
+ if (texture.texture) gl.deleteTexture(texture.texture);
4564
+ program.remove();
4565
+ }
4566
+ };
4567
+ }
4568
+
4569
+ // src/view3d/crossfade/panoramaSphere.ts
4570
+ var DEG_PER_PX = 0.15;
4571
+ function loadImage(url) {
4572
+ return new Promise((resolve, reject) => {
4573
+ const img = new Image();
4574
+ img.crossOrigin = "anonymous";
4575
+ img.onload = () => resolve(img);
4576
+ img.onerror = () => reject(new Error("panorama_image_failed"));
4577
+ img.src = url;
4578
+ });
4579
+ }
4580
+ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4581
+ const fadeMs = opts.fadeMs ?? 400;
4582
+ let bearing = view?.initialBearingDeg ?? 0;
4583
+ let pitch = 0;
4584
+ let disposed = false;
4585
+ if (!view && deps.focalWorld) {
4586
+ const p = deps.camera.position;
4587
+ const dx = deps.focalWorld[0] - p.x;
4588
+ const dy = deps.focalWorld[1] - p.y;
4589
+ const dz = deps.focalWorld[2] - p.z;
4590
+ const flat = Math.hypot(dx, dz);
4591
+ if (flat > 1e-3) {
4592
+ bearing = Math.atan2(dx, -dz) * 180 / Math.PI;
4593
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, Math.atan2(dy, flat) * 180 / Math.PI));
4594
+ }
4595
+ }
4596
+ let sphere = null;
4597
+ if (view) {
4598
+ let image;
4599
+ try {
4600
+ image = await loadImage(view.url);
4601
+ } catch {
4602
+ return null;
4603
+ }
4604
+ try {
4605
+ sphere = createPanoSphere(deps.gl, image);
4606
+ } catch {
4607
+ return null;
4608
+ }
4609
+ sphere.mesh.renderOrder = 999;
4610
+ sphere.mesh.setParent(deps.scene);
4611
+ }
4612
+ const priorFov = deps.camera.fov;
4613
+ deps.camera.perspective({ fov: VFOV_DEG, aspect: deps.camera.aspect });
4614
+ const priorQuat = deps.camera.quaternion.slice();
4615
+ const aim = () => {
4616
+ const p = deps.camera.position;
4617
+ sphere?.mesh.position.set(p.x, p.y, p.z);
4618
+ const [dx, dy, dz] = bearingPitchToDirection(bearing, pitch);
4619
+ deps.camera.lookAt([p.x + dx, p.y + dy, p.z + dz]);
4620
+ deps.requestRender();
4621
+ };
4622
+ aim();
4623
+ const root = document.createElement("div");
4624
+ root.setAttribute("role", "dialog");
4625
+ root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
4626
+ Object.assign(root.style, {
4627
+ position: "absolute",
4628
+ inset: "0",
4629
+ zIndex: "10",
4630
+ cursor: "grab",
4631
+ touchAction: "none"
4632
+ });
4633
+ const closeBtn = document.createElement("button");
4634
+ closeBtn.type = "button";
4635
+ closeBtn.setAttribute("aria-label", "Close");
4636
+ closeBtn.textContent = "\u2715";
4637
+ Object.assign(closeBtn.style, {
4638
+ position: "absolute",
4639
+ top: "12px",
4640
+ right: "12px",
4641
+ zIndex: "2",
4642
+ width: "34px",
4643
+ height: "34px",
4644
+ borderRadius: "999px",
4645
+ cursor: "pointer",
4646
+ border: "1px solid rgba(255,255,255,0.25)",
4647
+ background: "rgba(8,12,18,0.6)",
4648
+ color: "#e6edf3",
4649
+ fontSize: "15px",
4650
+ lineHeight: "1"
4651
+ });
4652
+ root.appendChild(closeBtn);
4653
+ const hint = document.createElement("div");
4654
+ hint.textContent = "Drag to look around \xB7 Esc to close";
4655
+ Object.assign(hint.style, {
4656
+ position: "absolute",
4657
+ bottom: "12px",
4658
+ left: "0",
4659
+ right: "0",
4660
+ textAlign: "center",
4661
+ color: "rgba(230,237,243,0.7)",
4662
+ font: "12px ui-sans-serif, system-ui, sans-serif",
4663
+ pointerEvents: "none"
4664
+ });
4665
+ root.appendChild(hint);
4666
+ container.appendChild(root);
4667
+ let dragging = false;
4668
+ let lastX = 0;
4669
+ let lastY = 0;
4670
+ const onDown = (e) => {
4671
+ if (e.target === closeBtn) return;
4672
+ dragging = true;
4673
+ lastX = e.clientX;
4674
+ lastY = e.clientY;
4675
+ root.setPointerCapture?.(e.pointerId);
4676
+ root.style.cursor = "grabbing";
4677
+ };
4678
+ const onMove = (e) => {
4679
+ if (!dragging) return;
4680
+ bearing += (e.clientX - lastX) * DEG_PER_PX;
4681
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.clientY - lastY) * DEG_PER_PX));
4682
+ lastX = e.clientX;
4683
+ lastY = e.clientY;
4684
+ aim();
4685
+ };
4686
+ const onUp = () => {
4687
+ dragging = false;
4688
+ root.style.cursor = "grab";
4689
+ };
4690
+ root.addEventListener("pointerdown", onDown);
4691
+ root.addEventListener("pointermove", onMove);
4692
+ root.addEventListener("pointerup", onUp);
4693
+ root.addEventListener("pointercancel", onUp);
4694
+ const onKey = (e) => {
4695
+ if (e.key !== "Escape" || disposed) return;
4696
+ e.stopPropagation();
4697
+ handle.close();
4698
+ };
4699
+ window.addEventListener("keydown", onKey);
4700
+ let raf = 0;
4701
+ const fadeTo = (target, done) => {
4702
+ if (!sphere) {
4703
+ done?.();
4704
+ return;
4705
+ }
4706
+ const from = target === 1 ? 0 : 1;
4707
+ const start = performance.now();
4708
+ const step = () => {
4709
+ if (disposed) return;
4710
+ const t = fadeMs <= 0 ? 1 : Math.min(1, (performance.now() - start) / fadeMs);
4711
+ sphere?.setOpacity(from + (target - from) * t);
4712
+ deps.requestRender();
4713
+ if (t < 1) raf = requestAnimationFrame(step);
4714
+ else done?.();
4715
+ };
4716
+ raf = requestAnimationFrame(step);
4717
+ };
4718
+ fadeTo(1);
4719
+ const teardown = () => {
4720
+ if (disposed) return;
4721
+ disposed = true;
4722
+ cancelAnimationFrame(raf);
4723
+ root.removeEventListener("pointerdown", onDown);
4724
+ root.removeEventListener("pointermove", onMove);
4725
+ root.removeEventListener("pointerup", onUp);
4726
+ root.removeEventListener("pointercancel", onUp);
4727
+ window.removeEventListener("keydown", onKey);
4728
+ root.remove();
4729
+ sphere?.dispose();
4730
+ deps.camera.perspective({ fov: priorFov, aspect: deps.camera.aspect });
4731
+ deps.camera.quaternion.set(priorQuat[0], priorQuat[1], priorQuat[2], priorQuat[3]);
4732
+ deps.requestRender();
4733
+ };
4734
+ const handle = {
4735
+ close() {
4736
+ if (disposed) return;
4737
+ const finish = () => {
4738
+ teardown();
4739
+ opts.onClose?.();
4740
+ };
4741
+ cancelAnimationFrame(raf);
4742
+ fadeTo(0, finish);
4743
+ },
4744
+ dispose: teardown
4745
+ };
4746
+ closeBtn.addEventListener("click", () => handle.close());
4747
+ return handle;
4748
+ }
4749
+
4280
4750
  // src/view3d/analytics.ts
4281
4751
  var now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
4282
4752
  var Analytics3D = class {
@@ -4551,7 +5021,7 @@ function mountVenue3D(container, input, opts = {}) {
4551
5021
  zIndex: "4"
4552
5022
  });
4553
5023
  overviewChip.addEventListener("click", () => {
4554
- if (disposed || frozen) return;
5024
+ if (disposed || frozen || panorama) return;
4555
5025
  cancelFlight();
4556
5026
  removeArriveChip();
4557
5027
  orbit.frameSoft(model.bounds, stageAzimuth);
@@ -4579,18 +5049,36 @@ function mountVenue3D(container, input, opts = {}) {
4579
5049
  }
4580
5050
  if (disposed || gen !== flightGen) return;
4581
5051
  removeArriveChip();
4582
- panorama = mountPanorama(container, view, {
4583
- fadeMs,
4584
- seatLabel: seatId,
4585
- onClose: () => {
4586
- panorama = null;
4587
- frozen = false;
4588
- analytics.panoramaClosed();
4589
- orbit.resumeAfterFlight(model.focalWorld);
4590
- loop.requestRender();
4591
- showArriveChip(seatId, flightGen);
4592
- }
4593
- });
5052
+ const onClose = () => {
5053
+ panorama = null;
5054
+ labelOverlay.setVisible(true);
5055
+ frozen = false;
5056
+ analytics.panoramaClosed();
5057
+ orbit.resumeAfterFlight(model.focalWorld);
5058
+ loop.requestRender();
5059
+ showArriveChip(seatId, flightGen);
5060
+ };
5061
+ const sceneMode = view.generated === true;
5062
+ const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5063
+ gl: glctx.gl,
5064
+ scene: gpu.main,
5065
+ camera: orbit.camera,
5066
+ requestRender: () => loop.requestRender(),
5067
+ focalWorld: model.focalWorld
5068
+ }, { fadeMs, seatLabel: seatId, onClose }) : null;
5069
+ if (disposed || gen !== flightGen) {
5070
+ spherical?.dispose();
5071
+ return;
5072
+ }
5073
+ if (spherical) {
5074
+ orbit.syncFromCamera();
5075
+ labelOverlay.setVisible(false);
5076
+ frozen = false;
5077
+ loop.requestRender();
5078
+ panorama = spherical;
5079
+ } else {
5080
+ panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
5081
+ }
4594
5082
  analytics.panoramaOpened();
4595
5083
  };
4596
5084
  const cancelFlight = () => {