@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.
@@ -34,7 +34,7 @@ __export(view3d_exports, {
34
34
  mountVenue3D: () => mountVenue3D
35
35
  });
36
36
  module.exports = __toCommonJS(view3d_exports);
37
- var import_ogl7 = require("ogl");
37
+ var import_ogl8 = require("ogl");
38
38
 
39
39
  // src/view3d/gl/context.ts
40
40
  var import_ogl = require("ogl");
@@ -52,13 +52,36 @@ var SEAT_STATE_COLORS = {
52
52
  selected: [0.24, 0.74, 1],
53
53
  dimmed: [0.28, 0.32, 0.37]
54
54
  };
55
+ function upholsteryTone(rgb) {
56
+ const luma = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
57
+ const SAT = 0.42;
58
+ const VALUE = 0.62;
59
+ return [
60
+ (luma + (rgb[0] - luma) * SAT) * VALUE,
61
+ (luma + (rgb[1] - luma) * SAT) * VALUE,
62
+ (luma + (rgb[2] - luma) * SAT) * VALUE
63
+ ];
64
+ }
55
65
  var STRUCTURE = {
56
66
  ground: [0.07, 0.085, 0.11],
57
67
  tierTop: [0.24, 0.28, 0.34],
58
68
  tierWall: [0.17, 0.2, 0.25],
59
- stageTop: [0.42, 0.36, 0.26],
60
- // warm, slightly emissive read
61
- stageWall: [0.26, 0.22, 0.16],
69
+ /**
70
+ * The lit performance surface.
71
+ *
72
+ * This was [0.42, 0.36, 0.26] and commented "slightly emissive read", which it
73
+ * was not — under the scene's rig it resolved to a mid-brown slab, and from a
74
+ * seat the stage was the DIMMEST large surface in a dark hall. That is exactly
75
+ * backwards: the stage is the one place in a venue with light pointed at it,
76
+ * and it is what a buyer's eye should land on when they arrive at their seat.
77
+ *
78
+ * Bright and warm enough to hold that role against the surrounding structure,
79
+ * which sits around 0.24. An authored `fill` still tints it (see `buildShape`),
80
+ * so an organizer's own stage colour survives — it is just no longer lit like
81
+ * a basement.
82
+ */
83
+ stageTop: [0.86, 0.64, 0.36],
84
+ stageWall: [0.34, 0.26, 0.18],
62
85
  decorTop: [0.22, 0.25, 0.29],
63
86
  decorWall: [0.15, 0.17, 0.2],
64
87
  gaTop: [0.24, 0.28, 0.33],
@@ -1175,7 +1198,8 @@ function rectPolygon(x, y, w, h) {
1175
1198
  }
1176
1199
 
1177
1200
  // src/view3d/scene/seatChair.ts
1178
- var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
1201
+ var CHAIR_PART = { pedestal: 0, pad: 1, back: 2, body: 3, head: 4 };
1202
+ var CHAIR_PART_OCCUPANT_MIN = CHAIR_PART.body;
1179
1203
  var CHAIR_PITCH_FRACTION = 0.44;
1180
1204
  var CHAIR_HALF_WIDTH_MIN_M = 0.15;
1181
1205
  var CHAIR_HALF_WIDTH_MAX_M = 0.3;
@@ -1205,6 +1229,37 @@ var BOXES = [
1205
1229
  part: CHAIR_PART.back,
1206
1230
  min: [-1, BACK_BASE_M, -1],
1207
1231
  max: [1, 0.92, -0.72]
1232
+ },
1233
+ // --- the occupant ---------------------------------------------------------
1234
+ //
1235
+ // A hall with every seat empty reads as an architectural model, not a venue.
1236
+ // The 2048-px panorama this replaced drew a crowd; losing it was the price of
1237
+ // sharpness, and this is how it is bought back — in geometry, where there is
1238
+ // no resolution ceiling.
1239
+ //
1240
+ // Deliberately two blocks and no limbs. At the range these are visible a
1241
+ // person is a torso and a head, and every extra part multiplies by the number
1242
+ // of occupied seats in view. The silhouette is what carries it, exactly as it
1243
+ // does in the generated panorama's head-and-shoulder figures.
1244
+ //
1245
+ // Sized as a seated adult against the 0.92 m chair back: hips at the pad top,
1246
+ // shoulders just above the back panel, head clear of it. Torso is narrower
1247
+ // than the chair so neighbours never interpenetrate at any pitch, and it sits
1248
+ // forward of the back panel rather than inside it.
1249
+ {
1250
+ part: CHAIR_PART.body,
1251
+ min: [-0.66, PAD_TOP_M, -0.58],
1252
+ max: [0.66, 1, 0.26]
1253
+ },
1254
+ // The head is deliberately SMALL. Sized by eye against the chair it came out
1255
+ // near-cubic and read as Lego; a real head is about 0.16 m across and 0.22 m
1256
+ // tall, which against a 0.24 m chair half-width is roughly a third of the
1257
+ // chair's width and clearly taller than it is wide. Getting this ratio wrong
1258
+ // is what makes a crowd look like toys rather than people.
1259
+ {
1260
+ part: CHAIR_PART.head,
1261
+ min: [-0.34, 1.03, -0.4],
1262
+ max: [0.34, 1.27, 0.02]
1208
1263
  }
1209
1264
  ];
1210
1265
  var FACES = [
@@ -1322,6 +1377,15 @@ function computeSeatYaw(iPosition, count, rowIdAt, focal) {
1322
1377
 
1323
1378
  // src/view3d/scene/seatInstances.ts
1324
1379
  var SEAT_DOT_RADIUS_M = 0.22;
1380
+ function seatOccupantSeed(stateIndex, seatIndex) {
1381
+ const state = SEAT_STATES[stateIndex];
1382
+ if (state !== "sold" && state !== "held") return -1;
1383
+ let h = (seatIndex + 1) * 2654435761;
1384
+ h ^= h >>> 15;
1385
+ h = Math.imul(h, 2246822519);
1386
+ h ^= h >>> 13;
1387
+ return (h >>> 0) % 1e5 / 1e5;
1388
+ }
1325
1389
  var SEAT_PITCH_FRACTION = 0.42;
1326
1390
  function nearestNeighbourSpacing(seats) {
1327
1391
  const n = seats.length;
@@ -1384,10 +1448,11 @@ function seatSurfaceY(seat) {
1384
1448
  if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
1385
1449
  return 0;
1386
1450
  }
1387
- function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1451
+ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor) {
1388
1452
  const count = seats.length;
1389
1453
  const iPosition = new Float32Array(count * 3);
1390
1454
  const iState = new Float32Array(count);
1455
+ const iCategory = new Float32Array(count * 3);
1391
1456
  const iMaxRadius = new Float32Array(count);
1392
1457
  const iChairWidth = new Float32Array(count);
1393
1458
  const iRing = new Float32Array(count * 3);
@@ -1403,6 +1468,10 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1403
1468
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
1404
1469
  iPosition[i * 3 + 2] = seat.y * M;
1405
1470
  iState[i] = seatStateIndex(initial ? initial(seat) : "available");
1471
+ const cat = hexToRgb(categoryColor?.get(seat.categoryKey) ?? "#6e7bff") ?? [0.43, 0.48, 1];
1472
+ iCategory[i * 3] = cat[0];
1473
+ iCategory[i * 3 + 1] = cat[1];
1474
+ iCategory[i * 3 + 2] = cat[2];
1406
1475
  if (seat.accessibility?.length) {
1407
1476
  const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
1408
1477
  if (rgb) {
@@ -1417,6 +1486,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
1417
1486
  count,
1418
1487
  iPosition,
1419
1488
  iState,
1489
+ iCategory,
1420
1490
  iMaxRadius,
1421
1491
  iRing,
1422
1492
  idToIndex,
@@ -3124,11 +3194,27 @@ function shapePolygon(shape) {
3124
3194
  }
3125
3195
  return null;
3126
3196
  }
3127
- function buildShape(builder, shape, base, S) {
3197
+ function buildShape(builder, shape, base, S, stages) {
3128
3198
  const poly = shapePolygon(shape);
3129
3199
  if (!poly) return;
3130
3200
  const isStage = shape.role === "stage";
3131
3201
  const height = isStage ? base + 1 : base + 0.25;
3202
+ if (isStage && stages) {
3203
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
3204
+ for (const pt of poly) {
3205
+ if (pt.x < minX) minX = pt.x;
3206
+ if (pt.x > maxX) maxX = pt.x;
3207
+ if (pt.y < minZ) minZ = pt.y;
3208
+ if (pt.y > maxZ) maxZ = pt.y;
3209
+ }
3210
+ stages.push({
3211
+ cx: (minX + maxX) / 2 * M,
3212
+ cz: (minZ + maxZ) / 2 * M,
3213
+ y: height,
3214
+ halfX: Math.max((maxX - minX) / 2 * M, 0.5),
3215
+ halfZ: Math.max((maxZ - minZ) / 2 * M, 0.5)
3216
+ });
3217
+ }
3132
3218
  const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
3133
3219
  const colWall = isStage ? S.stageWall : S.decorWall;
3134
3220
  extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
@@ -3238,6 +3324,7 @@ function buildSceneModel(input) {
3238
3324
  const sectionFills = resolveSectionFills(doc, seats);
3239
3325
  const catColor = /* @__PURE__ */ new Map();
3240
3326
  for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
3327
+ const stageBounds = [];
3241
3328
  const surfaces = buildVenueSurfaces(units, seats);
3242
3329
  const seatFloor = new Float32Array(seats.length);
3243
3330
  for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
@@ -3247,7 +3334,7 @@ function buildSceneModel(input) {
3247
3334
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
3248
3335
  for (const o of unit.objects) {
3249
3336
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
3250
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
3337
+ else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S, stageBounds);
3251
3338
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3252
3339
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3253
3340
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
@@ -3256,7 +3343,7 @@ function buildSceneModel(input) {
3256
3343
  }
3257
3344
  const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
3258
3345
  const solids = mergeMeshData([builder.build()]);
3259
- const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
3346
+ const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor, catColor);
3260
3347
  const zoneDefs = doc.zones ?? [];
3261
3348
  const zones = [];
3262
3349
  if (zoneDefs.length) {
@@ -3509,6 +3596,7 @@ function buildSceneModel(input) {
3509
3596
  // Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
3510
3597
  focalWorld: [focal.x * M, 1.5, focal.y * M],
3511
3598
  zones,
3599
+ stages: stageBounds,
3512
3600
  sections,
3513
3601
  labels,
3514
3602
  floors
@@ -3554,6 +3642,20 @@ var LabelOverlay = class {
3554
3642
  if (opts.fontFamily) s.fontFamily = opts.fontFamily;
3555
3643
  container.appendChild(this.root);
3556
3644
  }
3645
+ /**
3646
+ * Hide or show the whole overlay without disturbing which labels exist.
3647
+ *
3648
+ * The in-scene panorama sphere is GL, so it draws BEHIND every DOM label —
3649
+ * row and seat labels floated on top of a 360 photo until this existed. The
3650
+ * old DOM panorama never needed it because its opaque div covered them.
3651
+ *
3652
+ * Deliberately not `setLabels([])`: that destroys the nodes and the declutter
3653
+ * state, so closing the panorama would rebuild and re-rank the whole overlay
3654
+ * and flash. Visibility is a view concern, not a data one.
3655
+ */
3656
+ setVisible(visible) {
3657
+ this.root.style.visibility = visible ? "" : "hidden";
3658
+ }
3557
3659
  setLabels(labels) {
3558
3660
  this.labels = labels;
3559
3661
  for (const [id, node] of this.nodes) {
@@ -3817,13 +3919,14 @@ var CHAIR_VERT = (
3817
3919
  precision highp float;
3818
3920
  in vec3 position; // local: x/z in units of the seat radius, y in METRES
3819
3921
  in vec3 normal;
3820
- in float part; // 0 = pedestal, 1 = pad, 2 = back
3922
+ in float part; // 0 = pedestal, 1 = pad, 2 = back, 3 = body, 4 = head
3821
3923
  in vec3 iOffset; // per-instance world deck point (identical to the dot's)
3822
3924
  in vec3 iColor; // per-instance state colour
3823
3925
  in float iRadius; // per-instance horizontal half-width, world metres
3824
3926
  in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3825
3927
  in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3826
3928
  in float iFloor;
3929
+ in float iSeed; // <0 = seat is empty; else per-person hash in [0,1)
3827
3930
  uniform mat4 modelViewMatrix;
3828
3931
  uniform mat4 projectionMatrix;
3829
3932
  uniform float uChairFull;
@@ -3839,6 +3942,8 @@ out float vPart;
3839
3942
  out float vHeight; // local height in metres, for the vertical occlusion ramp
3840
3943
  out vec3 vRing;
3841
3944
  out float vDim;
3945
+ out float vOccupant; // 1 = this vertex belongs to a person, not to the chair
3946
+ out vec3 vOccupantTint;
3842
3947
  ${CHAIR_WEIGHT_GLSL}
3843
3948
  void main() {
3844
3949
  vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
@@ -3847,12 +3952,35 @@ void main() {
3847
3952
  // chairs, but nobody gets a short one (people are the same height at every
3848
3953
  // seat pitch).
3849
3954
  vec3 p = position;
3955
+ // The occupant. Parts 3 and 4 are a person; everything below is furniture.
3956
+ //
3957
+ // One instanced mesh draws both an empty seat and a taken one, because the
3958
+ // alternative \u2014 a second geometry and a second draw call gathered per frame \u2014
3959
+ // would double the near-field cost to show what is already known per instance.
3960
+ // An unoccupied seat collapses its person to a point at the seat, which the
3961
+ // rasteriser discards, exactly as the chair itself collapses at w=0.
3962
+ float occupant = step(2.5, part);
3963
+ float taken = step(0.0, iSeed);
3964
+ vOccupant = occupant;
3965
+ if (occupant > 0.5) {
3966
+ if (taken < 0.5) {
3967
+ p = vec3(0.0); // empty seat: no person
3968
+ } else {
3969
+ // Vary the build so a sold-out row is people rather than a rank of
3970
+ // identical mannequins: +/-6% height and +/-8% width off the hash.
3971
+ p.y *= 0.94 + 0.12 * iSeed;
3972
+ p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
3973
+ }
3974
+ }
3850
3975
  p.xz *= iRadius;
3851
3976
  // 2. Lean the back. Done here rather than in the base mesh so the lean is a
3852
3977
  // real angle in METRES \u2014 baked into the mesh it would scale with the seat's
3853
3978
  // width and the same chair would lean 20 degrees on a wide stadium row and
3854
3979
  // 6 on a tight theatre one.
3855
- float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3980
+ // Only the BACK panel rakes. The old test (part > 1.5) meant "the back", and
3981
+ // now also catches the occupant \u2014 leaning a person by the panel's rule would
3982
+ // translate their head backwards by a rake measured from the panel's base.
3983
+ float rake = (part > 1.5 && part < 2.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
3856
3984
  p.z -= rake;
3857
3985
  // 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
3858
3986
  // opacity \u2014 which is what makes the handover invisible. sqrt front-loads
@@ -3866,7 +3994,7 @@ void main() {
3866
3994
  // unchanged; and the rake is a shear, whose normal transform adds a y term.
3867
3995
  // Skipping either lights the raked back as though it were still vertical.
3868
3996
  vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
3869
- if (part > 1.5) n.y += uBackRake * n.z;
3997
+ if (part > 1.5 && part < 2.5) n.y += uBackRake * n.z;
3870
3998
  n = normalize(n);
3871
3999
  vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
3872
4000
  vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
@@ -3874,6 +4002,15 @@ void main() {
3874
4002
  vNormalWorld = rn;
3875
4003
  vNormalView = normalize(mat3(modelViewMatrix) * rn);
3876
4004
  vColor = iColor;
4005
+ // Occupant colour, resolved here so the fragment stage needs no extra
4006
+ // varyings beyond this one. A person is NOT painted in the seat's state
4007
+ // colour: a sold seat is red, and a hall of red people reads as a warning,
4008
+ // not an audience. Hair/clothing for the body, a warm tone for the head,
4009
+ // both varied by the same per-person hash.
4010
+ float t = fract(iSeed * 3.71);
4011
+ vec3 clothes = mix(vec3(0.13, 0.15, 0.20), vec3(0.34, 0.30, 0.36), t);
4012
+ vec3 skin = mix(vec3(0.52, 0.38, 0.29), vec3(0.86, 0.70, 0.58), fract(iSeed * 11.3));
4013
+ vOccupantTint = (part > 3.5) ? skin : clothes;
3877
4014
  vPart = part;
3878
4015
  vHeight = position.y;
3879
4016
  vRing = iRing;
@@ -3893,6 +4030,8 @@ in float vPart;
3893
4030
  in float vHeight;
3894
4031
  in vec3 vRing;
3895
4032
  in float vDim;
4033
+ in float vOccupant;
4034
+ in vec3 vOccupantTint;
3896
4035
  uniform vec3 uKeyDir;
3897
4036
  uniform vec3 uFadeColor;
3898
4037
  out vec4 fragColor;
@@ -3905,6 +4044,7 @@ void main() {
3905
4044
  vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
3906
4045
  float fill = max(dot(N, fillDir), 0.0);
3907
4046
  vec3 tint = vColor;
4047
+ if (vOccupant > 0.5) tint = vOccupantTint;
3908
4048
  // The accommodation ring, kept legible once the dot (which drew it) is gone:
3909
4049
  // an accessible seat's PEDESTAL is painted in the ring colour, so the marker
3910
4050
  // survives to close range instead of vanishing exactly when the buyer arrives.
@@ -3913,12 +4053,16 @@ void main() {
3913
4053
  // Pad brightest, back a step below it, pedestal darkest. Three untextured
3914
4054
  // boxes only read as one object if they are separated tonally \u2014 with a single
3915
4055
  // flat colour the chair silhouettes as a crate.
3916
- float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
4056
+ // A person is lit as a person, not as upholstery: no part shading, and less
4057
+ // of the pad's sheen, so a head does not read as a polished box.
4058
+ float partShade = vOccupant > 0.5 ? 0.95 : (vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80));
3917
4059
  // Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
3918
4060
  // sits to the deck the less sky it can actually see. This is the depth cue \u2014
3919
4061
  // without it the pad top, the back and the deck all resolve to the same flat
3920
4062
  // value and the row loses its form entirely.
3921
- float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
4063
+ // Occupants rise above the 0.92 m chair back, so their ramp uses their own
4064
+ // height or every head would clamp to full brightness and float.
4065
+ float ao = mix(0.58, 1.0, clamp(vHeight / (vOccupant > 0.5 ? 1.30 : 0.92), 0.0, 1.0));
3922
4066
  vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
3923
4067
  float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
3924
4068
  // A brighter rim than the solids get: it picks out every chair's own edge,
@@ -4113,12 +4257,13 @@ function createBackgroundProgram(gl, top, bottom) {
4113
4257
  }
4114
4258
 
4115
4259
  // src/view3d/scene/build.ts
4116
- function writeSeatColors(iColor, iState, start, count, states) {
4260
+ function writeSeatColors(iColor, iState, start, count, states, iCategory) {
4117
4261
  for (let i = start; i < start + count; i++) {
4118
- const c = states[iState[i]] ?? states[0];
4119
- iColor[i * 3] = c[0];
4120
- iColor[i * 3 + 1] = c[1];
4121
- iColor[i * 3 + 2] = c[2];
4262
+ const useCategory = iCategory && iState[i] === 0;
4263
+ const c = useCategory ? null : states[iState[i]] ?? states[0];
4264
+ iColor[i * 3] = c ? c[0] : iCategory[i * 3];
4265
+ iColor[i * 3 + 1] = c ? c[1] : iCategory[i * 3 + 1];
4266
+ iColor[i * 3 + 2] = c ? c[2] : iCategory[i * 3 + 2];
4122
4267
  }
4123
4268
  }
4124
4269
  var SEAT_QUAD = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
@@ -4144,7 +4289,7 @@ function buildGpuScene(gl, model) {
4144
4289
  const seatProg = createSeatProgram(gl);
4145
4290
  const iColor = new Float32Array(model.seats.count * 3);
4146
4291
  const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
4147
- writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
4292
+ writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors, model.seats.iCategory);
4148
4293
  const seatGeo = new import_ogl4.Geometry(gl, {
4149
4294
  position: { size: 2, data: SEAT_QUAD },
4150
4295
  iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
@@ -4169,6 +4314,7 @@ function buildGpuScene(gl, model) {
4169
4314
  const cYaw = new Float32Array(CAP);
4170
4315
  const cRing = new Float32Array(CAP * 3);
4171
4316
  const cFloor = new Float32Array(CAP);
4317
+ const cSeed = new Float32Array(CAP);
4172
4318
  const chairGeo = new import_ogl4.Geometry(gl, {
4173
4319
  position: { size: 3, data: chairBase.position },
4174
4320
  normal: { size: 3, data: chairBase.normal },
@@ -4179,7 +4325,8 @@ function buildGpuScene(gl, model) {
4179
4325
  iRadius: { size: 1, data: cRadius, instanced: 1 },
4180
4326
  iYaw: { size: 1, data: cYaw, instanced: 1 },
4181
4327
  iRing: { size: 3, data: cRing, instanced: 1 },
4182
- iFloor: { size: 1, data: cFloor, instanced: 1 }
4328
+ iFloor: { size: 1, data: cFloor, instanced: 1 },
4329
+ iSeed: { size: 1, data: cSeed, instanced: 1 }
4183
4330
  });
4184
4331
  const chairMesh = new import_ogl4.Mesh(gl, { geometry: chairGeo, program: chairProg });
4185
4332
  chairMesh.frustumCulled = false;
@@ -4189,10 +4336,18 @@ function buildGpuScene(gl, model) {
4189
4336
  const src = model.seats;
4190
4337
  for (let k = 0; k < nearCount; k++) {
4191
4338
  const i = nearIndices[k];
4192
- const c = stateColors[src.iState[i]] ?? stateColors[0];
4193
- cColor[k * 3] = c[0];
4194
- cColor[k * 3 + 1] = c[1];
4195
- cColor[k * 3 + 2] = c[2];
4339
+ const useCategory = src.iCategory && src.iState[i] === 0;
4340
+ const c = useCategory ? null : stateColors[src.iState[i]] ?? stateColors[0];
4341
+ if (c) {
4342
+ cColor[k * 3] = c[0];
4343
+ cColor[k * 3 + 1] = c[1];
4344
+ cColor[k * 3 + 2] = c[2];
4345
+ } else {
4346
+ const u = upholsteryTone([src.iCategory[i * 3], src.iCategory[i * 3 + 1], src.iCategory[i * 3 + 2]]);
4347
+ cColor[k * 3] = u[0];
4348
+ cColor[k * 3 + 1] = u[1];
4349
+ cColor[k * 3 + 2] = u[2];
4350
+ }
4196
4351
  }
4197
4352
  chairGeo.attributes.iColor.needsUpdate = true;
4198
4353
  };
@@ -4227,6 +4382,7 @@ function buildGpuScene(gl, model) {
4227
4382
  cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
4228
4383
  cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
4229
4384
  cFloor[k] = src.iFloor[i];
4385
+ cSeed[k] = seatOccupantSeed(src.iState[i], i);
4230
4386
  }
4231
4387
  writeChairColors();
4232
4388
  chairGeo.attributes.iOffset.needsUpdate = true;
@@ -4234,6 +4390,7 @@ function buildGpuScene(gl, model) {
4234
4390
  chairGeo.attributes.iYaw.needsUpdate = true;
4235
4391
  chairGeo.attributes.iRing.needsUpdate = true;
4236
4392
  chairGeo.attributes.iFloor.needsUpdate = true;
4393
+ chairGeo.attributes.iSeed.needsUpdate = true;
4237
4394
  chairGeo.instancedCount = n;
4238
4395
  if (!chairMesh.parent) chairMesh.setParent(main);
4239
4396
  scene.drawCalls = 4;
@@ -4244,7 +4401,9 @@ function buildGpuScene(gl, model) {
4244
4401
  uploadSeatStateRuns(runs) {
4245
4402
  if (!runs.length) return;
4246
4403
  if (nearCount) writeChairColors();
4247
- for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
4404
+ for (const run of runs) {
4405
+ writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors, model.seats.iCategory);
4406
+ }
4248
4407
  const buffer = colorAttr.buffer;
4249
4408
  if (!buffer) {
4250
4409
  colorAttr.needsUpdate = true;
@@ -4750,6 +4909,317 @@ function mountPanorama(container, view, opts = {}) {
4750
4909
  };
4751
4910
  }
4752
4911
 
4912
+ // src/view3d/scene/panoSphere.ts
4913
+ var import_ogl7 = require("ogl");
4914
+ var PANO_SEGMENTS_LON = 64;
4915
+ var PANO_SEGMENTS_LAT = 32;
4916
+ var PANO_RADIUS_M = 500;
4917
+ function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEGMENTS_LAT, radius = PANO_RADIUS_M) {
4918
+ const lonCount = segmentsLon + 1;
4919
+ const latCount = segmentsLat + 1;
4920
+ const vertexCount = lonCount * latCount;
4921
+ const position = new Float32Array(vertexCount * 3);
4922
+ const uv = new Float32Array(vertexCount * 2);
4923
+ for (let iLat = 0; iLat < latCount; iLat++) {
4924
+ const v = iLat / segmentsLat;
4925
+ const polar = v * Math.PI;
4926
+ const sinPolar = Math.sin(polar);
4927
+ const cosPolar = Math.cos(polar);
4928
+ for (let iLon = 0; iLon < lonCount; iLon++) {
4929
+ const u = iLon / segmentsLon;
4930
+ const azimuth = (u - 0.5) * Math.PI * 2;
4931
+ const i = iLat * lonCount + iLon;
4932
+ position[i * 3] = radius * sinPolar * Math.sin(azimuth);
4933
+ position[i * 3 + 1] = radius * cosPolar;
4934
+ position[i * 3 + 2] = -radius * sinPolar * Math.cos(azimuth);
4935
+ uv[i * 2] = u;
4936
+ uv[i * 2 + 1] = v;
4937
+ }
4938
+ }
4939
+ const index = new Uint16Array(segmentsLon * segmentsLat * 6);
4940
+ let w = 0;
4941
+ for (let iLat = 0; iLat < segmentsLat; iLat++) {
4942
+ for (let iLon = 0; iLon < segmentsLon; iLon++) {
4943
+ const a = iLat * lonCount + iLon;
4944
+ const b = a + lonCount;
4945
+ index[w++] = a;
4946
+ index[w++] = b;
4947
+ index[w++] = a + 1;
4948
+ index[w++] = a + 1;
4949
+ index[w++] = b;
4950
+ index[w++] = b + 1;
4951
+ }
4952
+ }
4953
+ return { position, uv, index };
4954
+ }
4955
+ function bearingPitchToDirection(bearingDeg, pitchDeg) {
4956
+ const yaw = bearingDeg * Math.PI / 180;
4957
+ const pitch = pitchDeg * Math.PI / 180;
4958
+ const cosPitch = Math.cos(pitch);
4959
+ return [cosPitch * Math.sin(yaw), Math.sin(pitch), -cosPitch * Math.cos(yaw)];
4960
+ }
4961
+ var PANO_VERT = (
4962
+ /* glsl */
4963
+ `#version 300 es
4964
+ precision highp float;
4965
+ in vec3 position;
4966
+ in vec2 uv;
4967
+ uniform mat4 modelViewMatrix;
4968
+ uniform mat4 projectionMatrix;
4969
+ out vec2 vUv;
4970
+ void main() {
4971
+ vUv = uv;
4972
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
4973
+ }`
4974
+ );
4975
+ var PANO_FRAG = (
4976
+ /* glsl */
4977
+ `#version 300 es
4978
+ precision highp float;
4979
+ in vec2 vUv;
4980
+ uniform sampler2D uPano;
4981
+ uniform float uOpacity;
4982
+ out vec4 fragColor;
4983
+ void main() {
4984
+ vec3 rgb = texture(uPano, vUv).rgb;
4985
+ fragColor = vec4(rgb, uOpacity);
4986
+ }`
4987
+ );
4988
+ function createPanoSphere(gl, image) {
4989
+ const { position, uv, index } = buildPanoSphere();
4990
+ const geometry = new import_ogl7.Geometry(gl, {
4991
+ position: { size: 3, data: position },
4992
+ uv: { size: 2, data: uv },
4993
+ index: { data: index }
4994
+ });
4995
+ const texture = new import_ogl7.Texture(gl, {
4996
+ image,
4997
+ // The seam column is duplicated in geometry, so CLAMP is correct and avoids
4998
+ // a wrapped bilinear tap bleeding the far edge of the image into it.
4999
+ wrapS: gl.CLAMP_TO_EDGE,
5000
+ wrapT: gl.CLAMP_TO_EDGE,
5001
+ generateMipmaps: true,
5002
+ // MUST be false, and this is the one line that decides whether the whole
5003
+ // panorama is upside down.
5004
+ //
5005
+ // OGL defaults `flipY` to true for 2D textures, which is right for the usual
5006
+ // case: a UV of 0 means the BOTTOM of a quad, an image's first row is its
5007
+ // TOP, and flipping on upload reconciles the two. This sphere is the other
5008
+ // case. Its `v` is authored in IMAGE space — v=0 is the top of the equirect
5009
+ // and maps to the top of the sphere (`buildPanoSphere`, and the mapping
5010
+ // `generatePanorama` and the 2D viewer both use). Flipping on upload as well
5011
+ // applies the correction twice: the stadium pitch renders on the ceiling and
5012
+ // the audience sits overhead.
5013
+ //
5014
+ // The geometry tests state the v mapping and pass either way — they never
5015
+ // touch GL — so this is not something a unit test can defend. It was caught
5016
+ // by looking at a stadium.
5017
+ flipY: false
5018
+ });
5019
+ const program = new import_ogl7.Program(gl, {
5020
+ vertex: PANO_VERT,
5021
+ fragment: PANO_FRAG,
5022
+ uniforms: { uPano: { value: texture }, uOpacity: { value: 0 } },
5023
+ transparent: true,
5024
+ depthTest: false,
5025
+ depthWrite: false
5026
+ });
5027
+ const mesh = new import_ogl7.Mesh(gl, { geometry, program });
5028
+ return {
5029
+ mesh,
5030
+ setOpacity(value) {
5031
+ program.uniforms.uOpacity.value = Math.max(0, Math.min(1, value));
5032
+ },
5033
+ dispose() {
5034
+ mesh.setParent(null);
5035
+ geometry.remove();
5036
+ if (texture.texture) gl.deleteTexture(texture.texture);
5037
+ program.remove();
5038
+ }
5039
+ };
5040
+ }
5041
+
5042
+ // src/view3d/crossfade/panoramaSphere.ts
5043
+ var DEG_PER_PX = 0.15;
5044
+ function loadImage(url) {
5045
+ return new Promise((resolve, reject) => {
5046
+ const img = new Image();
5047
+ img.crossOrigin = "anonymous";
5048
+ img.onload = () => resolve(img);
5049
+ img.onerror = () => reject(new Error("panorama_image_failed"));
5050
+ img.src = url;
5051
+ });
5052
+ }
5053
+ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5054
+ const fadeMs = opts.fadeMs ?? 400;
5055
+ let bearing = view?.initialBearingDeg ?? 0;
5056
+ let pitch = 0;
5057
+ let disposed = false;
5058
+ if (!view && deps.focalWorld) {
5059
+ const p = deps.camera.position;
5060
+ const dx = deps.focalWorld[0] - p.x;
5061
+ const dy = deps.focalWorld[1] - p.y;
5062
+ const dz = deps.focalWorld[2] - p.z;
5063
+ const flat = Math.hypot(dx, dz);
5064
+ if (flat > 1e-3) {
5065
+ bearing = Math.atan2(dx, -dz) * 180 / Math.PI;
5066
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, Math.atan2(dy, flat) * 180 / Math.PI));
5067
+ }
5068
+ }
5069
+ let sphere = null;
5070
+ if (view) {
5071
+ let image;
5072
+ try {
5073
+ image = await loadImage(view.url);
5074
+ } catch {
5075
+ return null;
5076
+ }
5077
+ try {
5078
+ sphere = createPanoSphere(deps.gl, image);
5079
+ } catch {
5080
+ return null;
5081
+ }
5082
+ sphere.mesh.renderOrder = 999;
5083
+ sphere.mesh.setParent(deps.scene);
5084
+ }
5085
+ const priorFov = deps.camera.fov;
5086
+ deps.camera.perspective({ fov: VFOV_DEG, aspect: deps.camera.aspect });
5087
+ const priorQuat = deps.camera.quaternion.slice();
5088
+ const aim = () => {
5089
+ const p = deps.camera.position;
5090
+ sphere?.mesh.position.set(p.x, p.y, p.z);
5091
+ const [dx, dy, dz] = bearingPitchToDirection(bearing, pitch);
5092
+ deps.camera.lookAt([p.x + dx, p.y + dy, p.z + dz]);
5093
+ deps.requestRender();
5094
+ };
5095
+ aim();
5096
+ const root = document.createElement("div");
5097
+ root.setAttribute("role", "dialog");
5098
+ root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
5099
+ Object.assign(root.style, {
5100
+ position: "absolute",
5101
+ inset: "0",
5102
+ zIndex: "10",
5103
+ cursor: "grab",
5104
+ touchAction: "none"
5105
+ });
5106
+ const closeBtn = document.createElement("button");
5107
+ closeBtn.type = "button";
5108
+ closeBtn.setAttribute("aria-label", "Close");
5109
+ closeBtn.textContent = "\u2715";
5110
+ Object.assign(closeBtn.style, {
5111
+ position: "absolute",
5112
+ top: "12px",
5113
+ right: "12px",
5114
+ zIndex: "2",
5115
+ width: "34px",
5116
+ height: "34px",
5117
+ borderRadius: "999px",
5118
+ cursor: "pointer",
5119
+ border: "1px solid rgba(255,255,255,0.25)",
5120
+ background: "rgba(8,12,18,0.6)",
5121
+ color: "#e6edf3",
5122
+ fontSize: "15px",
5123
+ lineHeight: "1"
5124
+ });
5125
+ root.appendChild(closeBtn);
5126
+ const hint = document.createElement("div");
5127
+ hint.textContent = "Drag to look around \xB7 Esc to close";
5128
+ Object.assign(hint.style, {
5129
+ position: "absolute",
5130
+ bottom: "12px",
5131
+ left: "0",
5132
+ right: "0",
5133
+ textAlign: "center",
5134
+ color: "rgba(230,237,243,0.7)",
5135
+ font: "12px ui-sans-serif, system-ui, sans-serif",
5136
+ pointerEvents: "none"
5137
+ });
5138
+ root.appendChild(hint);
5139
+ container.appendChild(root);
5140
+ let dragging = false;
5141
+ let lastX = 0;
5142
+ let lastY = 0;
5143
+ const onDown = (e) => {
5144
+ if (e.target === closeBtn) return;
5145
+ dragging = true;
5146
+ lastX = e.clientX;
5147
+ lastY = e.clientY;
5148
+ root.setPointerCapture?.(e.pointerId);
5149
+ root.style.cursor = "grabbing";
5150
+ };
5151
+ const onMove = (e) => {
5152
+ if (!dragging) return;
5153
+ bearing += (e.clientX - lastX) * DEG_PER_PX;
5154
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.clientY - lastY) * DEG_PER_PX));
5155
+ lastX = e.clientX;
5156
+ lastY = e.clientY;
5157
+ aim();
5158
+ };
5159
+ const onUp = () => {
5160
+ dragging = false;
5161
+ root.style.cursor = "grab";
5162
+ };
5163
+ root.addEventListener("pointerdown", onDown);
5164
+ root.addEventListener("pointermove", onMove);
5165
+ root.addEventListener("pointerup", onUp);
5166
+ root.addEventListener("pointercancel", onUp);
5167
+ const onKey = (e) => {
5168
+ if (e.key !== "Escape" || disposed) return;
5169
+ e.stopPropagation();
5170
+ handle.close();
5171
+ };
5172
+ window.addEventListener("keydown", onKey);
5173
+ let raf = 0;
5174
+ const fadeTo = (target, done) => {
5175
+ if (!sphere) {
5176
+ done?.();
5177
+ return;
5178
+ }
5179
+ const from = target === 1 ? 0 : 1;
5180
+ const start = performance.now();
5181
+ const step = () => {
5182
+ if (disposed) return;
5183
+ const t = fadeMs <= 0 ? 1 : Math.min(1, (performance.now() - start) / fadeMs);
5184
+ sphere?.setOpacity(from + (target - from) * t);
5185
+ deps.requestRender();
5186
+ if (t < 1) raf = requestAnimationFrame(step);
5187
+ else done?.();
5188
+ };
5189
+ raf = requestAnimationFrame(step);
5190
+ };
5191
+ fadeTo(1);
5192
+ const teardown = () => {
5193
+ if (disposed) return;
5194
+ disposed = true;
5195
+ cancelAnimationFrame(raf);
5196
+ root.removeEventListener("pointerdown", onDown);
5197
+ root.removeEventListener("pointermove", onMove);
5198
+ root.removeEventListener("pointerup", onUp);
5199
+ root.removeEventListener("pointercancel", onUp);
5200
+ window.removeEventListener("keydown", onKey);
5201
+ root.remove();
5202
+ sphere?.dispose();
5203
+ deps.camera.perspective({ fov: priorFov, aspect: deps.camera.aspect });
5204
+ deps.camera.quaternion.set(priorQuat[0], priorQuat[1], priorQuat[2], priorQuat[3]);
5205
+ deps.requestRender();
5206
+ };
5207
+ const handle = {
5208
+ close() {
5209
+ if (disposed) return;
5210
+ const finish = () => {
5211
+ teardown();
5212
+ opts.onClose?.();
5213
+ };
5214
+ cancelAnimationFrame(raf);
5215
+ fadeTo(0, finish);
5216
+ },
5217
+ dispose: teardown
5218
+ };
5219
+ closeBtn.addEventListener("click", () => handle.close());
5220
+ return handle;
5221
+ }
5222
+
4753
5223
  // src/view3d/analytics.ts
4754
5224
  var now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
4755
5225
  var Analytics3D = class {
@@ -4961,7 +5431,7 @@ function mountVenue3D(container, input, opts = {}) {
4961
5431
  ];
4962
5432
  const placeCameraFinal = (finalPos, focal) => {
4963
5433
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
4964
- orbit.camera.lookAt(new import_ogl7.Vec3(focal[0], focal[1], focal[2]));
5434
+ orbit.camera.lookAt(new import_ogl8.Vec3(focal[0], focal[1], focal[2]));
4965
5435
  orbit.camera.fov = FOV_END;
4966
5436
  orbit.camera.updateProjectionMatrix();
4967
5437
  };
@@ -5024,7 +5494,7 @@ function mountVenue3D(container, input, opts = {}) {
5024
5494
  zIndex: "4"
5025
5495
  });
5026
5496
  overviewChip.addEventListener("click", () => {
5027
- if (disposed || frozen) return;
5497
+ if (disposed || frozen || panorama) return;
5028
5498
  cancelFlight();
5029
5499
  removeArriveChip();
5030
5500
  orbit.frameSoft(model.bounds, stageAzimuth);
@@ -5052,18 +5522,36 @@ function mountVenue3D(container, input, opts = {}) {
5052
5522
  }
5053
5523
  if (disposed || gen !== flightGen) return;
5054
5524
  removeArriveChip();
5055
- panorama = mountPanorama(container, view, {
5056
- fadeMs,
5057
- seatLabel: seatId,
5058
- onClose: () => {
5059
- panorama = null;
5060
- frozen = false;
5061
- analytics.panoramaClosed();
5062
- orbit.resumeAfterFlight(model.focalWorld);
5063
- loop.requestRender();
5064
- showArriveChip(seatId, flightGen);
5065
- }
5066
- });
5525
+ const onClose = () => {
5526
+ panorama = null;
5527
+ labelOverlay.setVisible(true);
5528
+ frozen = false;
5529
+ analytics.panoramaClosed();
5530
+ orbit.resumeAfterFlight(model.focalWorld);
5531
+ loop.requestRender();
5532
+ showArriveChip(seatId, flightGen);
5533
+ };
5534
+ const sceneMode = view.generated === true;
5535
+ const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5536
+ gl: glctx.gl,
5537
+ scene: gpu.main,
5538
+ camera: orbit.camera,
5539
+ requestRender: () => loop.requestRender(),
5540
+ focalWorld: model.focalWorld
5541
+ }, { fadeMs, seatLabel: seatId, onClose }) : null;
5542
+ if (disposed || gen !== flightGen) {
5543
+ spherical?.dispose();
5544
+ return;
5545
+ }
5546
+ if (spherical) {
5547
+ orbit.syncFromCamera();
5548
+ labelOverlay.setVisible(false);
5549
+ frozen = false;
5550
+ loop.requestRender();
5551
+ panorama = spherical;
5552
+ } else {
5553
+ panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
5554
+ }
5067
5555
  analytics.panoramaOpened();
5068
5556
  };
5069
5557
  const cancelFlight = () => {
@@ -5096,7 +5584,7 @@ function mountVenue3D(container, input, opts = {}) {
5096
5584
  showArriveChip(seatId, gen);
5097
5585
  return Promise.resolve();
5098
5586
  }
5099
- const startQuat = new import_ogl7.Quat().copy(orbit.camera.quaternion);
5587
+ const startQuat = new import_ogl8.Quat().copy(orbit.camera.quaternion);
5100
5588
  const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
5101
5589
  loop.requestRender();
5102
5590
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {