@seatlayer/core 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-FGS67GT3.js → chunk-MGC5BVAD.js} +156 -33
- package/dist/chunk-MGC5BVAD.js.map +1 -0
- package/dist/index.cjs +209 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -5
- package/dist/index.d.ts +28 -5
- package/dist/index.js +61 -13
- package/dist/index.js.map +1 -1
- package/dist/{types-CG2pEDoI.d.cts → types-DO_J5DtX.d.cts} +27 -1
- package/dist/{types-CG2pEDoI.d.ts → types-DO_J5DtX.d.ts} +27 -1
- package/dist/view3d/index.cjs +1121 -304
- package/dist/view3d/index.cjs.map +1 -1
- package/dist/view3d/index.d.cts +24 -1
- package/dist/view3d/index.d.ts +24 -1
- package/dist/view3d/index.js +1116 -305
- package/dist/view3d/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-FGS67GT3.js.map +0 -1
package/dist/view3d/index.cjs
CHANGED
|
@@ -207,10 +207,18 @@ var OrbitCamera = class {
|
|
|
207
207
|
this.maxDist = 100;
|
|
208
208
|
this.gestureFired = false;
|
|
209
209
|
this.dragging = false;
|
|
210
|
+
this.panning = false;
|
|
210
211
|
this.lastX = 0;
|
|
211
212
|
this.lastY = 0;
|
|
212
213
|
this.activePointers = /* @__PURE__ */ new Map();
|
|
213
214
|
this.pinchDist = 0;
|
|
215
|
+
this.pinchCx = 0;
|
|
216
|
+
this.pinchCy = 0;
|
|
217
|
+
/** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
|
|
218
|
+
this.targetT = new import_ogl2.Vec3();
|
|
219
|
+
/** Venue centre + radius, so pan can be clamped to somewhere still useful. */
|
|
220
|
+
this.panAnchor = new import_ogl2.Vec3();
|
|
221
|
+
this.panLimit = 0;
|
|
214
222
|
this.camera = new import_ogl2.Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
|
|
215
223
|
this.canvas = canvas;
|
|
216
224
|
this.requestRender = requestRender;
|
|
@@ -222,12 +230,17 @@ var OrbitCamera = class {
|
|
|
222
230
|
}
|
|
223
231
|
this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
224
232
|
if (this.activePointers.size === 1) {
|
|
225
|
-
this.
|
|
233
|
+
this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
|
|
234
|
+
this.dragging = !this.panning;
|
|
226
235
|
this.lastX = e.clientX;
|
|
227
236
|
this.lastY = e.clientY;
|
|
228
237
|
} else if (this.activePointers.size === 2) {
|
|
229
238
|
this.dragging = false;
|
|
239
|
+
this.panning = false;
|
|
230
240
|
this.pinchDist = this.currentPinchDistance();
|
|
241
|
+
const c = this.pinchCentroid();
|
|
242
|
+
this.pinchCx = c.x;
|
|
243
|
+
this.pinchCy = c.y;
|
|
231
244
|
}
|
|
232
245
|
};
|
|
233
246
|
this.onPointerMove = (e) => {
|
|
@@ -240,11 +253,24 @@ var OrbitCamera = class {
|
|
|
240
253
|
this.fireGesture();
|
|
241
254
|
}
|
|
242
255
|
this.pinchDist = d;
|
|
256
|
+
const c = this.pinchCentroid();
|
|
257
|
+
this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
|
|
258
|
+
this.pinchCx = c.x;
|
|
259
|
+
this.pinchCy = c.y;
|
|
243
260
|
return;
|
|
244
261
|
}
|
|
245
|
-
if (!this.dragging) return;
|
|
246
262
|
const dx = e.clientX - this.lastX;
|
|
247
263
|
const dy = e.clientY - this.lastY;
|
|
264
|
+
if (this.panning) {
|
|
265
|
+
this.lastX = e.clientX;
|
|
266
|
+
this.lastY = e.clientY;
|
|
267
|
+
if (dx !== 0 || dy !== 0) {
|
|
268
|
+
this.panBy(dx, dy);
|
|
269
|
+
this.fireGesture();
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!this.dragging) return;
|
|
248
274
|
this.lastX = e.clientX;
|
|
249
275
|
this.lastY = e.clientY;
|
|
250
276
|
if (dx !== 0 || dy !== 0) this.fireGesture();
|
|
@@ -259,7 +285,13 @@ var OrbitCamera = class {
|
|
|
259
285
|
} catch {
|
|
260
286
|
}
|
|
261
287
|
if (this.activePointers.size < 2) this.pinchDist = 0;
|
|
262
|
-
if (this.activePointers.size === 0)
|
|
288
|
+
if (this.activePointers.size === 0) {
|
|
289
|
+
this.dragging = false;
|
|
290
|
+
this.panning = false;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
this.onContextMenu = (e) => {
|
|
294
|
+
e.preventDefault();
|
|
263
295
|
};
|
|
264
296
|
this.onWheel = (e) => {
|
|
265
297
|
e.preventDefault();
|
|
@@ -273,6 +305,55 @@ var OrbitCamera = class {
|
|
|
273
305
|
canvas.addEventListener("pointerup", this.onPointerUp);
|
|
274
306
|
canvas.addEventListener("pointercancel", this.onPointerUp);
|
|
275
307
|
canvas.addEventListener("wheel", this.onWheel, { passive: false });
|
|
308
|
+
canvas.addEventListener("contextmenu", this.onContextMenu);
|
|
309
|
+
}
|
|
310
|
+
pinchCentroid() {
|
|
311
|
+
const pts = [...this.activePointers.values()];
|
|
312
|
+
if (!pts.length) return { x: 0, y: 0 };
|
|
313
|
+
let x = 0;
|
|
314
|
+
let y = 0;
|
|
315
|
+
for (const p of pts) {
|
|
316
|
+
x += p.x;
|
|
317
|
+
y += p.y;
|
|
318
|
+
}
|
|
319
|
+
return { x: x / pts.length, y: y / pts.length };
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Slide the orbit pivot across the camera's own screen plane.
|
|
323
|
+
*
|
|
324
|
+
* Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
|
|
325
|
+
* under the cursor whatever the zoom: at the overview a drag sweeps the whole
|
|
326
|
+
* bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
|
|
327
|
+
* would be unusable at one end or the other.
|
|
328
|
+
*/
|
|
329
|
+
panBy(dxPx, dyPx) {
|
|
330
|
+
const h = this.canvas.clientHeight || 1;
|
|
331
|
+
const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
|
|
332
|
+
const sinA = Math.sin(this.azimuth);
|
|
333
|
+
const cosA = Math.cos(this.azimuth);
|
|
334
|
+
const rightX = cosA;
|
|
335
|
+
const rightZ = -sinA;
|
|
336
|
+
const cp = Math.cos(this.polar);
|
|
337
|
+
const sp = Math.sin(this.polar);
|
|
338
|
+
const fwdX = -sinA * cp;
|
|
339
|
+
const fwdZ = -cosA * cp;
|
|
340
|
+
this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
|
|
341
|
+
this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
|
|
342
|
+
this.targetT.y += dyPx * sp * perPx;
|
|
343
|
+
this.clampPan();
|
|
344
|
+
this.requestRender();
|
|
345
|
+
}
|
|
346
|
+
/** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
|
|
347
|
+
* venue entirely — the Overview chip should never be the only way back. */
|
|
348
|
+
clampPan() {
|
|
349
|
+
if (this.panLimit <= 0) return;
|
|
350
|
+
const lim = this.panLimit;
|
|
351
|
+
const cx = this.panAnchor.x;
|
|
352
|
+
const cy = this.panAnchor.y;
|
|
353
|
+
const cz = this.panAnchor.z;
|
|
354
|
+
this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
|
|
355
|
+
this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
|
|
356
|
+
this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
|
|
276
357
|
}
|
|
277
358
|
/** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
|
|
278
359
|
fireGesture() {
|
|
@@ -299,6 +380,9 @@ var OrbitCamera = class {
|
|
|
299
380
|
*/
|
|
300
381
|
frame(bounds, intro = false, stageAzimuth) {
|
|
301
382
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
383
|
+
this.targetT.copy(this.target);
|
|
384
|
+
this.panAnchor.copy(this.target);
|
|
385
|
+
this.panLimit = Math.max(1, bounds.radius) * 1.5;
|
|
302
386
|
const r = Math.max(1, bounds.radius);
|
|
303
387
|
const halfV = this.fovY * DEG / 2;
|
|
304
388
|
const aspect = this.camera.aspect || 1;
|
|
@@ -330,6 +414,9 @@ var OrbitCamera = class {
|
|
|
330
414
|
*/
|
|
331
415
|
frameSoft(bounds, stageAzimuth) {
|
|
332
416
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
417
|
+
this.targetT.copy(this.target);
|
|
418
|
+
this.panAnchor.copy(this.target);
|
|
419
|
+
this.panLimit = Math.max(1, bounds.radius) * 1.5;
|
|
333
420
|
this.syncFromCamera();
|
|
334
421
|
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
335
422
|
const r = Math.max(1, bounds.radius);
|
|
@@ -346,10 +433,17 @@ var OrbitCamera = class {
|
|
|
346
433
|
const da = this.azT - this.azimuth;
|
|
347
434
|
const dp = this.polT - this.polar;
|
|
348
435
|
const dd = this.distT - this.distance;
|
|
349
|
-
const
|
|
436
|
+
const tx = this.targetT.x - this.target.x;
|
|
437
|
+
const ty = this.targetT.y - this.target.y;
|
|
438
|
+
const tz = this.targetT.z - this.target.z;
|
|
439
|
+
const panEps = Math.max(1e-4, this.distance * 1e-4);
|
|
440
|
+
const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4 || Math.abs(tx) > panEps || Math.abs(ty) > panEps || Math.abs(tz) > panEps;
|
|
350
441
|
this.azimuth += da * DAMP;
|
|
351
442
|
this.polar += dp * DAMP;
|
|
352
443
|
this.distance += dd * DAMP;
|
|
444
|
+
this.target.x += tx * DAMP;
|
|
445
|
+
this.target.y += ty * DAMP;
|
|
446
|
+
this.target.z += tz * DAMP;
|
|
353
447
|
if (moving) this.applyPosition();
|
|
354
448
|
return moving;
|
|
355
449
|
}
|
|
@@ -375,6 +469,7 @@ var OrbitCamera = class {
|
|
|
375
469
|
/** Point the orbit pivot at a new world target without moving the camera. */
|
|
376
470
|
setTarget(target) {
|
|
377
471
|
this.target.set(target[0], target[1], target[2]);
|
|
472
|
+
this.targetT.copy(this.target);
|
|
378
473
|
}
|
|
379
474
|
/** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
|
|
380
475
|
* flight ends looking at `target` (the venue focal), so re-pivot there first —
|
|
@@ -382,6 +477,7 @@ var OrbitCamera = class {
|
|
|
382
477
|
resumeAfterFlight(target) {
|
|
383
478
|
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
384
479
|
if (target) this.target.set(target[0], target[1], target[2]);
|
|
480
|
+
this.targetT.copy(this.target);
|
|
385
481
|
this.syncFromCamera();
|
|
386
482
|
}
|
|
387
483
|
applyPosition() {
|
|
@@ -398,6 +494,7 @@ var OrbitCamera = class {
|
|
|
398
494
|
this.canvas.removeEventListener("pointerup", this.onPointerUp);
|
|
399
495
|
this.canvas.removeEventListener("pointercancel", this.onPointerUp);
|
|
400
496
|
this.canvas.removeEventListener("wheel", this.onWheel);
|
|
497
|
+
this.canvas.removeEventListener("contextmenu", this.onContextMenu);
|
|
401
498
|
this.activePointers.clear();
|
|
402
499
|
}
|
|
403
500
|
};
|
|
@@ -446,17 +543,129 @@ var RenderLoop = class {
|
|
|
446
543
|
};
|
|
447
544
|
|
|
448
545
|
// src/view3d/lod.ts
|
|
546
|
+
var SEAT_MIN_PIXELS_NEAR = 2.5;
|
|
547
|
+
var SEAT_MIN_PIXELS_FAR = 1.15;
|
|
548
|
+
var CHAIR_FULL_M = 12;
|
|
549
|
+
var CHAIR_NONE_M = 22;
|
|
550
|
+
var CHAIR_GATHER_M = 30;
|
|
551
|
+
var CHAIR_REBUILD_M = 4;
|
|
552
|
+
var CHAIR_MAX_INSTANCES = 8192;
|
|
553
|
+
var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
|
|
449
554
|
function computeSeatLod(distance, radius) {
|
|
450
555
|
const near = radius * 1.4;
|
|
451
556
|
const far = radius * 3.2;
|
|
452
|
-
if (distance <= near) return { scale: 1, fade: 0 };
|
|
557
|
+
if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
|
|
453
558
|
const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
|
|
454
559
|
return {
|
|
455
560
|
scale: 1 - t * 0.4,
|
|
456
|
-
fade: t * 0.55
|
|
561
|
+
fade: t * 0.55,
|
|
562
|
+
// Tapered on the same ramp as the fade: as the block starts reading by its
|
|
563
|
+
// tier tint, the dots stop fighting each other for pixels.
|
|
564
|
+
minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
|
|
457
565
|
};
|
|
458
566
|
}
|
|
459
567
|
|
|
568
|
+
// src/view3d/scene/nearField.ts
|
|
569
|
+
var SEATS_PER_CELL = 4;
|
|
570
|
+
var NearFieldIndex = class {
|
|
571
|
+
constructor(iPosition, count) {
|
|
572
|
+
this.minX = 0;
|
|
573
|
+
this.minZ = 0;
|
|
574
|
+
this.cell = 1;
|
|
575
|
+
this.cols = 1;
|
|
576
|
+
this.rows = 1;
|
|
577
|
+
/** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
|
|
578
|
+
this.cellStart = null;
|
|
579
|
+
this.cellItems = null;
|
|
580
|
+
this.iPosition = iPosition;
|
|
581
|
+
this.count = count;
|
|
582
|
+
}
|
|
583
|
+
/** Built on demand; safe to call repeatedly. */
|
|
584
|
+
ensureGrid() {
|
|
585
|
+
if (this.cellStart || this.count === 0) return;
|
|
586
|
+
const p = this.iPosition;
|
|
587
|
+
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
|
588
|
+
for (let i = 0; i < this.count; i++) {
|
|
589
|
+
const x = p[i * 3], z = p[i * 3 + 2];
|
|
590
|
+
if (x < minX) minX = x;
|
|
591
|
+
if (x > maxX) maxX = x;
|
|
592
|
+
if (z < minZ) minZ = z;
|
|
593
|
+
if (z > maxZ) maxZ = z;
|
|
594
|
+
}
|
|
595
|
+
const w = Math.max(maxX - minX, 1e-3);
|
|
596
|
+
const h = Math.max(maxZ - minZ, 1e-3);
|
|
597
|
+
this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
|
|
598
|
+
this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
|
|
599
|
+
this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
|
|
600
|
+
this.minX = minX;
|
|
601
|
+
this.minZ = minZ;
|
|
602
|
+
const nCells = this.cols * this.rows;
|
|
603
|
+
const start = new Int32Array(nCells + 1);
|
|
604
|
+
const cellOf = (i) => {
|
|
605
|
+
const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
|
|
606
|
+
const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
|
|
607
|
+
return cz * this.cols + cx;
|
|
608
|
+
};
|
|
609
|
+
for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
|
|
610
|
+
for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
|
|
611
|
+
const items = new Int32Array(this.count);
|
|
612
|
+
const cursor = start.slice(0, nCells);
|
|
613
|
+
for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
|
|
614
|
+
this.cellStart = start;
|
|
615
|
+
this.cellItems = items;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
|
|
619
|
+
* nearest cell-ring first, and return how many were written.
|
|
620
|
+
*
|
|
621
|
+
* Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
|
|
622
|
+
* bites it drops the OUTERMOST seats, which are the ones already past the fade
|
|
623
|
+
* band and drawing nothing. A cap that truncated in index order would instead
|
|
624
|
+
* punch holes in the row you are sitting in.
|
|
625
|
+
*
|
|
626
|
+
* Note this is a horizontal (XZ) query and ignores height. A stacked venue's
|
|
627
|
+
* upper tier is therefore gathered along with the stalls beneath it — which is
|
|
628
|
+
* correct, because the fade weight is re-derived from true view depth in the
|
|
629
|
+
* shader anyway. The grid's only job is to bound the candidate set.
|
|
630
|
+
*/
|
|
631
|
+
gather(camX, camZ, radius, out) {
|
|
632
|
+
this.ensureGrid();
|
|
633
|
+
const start = this.cellStart;
|
|
634
|
+
const items = this.cellItems;
|
|
635
|
+
if (!start || !items) return 0;
|
|
636
|
+
const cap = out.length;
|
|
637
|
+
const r2 = radius * radius;
|
|
638
|
+
const cx = Math.floor((camX - this.minX) / this.cell);
|
|
639
|
+
const cz = Math.floor((camZ - this.minZ) / this.cell);
|
|
640
|
+
const maxRing = Math.ceil(radius / this.cell) + 1;
|
|
641
|
+
const p = this.iPosition;
|
|
642
|
+
let n = 0;
|
|
643
|
+
for (let ring = 0; ring <= maxRing && n < cap; ring++) {
|
|
644
|
+
const z0 = cz - ring, z1 = cz + ring;
|
|
645
|
+
const x0 = cx - ring, x1 = cx + ring;
|
|
646
|
+
for (let gz = z0; gz <= z1 && n < cap; gz++) {
|
|
647
|
+
if (gz < 0 || gz >= this.rows) continue;
|
|
648
|
+
const edge = gz === z0 || gz === z1;
|
|
649
|
+
for (let gx = x0; gx <= x1 && n < cap; gx++) {
|
|
650
|
+
if (!edge && gx !== x0 && gx !== x1) {
|
|
651
|
+
gx = x1 - 1;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (gx < 0 || gx >= this.cols) continue;
|
|
655
|
+
const c = gz * this.cols + gx;
|
|
656
|
+
for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
|
|
657
|
+
const i = items[k];
|
|
658
|
+
const dx = p[i * 3] - camX;
|
|
659
|
+
const dz = p[i * 3 + 2] - camZ;
|
|
660
|
+
if (dx * dx + dz * dz <= r2) out[n++] = i;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return n;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
460
669
|
// src/core/types.ts
|
|
461
670
|
var ACCESSIBILITY_TYPES = [
|
|
462
671
|
{ key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
|
|
@@ -483,6 +692,12 @@ function accessibilityRingColor(types) {
|
|
|
483
692
|
const primary = types?.[0];
|
|
484
693
|
return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
|
|
485
694
|
}
|
|
695
|
+
var SEAT_COMMERCIAL_MARKS = [
|
|
696
|
+
{ key: "obstructedView", label: "Obstructed view", short: "Obstructed", icon: "\u26D4" },
|
|
697
|
+
{ key: "restrictedView", label: "Restricted view", short: "Restricted", icon: "\u{1F441}" },
|
|
698
|
+
{ key: "premium", label: "Premium seat", short: "Premium", icon: "\u2605" }
|
|
699
|
+
];
|
|
700
|
+
var SEAT_COMMERCIAL_LABEL = new Map(SEAT_COMMERCIAL_MARKS.map((mark) => [mark.key, mark]));
|
|
486
701
|
var SURROUNDINGS_SHAPE_ROLES = [
|
|
487
702
|
"reference-focal",
|
|
488
703
|
"bar",
|
|
@@ -959,6 +1174,152 @@ function rectPolygon(x, y, w, h) {
|
|
|
959
1174
|
];
|
|
960
1175
|
}
|
|
961
1176
|
|
|
1177
|
+
// src/view3d/scene/seatChair.ts
|
|
1178
|
+
var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
|
|
1179
|
+
var CHAIR_PITCH_FRACTION = 0.44;
|
|
1180
|
+
var CHAIR_HALF_WIDTH_MIN_M = 0.15;
|
|
1181
|
+
var CHAIR_HALF_WIDTH_MAX_M = 0.3;
|
|
1182
|
+
var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
|
|
1183
|
+
function chairHalfWidth(pitchM) {
|
|
1184
|
+
if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
|
|
1185
|
+
return CHAIR_HALF_WIDTH_DEFAULT_M;
|
|
1186
|
+
}
|
|
1187
|
+
return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
|
|
1188
|
+
}
|
|
1189
|
+
var BACK_RAKE_SLOPE = 0.21;
|
|
1190
|
+
var PAD_BACK_GAP_M = 0.05;
|
|
1191
|
+
var PAD_TOP_M = 0.45;
|
|
1192
|
+
var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
|
|
1193
|
+
var BOXES = [
|
|
1194
|
+
// Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
|
|
1195
|
+
// over the deck and the row reads as hovering trays.
|
|
1196
|
+
{ part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
|
|
1197
|
+
// Seat pad — a full seat width across and about as deep, which is what a real
|
|
1198
|
+
// one is. Its depth is bounded by the same pitch as its width, because the
|
|
1199
|
+
// pitch measure is the tighter of the in-row and row-to-row spacings, so a
|
|
1200
|
+
// tightly-raked tier cannot drive a pad into the back of the row in front.
|
|
1201
|
+
{ part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
|
|
1202
|
+
// Back panel — thin, raked, and the tallest thing in the row, so it is what
|
|
1203
|
+
// carries the state colour when you look along a row from behind.
|
|
1204
|
+
{
|
|
1205
|
+
part: CHAIR_PART.back,
|
|
1206
|
+
min: [-1, BACK_BASE_M, -1],
|
|
1207
|
+
max: [1, 0.92, -0.72]
|
|
1208
|
+
}
|
|
1209
|
+
];
|
|
1210
|
+
var FACES = [
|
|
1211
|
+
// +X
|
|
1212
|
+
{ n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
|
|
1213
|
+
// -X
|
|
1214
|
+
{ n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
|
|
1215
|
+
// +Y
|
|
1216
|
+
{ n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
|
|
1217
|
+
// -Y
|
|
1218
|
+
{ n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
|
|
1219
|
+
// +Z
|
|
1220
|
+
{ n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
|
|
1221
|
+
// -Z
|
|
1222
|
+
{ n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
|
|
1223
|
+
];
|
|
1224
|
+
function buildChairMesh() {
|
|
1225
|
+
const vertexCount = BOXES.length * FACES.length * 4;
|
|
1226
|
+
const indexCount = BOXES.length * FACES.length * 6;
|
|
1227
|
+
const position = new Float32Array(vertexCount * 3);
|
|
1228
|
+
const normal = new Float32Array(vertexCount * 3);
|
|
1229
|
+
const part = new Float32Array(vertexCount);
|
|
1230
|
+
const index = new Uint16Array(indexCount);
|
|
1231
|
+
let v = 0;
|
|
1232
|
+
let t = 0;
|
|
1233
|
+
for (const box of BOXES) {
|
|
1234
|
+
for (const face of FACES) {
|
|
1235
|
+
const base = v;
|
|
1236
|
+
const corner3 = new Float32Array(12);
|
|
1237
|
+
for (let ci = 0; ci < 4; ci++) {
|
|
1238
|
+
const corner = face.c[ci];
|
|
1239
|
+
for (let a = 0; a < 3; a++) {
|
|
1240
|
+
corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
|
|
1244
|
+
const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
|
|
1245
|
+
let nx = ay * bz - az * by;
|
|
1246
|
+
let ny = az * bx - ax * bz;
|
|
1247
|
+
let nz = ax * by - ay * bx;
|
|
1248
|
+
const nl = Math.hypot(nx, ny, nz);
|
|
1249
|
+
if (nl > 1e-9) {
|
|
1250
|
+
nx /= nl;
|
|
1251
|
+
ny /= nl;
|
|
1252
|
+
nz /= nl;
|
|
1253
|
+
} else {
|
|
1254
|
+
nx = face.n[0];
|
|
1255
|
+
ny = face.n[1];
|
|
1256
|
+
nz = face.n[2];
|
|
1257
|
+
}
|
|
1258
|
+
if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
|
|
1259
|
+
nx = -nx;
|
|
1260
|
+
ny = -ny;
|
|
1261
|
+
nz = -nz;
|
|
1262
|
+
}
|
|
1263
|
+
for (let ci = 0; ci < 4; ci++) {
|
|
1264
|
+
position[v * 3] = corner3[ci * 3];
|
|
1265
|
+
position[v * 3 + 1] = corner3[ci * 3 + 1];
|
|
1266
|
+
position[v * 3 + 2] = corner3[ci * 3 + 2];
|
|
1267
|
+
normal[v * 3] = nx;
|
|
1268
|
+
normal[v * 3 + 1] = ny;
|
|
1269
|
+
normal[v * 3 + 2] = nz;
|
|
1270
|
+
part[v] = box.part;
|
|
1271
|
+
v++;
|
|
1272
|
+
}
|
|
1273
|
+
index[t++] = base;
|
|
1274
|
+
index[t++] = base + 1;
|
|
1275
|
+
index[t++] = base + 2;
|
|
1276
|
+
index[t++] = base;
|
|
1277
|
+
index[t++] = base + 2;
|
|
1278
|
+
index[t++] = base + 3;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return { position, normal, part, index, vertexCount, indexCount };
|
|
1282
|
+
}
|
|
1283
|
+
function computeSeatYaw(iPosition, count, rowIdAt, focal) {
|
|
1284
|
+
const yaw = new Float32Array(count);
|
|
1285
|
+
const px = (i) => iPosition[i * 3];
|
|
1286
|
+
const pz = (i) => iPosition[i * 3 + 2];
|
|
1287
|
+
let runStart = 0;
|
|
1288
|
+
const flushRun = (start, end) => {
|
|
1289
|
+
const n = end - start;
|
|
1290
|
+
for (let i = start; i < end; i++) {
|
|
1291
|
+
const [fx, fz] = focal(i);
|
|
1292
|
+
let dx = fx - px(i);
|
|
1293
|
+
let dz = fz - pz(i);
|
|
1294
|
+
if (n >= 2) {
|
|
1295
|
+
const a = Math.max(start, i - 1);
|
|
1296
|
+
const b = Math.min(end - 1, i + 1);
|
|
1297
|
+
const tx = px(b) - px(a);
|
|
1298
|
+
const tz = pz(b) - pz(a);
|
|
1299
|
+
const tl = Math.hypot(tx, tz);
|
|
1300
|
+
if (tl > 1e-6) {
|
|
1301
|
+
let nx = -tz / tl;
|
|
1302
|
+
let nz = tx / tl;
|
|
1303
|
+
if (nx * dx + nz * dz < 0) {
|
|
1304
|
+
nx = -nx;
|
|
1305
|
+
nz = -nz;
|
|
1306
|
+
}
|
|
1307
|
+
dx = nx;
|
|
1308
|
+
dz = nz;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
for (let i = 1; i <= count; i++) {
|
|
1315
|
+
if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
|
|
1316
|
+
flushRun(runStart, i);
|
|
1317
|
+
runStart = i;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return yaw;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
962
1323
|
// src/view3d/scene/seatInstances.ts
|
|
963
1324
|
var SEAT_DOT_RADIUS_M = 0.22;
|
|
964
1325
|
var SEAT_PITCH_FRACTION = 0.42;
|
|
@@ -1028,6 +1389,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
1028
1389
|
const iPosition = new Float32Array(count * 3);
|
|
1029
1390
|
const iState = new Float32Array(count);
|
|
1030
1391
|
const iMaxRadius = new Float32Array(count);
|
|
1392
|
+
const iChairWidth = new Float32Array(count);
|
|
1031
1393
|
const iRing = new Float32Array(count * 3);
|
|
1032
1394
|
const idToIndex = /* @__PURE__ */ new Map();
|
|
1033
1395
|
const spacing = nearestNeighbourSpacing(seats);
|
|
@@ -1036,6 +1398,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
1036
1398
|
const resolved = surfaces?.seatPitchU(i);
|
|
1037
1399
|
const pitchM = (resolved ?? spacing[i]) * M;
|
|
1038
1400
|
iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
|
|
1401
|
+
iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
|
|
1039
1402
|
iPosition[i * 3] = seat.x * M;
|
|
1040
1403
|
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
1041
1404
|
iPosition[i * 3 + 2] = seat.y * M;
|
|
@@ -1057,7 +1420,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
1057
1420
|
iMaxRadius,
|
|
1058
1421
|
iRing,
|
|
1059
1422
|
idToIndex,
|
|
1060
|
-
|
|
1423
|
+
iChairWidth,
|
|
1424
|
+
iFloor: seatFloor ?? new Float32Array(count),
|
|
1425
|
+
iYaw: new Float32Array(count)
|
|
1061
1426
|
};
|
|
1062
1427
|
}
|
|
1063
1428
|
var RUN_MERGE_GAP = 64;
|
|
@@ -1192,6 +1557,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
|
|
|
1192
1557
|
}
|
|
1193
1558
|
return kept;
|
|
1194
1559
|
}
|
|
1560
|
+
var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
|
|
1561
|
+
function focusScore(screen, worldDistance, width, height) {
|
|
1562
|
+
const half = Math.max(1, Math.min(width, height) * 0.5);
|
|
1563
|
+
const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
|
|
1564
|
+
return worldDistance * (1 + 1.5 * off);
|
|
1565
|
+
}
|
|
1566
|
+
function pickDenseLabels(items, separationX, separationY, budget) {
|
|
1567
|
+
const ordered = [...items].sort((a, b) => a.focus - b.focus);
|
|
1568
|
+
const kept = [];
|
|
1569
|
+
for (const item of ordered) {
|
|
1570
|
+
if (kept.length >= budget) break;
|
|
1571
|
+
let clash = false;
|
|
1572
|
+
for (const k of kept) {
|
|
1573
|
+
if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
|
|
1574
|
+
clash = true;
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (!clash) kept.push(item);
|
|
1579
|
+
}
|
|
1580
|
+
return kept;
|
|
1581
|
+
}
|
|
1195
1582
|
function centroidOf(points) {
|
|
1196
1583
|
if (!points.length) return null;
|
|
1197
1584
|
let x = 0, y = 0;
|
|
@@ -1732,195 +2119,6 @@ function buildSectionRake(rows, focal) {
|
|
|
1732
2119
|
return rowsRake(fits);
|
|
1733
2120
|
}
|
|
1734
2121
|
|
|
1735
|
-
// src/view3d/scene/surface.ts
|
|
1736
|
-
var FLAT_SLAB_TOP_M = 0.05;
|
|
1737
|
-
var CAP_MAX_ERROR_M = 0.05;
|
|
1738
|
-
var SEAT_CLEARANCE_M = 0.15;
|
|
1739
|
-
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1740
|
-
function bboxOf(pts) {
|
|
1741
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1742
|
-
for (const p of pts) {
|
|
1743
|
-
if (p.x < minX) minX = p.x;
|
|
1744
|
-
if (p.y < minY) minY = p.y;
|
|
1745
|
-
if (p.x > maxX) maxX = p.x;
|
|
1746
|
-
if (p.y > maxY) maxY = p.y;
|
|
1747
|
-
}
|
|
1748
|
-
return { minX, minY, maxX, maxY };
|
|
1749
|
-
}
|
|
1750
|
-
var MAX_TIER_RISE_M = 25;
|
|
1751
|
-
function buildVenueSurfaces(units, seats) {
|
|
1752
|
-
const bySection = /* @__PURE__ */ new Map();
|
|
1753
|
-
const seatOwner = new Array(seats.length).fill(null);
|
|
1754
|
-
const seatDeck = new Float64Array(seats.length);
|
|
1755
|
-
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
1756
|
-
const seatPitch = new Array(seats.length).fill(void 0);
|
|
1757
|
-
const acc = /* @__PURE__ */ new Map();
|
|
1758
|
-
const boxes = [];
|
|
1759
|
-
for (const unit of units) {
|
|
1760
|
-
for (const o of unit.objects) {
|
|
1761
|
-
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1762
|
-
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
1763
|
-
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
1764
|
-
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1768
|
-
const s = seats[i];
|
|
1769
|
-
for (const b of boxes) {
|
|
1770
|
-
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
1771
|
-
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
1772
|
-
seatOwner[i] = b.id;
|
|
1773
|
-
const a = acc.get(b.id);
|
|
1774
|
-
a.hasSeats = true;
|
|
1775
|
-
const f = s.focalPoint ?? b.unit.focal;
|
|
1776
|
-
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
1777
|
-
if (d < a.frontU) a.frontU = d;
|
|
1778
|
-
a.seatIndices.push(i);
|
|
1779
|
-
const rowKey = s.rowId || `__seat-${i}`;
|
|
1780
|
-
const arr = a.rows.get(rowKey);
|
|
1781
|
-
if (arr) arr.push({ x: s.x, y: s.y });
|
|
1782
|
-
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
1783
|
-
break;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
for (const [id, a] of acc) {
|
|
1787
|
-
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
1788
|
-
const bottomY = a.unit.baseHeightM;
|
|
1789
|
-
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
1790
|
-
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
1791
|
-
const kind = a.section.surfaceKind;
|
|
1792
|
-
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
1793
|
-
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
1794
|
-
const needsRake = structure.rows.length < 2;
|
|
1795
|
-
const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
|
|
1796
|
-
let frontU = Infinity;
|
|
1797
|
-
if (rake) {
|
|
1798
|
-
if (a.hasSeats) {
|
|
1799
|
-
for (const pts of a.rows.values()) {
|
|
1800
|
-
for (const p of pts) {
|
|
1801
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1802
|
-
if (d < frontU) frontU = d;
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
1807
|
-
frontU = Infinity;
|
|
1808
|
-
for (const p of a.section.outline) {
|
|
1809
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1810
|
-
if (d < frontU) frontU = d;
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
const flatTop = bottomY + FLAT_SLAB_TOP_M;
|
|
1815
|
-
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
1816
|
-
const levelFor = (depthU) => {
|
|
1817
|
-
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
1818
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1819
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1820
|
-
};
|
|
1821
|
-
const levelForBlockDepth = (blockDepthU) => {
|
|
1822
|
-
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
1823
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1824
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1825
|
-
};
|
|
1826
|
-
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
1827
|
-
pts: r.pts,
|
|
1828
|
-
y: levelForBlockDepth(r.blockDepth),
|
|
1829
|
-
depth: r.blockDepth,
|
|
1830
|
-
blockId: r.blockId
|
|
1831
|
-
}));
|
|
1832
|
-
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
1833
|
-
const rowBounds = rowLevels.map((r) => {
|
|
1834
|
-
let cx = 0, cy = 0;
|
|
1835
|
-
for (const p of r.pts) {
|
|
1836
|
-
cx += p.x;
|
|
1837
|
-
cy += p.y;
|
|
1838
|
-
}
|
|
1839
|
-
const n = r.pts.length || 1;
|
|
1840
|
-
cx /= n;
|
|
1841
|
-
cy /= n;
|
|
1842
|
-
let rad = 0;
|
|
1843
|
-
for (const p of r.pts) {
|
|
1844
|
-
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1845
|
-
if (d > rad) rad = d;
|
|
1846
|
-
}
|
|
1847
|
-
return { cx, cy, rad };
|
|
1848
|
-
});
|
|
1849
|
-
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
1850
|
-
let best = Infinity, bestY = landingY;
|
|
1851
|
-
for (let i = 0; i < rowLevels.length; i++) {
|
|
1852
|
-
const b = rowBounds[i];
|
|
1853
|
-
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
1854
|
-
const d = distanceToPolyline(rowLevels[i].pts, x, y);
|
|
1855
|
-
if (d < best) {
|
|
1856
|
-
best = d;
|
|
1857
|
-
bestY = rowLevels[i].y;
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
return bestY;
|
|
1861
|
-
} : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
|
|
1862
|
-
const UP = [0, 1, 0];
|
|
1863
|
-
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
1864
|
-
if (!rake) return UP;
|
|
1865
|
-
const d = rake.depthAt(x, y);
|
|
1866
|
-
if (d <= frontU) return UP;
|
|
1867
|
-
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
1868
|
-
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
1869
|
-
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
1870
|
-
const [gx, gy] = rake.gradientAt(x, y);
|
|
1871
|
-
if (gx === 0 && gy === 0) return UP;
|
|
1872
|
-
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
1873
|
-
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
1874
|
-
};
|
|
1875
|
-
if (!flat) {
|
|
1876
|
-
for (const r of structure.rows) {
|
|
1877
|
-
const y = levelForBlockDepth(r.blockDepth);
|
|
1878
|
-
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
for (const r of structure.rows) {
|
|
1882
|
-
const gaps = [];
|
|
1883
|
-
for (let k = 1; k < r.pts.length; k++) {
|
|
1884
|
-
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
1885
|
-
if (d > 1e-6) gaps.push(d);
|
|
1886
|
-
}
|
|
1887
|
-
gaps.sort((x, y) => x - y);
|
|
1888
|
-
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
1889
|
-
let across = Infinity;
|
|
1890
|
-
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
1891
|
-
if (probe) {
|
|
1892
|
-
for (const other of structure.rows) {
|
|
1893
|
-
if (other === r || other.blockId !== r.blockId) continue;
|
|
1894
|
-
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
1895
|
-
if (d > 1e-6 && d < across) across = d;
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
1899
|
-
if (Number.isFinite(pitch) && pitch > 0) {
|
|
1900
|
-
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
1904
|
-
}
|
|
1905
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1906
|
-
const ownerId = seatOwner[i];
|
|
1907
|
-
const s = seats[i];
|
|
1908
|
-
if (ownerId) {
|
|
1909
|
-
const own = seatRowLevel[i];
|
|
1910
|
-
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
1911
|
-
continue;
|
|
1912
|
-
}
|
|
1913
|
-
const eye = s.eyeHeightM;
|
|
1914
|
-
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1915
|
-
}
|
|
1916
|
-
return {
|
|
1917
|
-
bySection,
|
|
1918
|
-
seatOwner,
|
|
1919
|
-
seatDeckY: (i) => seatDeck[i],
|
|
1920
|
-
seatPitchU: (i) => seatPitch[i]
|
|
1921
|
-
};
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
2122
|
// src/view3d/scene/deckBands.ts
|
|
1925
2123
|
var import_polygon_clipping2 = __toESM(require("polygon-clipping"), 1);
|
|
1926
2124
|
var import_earcut2 = __toESM(require("earcut"), 1);
|
|
@@ -1969,6 +2167,68 @@ function extendEnds(pts, by) {
|
|
|
1969
2167
|
});
|
|
1970
2168
|
return out;
|
|
1971
2169
|
}
|
|
2170
|
+
var CORNER_STEP_RAD = Math.PI / 12;
|
|
2171
|
+
function convexHull(pts) {
|
|
2172
|
+
if (pts.length < 3) return [...pts];
|
|
2173
|
+
const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
|
|
2174
|
+
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
2175
|
+
const half = (src) => {
|
|
2176
|
+
const out = [];
|
|
2177
|
+
for (const p of src) {
|
|
2178
|
+
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
|
|
2179
|
+
out.push(p);
|
|
2180
|
+
}
|
|
2181
|
+
out.pop();
|
|
2182
|
+
return out;
|
|
2183
|
+
};
|
|
2184
|
+
const hull = [...half(s), ...half([...s].reverse())];
|
|
2185
|
+
return hull.length >= 3 ? hull : [...pts];
|
|
2186
|
+
}
|
|
2187
|
+
function seatClusterPatch(pts, pad) {
|
|
2188
|
+
if (!pts.length || !(pad > 0)) return [];
|
|
2189
|
+
const hull = convexHull(pts);
|
|
2190
|
+
const arc = (v, from, to, out2) => {
|
|
2191
|
+
let sweep = to - from;
|
|
2192
|
+
while (sweep < 0) sweep += Math.PI * 2;
|
|
2193
|
+
while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
|
|
2194
|
+
const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
|
|
2195
|
+
const r = pad / Math.cos(sweep / steps / 2);
|
|
2196
|
+
for (let k = 0; k <= steps; k++) {
|
|
2197
|
+
const a = from + sweep * k / steps;
|
|
2198
|
+
out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
if (hull.length < 3) {
|
|
2202
|
+
let cx = 0, cy = 0;
|
|
2203
|
+
for (const p of hull) {
|
|
2204
|
+
cx += p.x;
|
|
2205
|
+
cy += p.y;
|
|
2206
|
+
}
|
|
2207
|
+
cx /= hull.length || 1;
|
|
2208
|
+
cy /= hull.length || 1;
|
|
2209
|
+
let far = 0;
|
|
2210
|
+
for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
|
|
2211
|
+
const out2 = [];
|
|
2212
|
+
const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
|
|
2213
|
+
const r = (far + pad) / Math.cos(Math.PI / steps);
|
|
2214
|
+
for (let k = 0; k < steps; k++) {
|
|
2215
|
+
const a = k / steps * Math.PI * 2;
|
|
2216
|
+
out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
|
|
2217
|
+
}
|
|
2218
|
+
return out2;
|
|
2219
|
+
}
|
|
2220
|
+
const n = hull.length;
|
|
2221
|
+
const edgeAngle = [];
|
|
2222
|
+
for (let i = 0; i < n; i++) {
|
|
2223
|
+
const a = hull[i], b = hull[(i + 1) % n];
|
|
2224
|
+
edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
|
|
2225
|
+
}
|
|
2226
|
+
const out = [];
|
|
2227
|
+
for (let i = 0; i < n; i++) {
|
|
2228
|
+
arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
|
|
2229
|
+
}
|
|
2230
|
+
return dedupeAdjacent(out);
|
|
2231
|
+
}
|
|
1972
2232
|
function distToPolyline(pts, x, y) {
|
|
1973
2233
|
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1974
2234
|
let best = Infinity;
|
|
@@ -2099,7 +2359,14 @@ function deckFootprints(rows, focal, shared) {
|
|
|
2099
2359
|
else byBlock.set(rows[i].blockId, [i]);
|
|
2100
2360
|
}
|
|
2101
2361
|
const out = [];
|
|
2102
|
-
for (const [,
|
|
2362
|
+
for (const [, allIndices] of byBlock) {
|
|
2363
|
+
const indices = [];
|
|
2364
|
+
for (const i of allIndices) {
|
|
2365
|
+
const patch = rows[i].patch;
|
|
2366
|
+
if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
|
|
2367
|
+
else indices.push(i);
|
|
2368
|
+
}
|
|
2369
|
+
if (!indices.length) continue;
|
|
2103
2370
|
indices.sort((a, b) => rows[a].depth - rows[b].depth);
|
|
2104
2371
|
const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
|
|
2105
2372
|
const usable = ribbons.filter((r) => r !== null);
|
|
@@ -2208,106 +2475,351 @@ var ClipTest = class {
|
|
|
2208
2475
|
if (p.y > this.maxY) this.maxY = p.y;
|
|
2209
2476
|
}
|
|
2210
2477
|
}
|
|
2211
|
-
}
|
|
2212
|
-
/** True when every point lies strictly inside ONE ring of the clip region. */
|
|
2213
|
-
containsAll(pts) {
|
|
2214
|
-
for (const p of pts) {
|
|
2215
|
-
if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
|
|
2216
|
-
}
|
|
2217
|
-
for (const ring of this.rings) {
|
|
2218
|
-
let all = true;
|
|
2219
|
-
for (const p of pts) {
|
|
2220
|
-
if (!pointInRing(ring, p.x, p.y)) {
|
|
2221
|
-
all = false;
|
|
2222
|
-
break;
|
|
2478
|
+
}
|
|
2479
|
+
/** True when every point lies strictly inside ONE ring of the clip region. */
|
|
2480
|
+
containsAll(pts) {
|
|
2481
|
+
for (const p of pts) {
|
|
2482
|
+
if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
|
|
2483
|
+
}
|
|
2484
|
+
for (const ring of this.rings) {
|
|
2485
|
+
let all = true;
|
|
2486
|
+
for (const p of pts) {
|
|
2487
|
+
if (!pointInRing(ring, p.x, p.y)) {
|
|
2488
|
+
all = false;
|
|
2489
|
+
break;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
if (all) return true;
|
|
2493
|
+
}
|
|
2494
|
+
return false;
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
|
|
2498
|
+
if (poly.length < 3) return;
|
|
2499
|
+
if (clipTest.containsAll(poly)) {
|
|
2500
|
+
const UPF = [0, 1, 0];
|
|
2501
|
+
const a = poly[0];
|
|
2502
|
+
for (let i = 1; i + 1 < poly.length; i++) {
|
|
2503
|
+
const b = poly[i], c = poly[i + 1];
|
|
2504
|
+
builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
|
|
2505
|
+
}
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
const ring = poly.map((p) => [p.x, p.y]);
|
|
2509
|
+
ring.push(ring[0]);
|
|
2510
|
+
let pieces;
|
|
2511
|
+
try {
|
|
2512
|
+
pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
|
|
2513
|
+
} catch {
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
const UP = [0, 1, 0];
|
|
2517
|
+
for (const poly2 of pieces) {
|
|
2518
|
+
if (!poly2.length || poly2[0].length < 4) continue;
|
|
2519
|
+
const outer = poly2[0];
|
|
2520
|
+
const flat = [];
|
|
2521
|
+
const pts = [];
|
|
2522
|
+
for (let i = 0; i < outer.length - 1; i++) {
|
|
2523
|
+
flat.push(outer[i][0], outer[i][1]);
|
|
2524
|
+
pts.push([outer[i][0], outer[i][1]]);
|
|
2525
|
+
}
|
|
2526
|
+
if (pts.length < 3) continue;
|
|
2527
|
+
const tris = (0, import_earcut2.default)(flat, void 0, 2);
|
|
2528
|
+
for (let i = 0; i < tris.length; i += 3) {
|
|
2529
|
+
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
2530
|
+
builder.tri(
|
|
2531
|
+
[a[0] * M, y, a[1] * M],
|
|
2532
|
+
[b[0] * M, y, b[1] * M],
|
|
2533
|
+
[c[0] * M, y, c[1] * M],
|
|
2534
|
+
UP,
|
|
2535
|
+
color
|
|
2536
|
+
);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
|
|
2541
|
+
if (rows.length < 2) return;
|
|
2542
|
+
const nbrs = shared ?? rowNeighbourhoods(rows);
|
|
2543
|
+
const UP = [0, 1, 0];
|
|
2544
|
+
const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
|
|
2545
|
+
const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
|
|
2546
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2547
|
+
const row = rows[i];
|
|
2548
|
+
if (row.patch && row.patch.length >= 3) {
|
|
2549
|
+
const patch = [...row.patch];
|
|
2550
|
+
const belowY2 = nbrs[i].belowY ?? landingY;
|
|
2551
|
+
if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
|
|
2552
|
+
else {
|
|
2553
|
+
const a = patch[0];
|
|
2554
|
+
for (let k = 1; k + 1 < patch.length; k++) {
|
|
2555
|
+
const b = patch[k], c = patch[k + 1];
|
|
2556
|
+
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);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
if (row.y > belowY2 + MIN_RISER_M) {
|
|
2560
|
+
let cx = 0, cy = 0;
|
|
2561
|
+
for (const p of patch) {
|
|
2562
|
+
cx += p.x;
|
|
2563
|
+
cy += p.y;
|
|
2564
|
+
}
|
|
2565
|
+
cx /= patch.length;
|
|
2566
|
+
cy /= patch.length;
|
|
2567
|
+
for (let k = 0; k < patch.length; k++) {
|
|
2568
|
+
const p = patch[k], q = patch[(k + 1) % patch.length];
|
|
2569
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
2570
|
+
const len = Math.hypot(dx, dy);
|
|
2571
|
+
if (len < 1e-6) continue;
|
|
2572
|
+
let nx = dy / len, ny = -dx / len;
|
|
2573
|
+
if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
|
|
2574
|
+
nx = -nx;
|
|
2575
|
+
ny = -ny;
|
|
2576
|
+
}
|
|
2577
|
+
const rn = [nx, 0, ny];
|
|
2578
|
+
const pt = [p.x * M, row.y, p.y * M];
|
|
2579
|
+
const qt = [q.x * M, row.y, q.y * M];
|
|
2580
|
+
const pb = [p.x * M, belowY2, p.y * M];
|
|
2581
|
+
const qb = [q.x * M, belowY2, q.y * M];
|
|
2582
|
+
builder.tri(pt, qt, qb, rn, colors.riser);
|
|
2583
|
+
builder.tri(pt, qb, pb, rn, colors.riser);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2588
|
+
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
2589
|
+
if (!rib) continue;
|
|
2590
|
+
const { pts, nrm, front, back } = rib;
|
|
2591
|
+
const belowY = nbrs[i].belowY ?? landingY;
|
|
2592
|
+
for (let k = 0; k + 1 < pts.length; k++) {
|
|
2593
|
+
const p = pts[k], q = pts[k + 1];
|
|
2594
|
+
const np = nrm[k], nq = nrm[k + 1];
|
|
2595
|
+
if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
|
|
2596
|
+
if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
|
|
2597
|
+
const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
|
|
2598
|
+
const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
|
|
2599
|
+
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
2600
|
+
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
2601
|
+
if (clipRing && clipTest) {
|
|
2602
|
+
emitClippedPoly(builder, clipRing, clipTest, [
|
|
2603
|
+
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
2604
|
+
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
2605
|
+
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
2606
|
+
{ x: p.x + np[0] * back, y: p.y + np[1] * back }
|
|
2607
|
+
], row.y, colors.tread);
|
|
2608
|
+
} else {
|
|
2609
|
+
builder.tri(pF, qF, qB, UP, colors.tread);
|
|
2610
|
+
builder.tri(pF, qB, pB, UP, colors.tread);
|
|
2611
|
+
}
|
|
2612
|
+
if (row.y > belowY + MIN_RISER_M) {
|
|
2613
|
+
const pFd = [pF[0], belowY, pF[2]];
|
|
2614
|
+
const qFd = [qF[0], belowY, qF[2]];
|
|
2615
|
+
const rn = [-np[0], 0, -np[1]];
|
|
2616
|
+
builder.tri(pF, qF, qFd, rn, colors.riser);
|
|
2617
|
+
builder.tri(pF, qFd, pFd, rn, colors.riser);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// src/view3d/scene/surface.ts
|
|
2624
|
+
var FLAT_SLAB_TOP_M = 0.05;
|
|
2625
|
+
var CAP_MAX_ERROR_M = 0.05;
|
|
2626
|
+
var SEAT_CLEARANCE_M = 0.15;
|
|
2627
|
+
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
2628
|
+
function bboxOf(pts) {
|
|
2629
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2630
|
+
for (const p of pts) {
|
|
2631
|
+
if (p.x < minX) minX = p.x;
|
|
2632
|
+
if (p.y < minY) minY = p.y;
|
|
2633
|
+
if (p.x > maxX) maxX = p.x;
|
|
2634
|
+
if (p.y > maxY) maxY = p.y;
|
|
2635
|
+
}
|
|
2636
|
+
return { minX, minY, maxX, maxY };
|
|
2637
|
+
}
|
|
2638
|
+
function pointInRing2(ring, x, y) {
|
|
2639
|
+
let inside = false;
|
|
2640
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
2641
|
+
const a = ring[i], b = ring[j];
|
|
2642
|
+
if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
2643
|
+
}
|
|
2644
|
+
return inside;
|
|
2645
|
+
}
|
|
2646
|
+
var MAX_TIER_RISE_M = 25;
|
|
2647
|
+
function buildVenueSurfaces(units, seats) {
|
|
2648
|
+
const bySection = /* @__PURE__ */ new Map();
|
|
2649
|
+
const seatOwner = new Array(seats.length).fill(null);
|
|
2650
|
+
const seatDeck = new Float64Array(seats.length);
|
|
2651
|
+
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
2652
|
+
const seatPitch = new Array(seats.length).fill(void 0);
|
|
2653
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2654
|
+
const boxes = [];
|
|
2655
|
+
const tableRowIds = /* @__PURE__ */ new Set();
|
|
2656
|
+
for (const unit of units) {
|
|
2657
|
+
for (const o of unit.objects) {
|
|
2658
|
+
if (o.type === "table") tableRowIds.add(o.id);
|
|
2659
|
+
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
2660
|
+
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
2661
|
+
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
2662
|
+
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2666
|
+
const s = seats[i];
|
|
2667
|
+
for (const b of boxes) {
|
|
2668
|
+
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
2669
|
+
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
2670
|
+
seatOwner[i] = b.id;
|
|
2671
|
+
const a = acc.get(b.id);
|
|
2672
|
+
a.hasSeats = true;
|
|
2673
|
+
const f = s.focalPoint ?? b.unit.focal;
|
|
2674
|
+
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
2675
|
+
if (d < a.frontU) a.frontU = d;
|
|
2676
|
+
a.seatIndices.push(i);
|
|
2677
|
+
const rowKey = s.rowId || `__seat-${i}`;
|
|
2678
|
+
const arr = a.rows.get(rowKey);
|
|
2679
|
+
if (arr) arr.push({ x: s.x, y: s.y });
|
|
2680
|
+
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
2681
|
+
break;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
for (const [id, a] of acc) {
|
|
2685
|
+
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
2686
|
+
const bottomY = a.unit.baseHeightM;
|
|
2687
|
+
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
2688
|
+
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
2689
|
+
const kind = a.section.surfaceKind;
|
|
2690
|
+
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
2691
|
+
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
2692
|
+
const needsRake = structure.rows.length < 2;
|
|
2693
|
+
const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
|
|
2694
|
+
let frontU = Infinity;
|
|
2695
|
+
if (rake) {
|
|
2696
|
+
if (a.hasSeats) {
|
|
2697
|
+
for (const pts of a.rows.values()) {
|
|
2698
|
+
for (const p of pts) {
|
|
2699
|
+
const d = rake.depthAt(p.x, p.y);
|
|
2700
|
+
if (d < frontU) frontU = d;
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
2705
|
+
frontU = Infinity;
|
|
2706
|
+
for (const p of a.section.outline) {
|
|
2707
|
+
const d = rake.depthAt(p.x, p.y);
|
|
2708
|
+
if (d < frontU) frontU = d;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
2713
|
+
const flatTop = Math.max(baseFloor, geo.height);
|
|
2714
|
+
const levelFor = (depthU) => {
|
|
2715
|
+
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
2716
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
2717
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
2718
|
+
};
|
|
2719
|
+
const levelForBlockDepth = (blockDepthU) => {
|
|
2720
|
+
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
2721
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
2722
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
2723
|
+
};
|
|
2724
|
+
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
2725
|
+
pts: r.pts,
|
|
2726
|
+
y: levelForBlockDepth(r.blockDepth),
|
|
2727
|
+
depth: r.blockDepth,
|
|
2728
|
+
blockId: r.blockId,
|
|
2729
|
+
...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
|
|
2730
|
+
}));
|
|
2731
|
+
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
2732
|
+
const rowBounds = rowLevels.map((r) => {
|
|
2733
|
+
const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
|
|
2734
|
+
let cx = 0, cy = 0;
|
|
2735
|
+
for (const p of ext) {
|
|
2736
|
+
cx += p.x;
|
|
2737
|
+
cy += p.y;
|
|
2738
|
+
}
|
|
2739
|
+
const n = ext.length || 1;
|
|
2740
|
+
cx /= n;
|
|
2741
|
+
cy /= n;
|
|
2742
|
+
let rad = 0;
|
|
2743
|
+
for (const p of ext) {
|
|
2744
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
2745
|
+
if (d > rad) rad = d;
|
|
2746
|
+
}
|
|
2747
|
+
return { cx, cy, rad };
|
|
2748
|
+
});
|
|
2749
|
+
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
2750
|
+
let best = Infinity, bestY = landingY;
|
|
2751
|
+
for (let i = 0; i < rowLevels.length; i++) {
|
|
2752
|
+
const b = rowBounds[i];
|
|
2753
|
+
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
2754
|
+
const patch = rowLevels[i].patch;
|
|
2755
|
+
const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
|
|
2756
|
+
if (d < best) {
|
|
2757
|
+
best = d;
|
|
2758
|
+
bestY = rowLevels[i].y;
|
|
2223
2759
|
}
|
|
2224
2760
|
}
|
|
2225
|
-
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
}
|
|
2246
|
-
const UP = [0, 1, 0];
|
|
2247
|
-
for (const poly of pieces) {
|
|
2248
|
-
if (!poly.length || poly[0].length < 4) continue;
|
|
2249
|
-
const outer = poly[0];
|
|
2250
|
-
const flat = [];
|
|
2251
|
-
const pts = [];
|
|
2252
|
-
for (let i = 0; i < outer.length - 1; i++) {
|
|
2253
|
-
flat.push(outer[i][0], outer[i][1]);
|
|
2254
|
-
pts.push([outer[i][0], outer[i][1]]);
|
|
2255
|
-
}
|
|
2256
|
-
if (pts.length < 3) continue;
|
|
2257
|
-
const tris = (0, import_earcut2.default)(flat, void 0, 2);
|
|
2258
|
-
for (let i = 0; i < tris.length; i += 3) {
|
|
2259
|
-
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
2260
|
-
builder.tri(
|
|
2261
|
-
[a[0] * M, y, a[1] * M],
|
|
2262
|
-
[b[0] * M, y, b[1] * M],
|
|
2263
|
-
[c[0] * M, y, c[1] * M],
|
|
2264
|
-
UP,
|
|
2265
|
-
color
|
|
2266
|
-
);
|
|
2761
|
+
return bestY;
|
|
2762
|
+
} : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
|
|
2763
|
+
const UP = [0, 1, 0];
|
|
2764
|
+
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
2765
|
+
if (!rake) return UP;
|
|
2766
|
+
const d = rake.depthAt(x, y);
|
|
2767
|
+
if (d <= frontU) return UP;
|
|
2768
|
+
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
2769
|
+
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
2770
|
+
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
2771
|
+
const [gx, gy] = rake.gradientAt(x, y);
|
|
2772
|
+
if (gx === 0 && gy === 0) return UP;
|
|
2773
|
+
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
2774
|
+
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
2775
|
+
};
|
|
2776
|
+
if (!flat) {
|
|
2777
|
+
for (const r of structure.rows) {
|
|
2778
|
+
const y = levelForBlockDepth(r.blockDepth);
|
|
2779
|
+
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
2780
|
+
}
|
|
2267
2781
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
const UP = [0, 1, 0];
|
|
2274
|
-
const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
|
|
2275
|
-
const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
|
|
2276
|
-
for (let i = 0; i < rows.length; i++) {
|
|
2277
|
-
const row = rows[i];
|
|
2278
|
-
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
2279
|
-
if (!rib) continue;
|
|
2280
|
-
const { pts, nrm, front, back } = rib;
|
|
2281
|
-
const belowY = nbrs[i].belowY ?? landingY;
|
|
2282
|
-
for (let k = 0; k + 1 < pts.length; k++) {
|
|
2283
|
-
const p = pts[k], q = pts[k + 1];
|
|
2284
|
-
const np = nrm[k], nq = nrm[k + 1];
|
|
2285
|
-
if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
|
|
2286
|
-
if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
|
|
2287
|
-
const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
|
|
2288
|
-
const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
|
|
2289
|
-
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
2290
|
-
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
2291
|
-
if (clipRing && clipTest) {
|
|
2292
|
-
emitClippedQuad(builder, clipRing, clipTest, [
|
|
2293
|
-
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
2294
|
-
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
2295
|
-
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
2296
|
-
{ x: p.x + np[0] * back, y: p.y + np[1] * back }
|
|
2297
|
-
], row.y, colors.tread);
|
|
2298
|
-
} else {
|
|
2299
|
-
builder.tri(pF, qF, qB, UP, colors.tread);
|
|
2300
|
-
builder.tri(pF, qB, pB, UP, colors.tread);
|
|
2782
|
+
for (const r of structure.rows) {
|
|
2783
|
+
const gaps = [];
|
|
2784
|
+
for (let k = 1; k < r.pts.length; k++) {
|
|
2785
|
+
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
2786
|
+
if (d > 1e-6) gaps.push(d);
|
|
2301
2787
|
}
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2788
|
+
gaps.sort((x, y) => x - y);
|
|
2789
|
+
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
2790
|
+
let across = Infinity;
|
|
2791
|
+
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
2792
|
+
if (probe) {
|
|
2793
|
+
for (const other of structure.rows) {
|
|
2794
|
+
if (other === r || other.blockId !== r.blockId) continue;
|
|
2795
|
+
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
2796
|
+
if (d > 1e-6 && d < across) across = d;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
2800
|
+
if (Number.isFinite(pitch) && pitch > 0) {
|
|
2801
|
+
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
2308
2802
|
}
|
|
2309
2803
|
}
|
|
2804
|
+
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
2805
|
+
}
|
|
2806
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2807
|
+
const ownerId = seatOwner[i];
|
|
2808
|
+
const s = seats[i];
|
|
2809
|
+
if (ownerId) {
|
|
2810
|
+
const own = seatRowLevel[i];
|
|
2811
|
+
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
2812
|
+
continue;
|
|
2813
|
+
}
|
|
2814
|
+
const eye = s.eyeHeightM;
|
|
2815
|
+
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
2310
2816
|
}
|
|
2817
|
+
return {
|
|
2818
|
+
bySection,
|
|
2819
|
+
seatOwner,
|
|
2820
|
+
seatDeckY: (i) => seatDeck[i],
|
|
2821
|
+
seatPitchU: (i) => seatPitch[i]
|
|
2822
|
+
};
|
|
2311
2823
|
}
|
|
2312
2824
|
|
|
2313
2825
|
// src/view3d/scene/sceneModel.ts
|
|
@@ -2475,22 +2987,39 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
|
|
|
2475
2987
|
}
|
|
2476
2988
|
const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
|
|
2477
2989
|
const topN = (p) => surface.normalAt(p.x, p.y);
|
|
2478
|
-
|
|
2990
|
+
const level = surface.flat ? surface.landingY : void 0;
|
|
2991
|
+
for (const ring of claimed.subtract(outline, level)) {
|
|
2479
2992
|
extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
|
|
2480
2993
|
}
|
|
2481
2994
|
}
|
|
2995
|
+
function coplanarLevels(a, b) {
|
|
2996
|
+
if (a === void 0 || b === void 0) return true;
|
|
2997
|
+
return Math.abs(a - b) < 1e-3;
|
|
2998
|
+
}
|
|
2482
2999
|
var ClaimedArea = class {
|
|
2483
3000
|
constructor() {
|
|
2484
3001
|
this.rings = [];
|
|
2485
3002
|
this.boxes = [];
|
|
3003
|
+
/** Constant deck height of each claim, or undefined when it is not level. */
|
|
3004
|
+
this.levels = [];
|
|
2486
3005
|
}
|
|
2487
|
-
/**
|
|
2488
|
-
|
|
3006
|
+
/**
|
|
3007
|
+
* `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
|
|
3008
|
+
*
|
|
3009
|
+
* `level` is the claim's constant deck height when it has one. Two decks only
|
|
3010
|
+
* z-fight when they are coplanar, so only a coplanar claim may take ground
|
|
3011
|
+
* away — an elevated box hanging over a ground-level section overlaps it in
|
|
3012
|
+
* plan and must still draw its whole floor, or the box loses the part of its
|
|
3013
|
+
* deck that shares a footprint with whatever is underneath it. Passing
|
|
3014
|
+
* undefined (a surface with no single height) keeps the original
|
|
3015
|
+
* clip-against-everything behaviour.
|
|
3016
|
+
*/
|
|
3017
|
+
subtract(ring, level) {
|
|
2489
3018
|
const closed = ring.map((p) => [p.x, p.y]);
|
|
2490
3019
|
if (closed.length < 3) return [];
|
|
2491
3020
|
closed.push(closed[0]);
|
|
2492
3021
|
const box = bboxOfRing(ring);
|
|
2493
|
-
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
|
|
3022
|
+
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
|
|
2494
3023
|
let pieces = [[closed]];
|
|
2495
3024
|
if (overlapping.length) {
|
|
2496
3025
|
try {
|
|
@@ -2502,6 +3031,7 @@ var ClaimedArea = class {
|
|
|
2502
3031
|
}
|
|
2503
3032
|
this.rings.push([closed]);
|
|
2504
3033
|
this.boxes.push(box);
|
|
3034
|
+
this.levels.push(level);
|
|
2505
3035
|
const out = [];
|
|
2506
3036
|
for (const poly of pieces) {
|
|
2507
3037
|
if (!poly.length) continue;
|
|
@@ -2957,6 +3487,15 @@ function buildSceneModel(input) {
|
|
|
2957
3487
|
});
|
|
2958
3488
|
}
|
|
2959
3489
|
}
|
|
3490
|
+
seatData.iYaw = computeSeatYaw(
|
|
3491
|
+
seatData.iPosition,
|
|
3492
|
+
seats.length,
|
|
3493
|
+
(i) => seats[i].rowId,
|
|
3494
|
+
(i) => {
|
|
3495
|
+
const f = units[seatFloor[i]]?.focal ?? focal;
|
|
3496
|
+
return [f.x * M, f.y * M];
|
|
3497
|
+
}
|
|
3498
|
+
);
|
|
2960
3499
|
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
2961
3500
|
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
2962
3501
|
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
@@ -2994,7 +3533,11 @@ var KIND_STYLE = {
|
|
|
2994
3533
|
var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
|
|
2995
3534
|
var DENSE_SEPARATION = {
|
|
2996
3535
|
row: { x: 62, y: 16 },
|
|
2997
|
-
|
|
3536
|
+
// Widened from 24: at close range seat labels stopped overlapping at all, so
|
|
3537
|
+
// the separation had no work left to do and the budget was carrying the whole
|
|
3538
|
+
// load. A wider box means the few labels that ARE kept are spread across the
|
|
3539
|
+
// seating instead of clustering into one stack.
|
|
3540
|
+
seat: { x: 46, y: 18 }
|
|
2998
3541
|
};
|
|
2999
3542
|
var LabelOverlay = class {
|
|
3000
3543
|
constructor(container, opts = {}) {
|
|
@@ -3025,7 +3568,7 @@ var LabelOverlay = class {
|
|
|
3025
3568
|
*
|
|
3026
3569
|
* `viewProjection` is column-major, as OGL supplies it.
|
|
3027
3570
|
*/
|
|
3028
|
-
update(viewProjection, width, height, cameraDistance, venueRadius) {
|
|
3571
|
+
update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
|
|
3029
3572
|
if (!this.labels.length) return;
|
|
3030
3573
|
const kinds = visibleLabelKinds(cameraDistance, venueRadius);
|
|
3031
3574
|
const candidates = [];
|
|
@@ -3033,15 +3576,24 @@ var LabelOverlay = class {
|
|
|
3033
3576
|
if (!kinds.has(label.kind)) continue;
|
|
3034
3577
|
const screen = projectToScreen(viewProjection, label.anchor, width, height);
|
|
3035
3578
|
if (!screen.visible) continue;
|
|
3036
|
-
|
|
3579
|
+
const world = cameraWorld ? Math.hypot(
|
|
3580
|
+
label.anchor[0] - cameraWorld[0],
|
|
3581
|
+
label.anchor[1] - cameraWorld[1],
|
|
3582
|
+
label.anchor[2] - cameraWorld[2]
|
|
3583
|
+
) : 1;
|
|
3584
|
+
candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
|
|
3037
3585
|
}
|
|
3038
3586
|
const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
|
|
3039
3587
|
const kept = [
|
|
3040
3588
|
...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
|
|
3041
|
-
|
|
3589
|
+
// The dense rungs are BUDGETED, not merely deduplicated — see
|
|
3590
|
+
// DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
|
|
3591
|
+
// the camera gets.
|
|
3592
|
+
...["row", "seat"].flatMap((kind) => pickDenseLabels(
|
|
3042
3593
|
candidates.filter((c) => c.label.kind === kind),
|
|
3043
3594
|
DENSE_SEPARATION[kind].x,
|
|
3044
|
-
DENSE_SEPARATION[kind].y
|
|
3595
|
+
DENSE_SEPARATION[kind].y,
|
|
3596
|
+
DENSE_LABEL_BUDGET[kind]
|
|
3045
3597
|
))
|
|
3046
3598
|
];
|
|
3047
3599
|
const keptIds = new Set(kept.map((k) => k.label.id));
|
|
@@ -3090,6 +3642,13 @@ var import_ogl4 = require("ogl");
|
|
|
3090
3642
|
|
|
3091
3643
|
// src/view3d/scene/materials.ts
|
|
3092
3644
|
var import_ogl3 = require("ogl");
|
|
3645
|
+
var CHAIR_WEIGHT_GLSL = (
|
|
3646
|
+
/* glsl */
|
|
3647
|
+
`
|
|
3648
|
+
float chairWeight(float depth) {
|
|
3649
|
+
return 1.0 - smoothstep(uChairFull, uChairNone, depth);
|
|
3650
|
+
}`
|
|
3651
|
+
);
|
|
3093
3652
|
var SOLID_VERT = (
|
|
3094
3653
|
/* glsl */
|
|
3095
3654
|
`#version 300 es
|
|
@@ -3169,14 +3728,23 @@ uniform float uSeatScale;
|
|
|
3169
3728
|
uniform float uMinPixels;
|
|
3170
3729
|
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
3171
3730
|
uniform float uFocusFloor; // -1 = show every floor
|
|
3731
|
+
uniform float uChairFull; // view depth at which the chair mesh is full size
|
|
3732
|
+
uniform float uChairNone; // ...and at which it has scaled away entirely
|
|
3172
3733
|
out vec2 vUv;
|
|
3173
3734
|
out vec3 vColor;
|
|
3174
3735
|
out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
|
|
3175
3736
|
out vec3 vRing;
|
|
3176
3737
|
out float vDim;
|
|
3738
|
+
out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
|
|
3739
|
+
${CHAIR_WEIGHT_GLSL}
|
|
3177
3740
|
void main() {
|
|
3178
3741
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
3179
3742
|
float depth = max(-mv.z, 0.001);
|
|
3743
|
+
// Hand the seat over to the chair mesh as it comes into range. Derived from
|
|
3744
|
+
// this instance's OWN depth rather than from a global uniform, so a row two
|
|
3745
|
+
// metres away and the far side of the bowl resolve differently in the same
|
|
3746
|
+
// frame \u2014 which is the entire point of a ladder over a switch.
|
|
3747
|
+
vDotWeight = 1.0 - chairWeight(depth);
|
|
3180
3748
|
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
3181
3749
|
// Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
|
|
3182
3750
|
// unbounded growth is what merges neighbouring rows into one mass at range.
|
|
@@ -3210,6 +3778,7 @@ in vec3 vColor;
|
|
|
3210
3778
|
in float vBudget;
|
|
3211
3779
|
in vec3 vRing;
|
|
3212
3780
|
in float vDim;
|
|
3781
|
+
in float vDotWeight;
|
|
3213
3782
|
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
3214
3783
|
uniform vec3 uFadeColor;
|
|
3215
3784
|
out vec4 fragColor;
|
|
@@ -3234,9 +3803,131 @@ void main() {
|
|
|
3234
3803
|
// Seats on an unfocused floor recede with their structure.
|
|
3235
3804
|
c = mix(c, uFadeColor, vDim * 0.75);
|
|
3236
3805
|
alpha *= mix(1.0, 0.30, vDim);
|
|
3806
|
+
// Yield to the chair. The chair grows out of this exact point, so through the
|
|
3807
|
+
// band the dot is always at least as big as the chair inside it and the seat
|
|
3808
|
+
// never thins out to nothing in between.
|
|
3809
|
+
alpha *= vDotWeight;
|
|
3810
|
+
if (alpha <= 0.0) discard;
|
|
3237
3811
|
fragColor = vec4(c, alpha);
|
|
3238
3812
|
}`
|
|
3239
3813
|
);
|
|
3814
|
+
var CHAIR_VERT = (
|
|
3815
|
+
/* glsl */
|
|
3816
|
+
`#version 300 es
|
|
3817
|
+
precision highp float;
|
|
3818
|
+
in vec3 position; // local: x/z in units of the seat radius, y in METRES
|
|
3819
|
+
in vec3 normal;
|
|
3820
|
+
in float part; // 0 = pedestal, 1 = pad, 2 = back
|
|
3821
|
+
in vec3 iOffset; // per-instance world deck point (identical to the dot's)
|
|
3822
|
+
in vec3 iColor; // per-instance state colour
|
|
3823
|
+
in float iRadius; // per-instance horizontal half-width, world metres
|
|
3824
|
+
in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
|
|
3825
|
+
in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
|
|
3826
|
+
in float iFloor;
|
|
3827
|
+
uniform mat4 modelViewMatrix;
|
|
3828
|
+
uniform mat4 projectionMatrix;
|
|
3829
|
+
uniform float uChairFull;
|
|
3830
|
+
uniform float uChairNone;
|
|
3831
|
+
uniform float uFocusFloor;
|
|
3832
|
+
uniform float uBackRake; // metres of z per metre of rise, above uBackBase
|
|
3833
|
+
uniform float uBackBase; // local height at which the back starts
|
|
3834
|
+
out vec3 vColor;
|
|
3835
|
+
out vec3 vNormalWorld;
|
|
3836
|
+
out vec3 vNormalView;
|
|
3837
|
+
out vec3 vPosView;
|
|
3838
|
+
out float vPart;
|
|
3839
|
+
out float vHeight; // local height in metres, for the vertical occlusion ramp
|
|
3840
|
+
out vec3 vRing;
|
|
3841
|
+
out float vDim;
|
|
3842
|
+
${CHAIR_WEIGHT_GLSL}
|
|
3843
|
+
void main() {
|
|
3844
|
+
vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
|
|
3845
|
+
float w = chairWeight(max(-anchor.z, 0.001));
|
|
3846
|
+
// 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
|
|
3847
|
+
// chairs, but nobody gets a short one (people are the same height at every
|
|
3848
|
+
// seat pitch).
|
|
3849
|
+
vec3 p = position;
|
|
3850
|
+
p.xz *= iRadius;
|
|
3851
|
+
// 2. Lean the back. Done here rather than in the base mesh so the lean is a
|
|
3852
|
+
// real angle in METRES \u2014 baked into the mesh it would scale with the seat's
|
|
3853
|
+
// width and the same chair would lean 20 degrees on a wide stadium row and
|
|
3854
|
+
// 6 on a tight theatre one.
|
|
3855
|
+
float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
|
|
3856
|
+
p.z -= rake;
|
|
3857
|
+
// 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
|
|
3858
|
+
// opacity \u2014 which is what makes the handover invisible. sqrt front-loads
|
|
3859
|
+
// the growth so the chair is already near full size while the dot is still
|
|
3860
|
+
// half there; see chairScale() in lod.ts.
|
|
3861
|
+
p *= sqrt(w);
|
|
3862
|
+
float c = cos(iYaw), s = sin(iYaw);
|
|
3863
|
+
vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
|
|
3864
|
+
// Normals under the same two transforms, in reverse and inverted-transposed.
|
|
3865
|
+
// The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
|
|
3866
|
+
// unchanged; and the rake is a shear, whose normal transform adds a y term.
|
|
3867
|
+
// Skipping either lights the raked back as though it were still vertical.
|
|
3868
|
+
vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
|
|
3869
|
+
if (part > 1.5) n.y += uBackRake * n.z;
|
|
3870
|
+
n = normalize(n);
|
|
3871
|
+
vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
|
|
3872
|
+
vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
|
|
3873
|
+
vPosView = mv.xyz;
|
|
3874
|
+
vNormalWorld = rn;
|
|
3875
|
+
vNormalView = normalize(mat3(modelViewMatrix) * rn);
|
|
3876
|
+
vColor = iColor;
|
|
3877
|
+
vPart = part;
|
|
3878
|
+
vHeight = position.y;
|
|
3879
|
+
vRing = iRing;
|
|
3880
|
+
vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
3881
|
+
gl_Position = projectionMatrix * mv;
|
|
3882
|
+
}`
|
|
3883
|
+
);
|
|
3884
|
+
var CHAIR_FRAG = (
|
|
3885
|
+
/* glsl */
|
|
3886
|
+
`#version 300 es
|
|
3887
|
+
precision highp float;
|
|
3888
|
+
in vec3 vColor;
|
|
3889
|
+
in vec3 vNormalWorld;
|
|
3890
|
+
in vec3 vNormalView;
|
|
3891
|
+
in vec3 vPosView;
|
|
3892
|
+
in float vPart;
|
|
3893
|
+
in float vHeight;
|
|
3894
|
+
in vec3 vRing;
|
|
3895
|
+
in float vDim;
|
|
3896
|
+
uniform vec3 uKeyDir;
|
|
3897
|
+
uniform vec3 uFadeColor;
|
|
3898
|
+
out vec4 fragColor;
|
|
3899
|
+
void main() {
|
|
3900
|
+
vec3 N = normalize(vNormalWorld);
|
|
3901
|
+
vec3 V = normalize(-vPosView);
|
|
3902
|
+
// The solids' rig, unchanged, so the chairs sit in the venue's light.
|
|
3903
|
+
float hemi = 0.5 + 0.5 * N.y;
|
|
3904
|
+
float key = max(dot(N, uKeyDir), 0.0);
|
|
3905
|
+
vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
|
|
3906
|
+
float fill = max(dot(N, fillDir), 0.0);
|
|
3907
|
+
vec3 tint = vColor;
|
|
3908
|
+
// The accommodation ring, kept legible once the dot (which drew it) is gone:
|
|
3909
|
+
// an accessible seat's PEDESTAL is painted in the ring colour, so the marker
|
|
3910
|
+
// survives to close range instead of vanishing exactly when the buyer arrives.
|
|
3911
|
+
float ringMask = step(0.001, dot(vRing, vRing));
|
|
3912
|
+
if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
|
|
3913
|
+
// Pad brightest, back a step below it, pedestal darkest. Three untextured
|
|
3914
|
+
// boxes only read as one object if they are separated tonally \u2014 with a single
|
|
3915
|
+
// flat colour the chair silhouettes as a crate.
|
|
3916
|
+
float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
|
|
3917
|
+
// Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
|
|
3918
|
+
// sits to the deck the less sky it can actually see. This is the depth cue \u2014
|
|
3919
|
+
// without it the pad top, the back and the deck all resolve to the same flat
|
|
3920
|
+
// value and the row loses its form entirely.
|
|
3921
|
+
float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
|
|
3922
|
+
vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
|
|
3923
|
+
float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
|
|
3924
|
+
// A brighter rim than the solids get: it picks out every chair's own edge,
|
|
3925
|
+
// which is what stops a block of them merging into one mass up close.
|
|
3926
|
+
base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
|
|
3927
|
+
base = mix(base, uFadeColor, vDim * 0.75);
|
|
3928
|
+
fragColor = vec4(base, 1.0);
|
|
3929
|
+
}`
|
|
3930
|
+
);
|
|
3240
3931
|
var BG_VERT = (
|
|
3241
3932
|
/* glsl */
|
|
3242
3933
|
`#version 300 es
|
|
@@ -3333,7 +4024,7 @@ function createSeatPickProgram(gl) {
|
|
|
3333
4024
|
uniforms: {
|
|
3334
4025
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
3335
4026
|
uSeatScale: { value: 1 },
|
|
3336
|
-
uMinPixels: { value:
|
|
4027
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
3337
4028
|
uPixelToWorld: { value: 2e-3 }
|
|
3338
4029
|
}
|
|
3339
4030
|
});
|
|
@@ -3378,11 +4069,32 @@ function createSeatProgram(gl) {
|
|
|
3378
4069
|
uniforms: {
|
|
3379
4070
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
3380
4071
|
uSeatScale: { value: 1 },
|
|
3381
|
-
uMinPixels: { value:
|
|
4072
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
3382
4073
|
uPixelToWorld: { value: 2e-3 },
|
|
3383
4074
|
uSeatFade: { value: 0 },
|
|
3384
4075
|
uFocusFloor: { value: -1 },
|
|
3385
|
-
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
|
|
4076
|
+
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
|
|
4077
|
+
uChairFull: { value: CHAIR_FULL_M },
|
|
4078
|
+
uChairNone: { value: CHAIR_NONE_M }
|
|
4079
|
+
}
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
function createChairProgram(gl) {
|
|
4083
|
+
return new import_ogl3.Program(gl, {
|
|
4084
|
+
vertex: CHAIR_VERT,
|
|
4085
|
+
fragment: CHAIR_FRAG,
|
|
4086
|
+
transparent: false,
|
|
4087
|
+
depthTest: true,
|
|
4088
|
+
depthWrite: true,
|
|
4089
|
+
cullFace: false,
|
|
4090
|
+
uniforms: {
|
|
4091
|
+
uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
|
|
4092
|
+
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
|
|
4093
|
+
uChairFull: { value: CHAIR_FULL_M },
|
|
4094
|
+
uChairNone: { value: CHAIR_NONE_M },
|
|
4095
|
+
uBackRake: { value: BACK_RAKE_SLOPE },
|
|
4096
|
+
uBackBase: { value: BACK_BASE_M },
|
|
4097
|
+
uFocusFloor: { value: -1 }
|
|
3386
4098
|
}
|
|
3387
4099
|
});
|
|
3388
4100
|
}
|
|
@@ -3448,16 +4160,90 @@ function buildGpuScene(gl, model) {
|
|
|
3448
4160
|
seatMesh.frustumCulled = false;
|
|
3449
4161
|
if (model.seats.count > 0) seatMesh.setParent(main);
|
|
3450
4162
|
const colorAttr = seatGeo.attributes.iColor;
|
|
3451
|
-
|
|
4163
|
+
const chairBase = buildChairMesh();
|
|
4164
|
+
const chairProg = createChairProgram(gl);
|
|
4165
|
+
const CAP = CHAIR_MAX_INSTANCES;
|
|
4166
|
+
const cOffset = new Float32Array(CAP * 3);
|
|
4167
|
+
const cColor = new Float32Array(CAP * 3);
|
|
4168
|
+
const cRadius = new Float32Array(CAP);
|
|
4169
|
+
const cYaw = new Float32Array(CAP);
|
|
4170
|
+
const cRing = new Float32Array(CAP * 3);
|
|
4171
|
+
const cFloor = new Float32Array(CAP);
|
|
4172
|
+
const chairGeo = new import_ogl4.Geometry(gl, {
|
|
4173
|
+
position: { size: 3, data: chairBase.position },
|
|
4174
|
+
normal: { size: 3, data: chairBase.normal },
|
|
4175
|
+
part: { size: 1, data: chairBase.part },
|
|
4176
|
+
index: { data: chairBase.index },
|
|
4177
|
+
iOffset: { size: 3, data: cOffset, instanced: 1 },
|
|
4178
|
+
iColor: { size: 3, data: cColor, instanced: 1 },
|
|
4179
|
+
iRadius: { size: 1, data: cRadius, instanced: 1 },
|
|
4180
|
+
iYaw: { size: 1, data: cYaw, instanced: 1 },
|
|
4181
|
+
iRing: { size: 3, data: cRing, instanced: 1 },
|
|
4182
|
+
iFloor: { size: 1, data: cFloor, instanced: 1 }
|
|
4183
|
+
});
|
|
4184
|
+
const chairMesh = new import_ogl4.Mesh(gl, { geometry: chairGeo, program: chairProg });
|
|
4185
|
+
chairMesh.frustumCulled = false;
|
|
4186
|
+
let nearCount = 0;
|
|
4187
|
+
let nearIndices = new Int32Array(0);
|
|
4188
|
+
const writeChairColors = () => {
|
|
4189
|
+
const src = model.seats;
|
|
4190
|
+
for (let k = 0; k < nearCount; k++) {
|
|
4191
|
+
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];
|
|
4196
|
+
}
|
|
4197
|
+
chairGeo.attributes.iColor.needsUpdate = true;
|
|
4198
|
+
};
|
|
4199
|
+
const scene = {
|
|
3452
4200
|
main,
|
|
3453
4201
|
background,
|
|
3454
4202
|
seatProgram: seatProg,
|
|
3455
4203
|
solidProgram: solidProg,
|
|
4204
|
+
chairProgram: chairProg,
|
|
3456
4205
|
seatGeometry: seatGeo,
|
|
3457
4206
|
solidGeometry: solidGeo,
|
|
3458
4207
|
drawCalls: 3,
|
|
4208
|
+
setNearSeats(indices, count) {
|
|
4209
|
+
const n = Math.min(count, CAP);
|
|
4210
|
+
nearIndices = indices;
|
|
4211
|
+
nearCount = n;
|
|
4212
|
+
if (n === 0) {
|
|
4213
|
+
if (chairMesh.parent) chairMesh.setParent(null);
|
|
4214
|
+
chairGeo.instancedCount = 0;
|
|
4215
|
+
scene.drawCalls = 3;
|
|
4216
|
+
return;
|
|
4217
|
+
}
|
|
4218
|
+
const src = model.seats;
|
|
4219
|
+
for (let k = 0; k < n; k++) {
|
|
4220
|
+
const i = indices[k];
|
|
4221
|
+
cOffset[k * 3] = src.iPosition[i * 3];
|
|
4222
|
+
cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
|
|
4223
|
+
cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
|
|
4224
|
+
cRadius[k] = src.iChairWidth[i];
|
|
4225
|
+
cYaw[k] = src.iYaw[i];
|
|
4226
|
+
cRing[k * 3] = src.iRing[i * 3];
|
|
4227
|
+
cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
|
|
4228
|
+
cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
|
|
4229
|
+
cFloor[k] = src.iFloor[i];
|
|
4230
|
+
}
|
|
4231
|
+
writeChairColors();
|
|
4232
|
+
chairGeo.attributes.iOffset.needsUpdate = true;
|
|
4233
|
+
chairGeo.attributes.iRadius.needsUpdate = true;
|
|
4234
|
+
chairGeo.attributes.iYaw.needsUpdate = true;
|
|
4235
|
+
chairGeo.attributes.iRing.needsUpdate = true;
|
|
4236
|
+
chairGeo.attributes.iFloor.needsUpdate = true;
|
|
4237
|
+
chairGeo.instancedCount = n;
|
|
4238
|
+
if (!chairMesh.parent) chairMesh.setParent(main);
|
|
4239
|
+
scene.drawCalls = 4;
|
|
4240
|
+
},
|
|
4241
|
+
nearSeatCount() {
|
|
4242
|
+
return nearCount;
|
|
4243
|
+
},
|
|
3459
4244
|
uploadSeatStateRuns(runs) {
|
|
3460
4245
|
if (!runs.length) return;
|
|
4246
|
+
if (nearCount) writeChairColors();
|
|
3461
4247
|
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
|
|
3462
4248
|
const buffer = colorAttr.buffer;
|
|
3463
4249
|
if (!buffer) {
|
|
@@ -3477,8 +4263,11 @@ function buildGpuScene(gl, model) {
|
|
|
3477
4263
|
solidProg.remove();
|
|
3478
4264
|
seatGeo.remove();
|
|
3479
4265
|
seatProg.remove();
|
|
4266
|
+
chairGeo.remove();
|
|
4267
|
+
chairProg.remove();
|
|
3480
4268
|
}
|
|
3481
4269
|
};
|
|
4270
|
+
return scene;
|
|
3482
4271
|
}
|
|
3483
4272
|
|
|
3484
4273
|
// src/view3d/pick/pickPipeline.ts
|
|
@@ -4046,6 +4835,28 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
4046
4835
|
gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
|
|
4047
4836
|
gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
4048
4837
|
gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
4838
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
4839
|
+
lastGatherX = Infinity;
|
|
4840
|
+
};
|
|
4841
|
+
const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
|
|
4842
|
+
const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
|
|
4843
|
+
let lastGatherX = Infinity;
|
|
4844
|
+
let lastGatherZ = Infinity;
|
|
4845
|
+
const updateNearField = () => {
|
|
4846
|
+
if (!gpu) return;
|
|
4847
|
+
const cam = orbit.camera.position;
|
|
4848
|
+
const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
|
|
4849
|
+
if (moved2 < CHAIR_REBUILD_M) return;
|
|
4850
|
+
lastGatherX = cam.x;
|
|
4851
|
+
lastGatherZ = cam.z;
|
|
4852
|
+
const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
|
|
4853
|
+
if (outside > CHAIR_GATHER_M) {
|
|
4854
|
+
if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
|
|
4855
|
+
return;
|
|
4856
|
+
}
|
|
4857
|
+
const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
|
|
4858
|
+
if (n === 0 && gpu.nearSeatCount() === 0) return;
|
|
4859
|
+
gpu.setNearSeats(nearBuf, n);
|
|
4049
4860
|
};
|
|
4050
4861
|
const glctx = new GLContext(container, {
|
|
4051
4862
|
onContextLost: () => {
|
|
@@ -4092,7 +4903,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
4092
4903
|
const u = gpu.seatProgram.uniforms;
|
|
4093
4904
|
u.uSeatScale.value = lod.scale;
|
|
4094
4905
|
u.uSeatFade.value = lod.fade;
|
|
4906
|
+
u.uMinPixels.value = lod.minPixels;
|
|
4095
4907
|
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
|
|
4908
|
+
updateNearField();
|
|
4096
4909
|
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
4097
4910
|
glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
|
|
4098
4911
|
labelOverlay.update(
|
|
@@ -4100,7 +4913,10 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
4100
4913
|
glctx.canvas.clientWidth || 1,
|
|
4101
4914
|
glctx.canvas.clientHeight || 1,
|
|
4102
4915
|
orbit.currentDistance,
|
|
4103
|
-
model.bounds.radius
|
|
4916
|
+
model.bounds.radius,
|
|
4917
|
+
// The dense label rungs rank by real distance from the eye, not by the
|
|
4918
|
+
// orbit radius — at the arrival pose those are wildly different numbers.
|
|
4919
|
+
[orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
|
|
4104
4920
|
);
|
|
4105
4921
|
return moving;
|
|
4106
4922
|
});
|
|
@@ -4373,6 +5189,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
4373
5189
|
if (gpu) {
|
|
4374
5190
|
gpu.seatProgram.uniforms.uFocusFloor.value = value;
|
|
4375
5191
|
gpu.solidProgram.uniforms.uFocusFloor.value = value;
|
|
5192
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = value;
|
|
4376
5193
|
}
|
|
4377
5194
|
focusedFloor = value;
|
|
4378
5195
|
if (index !== null) {
|