@seatlayer/core 0.30.1 → 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 +1484 -404
- package/dist/view3d/index.cjs.map +1 -1
- package/dist/view3d/index.d.cts +53 -2
- package/dist/view3d/index.d.ts +53 -2
- package/dist/view3d/index.js +1487 -413
- 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",
|
|
@@ -548,12 +763,42 @@ function isDegenerate(p0, p1, p2) {
|
|
|
548
763
|
const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
|
|
549
764
|
return 2 * area / longest < MIN_TRI_ALTITUDE_M;
|
|
550
765
|
}
|
|
766
|
+
var F32Buffer = class {
|
|
767
|
+
constructor(initial = 1 << 14) {
|
|
768
|
+
this.len = 0;
|
|
769
|
+
this.buf = new Float32Array(initial);
|
|
770
|
+
}
|
|
771
|
+
push3(a, b, c) {
|
|
772
|
+
if (this.len + 3 > this.buf.length) this.grow(this.len + 3);
|
|
773
|
+
this.buf[this.len++] = a;
|
|
774
|
+
this.buf[this.len++] = b;
|
|
775
|
+
this.buf[this.len++] = c;
|
|
776
|
+
}
|
|
777
|
+
push1(a) {
|
|
778
|
+
if (this.len + 1 > this.buf.length) this.grow(this.len + 1);
|
|
779
|
+
this.buf[this.len++] = a;
|
|
780
|
+
}
|
|
781
|
+
grow(need) {
|
|
782
|
+
let cap = this.buf.length * 2;
|
|
783
|
+
while (cap < need) cap *= 2;
|
|
784
|
+
const next = new Float32Array(cap);
|
|
785
|
+
next.set(this.buf.subarray(0, this.len));
|
|
786
|
+
this.buf = next;
|
|
787
|
+
}
|
|
788
|
+
get length() {
|
|
789
|
+
return this.len;
|
|
790
|
+
}
|
|
791
|
+
/** A copy trimmed to the used length. */
|
|
792
|
+
toArray() {
|
|
793
|
+
return this.buf.slice(0, this.len);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
551
796
|
var MeshBuilder = class {
|
|
552
797
|
constructor() {
|
|
553
|
-
this.pos =
|
|
554
|
-
this.nor =
|
|
555
|
-
this.col =
|
|
556
|
-
this.flr =
|
|
798
|
+
this.pos = new F32Buffer();
|
|
799
|
+
this.nor = new F32Buffer();
|
|
800
|
+
this.col = new F32Buffer();
|
|
801
|
+
this.flr = new F32Buffer();
|
|
557
802
|
/** Floor index stamped onto every triangle emitted from now on. */
|
|
558
803
|
this.currentFloor = 0;
|
|
559
804
|
}
|
|
@@ -564,28 +809,44 @@ var MeshBuilder = class {
|
|
|
564
809
|
/** One triangle with a shared (flat) normal and per-vertex colours. */
|
|
565
810
|
tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
|
|
566
811
|
if (isDegenerate(p0, p1, p2)) return;
|
|
567
|
-
this.pos.
|
|
568
|
-
this.
|
|
569
|
-
this.
|
|
570
|
-
this.
|
|
812
|
+
this.pos.push3(p0[0], p0[1], p0[2]);
|
|
813
|
+
this.pos.push3(p1[0], p1[1], p1[2]);
|
|
814
|
+
this.pos.push3(p2[0], p2[1], p2[2]);
|
|
815
|
+
this.nor.push3(n[0], n[1], n[2]);
|
|
816
|
+
this.nor.push3(n[0], n[1], n[2]);
|
|
817
|
+
this.nor.push3(n[0], n[1], n[2]);
|
|
818
|
+
this.col.push3(c0[0], c0[1], c0[2]);
|
|
819
|
+
this.col.push3(c1[0], c1[1], c1[2]);
|
|
820
|
+
this.col.push3(c2[0], c2[1], c2[2]);
|
|
821
|
+
this.flr.push1(this.currentFloor);
|
|
822
|
+
this.flr.push1(this.currentFloor);
|
|
823
|
+
this.flr.push1(this.currentFloor);
|
|
571
824
|
}
|
|
572
825
|
/** One triangle with independent per-vertex normals (smooth shading). */
|
|
573
826
|
triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
|
|
574
827
|
if (isDegenerate(p0, p1, p2)) return;
|
|
575
|
-
this.pos.
|
|
576
|
-
this.
|
|
577
|
-
this.
|
|
578
|
-
this.
|
|
828
|
+
this.pos.push3(p0[0], p0[1], p0[2]);
|
|
829
|
+
this.pos.push3(p1[0], p1[1], p1[2]);
|
|
830
|
+
this.pos.push3(p2[0], p2[1], p2[2]);
|
|
831
|
+
this.nor.push3(n0[0], n0[1], n0[2]);
|
|
832
|
+
this.nor.push3(n1[0], n1[1], n1[2]);
|
|
833
|
+
this.nor.push3(n2[0], n2[1], n2[2]);
|
|
834
|
+
this.col.push3(c0[0], c0[1], c0[2]);
|
|
835
|
+
this.col.push3(c1[0], c1[1], c1[2]);
|
|
836
|
+
this.col.push3(c2[0], c2[1], c2[2]);
|
|
837
|
+
this.flr.push1(this.currentFloor);
|
|
838
|
+
this.flr.push1(this.currentFloor);
|
|
839
|
+
this.flr.push1(this.currentFloor);
|
|
579
840
|
}
|
|
580
841
|
get vertexCount() {
|
|
581
842
|
return this.pos.length / 3;
|
|
582
843
|
}
|
|
583
844
|
build() {
|
|
584
845
|
return {
|
|
585
|
-
position:
|
|
586
|
-
normal:
|
|
587
|
-
color:
|
|
588
|
-
floor:
|
|
846
|
+
position: this.pos.toArray(),
|
|
847
|
+
normal: this.nor.toArray(),
|
|
848
|
+
color: this.col.toArray(),
|
|
849
|
+
floor: this.flr.toArray(),
|
|
589
850
|
count: this.pos.length / 3
|
|
590
851
|
};
|
|
591
852
|
}
|
|
@@ -913,6 +1174,152 @@ function rectPolygon(x, y, w, h) {
|
|
|
913
1174
|
];
|
|
914
1175
|
}
|
|
915
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
|
+
|
|
916
1323
|
// src/view3d/scene/seatInstances.ts
|
|
917
1324
|
var SEAT_DOT_RADIUS_M = 0.22;
|
|
918
1325
|
var SEAT_PITCH_FRACTION = 0.42;
|
|
@@ -982,6 +1389,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
982
1389
|
const iPosition = new Float32Array(count * 3);
|
|
983
1390
|
const iState = new Float32Array(count);
|
|
984
1391
|
const iMaxRadius = new Float32Array(count);
|
|
1392
|
+
const iChairWidth = new Float32Array(count);
|
|
985
1393
|
const iRing = new Float32Array(count * 3);
|
|
986
1394
|
const idToIndex = /* @__PURE__ */ new Map();
|
|
987
1395
|
const spacing = nearestNeighbourSpacing(seats);
|
|
@@ -990,6 +1398,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
990
1398
|
const resolved = surfaces?.seatPitchU(i);
|
|
991
1399
|
const pitchM = (resolved ?? spacing[i]) * M;
|
|
992
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);
|
|
993
1402
|
iPosition[i * 3] = seat.x * M;
|
|
994
1403
|
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
995
1404
|
iPosition[i * 3 + 2] = seat.y * M;
|
|
@@ -1011,7 +1420,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
1011
1420
|
iMaxRadius,
|
|
1012
1421
|
iRing,
|
|
1013
1422
|
idToIndex,
|
|
1014
|
-
|
|
1423
|
+
iChairWidth,
|
|
1424
|
+
iFloor: seatFloor ?? new Float32Array(count),
|
|
1425
|
+
iYaw: new Float32Array(count)
|
|
1015
1426
|
};
|
|
1016
1427
|
}
|
|
1017
1428
|
var RUN_MERGE_GAP = 64;
|
|
@@ -1093,16 +1504,23 @@ function themeSeatColorLUT(theme, order) {
|
|
|
1093
1504
|
var ZONE_MIN_DISTANCE = 1.15;
|
|
1094
1505
|
var SECTION_MAX_DISTANCE = 2.2;
|
|
1095
1506
|
var NEAR_MAX_DISTANCE = 0.85;
|
|
1507
|
+
var ROW_MAX_DISTANCE = 0.5;
|
|
1508
|
+
var SEAT_MAX_DISTANCE = 0.26;
|
|
1096
1509
|
function visibleLabelKinds(distance, venueRadius) {
|
|
1097
1510
|
const r = Math.max(1e-6, venueRadius);
|
|
1098
1511
|
const d = distance / r;
|
|
1099
1512
|
const out = /* @__PURE__ */ new Set();
|
|
1100
1513
|
if (d >= ZONE_MIN_DISTANCE) out.add("zone");
|
|
1101
|
-
if (d <= SECTION_MAX_DISTANCE)
|
|
1514
|
+
if (d <= SECTION_MAX_DISTANCE) {
|
|
1515
|
+
out.add("section");
|
|
1516
|
+
out.add("ga");
|
|
1517
|
+
}
|
|
1102
1518
|
if (d <= NEAR_MAX_DISTANCE) {
|
|
1103
1519
|
out.add("annotation");
|
|
1104
1520
|
out.add("booth");
|
|
1105
1521
|
}
|
|
1522
|
+
if (d <= ROW_MAX_DISTANCE) out.add("row");
|
|
1523
|
+
if (d <= SEAT_MAX_DISTANCE) out.add("seat");
|
|
1106
1524
|
return out;
|
|
1107
1525
|
}
|
|
1108
1526
|
function projectToScreen(viewProjection, p, width, height) {
|
|
@@ -1139,6 +1557,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
|
|
|
1139
1557
|
}
|
|
1140
1558
|
return kept;
|
|
1141
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
|
+
}
|
|
1142
1582
|
function centroidOf(points) {
|
|
1143
1583
|
if (!points.length) return null;
|
|
1144
1584
|
let x = 0, y = 0;
|
|
@@ -1679,191 +2119,6 @@ function buildSectionRake(rows, focal) {
|
|
|
1679
2119
|
return rowsRake(fits);
|
|
1680
2120
|
}
|
|
1681
2121
|
|
|
1682
|
-
// src/view3d/scene/surface.ts
|
|
1683
|
-
var FLAT_SLAB_TOP_M = 0.05;
|
|
1684
|
-
var CAP_MAX_ERROR_M = 0.05;
|
|
1685
|
-
var SEAT_CLEARANCE_M = 0.15;
|
|
1686
|
-
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1687
|
-
function bboxOf(pts) {
|
|
1688
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1689
|
-
for (const p of pts) {
|
|
1690
|
-
if (p.x < minX) minX = p.x;
|
|
1691
|
-
if (p.y < minY) minY = p.y;
|
|
1692
|
-
if (p.x > maxX) maxX = p.x;
|
|
1693
|
-
if (p.y > maxY) maxY = p.y;
|
|
1694
|
-
}
|
|
1695
|
-
return { minX, minY, maxX, maxY };
|
|
1696
|
-
}
|
|
1697
|
-
var MAX_TIER_RISE_M = 25;
|
|
1698
|
-
function buildVenueSurfaces(units, seats) {
|
|
1699
|
-
const bySection = /* @__PURE__ */ new Map();
|
|
1700
|
-
const seatOwner = new Array(seats.length).fill(null);
|
|
1701
|
-
const seatDeck = new Float64Array(seats.length);
|
|
1702
|
-
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
1703
|
-
const seatPitch = new Array(seats.length).fill(void 0);
|
|
1704
|
-
const acc = /* @__PURE__ */ new Map();
|
|
1705
|
-
const boxes = [];
|
|
1706
|
-
for (const unit of units) {
|
|
1707
|
-
for (const o of unit.objects) {
|
|
1708
|
-
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1709
|
-
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
1710
|
-
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
1711
|
-
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1715
|
-
const s = seats[i];
|
|
1716
|
-
for (const b of boxes) {
|
|
1717
|
-
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
1718
|
-
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
1719
|
-
seatOwner[i] = b.id;
|
|
1720
|
-
const a = acc.get(b.id);
|
|
1721
|
-
a.hasSeats = true;
|
|
1722
|
-
const f = s.focalPoint ?? b.unit.focal;
|
|
1723
|
-
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
1724
|
-
if (d < a.frontU) a.frontU = d;
|
|
1725
|
-
a.seatIndices.push(i);
|
|
1726
|
-
const rowKey = s.rowId || `__seat-${i}`;
|
|
1727
|
-
const arr = a.rows.get(rowKey);
|
|
1728
|
-
if (arr) arr.push({ x: s.x, y: s.y });
|
|
1729
|
-
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
1730
|
-
break;
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
for (const [id, a] of acc) {
|
|
1734
|
-
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
1735
|
-
const bottomY = a.unit.baseHeightM;
|
|
1736
|
-
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
1737
|
-
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
1738
|
-
const kind = a.section.surfaceKind;
|
|
1739
|
-
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
1740
|
-
const rake = buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal);
|
|
1741
|
-
let frontU = Infinity;
|
|
1742
|
-
if (a.hasSeats) {
|
|
1743
|
-
for (const pts of a.rows.values()) {
|
|
1744
|
-
for (const p of pts) {
|
|
1745
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1746
|
-
if (d < frontU) frontU = d;
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
1751
|
-
frontU = Infinity;
|
|
1752
|
-
for (const p of a.section.outline) {
|
|
1753
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1754
|
-
if (d < frontU) frontU = d;
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
const flatTop = bottomY + FLAT_SLAB_TOP_M;
|
|
1758
|
-
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
1759
|
-
const levelFor = (depthU) => {
|
|
1760
|
-
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
1761
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1762
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1763
|
-
};
|
|
1764
|
-
const levelForBlockDepth = (blockDepthU) => {
|
|
1765
|
-
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
1766
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1767
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1768
|
-
};
|
|
1769
|
-
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
1770
|
-
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
1771
|
-
pts: r.pts,
|
|
1772
|
-
y: levelForBlockDepth(r.blockDepth),
|
|
1773
|
-
depth: r.blockDepth,
|
|
1774
|
-
blockId: r.blockId
|
|
1775
|
-
}));
|
|
1776
|
-
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
1777
|
-
const rowBounds = rowLevels.map((r) => {
|
|
1778
|
-
let cx = 0, cy = 0;
|
|
1779
|
-
for (const p of r.pts) {
|
|
1780
|
-
cx += p.x;
|
|
1781
|
-
cy += p.y;
|
|
1782
|
-
}
|
|
1783
|
-
const n = r.pts.length || 1;
|
|
1784
|
-
cx /= n;
|
|
1785
|
-
cy /= n;
|
|
1786
|
-
let rad = 0;
|
|
1787
|
-
for (const p of r.pts) {
|
|
1788
|
-
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1789
|
-
if (d > rad) rad = d;
|
|
1790
|
-
}
|
|
1791
|
-
return { cx, cy, rad };
|
|
1792
|
-
});
|
|
1793
|
-
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
1794
|
-
let best = Infinity, bestY = landingY;
|
|
1795
|
-
for (let i = 0; i < rowLevels.length; i++) {
|
|
1796
|
-
const b = rowBounds[i];
|
|
1797
|
-
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
1798
|
-
const d = distanceToPolyline(rowLevels[i].pts, x, y);
|
|
1799
|
-
if (d < best) {
|
|
1800
|
-
best = d;
|
|
1801
|
-
bestY = rowLevels[i].y;
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
return bestY;
|
|
1805
|
-
} : (x, y) => levelFor(rake.depthAt(x, y));
|
|
1806
|
-
const UP = [0, 1, 0];
|
|
1807
|
-
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
1808
|
-
const d = rake.depthAt(x, y);
|
|
1809
|
-
if (d <= frontU) return UP;
|
|
1810
|
-
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
1811
|
-
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
1812
|
-
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
1813
|
-
const [gx, gy] = rake.gradientAt(x, y);
|
|
1814
|
-
if (gx === 0 && gy === 0) return UP;
|
|
1815
|
-
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
1816
|
-
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
1817
|
-
};
|
|
1818
|
-
if (!flat) {
|
|
1819
|
-
for (const r of structure.rows) {
|
|
1820
|
-
const y = levelForBlockDepth(r.blockDepth);
|
|
1821
|
-
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
for (const r of structure.rows) {
|
|
1825
|
-
const gaps = [];
|
|
1826
|
-
for (let k = 1; k < r.pts.length; k++) {
|
|
1827
|
-
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
1828
|
-
if (d > 1e-6) gaps.push(d);
|
|
1829
|
-
}
|
|
1830
|
-
gaps.sort((x, y) => x - y);
|
|
1831
|
-
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
1832
|
-
let across = Infinity;
|
|
1833
|
-
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
1834
|
-
if (probe) {
|
|
1835
|
-
for (const other of structure.rows) {
|
|
1836
|
-
if (other === r || other.blockId !== r.blockId) continue;
|
|
1837
|
-
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
1838
|
-
if (d > 1e-6 && d < across) across = d;
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
1842
|
-
if (Number.isFinite(pitch) && pitch > 0) {
|
|
1843
|
-
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
1847
|
-
}
|
|
1848
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1849
|
-
const ownerId = seatOwner[i];
|
|
1850
|
-
const s = seats[i];
|
|
1851
|
-
if (ownerId) {
|
|
1852
|
-
const own = seatRowLevel[i];
|
|
1853
|
-
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
1854
|
-
continue;
|
|
1855
|
-
}
|
|
1856
|
-
const eye = s.eyeHeightM;
|
|
1857
|
-
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1858
|
-
}
|
|
1859
|
-
return {
|
|
1860
|
-
bySection,
|
|
1861
|
-
seatOwner,
|
|
1862
|
-
seatDeckY: (i) => seatDeck[i],
|
|
1863
|
-
seatPitchU: (i) => seatPitch[i]
|
|
1864
|
-
};
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
2122
|
// src/view3d/scene/deckBands.ts
|
|
1868
2123
|
var import_polygon_clipping2 = __toESM(require("polygon-clipping"), 1);
|
|
1869
2124
|
var import_earcut2 = __toESM(require("earcut"), 1);
|
|
@@ -1912,9 +2167,71 @@ function extendEnds(pts, by) {
|
|
|
1912
2167
|
});
|
|
1913
2168
|
return out;
|
|
1914
2169
|
}
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
+
}
|
|
2232
|
+
function distToPolyline(pts, x, y) {
|
|
2233
|
+
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
2234
|
+
let best = Infinity;
|
|
1918
2235
|
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1919
2236
|
const a = pts[i], b = pts[i + 1];
|
|
1920
2237
|
const vx = b.x - a.x, vy = b.y - a.y;
|
|
@@ -1927,7 +2244,23 @@ function distToPolyline(pts, x, y) {
|
|
|
1927
2244
|
}
|
|
1928
2245
|
return best;
|
|
1929
2246
|
}
|
|
1930
|
-
function
|
|
2247
|
+
function rowNeighbourhoods(rows) {
|
|
2248
|
+
const bounds = rows.map((r) => {
|
|
2249
|
+
let cx = 0, cy = 0;
|
|
2250
|
+
for (const p of r.pts) {
|
|
2251
|
+
cx += p.x;
|
|
2252
|
+
cy += p.y;
|
|
2253
|
+
}
|
|
2254
|
+
const n = r.pts.length || 1;
|
|
2255
|
+
cx /= n;
|
|
2256
|
+
cy /= n;
|
|
2257
|
+
let rad = 0;
|
|
2258
|
+
for (const p of r.pts) {
|
|
2259
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
2260
|
+
if (d > rad) rad = d;
|
|
2261
|
+
}
|
|
2262
|
+
return { cx, cy, rad };
|
|
2263
|
+
});
|
|
1931
2264
|
const probesOf2 = (pts) => {
|
|
1932
2265
|
const n = pts.length;
|
|
1933
2266
|
if (n <= 2) return [...pts];
|
|
@@ -1942,6 +2275,9 @@ function neighbourhoods(rows) {
|
|
|
1942
2275
|
let nearest = Infinity;
|
|
1943
2276
|
for (let j = 0; j < rows.length; j++) {
|
|
1944
2277
|
if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
|
|
2278
|
+
const b = bounds[j];
|
|
2279
|
+
const lower = Math.hypot(c.x - b.cx, c.y - b.cy) - b.rad;
|
|
2280
|
+
if (lower >= nearest && lower >= bestFrontD) continue;
|
|
1945
2281
|
const d = distToPolyline(rows[j].pts, c.x, c.y);
|
|
1946
2282
|
if (d < nearest) nearest = d;
|
|
1947
2283
|
if (rows[j].depth < rows[i].depth && d < bestFrontD) {
|
|
@@ -1986,65 +2322,8 @@ function ribbonOf(rows, i, nbrs, focal) {
|
|
|
1986
2322
|
back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
|
|
1987
2323
|
};
|
|
1988
2324
|
}
|
|
1989
|
-
function deckFootprints(rows, focal) {
|
|
1990
|
-
if (rows.length < 2) return [];
|
|
1991
|
-
const nbrs = neighbourhoods(rows);
|
|
1992
|
-
const rings = [];
|
|
1993
|
-
const ribbons = [];
|
|
1994
|
-
for (let i = 0; i < rows.length; i++) {
|
|
1995
|
-
const r = ribbonOf(rows, i, nbrs, focal);
|
|
1996
|
-
ribbons.push(r);
|
|
1997
|
-
if (!r) continue;
|
|
1998
|
-
const f = [];
|
|
1999
|
-
const b = [];
|
|
2000
|
-
const rf = r.front + FOOTPRINT_MARGIN_U;
|
|
2001
|
-
const rb = r.back + FOOTPRINT_MARGIN_U;
|
|
2002
|
-
for (let k = 0; k < r.pts.length; k++) {
|
|
2003
|
-
const p = r.pts[k], n = r.nrm[k];
|
|
2004
|
-
f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
|
|
2005
|
-
b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
|
|
2006
|
-
}
|
|
2007
|
-
const ring = [...f, ...b.reverse()];
|
|
2008
|
-
if (ring.length < 3) continue;
|
|
2009
|
-
ring.push(ring[0]);
|
|
2010
|
-
rings.push([ring]);
|
|
2011
|
-
}
|
|
2012
|
-
if (!rings.length) return [];
|
|
2013
|
-
let merged;
|
|
2014
|
-
try {
|
|
2015
|
-
merged = import_polygon_clipping2.default.union(rings[0], ...rings.slice(1));
|
|
2016
|
-
} catch {
|
|
2017
|
-
return [];
|
|
2018
|
-
}
|
|
2019
|
-
const out = [];
|
|
2020
|
-
for (const poly of merged) {
|
|
2021
|
-
if (!poly.length || poly[0].length < 4) continue;
|
|
2022
|
-
const toPts = (ring) => {
|
|
2023
|
-
const pts = ring.map(([x, y]) => ({ x, y }));
|
|
2024
|
-
const first = pts[0], last = pts[pts.length - 1];
|
|
2025
|
-
if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
|
|
2026
|
-
return pts;
|
|
2027
|
-
};
|
|
2028
|
-
const outline = toPts(poly[0]);
|
|
2029
|
-
if (outline.length < 3) continue;
|
|
2030
|
-
let topY = Infinity;
|
|
2031
|
-
for (let i = 0; i < rows.length; i++) {
|
|
2032
|
-
const r = ribbons[i];
|
|
2033
|
-
if (!r) continue;
|
|
2034
|
-
const mid = r.pts[Math.floor(r.pts.length / 2)];
|
|
2035
|
-
if (pointInRing(outline, mid.x, mid.y) && rows[i].y < topY) topY = rows[i].y;
|
|
2036
|
-
}
|
|
2037
|
-
if (!Number.isFinite(topY)) continue;
|
|
2038
|
-
out.push({
|
|
2039
|
-
outline: simplifyRing(outline, FOOTPRINT_TOLERANCE_U),
|
|
2040
|
-
holes: poly.slice(1).map(toPts).map((h) => simplifyRing(h, FOOTPRINT_TOLERANCE_U)).filter((h) => h.length >= 3),
|
|
2041
|
-
topY
|
|
2042
|
-
});
|
|
2043
|
-
}
|
|
2044
|
-
return out;
|
|
2045
|
-
}
|
|
2046
|
-
var FOOTPRINT_TOLERANCE_U = 0.3;
|
|
2047
2325
|
var FOOTPRINT_MARGIN_U = 1.5;
|
|
2326
|
+
var FOOTPRINT_TOLERANCE_U = 0.3;
|
|
2048
2327
|
function simplifyRun(pts, tol) {
|
|
2049
2328
|
if (pts.length < 3) return pts;
|
|
2050
2329
|
const a = pts[0], b = pts[pts.length - 1];
|
|
@@ -2070,6 +2349,109 @@ function simplifyRing(ring, tol) {
|
|
|
2070
2349
|
out.pop();
|
|
2071
2350
|
return out.length >= 3 ? out : ring;
|
|
2072
2351
|
}
|
|
2352
|
+
function deckFootprints(rows, focal, shared) {
|
|
2353
|
+
if (rows.length < 2) return [];
|
|
2354
|
+
const nbrs = shared ?? rowNeighbourhoods(rows);
|
|
2355
|
+
const byBlock = /* @__PURE__ */ new Map();
|
|
2356
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2357
|
+
const a = byBlock.get(rows[i].blockId);
|
|
2358
|
+
if (a) a.push(i);
|
|
2359
|
+
else byBlock.set(rows[i].blockId, [i]);
|
|
2360
|
+
}
|
|
2361
|
+
const out = [];
|
|
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;
|
|
2370
|
+
indices.sort((a, b) => rows[a].depth - rows[b].depth);
|
|
2371
|
+
const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
|
|
2372
|
+
const usable = ribbons.filter((r) => r !== null);
|
|
2373
|
+
if (!usable.length) continue;
|
|
2374
|
+
const frontOf = (r, k) => ({
|
|
2375
|
+
x: r.pts[k].x - r.nrm[k][0] * (r.front + FOOTPRINT_MARGIN_U),
|
|
2376
|
+
y: r.pts[k].y - r.nrm[k][1] * (r.front + FOOTPRINT_MARGIN_U)
|
|
2377
|
+
});
|
|
2378
|
+
const backOf = (r, k) => ({
|
|
2379
|
+
x: r.pts[k].x + r.nrm[k][0] * (r.back + FOOTPRINT_MARGIN_U),
|
|
2380
|
+
y: r.pts[k].y + r.nrm[k][1] * (r.back + FOOTPRINT_MARGIN_U)
|
|
2381
|
+
});
|
|
2382
|
+
const first = usable[0], last = usable[usable.length - 1];
|
|
2383
|
+
const ring = [];
|
|
2384
|
+
for (let k = 0; k < first.pts.length; k++) ring.push(frontOf(first, k));
|
|
2385
|
+
for (const r of usable) ring.push(backOf(r, r.pts.length - 1));
|
|
2386
|
+
for (let k = last.pts.length - 1; k >= 0; k--) ring.push(backOf(last, k));
|
|
2387
|
+
for (let i = usable.length - 1; i >= 0; i--) ring.push(frontOf(usable[i], 0));
|
|
2388
|
+
const deduped = dedupeAdjacent(ring);
|
|
2389
|
+
let topY = Infinity;
|
|
2390
|
+
for (const i of indices) if (rows[i].y < topY) topY = rows[i].y;
|
|
2391
|
+
if (!Number.isFinite(topY)) continue;
|
|
2392
|
+
const covers = deduped.length >= 3 && Math.abs(ringArea(deduped)) > 1e-6 && usable.every((r) => {
|
|
2393
|
+
const mid = r.pts[Math.floor(r.pts.length / 2)];
|
|
2394
|
+
return pointInRing(deduped, mid.x, mid.y);
|
|
2395
|
+
});
|
|
2396
|
+
if (covers) {
|
|
2397
|
+
out.push({ outline: simplifyRing(deduped, FOOTPRINT_TOLERANCE_U), holes: [], topY });
|
|
2398
|
+
continue;
|
|
2399
|
+
}
|
|
2400
|
+
for (const poly of unionRibbons(usable)) out.push({ outline: poly, holes: [], topY });
|
|
2401
|
+
}
|
|
2402
|
+
return out;
|
|
2403
|
+
}
|
|
2404
|
+
function unionRibbons(ribbons) {
|
|
2405
|
+
const rings = [];
|
|
2406
|
+
for (const r of ribbons) {
|
|
2407
|
+
const f = [];
|
|
2408
|
+
const b = [];
|
|
2409
|
+
const rf = r.front + FOOTPRINT_MARGIN_U;
|
|
2410
|
+
const rb = r.back + FOOTPRINT_MARGIN_U;
|
|
2411
|
+
for (let k = 0; k < r.pts.length; k++) {
|
|
2412
|
+
const p = r.pts[k], n = r.nrm[k];
|
|
2413
|
+
f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
|
|
2414
|
+
b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
|
|
2415
|
+
}
|
|
2416
|
+
const ring = [...f, ...b.reverse()];
|
|
2417
|
+
if (ring.length < 3) continue;
|
|
2418
|
+
ring.push(ring[0]);
|
|
2419
|
+
rings.push([ring]);
|
|
2420
|
+
}
|
|
2421
|
+
if (!rings.length) return [];
|
|
2422
|
+
try {
|
|
2423
|
+
const merged = import_polygon_clipping2.default.union(rings[0], ...rings.slice(1));
|
|
2424
|
+
const out = [];
|
|
2425
|
+
for (const poly of merged) {
|
|
2426
|
+
if (!poly.length || poly[0].length < 4) continue;
|
|
2427
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
2428
|
+
const first = pts[0], last = pts[pts.length - 1];
|
|
2429
|
+
if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
|
|
2430
|
+
if (pts.length >= 3) out.push(simplifyRing(pts, FOOTPRINT_TOLERANCE_U));
|
|
2431
|
+
}
|
|
2432
|
+
return out;
|
|
2433
|
+
} catch {
|
|
2434
|
+
return [];
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
function ringArea(ring) {
|
|
2438
|
+
let a = 0;
|
|
2439
|
+
for (let i = 0, n = ring.length; i < n; i++) {
|
|
2440
|
+
const p = ring[i], q = ring[(i + 1) % n];
|
|
2441
|
+
a += p.x * q.y - q.x * p.y;
|
|
2442
|
+
}
|
|
2443
|
+
return a / 2;
|
|
2444
|
+
}
|
|
2445
|
+
function dedupeAdjacent(ring) {
|
|
2446
|
+
const out = [];
|
|
2447
|
+
for (const p of ring) {
|
|
2448
|
+
const last = out[out.length - 1];
|
|
2449
|
+
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 1e-9) continue;
|
|
2450
|
+
out.push(p);
|
|
2451
|
+
}
|
|
2452
|
+
while (out.length > 2 && Math.hypot(out[0].x - out[out.length - 1].x, out[0].y - out[out.length - 1].y) < 1e-9) out.pop();
|
|
2453
|
+
return out;
|
|
2454
|
+
}
|
|
2073
2455
|
function pointInRing(ring, x, y) {
|
|
2074
2456
|
let inside = false;
|
|
2075
2457
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
@@ -2093,106 +2475,351 @@ var ClipTest = class {
|
|
|
2093
2475
|
if (p.y > this.maxY) this.maxY = p.y;
|
|
2094
2476
|
}
|
|
2095
2477
|
}
|
|
2096
|
-
}
|
|
2097
|
-
/** True when every point lies strictly inside ONE ring of the clip region. */
|
|
2098
|
-
containsAll(pts) {
|
|
2099
|
-
for (const p of pts) {
|
|
2100
|
-
if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
|
|
2101
|
-
}
|
|
2102
|
-
for (const ring of this.rings) {
|
|
2103
|
-
let all = true;
|
|
2104
|
-
for (const p of pts) {
|
|
2105
|
-
if (!pointInRing(ring, p.x, p.y)) {
|
|
2106
|
-
all = false;
|
|
2107
|
-
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;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
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
|
+
}
|
|
2781
|
+
}
|
|
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);
|
|
2787
|
+
}
|
|
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;
|
|
2108
2797
|
}
|
|
2109
2798
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
};
|
|
2115
|
-
function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
|
|
2116
|
-
if (clipTest.containsAll(quad)) {
|
|
2117
|
-
const UPF = [0, 1, 0];
|
|
2118
|
-
const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
|
|
2119
|
-
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);
|
|
2120
|
-
builder.tri([a.x * M, y, a.y * M], [c.x * M, y, c.y * M], [d.x * M, y, d.y * M], UPF, color);
|
|
2121
|
-
return;
|
|
2122
|
-
}
|
|
2123
|
-
const ring = quad.map((p) => [p.x, p.y]);
|
|
2124
|
-
ring.push(ring[0]);
|
|
2125
|
-
let pieces;
|
|
2126
|
-
try {
|
|
2127
|
-
pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
|
|
2128
|
-
} catch {
|
|
2129
|
-
return;
|
|
2130
|
-
}
|
|
2131
|
-
const UP = [0, 1, 0];
|
|
2132
|
-
for (const poly of pieces) {
|
|
2133
|
-
if (!poly.length || poly[0].length < 4) continue;
|
|
2134
|
-
const outer = poly[0];
|
|
2135
|
-
const flat = [];
|
|
2136
|
-
const pts = [];
|
|
2137
|
-
for (let i = 0; i < outer.length - 1; i++) {
|
|
2138
|
-
flat.push(outer[i][0], outer[i][1]);
|
|
2139
|
-
pts.push([outer[i][0], outer[i][1]]);
|
|
2140
|
-
}
|
|
2141
|
-
if (pts.length < 3) continue;
|
|
2142
|
-
const tris = (0, import_earcut2.default)(flat, void 0, 2);
|
|
2143
|
-
for (let i = 0; i < tris.length; i += 3) {
|
|
2144
|
-
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
2145
|
-
builder.tri(
|
|
2146
|
-
[a[0] * M, y, a[1] * M],
|
|
2147
|
-
[b[0] * M, y, b[1] * M],
|
|
2148
|
-
[c[0] * M, y, c[1] * M],
|
|
2149
|
-
UP,
|
|
2150
|
-
color
|
|
2151
|
-
);
|
|
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;
|
|
2802
|
+
}
|
|
2152
2803
|
}
|
|
2804
|
+
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
2153
2805
|
}
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
for (let i = 0; i < rows.length; i++) {
|
|
2162
|
-
const row = rows[i];
|
|
2163
|
-
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
2164
|
-
if (!rib) continue;
|
|
2165
|
-
const { pts, nrm, front, back } = rib;
|
|
2166
|
-
const belowY = nbrs[i].belowY ?? landingY;
|
|
2167
|
-
for (let k = 0; k + 1 < pts.length; k++) {
|
|
2168
|
-
const p = pts[k], q = pts[k + 1];
|
|
2169
|
-
const np = nrm[k], nq = nrm[k + 1];
|
|
2170
|
-
if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
|
|
2171
|
-
if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
|
|
2172
|
-
const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
|
|
2173
|
-
const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
|
|
2174
|
-
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
2175
|
-
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
2176
|
-
if (clipRing && clipTest) {
|
|
2177
|
-
emitClippedQuad(builder, clipRing, clipTest, [
|
|
2178
|
-
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
2179
|
-
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
2180
|
-
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
2181
|
-
{ x: p.x + np[0] * back, y: p.y + np[1] * back }
|
|
2182
|
-
], row.y, colors.tread);
|
|
2183
|
-
} else {
|
|
2184
|
-
builder.tri(pF, qF, qB, UP, colors.tread);
|
|
2185
|
-
builder.tri(pF, qB, pB, UP, colors.tread);
|
|
2186
|
-
}
|
|
2187
|
-
if (row.y > belowY + MIN_RISER_M) {
|
|
2188
|
-
const pFd = [pF[0], belowY, pF[2]];
|
|
2189
|
-
const qFd = [qF[0], belowY, qF[2]];
|
|
2190
|
-
const rn = [-np[0], 0, -np[1]];
|
|
2191
|
-
builder.tri(pF, qF, qFd, rn, colors.riser);
|
|
2192
|
-
builder.tri(pF, qFd, pFd, rn, colors.riser);
|
|
2193
|
-
}
|
|
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;
|
|
2194
2813
|
}
|
|
2814
|
+
const eye = s.eyeHeightM;
|
|
2815
|
+
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
2195
2816
|
}
|
|
2817
|
+
return {
|
|
2818
|
+
bySection,
|
|
2819
|
+
seatOwner,
|
|
2820
|
+
seatDeckY: (i) => seatDeck[i],
|
|
2821
|
+
seatPitchU: (i) => seatPitch[i]
|
|
2822
|
+
};
|
|
2196
2823
|
}
|
|
2197
2824
|
|
|
2198
2825
|
// src/view3d/scene/sceneModel.ts
|
|
@@ -2257,9 +2884,7 @@ function boxesOverlap(a, b) {
|
|
|
2257
2884
|
return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
|
|
2258
2885
|
}
|
|
2259
2886
|
function paddedFootprint(section, siblings) {
|
|
2260
|
-
const __tp = performance.now();
|
|
2261
2887
|
const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
|
|
2262
|
-
__t("outsetRing", __tp);
|
|
2263
2888
|
const box = bboxOfRing(padded);
|
|
2264
2889
|
const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
|
|
2265
2890
|
if (!others.length) return [padded];
|
|
@@ -2323,9 +2948,8 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
|
|
|
2323
2948
|
const footprint = paddedFootprint(section, siblings);
|
|
2324
2949
|
const outline = footprint[0] ?? section.outline;
|
|
2325
2950
|
if (surface.rowLevels.length >= 2) {
|
|
2326
|
-
const
|
|
2327
|
-
const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
|
|
2328
|
-
__t("deckFootprints", __tf);
|
|
2951
|
+
const nbrs = rowNeighbourhoods(surface.rowLevels);
|
|
2952
|
+
const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal, nbrs), footprint);
|
|
2329
2953
|
const capUp = () => [0, 1, 0];
|
|
2330
2954
|
for (const b of blocks) {
|
|
2331
2955
|
extrudePrism(
|
|
@@ -2353,34 +2977,49 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
|
|
|
2353
2977
|
AO
|
|
2354
2978
|
);
|
|
2355
2979
|
}
|
|
2356
|
-
const __tb = performance.now();
|
|
2357
2980
|
emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
|
|
2358
2981
|
tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
|
|
2359
2982
|
// Risers read as the structure they are, a shade below their tread, which
|
|
2360
2983
|
// is what makes the stepping legible from a low angle.
|
|
2361
2984
|
riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
|
|
2362
|
-
}, footprint.length === 1 ? outline : footprint.flat());
|
|
2363
|
-
__t("emitDeckBands", __tb);
|
|
2985
|
+
}, footprint.length === 1 ? outline : footprint.flat(), nbrs);
|
|
2364
2986
|
return;
|
|
2365
2987
|
}
|
|
2366
2988
|
const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
|
|
2367
2989
|
const topN = (p) => surface.normalAt(p.x, p.y);
|
|
2368
|
-
|
|
2990
|
+
const level = surface.flat ? surface.landingY : void 0;
|
|
2991
|
+
for (const ring of claimed.subtract(outline, level)) {
|
|
2369
2992
|
extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
|
|
2370
2993
|
}
|
|
2371
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
|
+
}
|
|
2372
2999
|
var ClaimedArea = class {
|
|
2373
3000
|
constructor() {
|
|
2374
3001
|
this.rings = [];
|
|
2375
3002
|
this.boxes = [];
|
|
3003
|
+
/** Constant deck height of each claim, or undefined when it is not level. */
|
|
3004
|
+
this.levels = [];
|
|
2376
3005
|
}
|
|
2377
|
-
/**
|
|
2378
|
-
|
|
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) {
|
|
2379
3018
|
const closed = ring.map((p) => [p.x, p.y]);
|
|
2380
3019
|
if (closed.length < 3) return [];
|
|
2381
3020
|
closed.push(closed[0]);
|
|
2382
3021
|
const box = bboxOfRing(ring);
|
|
2383
|
-
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]));
|
|
2384
3023
|
let pieces = [[closed]];
|
|
2385
3024
|
if (overlapping.length) {
|
|
2386
3025
|
try {
|
|
@@ -2392,6 +3031,7 @@ var ClaimedArea = class {
|
|
|
2392
3031
|
}
|
|
2393
3032
|
this.rings.push([closed]);
|
|
2394
3033
|
this.boxes.push(box);
|
|
3034
|
+
this.levels.push(level);
|
|
2395
3035
|
const out = [];
|
|
2396
3036
|
for (const poly of pieces) {
|
|
2397
3037
|
if (!poly.length) continue;
|
|
@@ -2410,7 +3050,12 @@ var ZONE_LABEL_LIFT_M = 6;
|
|
|
2410
3050
|
var SECTION_LABEL_LIFT_M = 2.2;
|
|
2411
3051
|
var ANNOTATION_LIFT_M = 0.1;
|
|
2412
3052
|
var BOOTH_LABEL_LIFT_M = 0.3;
|
|
3053
|
+
var GA_LABEL_LIFT_M = 1.8;
|
|
3054
|
+
var ROW_LABEL_LIFT_M = 0.9;
|
|
3055
|
+
var SEAT_LABEL_LIFT_M = 0.55;
|
|
3056
|
+
var SEAT_LABEL_MAX = 6e3;
|
|
2413
3057
|
var TABLE_HEIGHT_M = 0.75;
|
|
3058
|
+
var DECOR_PLATE_M = 0.15;
|
|
2414
3059
|
function boothPolygon(booth) {
|
|
2415
3060
|
if (booth.points && booth.points.length >= 3) return booth.points;
|
|
2416
3061
|
const { center, width, height, rotation } = booth;
|
|
@@ -2484,10 +3129,63 @@ function buildShape(builder, shape, base, S) {
|
|
|
2484
3129
|
if (!poly) return;
|
|
2485
3130
|
const isStage = shape.role === "stage";
|
|
2486
3131
|
const height = isStage ? base + 1 : base + 0.25;
|
|
2487
|
-
const colTop = isStage ? S.stageTop : S.decorTop;
|
|
3132
|
+
const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
|
|
2488
3133
|
const colWall = isStage ? S.stageWall : S.decorWall;
|
|
2489
3134
|
extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
|
|
2490
3135
|
}
|
|
3136
|
+
function decorPolygon(decor) {
|
|
3137
|
+
const { x, y, width, height } = decor;
|
|
3138
|
+
if (!width || !height) return null;
|
|
3139
|
+
const cx = x + width / 2, cy = y + height / 2;
|
|
3140
|
+
const a = (decor.rotation ?? 0) * Math.PI / 180;
|
|
3141
|
+
const cos = Math.cos(a), sin = Math.sin(a);
|
|
3142
|
+
const hw = width / 2, hh = height / 2;
|
|
3143
|
+
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
|
|
3144
|
+
x: cx + lx * cos - ly * sin,
|
|
3145
|
+
y: cy + lx * sin + ly * cos
|
|
3146
|
+
}));
|
|
3147
|
+
}
|
|
3148
|
+
function decorPlatePaint(href) {
|
|
3149
|
+
if (!href || !href.startsWith("data:image/svg+xml")) return null;
|
|
3150
|
+
let svg = href.slice(href.indexOf(",") + 1);
|
|
3151
|
+
if (/;base64/i.test(href.slice(0, href.indexOf(",")))) {
|
|
3152
|
+
try {
|
|
3153
|
+
svg = atob(svg);
|
|
3154
|
+
} catch {
|
|
3155
|
+
return null;
|
|
3156
|
+
}
|
|
3157
|
+
} else {
|
|
3158
|
+
try {
|
|
3159
|
+
svg = decodeURIComponent(svg);
|
|
3160
|
+
} catch {
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
const covering = svg.match(/<(?:rect|path)\b[^>]*\bfill="([^"]+)"/i);
|
|
3164
|
+
if (!covering) return null;
|
|
3165
|
+
const paint = covering[1].trim();
|
|
3166
|
+
if (paint === "none" || paint === "transparent") return null;
|
|
3167
|
+
const gradient = paint.match(/^url\(#([^)]+)\)$/);
|
|
3168
|
+
if (!gradient) return hexToRgb(paint);
|
|
3169
|
+
const def = svg.match(new RegExp(`<(?:linear|radial)Gradient\\b[^>]*\\bid="${gradient[1]}"[^>]*>([\\s\\S]*?)</(?:linear|radial)Gradient>`, "i"));
|
|
3170
|
+
if (!def) return null;
|
|
3171
|
+
const stops = [];
|
|
3172
|
+
for (const m of def[1].matchAll(/stop-color="([^"]+)"/gi)) {
|
|
3173
|
+
const c = hexToRgb(m[1].trim());
|
|
3174
|
+
if (c) stops.push(c);
|
|
3175
|
+
}
|
|
3176
|
+
if (!stops.length) return null;
|
|
3177
|
+
return stops.reduce((acc, c, i) => mix(acc, c, 1 / (i + 1)), stops[0]);
|
|
3178
|
+
}
|
|
3179
|
+
function buildDecorImage(builder, decor, base, S) {
|
|
3180
|
+
if (decor.layer === "foreground") return;
|
|
3181
|
+
const poly = decorPolygon(decor);
|
|
3182
|
+
if (!poly) return;
|
|
3183
|
+
const top = base + DECOR_PLATE_M;
|
|
3184
|
+
let paint = tintTop(decorPlatePaint(decor.href), S.decorTop);
|
|
3185
|
+
const opacity = Math.max(0, Math.min(1, decor.opacity ?? 1));
|
|
3186
|
+
if (opacity < 1) paint = mix(S.ground, paint, opacity);
|
|
3187
|
+
extrudePrism(builder, poly, void 0, () => top, base, paint, mix(paint, S.decorWall, 0.5), AO);
|
|
3188
|
+
}
|
|
2491
3189
|
function buildGa(builder, ga, base, fill, S) {
|
|
2492
3190
|
if (!ga.points || ga.points.length < 3) return;
|
|
2493
3191
|
const colTop = tintTop(fill, S.gaTop);
|
|
@@ -2513,6 +3211,9 @@ function chartFootprint(units, seats) {
|
|
|
2513
3211
|
} else if (o.type === "table") {
|
|
2514
3212
|
const t = tablePolygon(o);
|
|
2515
3213
|
if (t) for (const p of t) acc(p.x, p.y);
|
|
3214
|
+
} else if (o.type === "decorImage") {
|
|
3215
|
+
const d = decorPolygon(o);
|
|
3216
|
+
if (d) for (const p of d) acc(p.x, p.y);
|
|
2516
3217
|
}
|
|
2517
3218
|
}
|
|
2518
3219
|
}
|
|
@@ -2524,12 +3225,7 @@ function chartFootprint(units, seats) {
|
|
|
2524
3225
|
}
|
|
2525
3226
|
return { minX, minY, maxX, maxY };
|
|
2526
3227
|
}
|
|
2527
|
-
var __PROF = {};
|
|
2528
|
-
var __t = (k, t0) => {
|
|
2529
|
-
__PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
|
|
2530
|
-
};
|
|
2531
3228
|
function buildSceneModel(input) {
|
|
2532
|
-
for (const k of Object.keys(__PROF)) delete __PROF[k];
|
|
2533
3229
|
const { doc, seats } = input;
|
|
2534
3230
|
const theme = resolveTheme3D(doc.theme);
|
|
2535
3231
|
const S = theme.structure;
|
|
@@ -2542,9 +3238,7 @@ function buildSceneModel(input) {
|
|
|
2542
3238
|
const sectionFills = resolveSectionFills(doc, seats);
|
|
2543
3239
|
const catColor = /* @__PURE__ */ new Map();
|
|
2544
3240
|
for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
|
|
2545
|
-
const __t0 = performance.now();
|
|
2546
3241
|
const surfaces = buildVenueSurfaces(units, seats);
|
|
2547
|
-
__t("surfaces", __t0);
|
|
2548
3242
|
const seatFloor = new Float32Array(seats.length);
|
|
2549
3243
|
for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
|
|
2550
3244
|
const unit = units[unitIndex];
|
|
@@ -2552,23 +3246,17 @@ function buildSceneModel(input) {
|
|
|
2552
3246
|
const claimed = new ClaimedArea();
|
|
2553
3247
|
const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
|
|
2554
3248
|
for (const o of unit.objects) {
|
|
2555
|
-
if (o.type === "section")
|
|
2556
|
-
|
|
2557
|
-
buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
|
|
2558
|
-
__t("buildTier", t);
|
|
2559
|
-
} else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
|
|
3249
|
+
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);
|
|
2560
3251
|
else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2561
3252
|
else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2562
3253
|
else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
3254
|
+
else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
|
|
2563
3255
|
}
|
|
2564
3256
|
}
|
|
2565
3257
|
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
2566
|
-
const __tm = performance.now();
|
|
2567
3258
|
const solids = mergeMeshData([builder.build()]);
|
|
2568
|
-
__t("meshBuild+merge", __tm);
|
|
2569
|
-
const __ts = performance.now();
|
|
2570
3259
|
const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
|
|
2571
|
-
__t("seatInstances", __ts);
|
|
2572
3260
|
const zoneDefs = doc.zones ?? [];
|
|
2573
3261
|
const zones = [];
|
|
2574
3262
|
if (zoneDefs.length) {
|
|
@@ -2628,6 +3316,7 @@ function buildSceneModel(input) {
|
|
|
2628
3316
|
}
|
|
2629
3317
|
}
|
|
2630
3318
|
const labels = [];
|
|
3319
|
+
const sections = [];
|
|
2631
3320
|
for (const z of zones) {
|
|
2632
3321
|
if (z.seatCount === 0) continue;
|
|
2633
3322
|
labels.push({
|
|
@@ -2640,6 +3329,7 @@ function buildSceneModel(input) {
|
|
|
2640
3329
|
}
|
|
2641
3330
|
{
|
|
2642
3331
|
const acc = /* @__PURE__ */ new Map();
|
|
3332
|
+
const ext = /* @__PURE__ */ new Map();
|
|
2643
3333
|
for (let i = 0; i < seats.length; i++) {
|
|
2644
3334
|
const owner = surfaces.seatOwner[i];
|
|
2645
3335
|
if (!owner) continue;
|
|
@@ -2652,18 +3342,87 @@ function buildSceneModel(input) {
|
|
|
2652
3342
|
a.x += seats[i].x;
|
|
2653
3343
|
a.y += seats[i].y;
|
|
2654
3344
|
a.deck += surfaces.seatDeckY(i);
|
|
3345
|
+
let e = ext.get(owner);
|
|
3346
|
+
if (!e) {
|
|
3347
|
+
e = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
|
3348
|
+
ext.set(owner, e);
|
|
3349
|
+
}
|
|
3350
|
+
if (seats[i].x < e.minX) e.minX = seats[i].x;
|
|
3351
|
+
if (seats[i].x > e.maxX) e.maxX = seats[i].x;
|
|
3352
|
+
if (seats[i].y < e.minY) e.minY = seats[i].y;
|
|
3353
|
+
if (seats[i].y > e.maxY) e.maxY = seats[i].y;
|
|
2655
3354
|
}
|
|
2656
3355
|
for (const unit of units) {
|
|
2657
3356
|
for (const o of unit.objects) {
|
|
2658
3357
|
if (o.type !== "section") continue;
|
|
2659
3358
|
const a = acc.get(o.id);
|
|
2660
3359
|
if (!a || a.n === 0) continue;
|
|
3360
|
+
const e = ext.get(o.id);
|
|
3361
|
+
const centre = [a.x / a.n * M, a.deck / a.n, a.y / a.n * M];
|
|
3362
|
+
sections.push({
|
|
3363
|
+
id: o.id,
|
|
3364
|
+
label: o.displayLabel || o.label || o.id,
|
|
3365
|
+
seatCount: a.n,
|
|
3366
|
+
center: centre,
|
|
3367
|
+
// Half-diagonal of the seat extent — the same fit the zones use.
|
|
3368
|
+
radius: Math.max(1, Math.hypot(e.maxX - e.minX, e.maxY - e.minY) * 0.5 * M),
|
|
3369
|
+
// A section faces its floor's focal, which is what the camera should
|
|
3370
|
+
// look along so the seats present their fronts.
|
|
3371
|
+
focalWorld: [unit.focal.x * M, unit.baseHeightM + 1.5, unit.focal.y * M]
|
|
3372
|
+
});
|
|
2661
3373
|
labels.push({
|
|
2662
3374
|
id: `section:${o.id}`,
|
|
2663
3375
|
kind: "section",
|
|
2664
3376
|
// The buyer-facing name wins over the technical one, as it does in 2D.
|
|
2665
3377
|
text: o.displayLabel || o.label || o.id,
|
|
2666
|
-
anchor: [
|
|
3378
|
+
anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]]
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
{
|
|
3384
|
+
for (const unit of units) {
|
|
3385
|
+
for (const o of unit.objects) {
|
|
3386
|
+
if (o.type !== "gaArea") continue;
|
|
3387
|
+
const c = centroidOf(o.points);
|
|
3388
|
+
if (!c) continue;
|
|
3389
|
+
const name = o.displayLabel || o.label || o.id;
|
|
3390
|
+
labels.push({
|
|
3391
|
+
id: `ga:${o.id}`,
|
|
3392
|
+
kind: "ga",
|
|
3393
|
+
text: o.capacity > 0 ? `${name} \xB7 ${o.capacity.toLocaleString()}` : name,
|
|
3394
|
+
anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M]
|
|
3395
|
+
});
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
const rowEnds = /* @__PURE__ */ new Map();
|
|
3399
|
+
for (let i = 0; i < seats.length; i++) {
|
|
3400
|
+
const s = seats[i];
|
|
3401
|
+
const far = Math.hypot(s.x - focal.x, s.y - focal.y);
|
|
3402
|
+
const cur = rowEnds.get(s.rowId);
|
|
3403
|
+
if (!cur || far > cur.far) rowEnds.set(s.rowId, { seat: s, index: i, far });
|
|
3404
|
+
}
|
|
3405
|
+
for (const [rowId, end] of rowEnds) {
|
|
3406
|
+
const cut = end.seat.label ? end.seat.label.lastIndexOf("-") : -1;
|
|
3407
|
+
const text = cut > 0 ? end.seat.label.slice(0, cut) : rowId;
|
|
3408
|
+
if (!text) continue;
|
|
3409
|
+
labels.push({
|
|
3410
|
+
id: `row:${rowId}`,
|
|
3411
|
+
kind: "row",
|
|
3412
|
+
text,
|
|
3413
|
+
anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
if (seats.length <= SEAT_LABEL_MAX) {
|
|
3417
|
+
for (let i = 0; i < seats.length; i++) {
|
|
3418
|
+
const s = seats[i];
|
|
3419
|
+
const text = s.displayLabel || s.label;
|
|
3420
|
+
if (!text) continue;
|
|
3421
|
+
labels.push({
|
|
3422
|
+
id: `seat:${s.id}`,
|
|
3423
|
+
kind: "seat",
|
|
3424
|
+
text,
|
|
3425
|
+
anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M]
|
|
2667
3426
|
});
|
|
2668
3427
|
}
|
|
2669
3428
|
}
|
|
@@ -2728,6 +3487,15 @@ function buildSceneModel(input) {
|
|
|
2728
3487
|
});
|
|
2729
3488
|
}
|
|
2730
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
|
+
);
|
|
2731
3499
|
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
2732
3500
|
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
2733
3501
|
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
@@ -2741,6 +3509,7 @@ function buildSceneModel(input) {
|
|
|
2741
3509
|
// Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
|
|
2742
3510
|
focalWorld: [focal.x * M, 1.5, focal.y * M],
|
|
2743
3511
|
zones,
|
|
3512
|
+
sections,
|
|
2744
3513
|
labels,
|
|
2745
3514
|
floors
|
|
2746
3515
|
};
|
|
@@ -2752,8 +3521,23 @@ var SEPARATION_Y_PX = 20;
|
|
|
2752
3521
|
var KIND_STYLE = {
|
|
2753
3522
|
zone: { size: 15, weight: "600", opacity: 0.95 },
|
|
2754
3523
|
section: { size: 12, weight: "500", opacity: 0.88 },
|
|
3524
|
+
ga: { size: 12, weight: "500", opacity: 0.88 },
|
|
2755
3525
|
booth: { size: 11, weight: "500", opacity: 0.85 },
|
|
2756
|
-
annotation: { size: 11, weight: "400", opacity: 0.75 }
|
|
3526
|
+
annotation: { size: 11, weight: "400", opacity: 0.75 },
|
|
3527
|
+
// Row and seat identity are quieter than the structure they sit inside: at
|
|
3528
|
+
// this range the venue is already understood and the label is a detail, so it
|
|
3529
|
+
// must not compete with the seating it is printed over.
|
|
3530
|
+
row: { size: 10.5, weight: "600", opacity: 0.8 },
|
|
3531
|
+
seat: { size: 9.5, weight: "500", opacity: 0.72 }
|
|
3532
|
+
};
|
|
3533
|
+
var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
|
|
3534
|
+
var DENSE_SEPARATION = {
|
|
3535
|
+
row: { x: 62, y: 16 },
|
|
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 }
|
|
2757
3541
|
};
|
|
2758
3542
|
var LabelOverlay = class {
|
|
2759
3543
|
constructor(container, opts = {}) {
|
|
@@ -2784,7 +3568,7 @@ var LabelOverlay = class {
|
|
|
2784
3568
|
*
|
|
2785
3569
|
* `viewProjection` is column-major, as OGL supplies it.
|
|
2786
3570
|
*/
|
|
2787
|
-
update(viewProjection, width, height, cameraDistance, venueRadius) {
|
|
3571
|
+
update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
|
|
2788
3572
|
if (!this.labels.length) return;
|
|
2789
3573
|
const kinds = visibleLabelKinds(cameraDistance, venueRadius);
|
|
2790
3574
|
const candidates = [];
|
|
@@ -2792,9 +3576,26 @@ var LabelOverlay = class {
|
|
|
2792
3576
|
if (!kinds.has(label.kind)) continue;
|
|
2793
3577
|
const screen = projectToScreen(viewProjection, label.anchor, width, height);
|
|
2794
3578
|
if (!screen.visible) continue;
|
|
2795
|
-
|
|
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) });
|
|
2796
3585
|
}
|
|
2797
|
-
const
|
|
3586
|
+
const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
|
|
3587
|
+
const kept = [
|
|
3588
|
+
...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
|
|
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(
|
|
3593
|
+
candidates.filter((c) => c.label.kind === kind),
|
|
3594
|
+
DENSE_SEPARATION[kind].x,
|
|
3595
|
+
DENSE_SEPARATION[kind].y,
|
|
3596
|
+
DENSE_LABEL_BUDGET[kind]
|
|
3597
|
+
))
|
|
3598
|
+
];
|
|
2798
3599
|
const keptIds = new Set(kept.map((k) => k.label.id));
|
|
2799
3600
|
for (const { label, screen } of kept) {
|
|
2800
3601
|
const node = this.nodeFor(label);
|
|
@@ -2841,6 +3642,13 @@ var import_ogl4 = require("ogl");
|
|
|
2841
3642
|
|
|
2842
3643
|
// src/view3d/scene/materials.ts
|
|
2843
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
|
+
);
|
|
2844
3652
|
var SOLID_VERT = (
|
|
2845
3653
|
/* glsl */
|
|
2846
3654
|
`#version 300 es
|
|
@@ -2920,14 +3728,23 @@ uniform float uSeatScale;
|
|
|
2920
3728
|
uniform float uMinPixels;
|
|
2921
3729
|
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
2922
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
|
|
2923
3733
|
out vec2 vUv;
|
|
2924
3734
|
out vec3 vColor;
|
|
2925
3735
|
out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
|
|
2926
3736
|
out vec3 vRing;
|
|
2927
3737
|
out float vDim;
|
|
3738
|
+
out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
|
|
3739
|
+
${CHAIR_WEIGHT_GLSL}
|
|
2928
3740
|
void main() {
|
|
2929
3741
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
2930
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);
|
|
2931
3748
|
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
2932
3749
|
// Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
|
|
2933
3750
|
// unbounded growth is what merges neighbouring rows into one mass at range.
|
|
@@ -2961,6 +3778,7 @@ in vec3 vColor;
|
|
|
2961
3778
|
in float vBudget;
|
|
2962
3779
|
in vec3 vRing;
|
|
2963
3780
|
in float vDim;
|
|
3781
|
+
in float vDotWeight;
|
|
2964
3782
|
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
2965
3783
|
uniform vec3 uFadeColor;
|
|
2966
3784
|
out vec4 fragColor;
|
|
@@ -2985,9 +3803,131 @@ void main() {
|
|
|
2985
3803
|
// Seats on an unfocused floor recede with their structure.
|
|
2986
3804
|
c = mix(c, uFadeColor, vDim * 0.75);
|
|
2987
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;
|
|
2988
3811
|
fragColor = vec4(c, alpha);
|
|
2989
3812
|
}`
|
|
2990
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
|
+
);
|
|
2991
3931
|
var BG_VERT = (
|
|
2992
3932
|
/* glsl */
|
|
2993
3933
|
`#version 300 es
|
|
@@ -3084,7 +4024,7 @@ function createSeatPickProgram(gl) {
|
|
|
3084
4024
|
uniforms: {
|
|
3085
4025
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
3086
4026
|
uSeatScale: { value: 1 },
|
|
3087
|
-
uMinPixels: { value:
|
|
4027
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
3088
4028
|
uPixelToWorld: { value: 2e-3 }
|
|
3089
4029
|
}
|
|
3090
4030
|
});
|
|
@@ -3129,11 +4069,32 @@ function createSeatProgram(gl) {
|
|
|
3129
4069
|
uniforms: {
|
|
3130
4070
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
3131
4071
|
uSeatScale: { value: 1 },
|
|
3132
|
-
uMinPixels: { value:
|
|
4072
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
3133
4073
|
uPixelToWorld: { value: 2e-3 },
|
|
3134
4074
|
uSeatFade: { value: 0 },
|
|
3135
4075
|
uFocusFloor: { value: -1 },
|
|
3136
|
-
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 }
|
|
3137
4098
|
}
|
|
3138
4099
|
});
|
|
3139
4100
|
}
|
|
@@ -3199,16 +4160,90 @@ function buildGpuScene(gl, model) {
|
|
|
3199
4160
|
seatMesh.frustumCulled = false;
|
|
3200
4161
|
if (model.seats.count > 0) seatMesh.setParent(main);
|
|
3201
4162
|
const colorAttr = seatGeo.attributes.iColor;
|
|
3202
|
-
|
|
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 = {
|
|
3203
4200
|
main,
|
|
3204
4201
|
background,
|
|
3205
4202
|
seatProgram: seatProg,
|
|
3206
4203
|
solidProgram: solidProg,
|
|
4204
|
+
chairProgram: chairProg,
|
|
3207
4205
|
seatGeometry: seatGeo,
|
|
3208
4206
|
solidGeometry: solidGeo,
|
|
3209
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
|
+
},
|
|
3210
4244
|
uploadSeatStateRuns(runs) {
|
|
3211
4245
|
if (!runs.length) return;
|
|
4246
|
+
if (nearCount) writeChairColors();
|
|
3212
4247
|
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
|
|
3213
4248
|
const buffer = colorAttr.buffer;
|
|
3214
4249
|
if (!buffer) {
|
|
@@ -3228,8 +4263,11 @@ function buildGpuScene(gl, model) {
|
|
|
3228
4263
|
solidProg.remove();
|
|
3229
4264
|
seatGeo.remove();
|
|
3230
4265
|
seatProg.remove();
|
|
4266
|
+
chairGeo.remove();
|
|
4267
|
+
chairProg.remove();
|
|
3231
4268
|
}
|
|
3232
4269
|
};
|
|
4270
|
+
return scene;
|
|
3233
4271
|
}
|
|
3234
4272
|
|
|
3235
4273
|
// src/view3d/pick/pickPipeline.ts
|
|
@@ -3797,6 +4835,28 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3797
4835
|
gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
|
|
3798
4836
|
gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
3799
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);
|
|
3800
4860
|
};
|
|
3801
4861
|
const glctx = new GLContext(container, {
|
|
3802
4862
|
onContextLost: () => {
|
|
@@ -3843,7 +4903,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3843
4903
|
const u = gpu.seatProgram.uniforms;
|
|
3844
4904
|
u.uSeatScale.value = lod.scale;
|
|
3845
4905
|
u.uSeatFade.value = lod.fade;
|
|
4906
|
+
u.uMinPixels.value = lod.minPixels;
|
|
3846
4907
|
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
|
|
4908
|
+
updateNearField();
|
|
3847
4909
|
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
3848
4910
|
glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
|
|
3849
4911
|
labelOverlay.update(
|
|
@@ -3851,7 +4913,10 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3851
4913
|
glctx.canvas.clientWidth || 1,
|
|
3852
4914
|
glctx.canvas.clientHeight || 1,
|
|
3853
4915
|
orbit.currentDistance,
|
|
3854
|
-
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]
|
|
3855
4920
|
);
|
|
3856
4921
|
return moving;
|
|
3857
4922
|
});
|
|
@@ -4124,6 +5189,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
4124
5189
|
if (gpu) {
|
|
4125
5190
|
gpu.seatProgram.uniforms.uFocusFloor.value = value;
|
|
4126
5191
|
gpu.solidProgram.uniforms.uFocusFloor.value = value;
|
|
5192
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = value;
|
|
4127
5193
|
}
|
|
4128
5194
|
focusedFloor = value;
|
|
4129
5195
|
if (index !== null) {
|
|
@@ -4150,6 +5216,20 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
4150
5216
|
loop.requestRender();
|
|
4151
5217
|
return true;
|
|
4152
5218
|
},
|
|
5219
|
+
sections() {
|
|
5220
|
+
return model.sections;
|
|
5221
|
+
},
|
|
5222
|
+
focusSection(sectionId) {
|
|
5223
|
+
const sec = model.sections.find((s) => s.id === sectionId);
|
|
5224
|
+
if (!sec || sec.seatCount === 0) return false;
|
|
5225
|
+
cinematic.cancel();
|
|
5226
|
+
const dx = sec.focalWorld[0] - sec.center[0];
|
|
5227
|
+
const dz = sec.focalWorld[2] - sec.center[2];
|
|
5228
|
+
const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
|
|
5229
|
+
orbit.frame({ center: sec.center, radius: sec.radius * 1.45 }, false, azimuth);
|
|
5230
|
+
loop.requestRender();
|
|
5231
|
+
return true;
|
|
5232
|
+
},
|
|
4153
5233
|
setReducedMotionForTest(value) {
|
|
4154
5234
|
reducedForced = value;
|
|
4155
5235
|
},
|