@seatlayer/core 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-FGS67GT3.js → chunk-MGC5BVAD.js} +156 -33
- package/dist/chunk-MGC5BVAD.js.map +1 -0
- package/dist/index.cjs +209 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -5
- package/dist/index.d.ts +28 -5
- package/dist/index.js +61 -13
- package/dist/index.js.map +1 -1
- package/dist/{types-CG2pEDoI.d.cts → types-DO_J5DtX.d.cts} +27 -1
- package/dist/{types-CG2pEDoI.d.ts → types-DO_J5DtX.d.ts} +27 -1
- package/dist/view3d/index.cjs +1121 -304
- package/dist/view3d/index.cjs.map +1 -1
- package/dist/view3d/index.d.cts +24 -1
- package/dist/view3d/index.d.ts +24 -1
- package/dist/view3d/index.js +1116 -305
- package/dist/view3d/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-FGS67GT3.js.map +0 -1
package/dist/view3d/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
pointInPolygonWithHoles,
|
|
8
8
|
resolveSection,
|
|
9
9
|
sectionGeometry
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-MGC5BVAD.js";
|
|
11
11
|
|
|
12
12
|
// src/view3d/index.ts
|
|
13
13
|
import { Quat as Quat2, Vec3 as Vec33 } from "ogl";
|
|
@@ -183,10 +183,18 @@ var OrbitCamera = class {
|
|
|
183
183
|
this.maxDist = 100;
|
|
184
184
|
this.gestureFired = false;
|
|
185
185
|
this.dragging = false;
|
|
186
|
+
this.panning = false;
|
|
186
187
|
this.lastX = 0;
|
|
187
188
|
this.lastY = 0;
|
|
188
189
|
this.activePointers = /* @__PURE__ */ new Map();
|
|
189
190
|
this.pinchDist = 0;
|
|
191
|
+
this.pinchCx = 0;
|
|
192
|
+
this.pinchCy = 0;
|
|
193
|
+
/** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
|
|
194
|
+
this.targetT = new Vec3();
|
|
195
|
+
/** Venue centre + radius, so pan can be clamped to somewhere still useful. */
|
|
196
|
+
this.panAnchor = new Vec3();
|
|
197
|
+
this.panLimit = 0;
|
|
190
198
|
this.camera = new Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
|
|
191
199
|
this.canvas = canvas;
|
|
192
200
|
this.requestRender = requestRender;
|
|
@@ -198,12 +206,17 @@ var OrbitCamera = class {
|
|
|
198
206
|
}
|
|
199
207
|
this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
200
208
|
if (this.activePointers.size === 1) {
|
|
201
|
-
this.
|
|
209
|
+
this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
|
|
210
|
+
this.dragging = !this.panning;
|
|
202
211
|
this.lastX = e.clientX;
|
|
203
212
|
this.lastY = e.clientY;
|
|
204
213
|
} else if (this.activePointers.size === 2) {
|
|
205
214
|
this.dragging = false;
|
|
215
|
+
this.panning = false;
|
|
206
216
|
this.pinchDist = this.currentPinchDistance();
|
|
217
|
+
const c = this.pinchCentroid();
|
|
218
|
+
this.pinchCx = c.x;
|
|
219
|
+
this.pinchCy = c.y;
|
|
207
220
|
}
|
|
208
221
|
};
|
|
209
222
|
this.onPointerMove = (e) => {
|
|
@@ -216,11 +229,24 @@ var OrbitCamera = class {
|
|
|
216
229
|
this.fireGesture();
|
|
217
230
|
}
|
|
218
231
|
this.pinchDist = d;
|
|
232
|
+
const c = this.pinchCentroid();
|
|
233
|
+
this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
|
|
234
|
+
this.pinchCx = c.x;
|
|
235
|
+
this.pinchCy = c.y;
|
|
219
236
|
return;
|
|
220
237
|
}
|
|
221
|
-
if (!this.dragging) return;
|
|
222
238
|
const dx = e.clientX - this.lastX;
|
|
223
239
|
const dy = e.clientY - this.lastY;
|
|
240
|
+
if (this.panning) {
|
|
241
|
+
this.lastX = e.clientX;
|
|
242
|
+
this.lastY = e.clientY;
|
|
243
|
+
if (dx !== 0 || dy !== 0) {
|
|
244
|
+
this.panBy(dx, dy);
|
|
245
|
+
this.fireGesture();
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!this.dragging) return;
|
|
224
250
|
this.lastX = e.clientX;
|
|
225
251
|
this.lastY = e.clientY;
|
|
226
252
|
if (dx !== 0 || dy !== 0) this.fireGesture();
|
|
@@ -235,7 +261,13 @@ var OrbitCamera = class {
|
|
|
235
261
|
} catch {
|
|
236
262
|
}
|
|
237
263
|
if (this.activePointers.size < 2) this.pinchDist = 0;
|
|
238
|
-
if (this.activePointers.size === 0)
|
|
264
|
+
if (this.activePointers.size === 0) {
|
|
265
|
+
this.dragging = false;
|
|
266
|
+
this.panning = false;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
this.onContextMenu = (e) => {
|
|
270
|
+
e.preventDefault();
|
|
239
271
|
};
|
|
240
272
|
this.onWheel = (e) => {
|
|
241
273
|
e.preventDefault();
|
|
@@ -249,6 +281,55 @@ var OrbitCamera = class {
|
|
|
249
281
|
canvas.addEventListener("pointerup", this.onPointerUp);
|
|
250
282
|
canvas.addEventListener("pointercancel", this.onPointerUp);
|
|
251
283
|
canvas.addEventListener("wheel", this.onWheel, { passive: false });
|
|
284
|
+
canvas.addEventListener("contextmenu", this.onContextMenu);
|
|
285
|
+
}
|
|
286
|
+
pinchCentroid() {
|
|
287
|
+
const pts = [...this.activePointers.values()];
|
|
288
|
+
if (!pts.length) return { x: 0, y: 0 };
|
|
289
|
+
let x = 0;
|
|
290
|
+
let y = 0;
|
|
291
|
+
for (const p of pts) {
|
|
292
|
+
x += p.x;
|
|
293
|
+
y += p.y;
|
|
294
|
+
}
|
|
295
|
+
return { x: x / pts.length, y: y / pts.length };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Slide the orbit pivot across the camera's own screen plane.
|
|
299
|
+
*
|
|
300
|
+
* Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
|
|
301
|
+
* under the cursor whatever the zoom: at the overview a drag sweeps the whole
|
|
302
|
+
* bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
|
|
303
|
+
* would be unusable at one end or the other.
|
|
304
|
+
*/
|
|
305
|
+
panBy(dxPx, dyPx) {
|
|
306
|
+
const h = this.canvas.clientHeight || 1;
|
|
307
|
+
const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
|
|
308
|
+
const sinA = Math.sin(this.azimuth);
|
|
309
|
+
const cosA = Math.cos(this.azimuth);
|
|
310
|
+
const rightX = cosA;
|
|
311
|
+
const rightZ = -sinA;
|
|
312
|
+
const cp = Math.cos(this.polar);
|
|
313
|
+
const sp = Math.sin(this.polar);
|
|
314
|
+
const fwdX = -sinA * cp;
|
|
315
|
+
const fwdZ = -cosA * cp;
|
|
316
|
+
this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
|
|
317
|
+
this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
|
|
318
|
+
this.targetT.y += dyPx * sp * perPx;
|
|
319
|
+
this.clampPan();
|
|
320
|
+
this.requestRender();
|
|
321
|
+
}
|
|
322
|
+
/** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
|
|
323
|
+
* venue entirely — the Overview chip should never be the only way back. */
|
|
324
|
+
clampPan() {
|
|
325
|
+
if (this.panLimit <= 0) return;
|
|
326
|
+
const lim = this.panLimit;
|
|
327
|
+
const cx = this.panAnchor.x;
|
|
328
|
+
const cy = this.panAnchor.y;
|
|
329
|
+
const cz = this.panAnchor.z;
|
|
330
|
+
this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
|
|
331
|
+
this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
|
|
332
|
+
this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
|
|
252
333
|
}
|
|
253
334
|
/** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
|
|
254
335
|
fireGesture() {
|
|
@@ -275,6 +356,9 @@ var OrbitCamera = class {
|
|
|
275
356
|
*/
|
|
276
357
|
frame(bounds, intro = false, stageAzimuth) {
|
|
277
358
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
359
|
+
this.targetT.copy(this.target);
|
|
360
|
+
this.panAnchor.copy(this.target);
|
|
361
|
+
this.panLimit = Math.max(1, bounds.radius) * 1.5;
|
|
278
362
|
const r = Math.max(1, bounds.radius);
|
|
279
363
|
const halfV = this.fovY * DEG / 2;
|
|
280
364
|
const aspect = this.camera.aspect || 1;
|
|
@@ -306,6 +390,9 @@ var OrbitCamera = class {
|
|
|
306
390
|
*/
|
|
307
391
|
frameSoft(bounds, stageAzimuth) {
|
|
308
392
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
393
|
+
this.targetT.copy(this.target);
|
|
394
|
+
this.panAnchor.copy(this.target);
|
|
395
|
+
this.panLimit = Math.max(1, bounds.radius) * 1.5;
|
|
309
396
|
this.syncFromCamera();
|
|
310
397
|
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
311
398
|
const r = Math.max(1, bounds.radius);
|
|
@@ -322,10 +409,17 @@ var OrbitCamera = class {
|
|
|
322
409
|
const da = this.azT - this.azimuth;
|
|
323
410
|
const dp = this.polT - this.polar;
|
|
324
411
|
const dd = this.distT - this.distance;
|
|
325
|
-
const
|
|
412
|
+
const tx = this.targetT.x - this.target.x;
|
|
413
|
+
const ty = this.targetT.y - this.target.y;
|
|
414
|
+
const tz = this.targetT.z - this.target.z;
|
|
415
|
+
const panEps = Math.max(1e-4, this.distance * 1e-4);
|
|
416
|
+
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;
|
|
326
417
|
this.azimuth += da * DAMP;
|
|
327
418
|
this.polar += dp * DAMP;
|
|
328
419
|
this.distance += dd * DAMP;
|
|
420
|
+
this.target.x += tx * DAMP;
|
|
421
|
+
this.target.y += ty * DAMP;
|
|
422
|
+
this.target.z += tz * DAMP;
|
|
329
423
|
if (moving) this.applyPosition();
|
|
330
424
|
return moving;
|
|
331
425
|
}
|
|
@@ -351,6 +445,7 @@ var OrbitCamera = class {
|
|
|
351
445
|
/** Point the orbit pivot at a new world target without moving the camera. */
|
|
352
446
|
setTarget(target) {
|
|
353
447
|
this.target.set(target[0], target[1], target[2]);
|
|
448
|
+
this.targetT.copy(this.target);
|
|
354
449
|
}
|
|
355
450
|
/** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
|
|
356
451
|
* flight ends looking at `target` (the venue focal), so re-pivot there first —
|
|
@@ -358,6 +453,7 @@ var OrbitCamera = class {
|
|
|
358
453
|
resumeAfterFlight(target) {
|
|
359
454
|
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
360
455
|
if (target) this.target.set(target[0], target[1], target[2]);
|
|
456
|
+
this.targetT.copy(this.target);
|
|
361
457
|
this.syncFromCamera();
|
|
362
458
|
}
|
|
363
459
|
applyPosition() {
|
|
@@ -374,6 +470,7 @@ var OrbitCamera = class {
|
|
|
374
470
|
this.canvas.removeEventListener("pointerup", this.onPointerUp);
|
|
375
471
|
this.canvas.removeEventListener("pointercancel", this.onPointerUp);
|
|
376
472
|
this.canvas.removeEventListener("wheel", this.onWheel);
|
|
473
|
+
this.canvas.removeEventListener("contextmenu", this.onContextMenu);
|
|
377
474
|
this.activePointers.clear();
|
|
378
475
|
}
|
|
379
476
|
};
|
|
@@ -422,17 +519,129 @@ var RenderLoop = class {
|
|
|
422
519
|
};
|
|
423
520
|
|
|
424
521
|
// src/view3d/lod.ts
|
|
522
|
+
var SEAT_MIN_PIXELS_NEAR = 2.5;
|
|
523
|
+
var SEAT_MIN_PIXELS_FAR = 1.15;
|
|
524
|
+
var CHAIR_FULL_M = 12;
|
|
525
|
+
var CHAIR_NONE_M = 22;
|
|
526
|
+
var CHAIR_GATHER_M = 30;
|
|
527
|
+
var CHAIR_REBUILD_M = 4;
|
|
528
|
+
var CHAIR_MAX_INSTANCES = 8192;
|
|
529
|
+
var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
|
|
425
530
|
function computeSeatLod(distance, radius) {
|
|
426
531
|
const near = radius * 1.4;
|
|
427
532
|
const far = radius * 3.2;
|
|
428
|
-
if (distance <= near) return { scale: 1, fade: 0 };
|
|
533
|
+
if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
|
|
429
534
|
const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
|
|
430
535
|
return {
|
|
431
536
|
scale: 1 - t * 0.4,
|
|
432
|
-
fade: t * 0.55
|
|
537
|
+
fade: t * 0.55,
|
|
538
|
+
// Tapered on the same ramp as the fade: as the block starts reading by its
|
|
539
|
+
// tier tint, the dots stop fighting each other for pixels.
|
|
540
|
+
minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
|
|
433
541
|
};
|
|
434
542
|
}
|
|
435
543
|
|
|
544
|
+
// src/view3d/scene/nearField.ts
|
|
545
|
+
var SEATS_PER_CELL = 4;
|
|
546
|
+
var NearFieldIndex = class {
|
|
547
|
+
constructor(iPosition, count) {
|
|
548
|
+
this.minX = 0;
|
|
549
|
+
this.minZ = 0;
|
|
550
|
+
this.cell = 1;
|
|
551
|
+
this.cols = 1;
|
|
552
|
+
this.rows = 1;
|
|
553
|
+
/** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
|
|
554
|
+
this.cellStart = null;
|
|
555
|
+
this.cellItems = null;
|
|
556
|
+
this.iPosition = iPosition;
|
|
557
|
+
this.count = count;
|
|
558
|
+
}
|
|
559
|
+
/** Built on demand; safe to call repeatedly. */
|
|
560
|
+
ensureGrid() {
|
|
561
|
+
if (this.cellStart || this.count === 0) return;
|
|
562
|
+
const p = this.iPosition;
|
|
563
|
+
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
|
564
|
+
for (let i = 0; i < this.count; i++) {
|
|
565
|
+
const x = p[i * 3], z = p[i * 3 + 2];
|
|
566
|
+
if (x < minX) minX = x;
|
|
567
|
+
if (x > maxX) maxX = x;
|
|
568
|
+
if (z < minZ) minZ = z;
|
|
569
|
+
if (z > maxZ) maxZ = z;
|
|
570
|
+
}
|
|
571
|
+
const w = Math.max(maxX - minX, 1e-3);
|
|
572
|
+
const h = Math.max(maxZ - minZ, 1e-3);
|
|
573
|
+
this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
|
|
574
|
+
this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
|
|
575
|
+
this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
|
|
576
|
+
this.minX = minX;
|
|
577
|
+
this.minZ = minZ;
|
|
578
|
+
const nCells = this.cols * this.rows;
|
|
579
|
+
const start = new Int32Array(nCells + 1);
|
|
580
|
+
const cellOf = (i) => {
|
|
581
|
+
const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
|
|
582
|
+
const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
|
|
583
|
+
return cz * this.cols + cx;
|
|
584
|
+
};
|
|
585
|
+
for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
|
|
586
|
+
for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
|
|
587
|
+
const items = new Int32Array(this.count);
|
|
588
|
+
const cursor = start.slice(0, nCells);
|
|
589
|
+
for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
|
|
590
|
+
this.cellStart = start;
|
|
591
|
+
this.cellItems = items;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
|
|
595
|
+
* nearest cell-ring first, and return how many were written.
|
|
596
|
+
*
|
|
597
|
+
* Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
|
|
598
|
+
* bites it drops the OUTERMOST seats, which are the ones already past the fade
|
|
599
|
+
* band and drawing nothing. A cap that truncated in index order would instead
|
|
600
|
+
* punch holes in the row you are sitting in.
|
|
601
|
+
*
|
|
602
|
+
* Note this is a horizontal (XZ) query and ignores height. A stacked venue's
|
|
603
|
+
* upper tier is therefore gathered along with the stalls beneath it — which is
|
|
604
|
+
* correct, because the fade weight is re-derived from true view depth in the
|
|
605
|
+
* shader anyway. The grid's only job is to bound the candidate set.
|
|
606
|
+
*/
|
|
607
|
+
gather(camX, camZ, radius, out) {
|
|
608
|
+
this.ensureGrid();
|
|
609
|
+
const start = this.cellStart;
|
|
610
|
+
const items = this.cellItems;
|
|
611
|
+
if (!start || !items) return 0;
|
|
612
|
+
const cap = out.length;
|
|
613
|
+
const r2 = radius * radius;
|
|
614
|
+
const cx = Math.floor((camX - this.minX) / this.cell);
|
|
615
|
+
const cz = Math.floor((camZ - this.minZ) / this.cell);
|
|
616
|
+
const maxRing = Math.ceil(radius / this.cell) + 1;
|
|
617
|
+
const p = this.iPosition;
|
|
618
|
+
let n = 0;
|
|
619
|
+
for (let ring = 0; ring <= maxRing && n < cap; ring++) {
|
|
620
|
+
const z0 = cz - ring, z1 = cz + ring;
|
|
621
|
+
const x0 = cx - ring, x1 = cx + ring;
|
|
622
|
+
for (let gz = z0; gz <= z1 && n < cap; gz++) {
|
|
623
|
+
if (gz < 0 || gz >= this.rows) continue;
|
|
624
|
+
const edge = gz === z0 || gz === z1;
|
|
625
|
+
for (let gx = x0; gx <= x1 && n < cap; gx++) {
|
|
626
|
+
if (!edge && gx !== x0 && gx !== x1) {
|
|
627
|
+
gx = x1 - 1;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (gx < 0 || gx >= this.cols) continue;
|
|
631
|
+
const c = gz * this.cols + gx;
|
|
632
|
+
for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
|
|
633
|
+
const i = items[k];
|
|
634
|
+
const dx = p[i * 3] - camX;
|
|
635
|
+
const dz = p[i * 3 + 2] - camZ;
|
|
636
|
+
if (dx * dx + dz * dz <= r2) out[n++] = i;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return n;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
436
645
|
// src/view3d/scene/geometry.ts
|
|
437
646
|
import earcut from "earcut";
|
|
438
647
|
import polygonClipping from "polygon-clipping";
|
|
@@ -859,6 +1068,152 @@ function rectPolygon(x, y, w, h) {
|
|
|
859
1068
|
];
|
|
860
1069
|
}
|
|
861
1070
|
|
|
1071
|
+
// src/view3d/scene/seatChair.ts
|
|
1072
|
+
var CHAIR_PART = { pedestal: 0, pad: 1, back: 2 };
|
|
1073
|
+
var CHAIR_PITCH_FRACTION = 0.44;
|
|
1074
|
+
var CHAIR_HALF_WIDTH_MIN_M = 0.15;
|
|
1075
|
+
var CHAIR_HALF_WIDTH_MAX_M = 0.3;
|
|
1076
|
+
var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
|
|
1077
|
+
function chairHalfWidth(pitchM) {
|
|
1078
|
+
if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
|
|
1079
|
+
return CHAIR_HALF_WIDTH_DEFAULT_M;
|
|
1080
|
+
}
|
|
1081
|
+
return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
|
|
1082
|
+
}
|
|
1083
|
+
var BACK_RAKE_SLOPE = 0.21;
|
|
1084
|
+
var PAD_BACK_GAP_M = 0.05;
|
|
1085
|
+
var PAD_TOP_M = 0.45;
|
|
1086
|
+
var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
|
|
1087
|
+
var BOXES = [
|
|
1088
|
+
// Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
|
|
1089
|
+
// over the deck and the row reads as hovering trays.
|
|
1090
|
+
{ part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
|
|
1091
|
+
// Seat pad — a full seat width across and about as deep, which is what a real
|
|
1092
|
+
// one is. Its depth is bounded by the same pitch as its width, because the
|
|
1093
|
+
// pitch measure is the tighter of the in-row and row-to-row spacings, so a
|
|
1094
|
+
// tightly-raked tier cannot drive a pad into the back of the row in front.
|
|
1095
|
+
{ part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
|
|
1096
|
+
// Back panel — thin, raked, and the tallest thing in the row, so it is what
|
|
1097
|
+
// carries the state colour when you look along a row from behind.
|
|
1098
|
+
{
|
|
1099
|
+
part: CHAIR_PART.back,
|
|
1100
|
+
min: [-1, BACK_BASE_M, -1],
|
|
1101
|
+
max: [1, 0.92, -0.72]
|
|
1102
|
+
}
|
|
1103
|
+
];
|
|
1104
|
+
var FACES = [
|
|
1105
|
+
// +X
|
|
1106
|
+
{ n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
|
|
1107
|
+
// -X
|
|
1108
|
+
{ n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
|
|
1109
|
+
// +Y
|
|
1110
|
+
{ n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
|
|
1111
|
+
// -Y
|
|
1112
|
+
{ n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
|
|
1113
|
+
// +Z
|
|
1114
|
+
{ n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
|
|
1115
|
+
// -Z
|
|
1116
|
+
{ n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
|
|
1117
|
+
];
|
|
1118
|
+
function buildChairMesh() {
|
|
1119
|
+
const vertexCount = BOXES.length * FACES.length * 4;
|
|
1120
|
+
const indexCount = BOXES.length * FACES.length * 6;
|
|
1121
|
+
const position = new Float32Array(vertexCount * 3);
|
|
1122
|
+
const normal = new Float32Array(vertexCount * 3);
|
|
1123
|
+
const part = new Float32Array(vertexCount);
|
|
1124
|
+
const index = new Uint16Array(indexCount);
|
|
1125
|
+
let v = 0;
|
|
1126
|
+
let t = 0;
|
|
1127
|
+
for (const box of BOXES) {
|
|
1128
|
+
for (const face of FACES) {
|
|
1129
|
+
const base = v;
|
|
1130
|
+
const corner3 = new Float32Array(12);
|
|
1131
|
+
for (let ci = 0; ci < 4; ci++) {
|
|
1132
|
+
const corner = face.c[ci];
|
|
1133
|
+
for (let a = 0; a < 3; a++) {
|
|
1134
|
+
corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
|
|
1138
|
+
const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
|
|
1139
|
+
let nx = ay * bz - az * by;
|
|
1140
|
+
let ny = az * bx - ax * bz;
|
|
1141
|
+
let nz = ax * by - ay * bx;
|
|
1142
|
+
const nl = Math.hypot(nx, ny, nz);
|
|
1143
|
+
if (nl > 1e-9) {
|
|
1144
|
+
nx /= nl;
|
|
1145
|
+
ny /= nl;
|
|
1146
|
+
nz /= nl;
|
|
1147
|
+
} else {
|
|
1148
|
+
nx = face.n[0];
|
|
1149
|
+
ny = face.n[1];
|
|
1150
|
+
nz = face.n[2];
|
|
1151
|
+
}
|
|
1152
|
+
if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
|
|
1153
|
+
nx = -nx;
|
|
1154
|
+
ny = -ny;
|
|
1155
|
+
nz = -nz;
|
|
1156
|
+
}
|
|
1157
|
+
for (let ci = 0; ci < 4; ci++) {
|
|
1158
|
+
position[v * 3] = corner3[ci * 3];
|
|
1159
|
+
position[v * 3 + 1] = corner3[ci * 3 + 1];
|
|
1160
|
+
position[v * 3 + 2] = corner3[ci * 3 + 2];
|
|
1161
|
+
normal[v * 3] = nx;
|
|
1162
|
+
normal[v * 3 + 1] = ny;
|
|
1163
|
+
normal[v * 3 + 2] = nz;
|
|
1164
|
+
part[v] = box.part;
|
|
1165
|
+
v++;
|
|
1166
|
+
}
|
|
1167
|
+
index[t++] = base;
|
|
1168
|
+
index[t++] = base + 1;
|
|
1169
|
+
index[t++] = base + 2;
|
|
1170
|
+
index[t++] = base;
|
|
1171
|
+
index[t++] = base + 2;
|
|
1172
|
+
index[t++] = base + 3;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return { position, normal, part, index, vertexCount, indexCount };
|
|
1176
|
+
}
|
|
1177
|
+
function computeSeatYaw(iPosition, count, rowIdAt, focal) {
|
|
1178
|
+
const yaw = new Float32Array(count);
|
|
1179
|
+
const px = (i) => iPosition[i * 3];
|
|
1180
|
+
const pz = (i) => iPosition[i * 3 + 2];
|
|
1181
|
+
let runStart = 0;
|
|
1182
|
+
const flushRun = (start, end) => {
|
|
1183
|
+
const n = end - start;
|
|
1184
|
+
for (let i = start; i < end; i++) {
|
|
1185
|
+
const [fx, fz] = focal(i);
|
|
1186
|
+
let dx = fx - px(i);
|
|
1187
|
+
let dz = fz - pz(i);
|
|
1188
|
+
if (n >= 2) {
|
|
1189
|
+
const a = Math.max(start, i - 1);
|
|
1190
|
+
const b = Math.min(end - 1, i + 1);
|
|
1191
|
+
const tx = px(b) - px(a);
|
|
1192
|
+
const tz = pz(b) - pz(a);
|
|
1193
|
+
const tl = Math.hypot(tx, tz);
|
|
1194
|
+
if (tl > 1e-6) {
|
|
1195
|
+
let nx = -tz / tl;
|
|
1196
|
+
let nz = tx / tl;
|
|
1197
|
+
if (nx * dx + nz * dz < 0) {
|
|
1198
|
+
nx = -nx;
|
|
1199
|
+
nz = -nz;
|
|
1200
|
+
}
|
|
1201
|
+
dx = nx;
|
|
1202
|
+
dz = nz;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
for (let i = 1; i <= count; i++) {
|
|
1209
|
+
if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
|
|
1210
|
+
flushRun(runStart, i);
|
|
1211
|
+
runStart = i;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return yaw;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
862
1217
|
// src/view3d/scene/seatInstances.ts
|
|
863
1218
|
var SEAT_DOT_RADIUS_M = 0.22;
|
|
864
1219
|
var SEAT_PITCH_FRACTION = 0.42;
|
|
@@ -928,6 +1283,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
928
1283
|
const iPosition = new Float32Array(count * 3);
|
|
929
1284
|
const iState = new Float32Array(count);
|
|
930
1285
|
const iMaxRadius = new Float32Array(count);
|
|
1286
|
+
const iChairWidth = new Float32Array(count);
|
|
931
1287
|
const iRing = new Float32Array(count * 3);
|
|
932
1288
|
const idToIndex = /* @__PURE__ */ new Map();
|
|
933
1289
|
const spacing = nearestNeighbourSpacing(seats);
|
|
@@ -936,6 +1292,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
936
1292
|
const resolved = surfaces?.seatPitchU(i);
|
|
937
1293
|
const pitchM = (resolved ?? spacing[i]) * M;
|
|
938
1294
|
iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
|
|
1295
|
+
iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
|
|
939
1296
|
iPosition[i * 3] = seat.x * M;
|
|
940
1297
|
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
941
1298
|
iPosition[i * 3 + 2] = seat.y * M;
|
|
@@ -957,7 +1314,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
957
1314
|
iMaxRadius,
|
|
958
1315
|
iRing,
|
|
959
1316
|
idToIndex,
|
|
960
|
-
|
|
1317
|
+
iChairWidth,
|
|
1318
|
+
iFloor: seatFloor ?? new Float32Array(count),
|
|
1319
|
+
iYaw: new Float32Array(count)
|
|
961
1320
|
};
|
|
962
1321
|
}
|
|
963
1322
|
var RUN_MERGE_GAP = 64;
|
|
@@ -1092,6 +1451,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
|
|
|
1092
1451
|
}
|
|
1093
1452
|
return kept;
|
|
1094
1453
|
}
|
|
1454
|
+
var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
|
|
1455
|
+
function focusScore(screen, worldDistance, width, height) {
|
|
1456
|
+
const half = Math.max(1, Math.min(width, height) * 0.5);
|
|
1457
|
+
const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
|
|
1458
|
+
return worldDistance * (1 + 1.5 * off);
|
|
1459
|
+
}
|
|
1460
|
+
function pickDenseLabels(items, separationX, separationY, budget) {
|
|
1461
|
+
const ordered = [...items].sort((a, b) => a.focus - b.focus);
|
|
1462
|
+
const kept = [];
|
|
1463
|
+
for (const item of ordered) {
|
|
1464
|
+
if (kept.length >= budget) break;
|
|
1465
|
+
let clash = false;
|
|
1466
|
+
for (const k of kept) {
|
|
1467
|
+
if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
|
|
1468
|
+
clash = true;
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (!clash) kept.push(item);
|
|
1473
|
+
}
|
|
1474
|
+
return kept;
|
|
1475
|
+
}
|
|
1095
1476
|
function centroidOf(points) {
|
|
1096
1477
|
if (!points.length) return null;
|
|
1097
1478
|
let x = 0, y = 0;
|
|
@@ -1265,195 +1646,6 @@ function buildSectionRake(rows, focal) {
|
|
|
1265
1646
|
return rowsRake(fits);
|
|
1266
1647
|
}
|
|
1267
1648
|
|
|
1268
|
-
// src/view3d/scene/surface.ts
|
|
1269
|
-
var FLAT_SLAB_TOP_M = 0.05;
|
|
1270
|
-
var CAP_MAX_ERROR_M = 0.05;
|
|
1271
|
-
var SEAT_CLEARANCE_M = 0.15;
|
|
1272
|
-
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1273
|
-
function bboxOf(pts) {
|
|
1274
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1275
|
-
for (const p of pts) {
|
|
1276
|
-
if (p.x < minX) minX = p.x;
|
|
1277
|
-
if (p.y < minY) minY = p.y;
|
|
1278
|
-
if (p.x > maxX) maxX = p.x;
|
|
1279
|
-
if (p.y > maxY) maxY = p.y;
|
|
1280
|
-
}
|
|
1281
|
-
return { minX, minY, maxX, maxY };
|
|
1282
|
-
}
|
|
1283
|
-
var MAX_TIER_RISE_M = 25;
|
|
1284
|
-
function buildVenueSurfaces(units, seats) {
|
|
1285
|
-
const bySection = /* @__PURE__ */ new Map();
|
|
1286
|
-
const seatOwner = new Array(seats.length).fill(null);
|
|
1287
|
-
const seatDeck = new Float64Array(seats.length);
|
|
1288
|
-
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
1289
|
-
const seatPitch = new Array(seats.length).fill(void 0);
|
|
1290
|
-
const acc = /* @__PURE__ */ new Map();
|
|
1291
|
-
const boxes = [];
|
|
1292
|
-
for (const unit of units) {
|
|
1293
|
-
for (const o of unit.objects) {
|
|
1294
|
-
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1295
|
-
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
1296
|
-
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
1297
|
-
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1301
|
-
const s = seats[i];
|
|
1302
|
-
for (const b of boxes) {
|
|
1303
|
-
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
1304
|
-
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
1305
|
-
seatOwner[i] = b.id;
|
|
1306
|
-
const a = acc.get(b.id);
|
|
1307
|
-
a.hasSeats = true;
|
|
1308
|
-
const f = s.focalPoint ?? b.unit.focal;
|
|
1309
|
-
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
1310
|
-
if (d < a.frontU) a.frontU = d;
|
|
1311
|
-
a.seatIndices.push(i);
|
|
1312
|
-
const rowKey = s.rowId || `__seat-${i}`;
|
|
1313
|
-
const arr = a.rows.get(rowKey);
|
|
1314
|
-
if (arr) arr.push({ x: s.x, y: s.y });
|
|
1315
|
-
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
1316
|
-
break;
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
for (const [id, a] of acc) {
|
|
1320
|
-
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
1321
|
-
const bottomY = a.unit.baseHeightM;
|
|
1322
|
-
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
1323
|
-
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
1324
|
-
const kind = a.section.surfaceKind;
|
|
1325
|
-
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
1326
|
-
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
1327
|
-
const needsRake = structure.rows.length < 2;
|
|
1328
|
-
const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
|
|
1329
|
-
let frontU = Infinity;
|
|
1330
|
-
if (rake) {
|
|
1331
|
-
if (a.hasSeats) {
|
|
1332
|
-
for (const pts of a.rows.values()) {
|
|
1333
|
-
for (const p of pts) {
|
|
1334
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1335
|
-
if (d < frontU) frontU = d;
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
1340
|
-
frontU = Infinity;
|
|
1341
|
-
for (const p of a.section.outline) {
|
|
1342
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1343
|
-
if (d < frontU) frontU = d;
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
const flatTop = bottomY + FLAT_SLAB_TOP_M;
|
|
1348
|
-
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
1349
|
-
const levelFor = (depthU) => {
|
|
1350
|
-
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
1351
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1352
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1353
|
-
};
|
|
1354
|
-
const levelForBlockDepth = (blockDepthU) => {
|
|
1355
|
-
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
1356
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1357
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1358
|
-
};
|
|
1359
|
-
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
1360
|
-
pts: r.pts,
|
|
1361
|
-
y: levelForBlockDepth(r.blockDepth),
|
|
1362
|
-
depth: r.blockDepth,
|
|
1363
|
-
blockId: r.blockId
|
|
1364
|
-
}));
|
|
1365
|
-
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
1366
|
-
const rowBounds = rowLevels.map((r) => {
|
|
1367
|
-
let cx = 0, cy = 0;
|
|
1368
|
-
for (const p of r.pts) {
|
|
1369
|
-
cx += p.x;
|
|
1370
|
-
cy += p.y;
|
|
1371
|
-
}
|
|
1372
|
-
const n = r.pts.length || 1;
|
|
1373
|
-
cx /= n;
|
|
1374
|
-
cy /= n;
|
|
1375
|
-
let rad = 0;
|
|
1376
|
-
for (const p of r.pts) {
|
|
1377
|
-
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1378
|
-
if (d > rad) rad = d;
|
|
1379
|
-
}
|
|
1380
|
-
return { cx, cy, rad };
|
|
1381
|
-
});
|
|
1382
|
-
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
1383
|
-
let best = Infinity, bestY = landingY;
|
|
1384
|
-
for (let i = 0; i < rowLevels.length; i++) {
|
|
1385
|
-
const b = rowBounds[i];
|
|
1386
|
-
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
1387
|
-
const d = distanceToPolyline(rowLevels[i].pts, x, y);
|
|
1388
|
-
if (d < best) {
|
|
1389
|
-
best = d;
|
|
1390
|
-
bestY = rowLevels[i].y;
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
return bestY;
|
|
1394
|
-
} : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
|
|
1395
|
-
const UP = [0, 1, 0];
|
|
1396
|
-
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
1397
|
-
if (!rake) return UP;
|
|
1398
|
-
const d = rake.depthAt(x, y);
|
|
1399
|
-
if (d <= frontU) return UP;
|
|
1400
|
-
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
1401
|
-
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
1402
|
-
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
1403
|
-
const [gx, gy] = rake.gradientAt(x, y);
|
|
1404
|
-
if (gx === 0 && gy === 0) return UP;
|
|
1405
|
-
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
1406
|
-
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
1407
|
-
};
|
|
1408
|
-
if (!flat) {
|
|
1409
|
-
for (const r of structure.rows) {
|
|
1410
|
-
const y = levelForBlockDepth(r.blockDepth);
|
|
1411
|
-
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
for (const r of structure.rows) {
|
|
1415
|
-
const gaps = [];
|
|
1416
|
-
for (let k = 1; k < r.pts.length; k++) {
|
|
1417
|
-
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
1418
|
-
if (d > 1e-6) gaps.push(d);
|
|
1419
|
-
}
|
|
1420
|
-
gaps.sort((x, y) => x - y);
|
|
1421
|
-
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
1422
|
-
let across = Infinity;
|
|
1423
|
-
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
1424
|
-
if (probe) {
|
|
1425
|
-
for (const other of structure.rows) {
|
|
1426
|
-
if (other === r || other.blockId !== r.blockId) continue;
|
|
1427
|
-
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
1428
|
-
if (d > 1e-6 && d < across) across = d;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
1432
|
-
if (Number.isFinite(pitch) && pitch > 0) {
|
|
1433
|
-
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
1437
|
-
}
|
|
1438
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1439
|
-
const ownerId = seatOwner[i];
|
|
1440
|
-
const s = seats[i];
|
|
1441
|
-
if (ownerId) {
|
|
1442
|
-
const own = seatRowLevel[i];
|
|
1443
|
-
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
1444
|
-
continue;
|
|
1445
|
-
}
|
|
1446
|
-
const eye = s.eyeHeightM;
|
|
1447
|
-
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1448
|
-
}
|
|
1449
|
-
return {
|
|
1450
|
-
bySection,
|
|
1451
|
-
seatOwner,
|
|
1452
|
-
seatDeckY: (i) => seatDeck[i],
|
|
1453
|
-
seatPitchU: (i) => seatPitch[i]
|
|
1454
|
-
};
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
1649
|
// src/view3d/scene/deckBands.ts
|
|
1458
1650
|
import polygonClipping2 from "polygon-clipping";
|
|
1459
1651
|
import earcut2 from "earcut";
|
|
@@ -1502,6 +1694,68 @@ function extendEnds(pts, by) {
|
|
|
1502
1694
|
});
|
|
1503
1695
|
return out;
|
|
1504
1696
|
}
|
|
1697
|
+
var CORNER_STEP_RAD = Math.PI / 12;
|
|
1698
|
+
function convexHull(pts) {
|
|
1699
|
+
if (pts.length < 3) return [...pts];
|
|
1700
|
+
const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
|
|
1701
|
+
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
1702
|
+
const half = (src) => {
|
|
1703
|
+
const out = [];
|
|
1704
|
+
for (const p of src) {
|
|
1705
|
+
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
|
|
1706
|
+
out.push(p);
|
|
1707
|
+
}
|
|
1708
|
+
out.pop();
|
|
1709
|
+
return out;
|
|
1710
|
+
};
|
|
1711
|
+
const hull = [...half(s), ...half([...s].reverse())];
|
|
1712
|
+
return hull.length >= 3 ? hull : [...pts];
|
|
1713
|
+
}
|
|
1714
|
+
function seatClusterPatch(pts, pad) {
|
|
1715
|
+
if (!pts.length || !(pad > 0)) return [];
|
|
1716
|
+
const hull = convexHull(pts);
|
|
1717
|
+
const arc = (v, from, to, out2) => {
|
|
1718
|
+
let sweep = to - from;
|
|
1719
|
+
while (sweep < 0) sweep += Math.PI * 2;
|
|
1720
|
+
while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
|
|
1721
|
+
const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
|
|
1722
|
+
const r = pad / Math.cos(sweep / steps / 2);
|
|
1723
|
+
for (let k = 0; k <= steps; k++) {
|
|
1724
|
+
const a = from + sweep * k / steps;
|
|
1725
|
+
out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
if (hull.length < 3) {
|
|
1729
|
+
let cx = 0, cy = 0;
|
|
1730
|
+
for (const p of hull) {
|
|
1731
|
+
cx += p.x;
|
|
1732
|
+
cy += p.y;
|
|
1733
|
+
}
|
|
1734
|
+
cx /= hull.length || 1;
|
|
1735
|
+
cy /= hull.length || 1;
|
|
1736
|
+
let far = 0;
|
|
1737
|
+
for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
|
|
1738
|
+
const out2 = [];
|
|
1739
|
+
const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
|
|
1740
|
+
const r = (far + pad) / Math.cos(Math.PI / steps);
|
|
1741
|
+
for (let k = 0; k < steps; k++) {
|
|
1742
|
+
const a = k / steps * Math.PI * 2;
|
|
1743
|
+
out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
|
|
1744
|
+
}
|
|
1745
|
+
return out2;
|
|
1746
|
+
}
|
|
1747
|
+
const n = hull.length;
|
|
1748
|
+
const edgeAngle = [];
|
|
1749
|
+
for (let i = 0; i < n; i++) {
|
|
1750
|
+
const a = hull[i], b = hull[(i + 1) % n];
|
|
1751
|
+
edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
|
|
1752
|
+
}
|
|
1753
|
+
const out = [];
|
|
1754
|
+
for (let i = 0; i < n; i++) {
|
|
1755
|
+
arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
|
|
1756
|
+
}
|
|
1757
|
+
return dedupeAdjacent(out);
|
|
1758
|
+
}
|
|
1505
1759
|
function distToPolyline(pts, x, y) {
|
|
1506
1760
|
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1507
1761
|
let best = Infinity;
|
|
@@ -1632,7 +1886,14 @@ function deckFootprints(rows, focal, shared) {
|
|
|
1632
1886
|
else byBlock.set(rows[i].blockId, [i]);
|
|
1633
1887
|
}
|
|
1634
1888
|
const out = [];
|
|
1635
|
-
for (const [,
|
|
1889
|
+
for (const [, allIndices] of byBlock) {
|
|
1890
|
+
const indices = [];
|
|
1891
|
+
for (const i of allIndices) {
|
|
1892
|
+
const patch = rows[i].patch;
|
|
1893
|
+
if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
|
|
1894
|
+
else indices.push(i);
|
|
1895
|
+
}
|
|
1896
|
+
if (!indices.length) continue;
|
|
1636
1897
|
indices.sort((a, b) => rows[a].depth - rows[b].depth);
|
|
1637
1898
|
const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
|
|
1638
1899
|
const usable = ribbons.filter((r) => r !== null);
|
|
@@ -1741,106 +2002,351 @@ var ClipTest = class {
|
|
|
1741
2002
|
if (p.y > this.maxY) this.maxY = p.y;
|
|
1742
2003
|
}
|
|
1743
2004
|
}
|
|
1744
|
-
}
|
|
1745
|
-
/** True when every point lies strictly inside ONE ring of the clip region. */
|
|
1746
|
-
containsAll(pts) {
|
|
1747
|
-
for (const p of pts) {
|
|
1748
|
-
if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
|
|
1749
|
-
}
|
|
1750
|
-
for (const ring of this.rings) {
|
|
1751
|
-
let all = true;
|
|
1752
|
-
for (const p of pts) {
|
|
1753
|
-
if (!pointInRing(ring, p.x, p.y)) {
|
|
1754
|
-
all = false;
|
|
1755
|
-
break;
|
|
2005
|
+
}
|
|
2006
|
+
/** True when every point lies strictly inside ONE ring of the clip region. */
|
|
2007
|
+
containsAll(pts) {
|
|
2008
|
+
for (const p of pts) {
|
|
2009
|
+
if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
|
|
2010
|
+
}
|
|
2011
|
+
for (const ring of this.rings) {
|
|
2012
|
+
let all = true;
|
|
2013
|
+
for (const p of pts) {
|
|
2014
|
+
if (!pointInRing(ring, p.x, p.y)) {
|
|
2015
|
+
all = false;
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
if (all) return true;
|
|
2020
|
+
}
|
|
2021
|
+
return false;
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
|
|
2025
|
+
if (poly.length < 3) return;
|
|
2026
|
+
if (clipTest.containsAll(poly)) {
|
|
2027
|
+
const UPF = [0, 1, 0];
|
|
2028
|
+
const a = poly[0];
|
|
2029
|
+
for (let i = 1; i + 1 < poly.length; i++) {
|
|
2030
|
+
const b = poly[i], c = poly[i + 1];
|
|
2031
|
+
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);
|
|
2032
|
+
}
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
const ring = poly.map((p) => [p.x, p.y]);
|
|
2036
|
+
ring.push(ring[0]);
|
|
2037
|
+
let pieces;
|
|
2038
|
+
try {
|
|
2039
|
+
pieces = polygonClipping2.intersection([ring], clipRing);
|
|
2040
|
+
} catch {
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
const UP = [0, 1, 0];
|
|
2044
|
+
for (const poly2 of pieces) {
|
|
2045
|
+
if (!poly2.length || poly2[0].length < 4) continue;
|
|
2046
|
+
const outer = poly2[0];
|
|
2047
|
+
const flat = [];
|
|
2048
|
+
const pts = [];
|
|
2049
|
+
for (let i = 0; i < outer.length - 1; i++) {
|
|
2050
|
+
flat.push(outer[i][0], outer[i][1]);
|
|
2051
|
+
pts.push([outer[i][0], outer[i][1]]);
|
|
2052
|
+
}
|
|
2053
|
+
if (pts.length < 3) continue;
|
|
2054
|
+
const tris = earcut2(flat, void 0, 2);
|
|
2055
|
+
for (let i = 0; i < tris.length; i += 3) {
|
|
2056
|
+
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
2057
|
+
builder.tri(
|
|
2058
|
+
[a[0] * M, y, a[1] * M],
|
|
2059
|
+
[b[0] * M, y, b[1] * M],
|
|
2060
|
+
[c[0] * M, y, c[1] * M],
|
|
2061
|
+
UP,
|
|
2062
|
+
color
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
|
|
2068
|
+
if (rows.length < 2) return;
|
|
2069
|
+
const nbrs = shared ?? rowNeighbourhoods(rows);
|
|
2070
|
+
const UP = [0, 1, 0];
|
|
2071
|
+
const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
|
|
2072
|
+
const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
|
|
2073
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2074
|
+
const row = rows[i];
|
|
2075
|
+
if (row.patch && row.patch.length >= 3) {
|
|
2076
|
+
const patch = [...row.patch];
|
|
2077
|
+
const belowY2 = nbrs[i].belowY ?? landingY;
|
|
2078
|
+
if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
|
|
2079
|
+
else {
|
|
2080
|
+
const a = patch[0];
|
|
2081
|
+
for (let k = 1; k + 1 < patch.length; k++) {
|
|
2082
|
+
const b = patch[k], c = patch[k + 1];
|
|
2083
|
+
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);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
if (row.y > belowY2 + MIN_RISER_M) {
|
|
2087
|
+
let cx = 0, cy = 0;
|
|
2088
|
+
for (const p of patch) {
|
|
2089
|
+
cx += p.x;
|
|
2090
|
+
cy += p.y;
|
|
2091
|
+
}
|
|
2092
|
+
cx /= patch.length;
|
|
2093
|
+
cy /= patch.length;
|
|
2094
|
+
for (let k = 0; k < patch.length; k++) {
|
|
2095
|
+
const p = patch[k], q = patch[(k + 1) % patch.length];
|
|
2096
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
2097
|
+
const len = Math.hypot(dx, dy);
|
|
2098
|
+
if (len < 1e-6) continue;
|
|
2099
|
+
let nx = dy / len, ny = -dx / len;
|
|
2100
|
+
if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
|
|
2101
|
+
nx = -nx;
|
|
2102
|
+
ny = -ny;
|
|
2103
|
+
}
|
|
2104
|
+
const rn = [nx, 0, ny];
|
|
2105
|
+
const pt = [p.x * M, row.y, p.y * M];
|
|
2106
|
+
const qt = [q.x * M, row.y, q.y * M];
|
|
2107
|
+
const pb = [p.x * M, belowY2, p.y * M];
|
|
2108
|
+
const qb = [q.x * M, belowY2, q.y * M];
|
|
2109
|
+
builder.tri(pt, qt, qb, rn, colors.riser);
|
|
2110
|
+
builder.tri(pt, qb, pb, rn, colors.riser);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
2116
|
+
if (!rib) continue;
|
|
2117
|
+
const { pts, nrm, front, back } = rib;
|
|
2118
|
+
const belowY = nbrs[i].belowY ?? landingY;
|
|
2119
|
+
for (let k = 0; k + 1 < pts.length; k++) {
|
|
2120
|
+
const p = pts[k], q = pts[k + 1];
|
|
2121
|
+
const np = nrm[k], nq = nrm[k + 1];
|
|
2122
|
+
if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
|
|
2123
|
+
if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
|
|
2124
|
+
const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
|
|
2125
|
+
const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
|
|
2126
|
+
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
2127
|
+
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
2128
|
+
if (clipRing && clipTest) {
|
|
2129
|
+
emitClippedPoly(builder, clipRing, clipTest, [
|
|
2130
|
+
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
2131
|
+
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
2132
|
+
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
2133
|
+
{ x: p.x + np[0] * back, y: p.y + np[1] * back }
|
|
2134
|
+
], row.y, colors.tread);
|
|
2135
|
+
} else {
|
|
2136
|
+
builder.tri(pF, qF, qB, UP, colors.tread);
|
|
2137
|
+
builder.tri(pF, qB, pB, UP, colors.tread);
|
|
2138
|
+
}
|
|
2139
|
+
if (row.y > belowY + MIN_RISER_M) {
|
|
2140
|
+
const pFd = [pF[0], belowY, pF[2]];
|
|
2141
|
+
const qFd = [qF[0], belowY, qF[2]];
|
|
2142
|
+
const rn = [-np[0], 0, -np[1]];
|
|
2143
|
+
builder.tri(pF, qF, qFd, rn, colors.riser);
|
|
2144
|
+
builder.tri(pF, qFd, pFd, rn, colors.riser);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// src/view3d/scene/surface.ts
|
|
2151
|
+
var FLAT_SLAB_TOP_M = 0.05;
|
|
2152
|
+
var CAP_MAX_ERROR_M = 0.05;
|
|
2153
|
+
var SEAT_CLEARANCE_M = 0.15;
|
|
2154
|
+
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
2155
|
+
function bboxOf(pts) {
|
|
2156
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2157
|
+
for (const p of pts) {
|
|
2158
|
+
if (p.x < minX) minX = p.x;
|
|
2159
|
+
if (p.y < minY) minY = p.y;
|
|
2160
|
+
if (p.x > maxX) maxX = p.x;
|
|
2161
|
+
if (p.y > maxY) maxY = p.y;
|
|
2162
|
+
}
|
|
2163
|
+
return { minX, minY, maxX, maxY };
|
|
2164
|
+
}
|
|
2165
|
+
function pointInRing2(ring, x, y) {
|
|
2166
|
+
let inside = false;
|
|
2167
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
2168
|
+
const a = ring[i], b = ring[j];
|
|
2169
|
+
if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
2170
|
+
}
|
|
2171
|
+
return inside;
|
|
2172
|
+
}
|
|
2173
|
+
var MAX_TIER_RISE_M = 25;
|
|
2174
|
+
function buildVenueSurfaces(units, seats) {
|
|
2175
|
+
const bySection = /* @__PURE__ */ new Map();
|
|
2176
|
+
const seatOwner = new Array(seats.length).fill(null);
|
|
2177
|
+
const seatDeck = new Float64Array(seats.length);
|
|
2178
|
+
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
2179
|
+
const seatPitch = new Array(seats.length).fill(void 0);
|
|
2180
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2181
|
+
const boxes = [];
|
|
2182
|
+
const tableRowIds = /* @__PURE__ */ new Set();
|
|
2183
|
+
for (const unit of units) {
|
|
2184
|
+
for (const o of unit.objects) {
|
|
2185
|
+
if (o.type === "table") tableRowIds.add(o.id);
|
|
2186
|
+
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
2187
|
+
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
2188
|
+
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
2189
|
+
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2193
|
+
const s = seats[i];
|
|
2194
|
+
for (const b of boxes) {
|
|
2195
|
+
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
2196
|
+
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
2197
|
+
seatOwner[i] = b.id;
|
|
2198
|
+
const a = acc.get(b.id);
|
|
2199
|
+
a.hasSeats = true;
|
|
2200
|
+
const f = s.focalPoint ?? b.unit.focal;
|
|
2201
|
+
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
2202
|
+
if (d < a.frontU) a.frontU = d;
|
|
2203
|
+
a.seatIndices.push(i);
|
|
2204
|
+
const rowKey = s.rowId || `__seat-${i}`;
|
|
2205
|
+
const arr = a.rows.get(rowKey);
|
|
2206
|
+
if (arr) arr.push({ x: s.x, y: s.y });
|
|
2207
|
+
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
2208
|
+
break;
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
for (const [id, a] of acc) {
|
|
2212
|
+
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
2213
|
+
const bottomY = a.unit.baseHeightM;
|
|
2214
|
+
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
2215
|
+
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
2216
|
+
const kind = a.section.surfaceKind;
|
|
2217
|
+
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
2218
|
+
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
2219
|
+
const needsRake = structure.rows.length < 2;
|
|
2220
|
+
const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
|
|
2221
|
+
let frontU = Infinity;
|
|
2222
|
+
if (rake) {
|
|
2223
|
+
if (a.hasSeats) {
|
|
2224
|
+
for (const pts of a.rows.values()) {
|
|
2225
|
+
for (const p of pts) {
|
|
2226
|
+
const d = rake.depthAt(p.x, p.y);
|
|
2227
|
+
if (d < frontU) frontU = d;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
2232
|
+
frontU = Infinity;
|
|
2233
|
+
for (const p of a.section.outline) {
|
|
2234
|
+
const d = rake.depthAt(p.x, p.y);
|
|
2235
|
+
if (d < frontU) frontU = d;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
2240
|
+
const flatTop = Math.max(baseFloor, geo.height);
|
|
2241
|
+
const levelFor = (depthU) => {
|
|
2242
|
+
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
2243
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
2244
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
2245
|
+
};
|
|
2246
|
+
const levelForBlockDepth = (blockDepthU) => {
|
|
2247
|
+
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
2248
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
2249
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
2250
|
+
};
|
|
2251
|
+
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
2252
|
+
pts: r.pts,
|
|
2253
|
+
y: levelForBlockDepth(r.blockDepth),
|
|
2254
|
+
depth: r.blockDepth,
|
|
2255
|
+
blockId: r.blockId,
|
|
2256
|
+
...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
|
|
2257
|
+
}));
|
|
2258
|
+
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
2259
|
+
const rowBounds = rowLevels.map((r) => {
|
|
2260
|
+
const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
|
|
2261
|
+
let cx = 0, cy = 0;
|
|
2262
|
+
for (const p of ext) {
|
|
2263
|
+
cx += p.x;
|
|
2264
|
+
cy += p.y;
|
|
2265
|
+
}
|
|
2266
|
+
const n = ext.length || 1;
|
|
2267
|
+
cx /= n;
|
|
2268
|
+
cy /= n;
|
|
2269
|
+
let rad = 0;
|
|
2270
|
+
for (const p of ext) {
|
|
2271
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
2272
|
+
if (d > rad) rad = d;
|
|
2273
|
+
}
|
|
2274
|
+
return { cx, cy, rad };
|
|
2275
|
+
});
|
|
2276
|
+
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
2277
|
+
let best = Infinity, bestY = landingY;
|
|
2278
|
+
for (let i = 0; i < rowLevels.length; i++) {
|
|
2279
|
+
const b = rowBounds[i];
|
|
2280
|
+
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
2281
|
+
const patch = rowLevels[i].patch;
|
|
2282
|
+
const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
|
|
2283
|
+
if (d < best) {
|
|
2284
|
+
best = d;
|
|
2285
|
+
bestY = rowLevels[i].y;
|
|
1756
2286
|
}
|
|
1757
2287
|
}
|
|
1758
|
-
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
}
|
|
1779
|
-
const UP = [0, 1, 0];
|
|
1780
|
-
for (const poly of pieces) {
|
|
1781
|
-
if (!poly.length || poly[0].length < 4) continue;
|
|
1782
|
-
const outer = poly[0];
|
|
1783
|
-
const flat = [];
|
|
1784
|
-
const pts = [];
|
|
1785
|
-
for (let i = 0; i < outer.length - 1; i++) {
|
|
1786
|
-
flat.push(outer[i][0], outer[i][1]);
|
|
1787
|
-
pts.push([outer[i][0], outer[i][1]]);
|
|
1788
|
-
}
|
|
1789
|
-
if (pts.length < 3) continue;
|
|
1790
|
-
const tris = earcut2(flat, void 0, 2);
|
|
1791
|
-
for (let i = 0; i < tris.length; i += 3) {
|
|
1792
|
-
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
1793
|
-
builder.tri(
|
|
1794
|
-
[a[0] * M, y, a[1] * M],
|
|
1795
|
-
[b[0] * M, y, b[1] * M],
|
|
1796
|
-
[c[0] * M, y, c[1] * M],
|
|
1797
|
-
UP,
|
|
1798
|
-
color
|
|
1799
|
-
);
|
|
2288
|
+
return bestY;
|
|
2289
|
+
} : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
|
|
2290
|
+
const UP = [0, 1, 0];
|
|
2291
|
+
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
2292
|
+
if (!rake) return UP;
|
|
2293
|
+
const d = rake.depthAt(x, y);
|
|
2294
|
+
if (d <= frontU) return UP;
|
|
2295
|
+
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
2296
|
+
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
2297
|
+
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
2298
|
+
const [gx, gy] = rake.gradientAt(x, y);
|
|
2299
|
+
if (gx === 0 && gy === 0) return UP;
|
|
2300
|
+
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
2301
|
+
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
2302
|
+
};
|
|
2303
|
+
if (!flat) {
|
|
2304
|
+
for (const r of structure.rows) {
|
|
2305
|
+
const y = levelForBlockDepth(r.blockDepth);
|
|
2306
|
+
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
2307
|
+
}
|
|
1800
2308
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
const UP = [0, 1, 0];
|
|
1807
|
-
const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
|
|
1808
|
-
const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
|
|
1809
|
-
for (let i = 0; i < rows.length; i++) {
|
|
1810
|
-
const row = rows[i];
|
|
1811
|
-
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
1812
|
-
if (!rib) continue;
|
|
1813
|
-
const { pts, nrm, front, back } = rib;
|
|
1814
|
-
const belowY = nbrs[i].belowY ?? landingY;
|
|
1815
|
-
for (let k = 0; k + 1 < pts.length; k++) {
|
|
1816
|
-
const p = pts[k], q = pts[k + 1];
|
|
1817
|
-
const np = nrm[k], nq = nrm[k + 1];
|
|
1818
|
-
if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
|
|
1819
|
-
if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
|
|
1820
|
-
const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
|
|
1821
|
-
const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
|
|
1822
|
-
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
1823
|
-
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
1824
|
-
if (clipRing && clipTest) {
|
|
1825
|
-
emitClippedQuad(builder, clipRing, clipTest, [
|
|
1826
|
-
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
1827
|
-
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
1828
|
-
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
1829
|
-
{ x: p.x + np[0] * back, y: p.y + np[1] * back }
|
|
1830
|
-
], row.y, colors.tread);
|
|
1831
|
-
} else {
|
|
1832
|
-
builder.tri(pF, qF, qB, UP, colors.tread);
|
|
1833
|
-
builder.tri(pF, qB, pB, UP, colors.tread);
|
|
2309
|
+
for (const r of structure.rows) {
|
|
2310
|
+
const gaps = [];
|
|
2311
|
+
for (let k = 1; k < r.pts.length; k++) {
|
|
2312
|
+
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
2313
|
+
if (d > 1e-6) gaps.push(d);
|
|
1834
2314
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2315
|
+
gaps.sort((x, y) => x - y);
|
|
2316
|
+
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
2317
|
+
let across = Infinity;
|
|
2318
|
+
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
2319
|
+
if (probe) {
|
|
2320
|
+
for (const other of structure.rows) {
|
|
2321
|
+
if (other === r || other.blockId !== r.blockId) continue;
|
|
2322
|
+
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
2323
|
+
if (d > 1e-6 && d < across) across = d;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
2327
|
+
if (Number.isFinite(pitch) && pitch > 0) {
|
|
2328
|
+
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
1841
2329
|
}
|
|
1842
2330
|
}
|
|
2331
|
+
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
2332
|
+
}
|
|
2333
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2334
|
+
const ownerId = seatOwner[i];
|
|
2335
|
+
const s = seats[i];
|
|
2336
|
+
if (ownerId) {
|
|
2337
|
+
const own = seatRowLevel[i];
|
|
2338
|
+
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
const eye = s.eyeHeightM;
|
|
2342
|
+
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1843
2343
|
}
|
|
2344
|
+
return {
|
|
2345
|
+
bySection,
|
|
2346
|
+
seatOwner,
|
|
2347
|
+
seatDeckY: (i) => seatDeck[i],
|
|
2348
|
+
seatPitchU: (i) => seatPitch[i]
|
|
2349
|
+
};
|
|
1844
2350
|
}
|
|
1845
2351
|
|
|
1846
2352
|
// src/view3d/scene/sceneModel.ts
|
|
@@ -2008,22 +2514,39 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
|
|
|
2008
2514
|
}
|
|
2009
2515
|
const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
|
|
2010
2516
|
const topN = (p) => surface.normalAt(p.x, p.y);
|
|
2011
|
-
|
|
2517
|
+
const level = surface.flat ? surface.landingY : void 0;
|
|
2518
|
+
for (const ring of claimed.subtract(outline, level)) {
|
|
2012
2519
|
extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
|
|
2013
2520
|
}
|
|
2014
2521
|
}
|
|
2522
|
+
function coplanarLevels(a, b) {
|
|
2523
|
+
if (a === void 0 || b === void 0) return true;
|
|
2524
|
+
return Math.abs(a - b) < 1e-3;
|
|
2525
|
+
}
|
|
2015
2526
|
var ClaimedArea = class {
|
|
2016
2527
|
constructor() {
|
|
2017
2528
|
this.rings = [];
|
|
2018
2529
|
this.boxes = [];
|
|
2530
|
+
/** Constant deck height of each claim, or undefined when it is not level. */
|
|
2531
|
+
this.levels = [];
|
|
2019
2532
|
}
|
|
2020
|
-
/**
|
|
2021
|
-
|
|
2533
|
+
/**
|
|
2534
|
+
* `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
|
|
2535
|
+
*
|
|
2536
|
+
* `level` is the claim's constant deck height when it has one. Two decks only
|
|
2537
|
+
* z-fight when they are coplanar, so only a coplanar claim may take ground
|
|
2538
|
+
* away — an elevated box hanging over a ground-level section overlaps it in
|
|
2539
|
+
* plan and must still draw its whole floor, or the box loses the part of its
|
|
2540
|
+
* deck that shares a footprint with whatever is underneath it. Passing
|
|
2541
|
+
* undefined (a surface with no single height) keeps the original
|
|
2542
|
+
* clip-against-everything behaviour.
|
|
2543
|
+
*/
|
|
2544
|
+
subtract(ring, level) {
|
|
2022
2545
|
const closed = ring.map((p) => [p.x, p.y]);
|
|
2023
2546
|
if (closed.length < 3) return [];
|
|
2024
2547
|
closed.push(closed[0]);
|
|
2025
2548
|
const box = bboxOfRing(ring);
|
|
2026
|
-
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
|
|
2549
|
+
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
|
|
2027
2550
|
let pieces = [[closed]];
|
|
2028
2551
|
if (overlapping.length) {
|
|
2029
2552
|
try {
|
|
@@ -2035,6 +2558,7 @@ var ClaimedArea = class {
|
|
|
2035
2558
|
}
|
|
2036
2559
|
this.rings.push([closed]);
|
|
2037
2560
|
this.boxes.push(box);
|
|
2561
|
+
this.levels.push(level);
|
|
2038
2562
|
const out = [];
|
|
2039
2563
|
for (const poly of pieces) {
|
|
2040
2564
|
if (!poly.length) continue;
|
|
@@ -2490,6 +3014,15 @@ function buildSceneModel(input) {
|
|
|
2490
3014
|
});
|
|
2491
3015
|
}
|
|
2492
3016
|
}
|
|
3017
|
+
seatData.iYaw = computeSeatYaw(
|
|
3018
|
+
seatData.iPosition,
|
|
3019
|
+
seats.length,
|
|
3020
|
+
(i) => seats[i].rowId,
|
|
3021
|
+
(i) => {
|
|
3022
|
+
const f = units[seatFloor[i]]?.focal ?? focal;
|
|
3023
|
+
return [f.x * M, f.y * M];
|
|
3024
|
+
}
|
|
3025
|
+
);
|
|
2493
3026
|
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
2494
3027
|
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
2495
3028
|
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
@@ -2527,7 +3060,11 @@ var KIND_STYLE = {
|
|
|
2527
3060
|
var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
|
|
2528
3061
|
var DENSE_SEPARATION = {
|
|
2529
3062
|
row: { x: 62, y: 16 },
|
|
2530
|
-
|
|
3063
|
+
// Widened from 24: at close range seat labels stopped overlapping at all, so
|
|
3064
|
+
// the separation had no work left to do and the budget was carrying the whole
|
|
3065
|
+
// load. A wider box means the few labels that ARE kept are spread across the
|
|
3066
|
+
// seating instead of clustering into one stack.
|
|
3067
|
+
seat: { x: 46, y: 18 }
|
|
2531
3068
|
};
|
|
2532
3069
|
var LabelOverlay = class {
|
|
2533
3070
|
constructor(container, opts = {}) {
|
|
@@ -2558,7 +3095,7 @@ var LabelOverlay = class {
|
|
|
2558
3095
|
*
|
|
2559
3096
|
* `viewProjection` is column-major, as OGL supplies it.
|
|
2560
3097
|
*/
|
|
2561
|
-
update(viewProjection, width, height, cameraDistance, venueRadius) {
|
|
3098
|
+
update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
|
|
2562
3099
|
if (!this.labels.length) return;
|
|
2563
3100
|
const kinds = visibleLabelKinds(cameraDistance, venueRadius);
|
|
2564
3101
|
const candidates = [];
|
|
@@ -2566,15 +3103,24 @@ var LabelOverlay = class {
|
|
|
2566
3103
|
if (!kinds.has(label.kind)) continue;
|
|
2567
3104
|
const screen = projectToScreen(viewProjection, label.anchor, width, height);
|
|
2568
3105
|
if (!screen.visible) continue;
|
|
2569
|
-
|
|
3106
|
+
const world = cameraWorld ? Math.hypot(
|
|
3107
|
+
label.anchor[0] - cameraWorld[0],
|
|
3108
|
+
label.anchor[1] - cameraWorld[1],
|
|
3109
|
+
label.anchor[2] - cameraWorld[2]
|
|
3110
|
+
) : 1;
|
|
3111
|
+
candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
|
|
2570
3112
|
}
|
|
2571
3113
|
const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
|
|
2572
3114
|
const kept = [
|
|
2573
3115
|
...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
|
|
2574
|
-
|
|
3116
|
+
// The dense rungs are BUDGETED, not merely deduplicated — see
|
|
3117
|
+
// DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
|
|
3118
|
+
// the camera gets.
|
|
3119
|
+
...["row", "seat"].flatMap((kind) => pickDenseLabels(
|
|
2575
3120
|
candidates.filter((c) => c.label.kind === kind),
|
|
2576
3121
|
DENSE_SEPARATION[kind].x,
|
|
2577
|
-
DENSE_SEPARATION[kind].y
|
|
3122
|
+
DENSE_SEPARATION[kind].y,
|
|
3123
|
+
DENSE_LABEL_BUDGET[kind]
|
|
2578
3124
|
))
|
|
2579
3125
|
];
|
|
2580
3126
|
const keptIds = new Set(kept.map((k) => k.label.id));
|
|
@@ -2623,6 +3169,13 @@ import { Geometry, Mesh, Transform } from "ogl";
|
|
|
2623
3169
|
|
|
2624
3170
|
// src/view3d/scene/materials.ts
|
|
2625
3171
|
import { Program } from "ogl";
|
|
3172
|
+
var CHAIR_WEIGHT_GLSL = (
|
|
3173
|
+
/* glsl */
|
|
3174
|
+
`
|
|
3175
|
+
float chairWeight(float depth) {
|
|
3176
|
+
return 1.0 - smoothstep(uChairFull, uChairNone, depth);
|
|
3177
|
+
}`
|
|
3178
|
+
);
|
|
2626
3179
|
var SOLID_VERT = (
|
|
2627
3180
|
/* glsl */
|
|
2628
3181
|
`#version 300 es
|
|
@@ -2702,14 +3255,23 @@ uniform float uSeatScale;
|
|
|
2702
3255
|
uniform float uMinPixels;
|
|
2703
3256
|
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
2704
3257
|
uniform float uFocusFloor; // -1 = show every floor
|
|
3258
|
+
uniform float uChairFull; // view depth at which the chair mesh is full size
|
|
3259
|
+
uniform float uChairNone; // ...and at which it has scaled away entirely
|
|
2705
3260
|
out vec2 vUv;
|
|
2706
3261
|
out vec3 vColor;
|
|
2707
3262
|
out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
|
|
2708
3263
|
out vec3 vRing;
|
|
2709
3264
|
out float vDim;
|
|
3265
|
+
out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
|
|
3266
|
+
${CHAIR_WEIGHT_GLSL}
|
|
2710
3267
|
void main() {
|
|
2711
3268
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
2712
3269
|
float depth = max(-mv.z, 0.001);
|
|
3270
|
+
// Hand the seat over to the chair mesh as it comes into range. Derived from
|
|
3271
|
+
// this instance's OWN depth rather than from a global uniform, so a row two
|
|
3272
|
+
// metres away and the far side of the bowl resolve differently in the same
|
|
3273
|
+
// frame \u2014 which is the entire point of a ladder over a switch.
|
|
3274
|
+
vDotWeight = 1.0 - chairWeight(depth);
|
|
2713
3275
|
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
2714
3276
|
// Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
|
|
2715
3277
|
// unbounded growth is what merges neighbouring rows into one mass at range.
|
|
@@ -2743,6 +3305,7 @@ in vec3 vColor;
|
|
|
2743
3305
|
in float vBudget;
|
|
2744
3306
|
in vec3 vRing;
|
|
2745
3307
|
in float vDim;
|
|
3308
|
+
in float vDotWeight;
|
|
2746
3309
|
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
2747
3310
|
uniform vec3 uFadeColor;
|
|
2748
3311
|
out vec4 fragColor;
|
|
@@ -2767,9 +3330,131 @@ void main() {
|
|
|
2767
3330
|
// Seats on an unfocused floor recede with their structure.
|
|
2768
3331
|
c = mix(c, uFadeColor, vDim * 0.75);
|
|
2769
3332
|
alpha *= mix(1.0, 0.30, vDim);
|
|
3333
|
+
// Yield to the chair. The chair grows out of this exact point, so through the
|
|
3334
|
+
// band the dot is always at least as big as the chair inside it and the seat
|
|
3335
|
+
// never thins out to nothing in between.
|
|
3336
|
+
alpha *= vDotWeight;
|
|
3337
|
+
if (alpha <= 0.0) discard;
|
|
2770
3338
|
fragColor = vec4(c, alpha);
|
|
2771
3339
|
}`
|
|
2772
3340
|
);
|
|
3341
|
+
var CHAIR_VERT = (
|
|
3342
|
+
/* glsl */
|
|
3343
|
+
`#version 300 es
|
|
3344
|
+
precision highp float;
|
|
3345
|
+
in vec3 position; // local: x/z in units of the seat radius, y in METRES
|
|
3346
|
+
in vec3 normal;
|
|
3347
|
+
in float part; // 0 = pedestal, 1 = pad, 2 = back
|
|
3348
|
+
in vec3 iOffset; // per-instance world deck point (identical to the dot's)
|
|
3349
|
+
in vec3 iColor; // per-instance state colour
|
|
3350
|
+
in float iRadius; // per-instance horizontal half-width, world metres
|
|
3351
|
+
in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
|
|
3352
|
+
in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
|
|
3353
|
+
in float iFloor;
|
|
3354
|
+
uniform mat4 modelViewMatrix;
|
|
3355
|
+
uniform mat4 projectionMatrix;
|
|
3356
|
+
uniform float uChairFull;
|
|
3357
|
+
uniform float uChairNone;
|
|
3358
|
+
uniform float uFocusFloor;
|
|
3359
|
+
uniform float uBackRake; // metres of z per metre of rise, above uBackBase
|
|
3360
|
+
uniform float uBackBase; // local height at which the back starts
|
|
3361
|
+
out vec3 vColor;
|
|
3362
|
+
out vec3 vNormalWorld;
|
|
3363
|
+
out vec3 vNormalView;
|
|
3364
|
+
out vec3 vPosView;
|
|
3365
|
+
out float vPart;
|
|
3366
|
+
out float vHeight; // local height in metres, for the vertical occlusion ramp
|
|
3367
|
+
out vec3 vRing;
|
|
3368
|
+
out float vDim;
|
|
3369
|
+
${CHAIR_WEIGHT_GLSL}
|
|
3370
|
+
void main() {
|
|
3371
|
+
vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
|
|
3372
|
+
float w = chairWeight(max(-anchor.z, 0.001));
|
|
3373
|
+
// 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
|
|
3374
|
+
// chairs, but nobody gets a short one (people are the same height at every
|
|
3375
|
+
// seat pitch).
|
|
3376
|
+
vec3 p = position;
|
|
3377
|
+
p.xz *= iRadius;
|
|
3378
|
+
// 2. Lean the back. Done here rather than in the base mesh so the lean is a
|
|
3379
|
+
// real angle in METRES \u2014 baked into the mesh it would scale with the seat's
|
|
3380
|
+
// width and the same chair would lean 20 degrees on a wide stadium row and
|
|
3381
|
+
// 6 on a tight theatre one.
|
|
3382
|
+
float rake = (part > 1.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
|
|
3383
|
+
p.z -= rake;
|
|
3384
|
+
// 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
|
|
3385
|
+
// opacity \u2014 which is what makes the handover invisible. sqrt front-loads
|
|
3386
|
+
// the growth so the chair is already near full size while the dot is still
|
|
3387
|
+
// half there; see chairScale() in lod.ts.
|
|
3388
|
+
p *= sqrt(w);
|
|
3389
|
+
float c = cos(iYaw), s = sin(iYaw);
|
|
3390
|
+
vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
|
|
3391
|
+
// Normals under the same two transforms, in reverse and inverted-transposed.
|
|
3392
|
+
// The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
|
|
3393
|
+
// unchanged; and the rake is a shear, whose normal transform adds a y term.
|
|
3394
|
+
// Skipping either lights the raked back as though it were still vertical.
|
|
3395
|
+
vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
|
|
3396
|
+
if (part > 1.5) n.y += uBackRake * n.z;
|
|
3397
|
+
n = normalize(n);
|
|
3398
|
+
vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
|
|
3399
|
+
vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
|
|
3400
|
+
vPosView = mv.xyz;
|
|
3401
|
+
vNormalWorld = rn;
|
|
3402
|
+
vNormalView = normalize(mat3(modelViewMatrix) * rn);
|
|
3403
|
+
vColor = iColor;
|
|
3404
|
+
vPart = part;
|
|
3405
|
+
vHeight = position.y;
|
|
3406
|
+
vRing = iRing;
|
|
3407
|
+
vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
3408
|
+
gl_Position = projectionMatrix * mv;
|
|
3409
|
+
}`
|
|
3410
|
+
);
|
|
3411
|
+
var CHAIR_FRAG = (
|
|
3412
|
+
/* glsl */
|
|
3413
|
+
`#version 300 es
|
|
3414
|
+
precision highp float;
|
|
3415
|
+
in vec3 vColor;
|
|
3416
|
+
in vec3 vNormalWorld;
|
|
3417
|
+
in vec3 vNormalView;
|
|
3418
|
+
in vec3 vPosView;
|
|
3419
|
+
in float vPart;
|
|
3420
|
+
in float vHeight;
|
|
3421
|
+
in vec3 vRing;
|
|
3422
|
+
in float vDim;
|
|
3423
|
+
uniform vec3 uKeyDir;
|
|
3424
|
+
uniform vec3 uFadeColor;
|
|
3425
|
+
out vec4 fragColor;
|
|
3426
|
+
void main() {
|
|
3427
|
+
vec3 N = normalize(vNormalWorld);
|
|
3428
|
+
vec3 V = normalize(-vPosView);
|
|
3429
|
+
// The solids' rig, unchanged, so the chairs sit in the venue's light.
|
|
3430
|
+
float hemi = 0.5 + 0.5 * N.y;
|
|
3431
|
+
float key = max(dot(N, uKeyDir), 0.0);
|
|
3432
|
+
vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
|
|
3433
|
+
float fill = max(dot(N, fillDir), 0.0);
|
|
3434
|
+
vec3 tint = vColor;
|
|
3435
|
+
// The accommodation ring, kept legible once the dot (which drew it) is gone:
|
|
3436
|
+
// an accessible seat's PEDESTAL is painted in the ring colour, so the marker
|
|
3437
|
+
// survives to close range instead of vanishing exactly when the buyer arrives.
|
|
3438
|
+
float ringMask = step(0.001, dot(vRing, vRing));
|
|
3439
|
+
if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
|
|
3440
|
+
// Pad brightest, back a step below it, pedestal darkest. Three untextured
|
|
3441
|
+
// boxes only read as one object if they are separated tonally \u2014 with a single
|
|
3442
|
+
// flat colour the chair silhouettes as a crate.
|
|
3443
|
+
float partShade = vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80);
|
|
3444
|
+
// Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
|
|
3445
|
+
// sits to the deck the less sky it can actually see. This is the depth cue \u2014
|
|
3446
|
+
// without it the pad top, the back and the deck all resolve to the same flat
|
|
3447
|
+
// value and the row loses its form entirely.
|
|
3448
|
+
float ao = mix(0.58, 1.0, clamp(vHeight / 0.92, 0.0, 1.0));
|
|
3449
|
+
vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
|
|
3450
|
+
float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
|
|
3451
|
+
// A brighter rim than the solids get: it picks out every chair's own edge,
|
|
3452
|
+
// which is what stops a block of them merging into one mass up close.
|
|
3453
|
+
base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
|
|
3454
|
+
base = mix(base, uFadeColor, vDim * 0.75);
|
|
3455
|
+
fragColor = vec4(base, 1.0);
|
|
3456
|
+
}`
|
|
3457
|
+
);
|
|
2773
3458
|
var BG_VERT = (
|
|
2774
3459
|
/* glsl */
|
|
2775
3460
|
`#version 300 es
|
|
@@ -2866,7 +3551,7 @@ function createSeatPickProgram(gl) {
|
|
|
2866
3551
|
uniforms: {
|
|
2867
3552
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
2868
3553
|
uSeatScale: { value: 1 },
|
|
2869
|
-
uMinPixels: { value:
|
|
3554
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
2870
3555
|
uPixelToWorld: { value: 2e-3 }
|
|
2871
3556
|
}
|
|
2872
3557
|
});
|
|
@@ -2911,11 +3596,32 @@ function createSeatProgram(gl) {
|
|
|
2911
3596
|
uniforms: {
|
|
2912
3597
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
2913
3598
|
uSeatScale: { value: 1 },
|
|
2914
|
-
uMinPixels: { value:
|
|
3599
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
2915
3600
|
uPixelToWorld: { value: 2e-3 },
|
|
2916
3601
|
uSeatFade: { value: 0 },
|
|
2917
3602
|
uFocusFloor: { value: -1 },
|
|
2918
|
-
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
|
|
3603
|
+
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
|
|
3604
|
+
uChairFull: { value: CHAIR_FULL_M },
|
|
3605
|
+
uChairNone: { value: CHAIR_NONE_M }
|
|
3606
|
+
}
|
|
3607
|
+
});
|
|
3608
|
+
}
|
|
3609
|
+
function createChairProgram(gl) {
|
|
3610
|
+
return new Program(gl, {
|
|
3611
|
+
vertex: CHAIR_VERT,
|
|
3612
|
+
fragment: CHAIR_FRAG,
|
|
3613
|
+
transparent: false,
|
|
3614
|
+
depthTest: true,
|
|
3615
|
+
depthWrite: true,
|
|
3616
|
+
cullFace: false,
|
|
3617
|
+
uniforms: {
|
|
3618
|
+
uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
|
|
3619
|
+
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
|
|
3620
|
+
uChairFull: { value: CHAIR_FULL_M },
|
|
3621
|
+
uChairNone: { value: CHAIR_NONE_M },
|
|
3622
|
+
uBackRake: { value: BACK_RAKE_SLOPE },
|
|
3623
|
+
uBackBase: { value: BACK_BASE_M },
|
|
3624
|
+
uFocusFloor: { value: -1 }
|
|
2919
3625
|
}
|
|
2920
3626
|
});
|
|
2921
3627
|
}
|
|
@@ -2981,16 +3687,90 @@ function buildGpuScene(gl, model) {
|
|
|
2981
3687
|
seatMesh.frustumCulled = false;
|
|
2982
3688
|
if (model.seats.count > 0) seatMesh.setParent(main);
|
|
2983
3689
|
const colorAttr = seatGeo.attributes.iColor;
|
|
2984
|
-
|
|
3690
|
+
const chairBase = buildChairMesh();
|
|
3691
|
+
const chairProg = createChairProgram(gl);
|
|
3692
|
+
const CAP = CHAIR_MAX_INSTANCES;
|
|
3693
|
+
const cOffset = new Float32Array(CAP * 3);
|
|
3694
|
+
const cColor = new Float32Array(CAP * 3);
|
|
3695
|
+
const cRadius = new Float32Array(CAP);
|
|
3696
|
+
const cYaw = new Float32Array(CAP);
|
|
3697
|
+
const cRing = new Float32Array(CAP * 3);
|
|
3698
|
+
const cFloor = new Float32Array(CAP);
|
|
3699
|
+
const chairGeo = new Geometry(gl, {
|
|
3700
|
+
position: { size: 3, data: chairBase.position },
|
|
3701
|
+
normal: { size: 3, data: chairBase.normal },
|
|
3702
|
+
part: { size: 1, data: chairBase.part },
|
|
3703
|
+
index: { data: chairBase.index },
|
|
3704
|
+
iOffset: { size: 3, data: cOffset, instanced: 1 },
|
|
3705
|
+
iColor: { size: 3, data: cColor, instanced: 1 },
|
|
3706
|
+
iRadius: { size: 1, data: cRadius, instanced: 1 },
|
|
3707
|
+
iYaw: { size: 1, data: cYaw, instanced: 1 },
|
|
3708
|
+
iRing: { size: 3, data: cRing, instanced: 1 },
|
|
3709
|
+
iFloor: { size: 1, data: cFloor, instanced: 1 }
|
|
3710
|
+
});
|
|
3711
|
+
const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
|
|
3712
|
+
chairMesh.frustumCulled = false;
|
|
3713
|
+
let nearCount = 0;
|
|
3714
|
+
let nearIndices = new Int32Array(0);
|
|
3715
|
+
const writeChairColors = () => {
|
|
3716
|
+
const src = model.seats;
|
|
3717
|
+
for (let k = 0; k < nearCount; k++) {
|
|
3718
|
+
const i = nearIndices[k];
|
|
3719
|
+
const c = stateColors[src.iState[i]] ?? stateColors[0];
|
|
3720
|
+
cColor[k * 3] = c[0];
|
|
3721
|
+
cColor[k * 3 + 1] = c[1];
|
|
3722
|
+
cColor[k * 3 + 2] = c[2];
|
|
3723
|
+
}
|
|
3724
|
+
chairGeo.attributes.iColor.needsUpdate = true;
|
|
3725
|
+
};
|
|
3726
|
+
const scene = {
|
|
2985
3727
|
main,
|
|
2986
3728
|
background,
|
|
2987
3729
|
seatProgram: seatProg,
|
|
2988
3730
|
solidProgram: solidProg,
|
|
3731
|
+
chairProgram: chairProg,
|
|
2989
3732
|
seatGeometry: seatGeo,
|
|
2990
3733
|
solidGeometry: solidGeo,
|
|
2991
3734
|
drawCalls: 3,
|
|
3735
|
+
setNearSeats(indices, count) {
|
|
3736
|
+
const n = Math.min(count, CAP);
|
|
3737
|
+
nearIndices = indices;
|
|
3738
|
+
nearCount = n;
|
|
3739
|
+
if (n === 0) {
|
|
3740
|
+
if (chairMesh.parent) chairMesh.setParent(null);
|
|
3741
|
+
chairGeo.instancedCount = 0;
|
|
3742
|
+
scene.drawCalls = 3;
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
const src = model.seats;
|
|
3746
|
+
for (let k = 0; k < n; k++) {
|
|
3747
|
+
const i = indices[k];
|
|
3748
|
+
cOffset[k * 3] = src.iPosition[i * 3];
|
|
3749
|
+
cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
|
|
3750
|
+
cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
|
|
3751
|
+
cRadius[k] = src.iChairWidth[i];
|
|
3752
|
+
cYaw[k] = src.iYaw[i];
|
|
3753
|
+
cRing[k * 3] = src.iRing[i * 3];
|
|
3754
|
+
cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
|
|
3755
|
+
cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
|
|
3756
|
+
cFloor[k] = src.iFloor[i];
|
|
3757
|
+
}
|
|
3758
|
+
writeChairColors();
|
|
3759
|
+
chairGeo.attributes.iOffset.needsUpdate = true;
|
|
3760
|
+
chairGeo.attributes.iRadius.needsUpdate = true;
|
|
3761
|
+
chairGeo.attributes.iYaw.needsUpdate = true;
|
|
3762
|
+
chairGeo.attributes.iRing.needsUpdate = true;
|
|
3763
|
+
chairGeo.attributes.iFloor.needsUpdate = true;
|
|
3764
|
+
chairGeo.instancedCount = n;
|
|
3765
|
+
if (!chairMesh.parent) chairMesh.setParent(main);
|
|
3766
|
+
scene.drawCalls = 4;
|
|
3767
|
+
},
|
|
3768
|
+
nearSeatCount() {
|
|
3769
|
+
return nearCount;
|
|
3770
|
+
},
|
|
2992
3771
|
uploadSeatStateRuns(runs) {
|
|
2993
3772
|
if (!runs.length) return;
|
|
3773
|
+
if (nearCount) writeChairColors();
|
|
2994
3774
|
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
|
|
2995
3775
|
const buffer = colorAttr.buffer;
|
|
2996
3776
|
if (!buffer) {
|
|
@@ -3010,8 +3790,11 @@ function buildGpuScene(gl, model) {
|
|
|
3010
3790
|
solidProg.remove();
|
|
3011
3791
|
seatGeo.remove();
|
|
3012
3792
|
seatProg.remove();
|
|
3793
|
+
chairGeo.remove();
|
|
3794
|
+
chairProg.remove();
|
|
3013
3795
|
}
|
|
3014
3796
|
};
|
|
3797
|
+
return scene;
|
|
3015
3798
|
}
|
|
3016
3799
|
|
|
3017
3800
|
// src/view3d/pick/pickPipeline.ts
|
|
@@ -3579,6 +4362,28 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3579
4362
|
gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
|
|
3580
4363
|
gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
3581
4364
|
gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
4365
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
4366
|
+
lastGatherX = Infinity;
|
|
4367
|
+
};
|
|
4368
|
+
const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
|
|
4369
|
+
const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
|
|
4370
|
+
let lastGatherX = Infinity;
|
|
4371
|
+
let lastGatherZ = Infinity;
|
|
4372
|
+
const updateNearField = () => {
|
|
4373
|
+
if (!gpu) return;
|
|
4374
|
+
const cam = orbit.camera.position;
|
|
4375
|
+
const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
|
|
4376
|
+
if (moved2 < CHAIR_REBUILD_M) return;
|
|
4377
|
+
lastGatherX = cam.x;
|
|
4378
|
+
lastGatherZ = cam.z;
|
|
4379
|
+
const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
|
|
4380
|
+
if (outside > CHAIR_GATHER_M) {
|
|
4381
|
+
if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
|
|
4382
|
+
return;
|
|
4383
|
+
}
|
|
4384
|
+
const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
|
|
4385
|
+
if (n === 0 && gpu.nearSeatCount() === 0) return;
|
|
4386
|
+
gpu.setNearSeats(nearBuf, n);
|
|
3582
4387
|
};
|
|
3583
4388
|
const glctx = new GLContext(container, {
|
|
3584
4389
|
onContextLost: () => {
|
|
@@ -3625,7 +4430,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3625
4430
|
const u = gpu.seatProgram.uniforms;
|
|
3626
4431
|
u.uSeatScale.value = lod.scale;
|
|
3627
4432
|
u.uSeatFade.value = lod.fade;
|
|
4433
|
+
u.uMinPixels.value = lod.minPixels;
|
|
3628
4434
|
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
|
|
4435
|
+
updateNearField();
|
|
3629
4436
|
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
3630
4437
|
glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
|
|
3631
4438
|
labelOverlay.update(
|
|
@@ -3633,7 +4440,10 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3633
4440
|
glctx.canvas.clientWidth || 1,
|
|
3634
4441
|
glctx.canvas.clientHeight || 1,
|
|
3635
4442
|
orbit.currentDistance,
|
|
3636
|
-
model.bounds.radius
|
|
4443
|
+
model.bounds.radius,
|
|
4444
|
+
// The dense label rungs rank by real distance from the eye, not by the
|
|
4445
|
+
// orbit radius — at the arrival pose those are wildly different numbers.
|
|
4446
|
+
[orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
|
|
3637
4447
|
);
|
|
3638
4448
|
return moving;
|
|
3639
4449
|
});
|
|
@@ -3906,6 +4716,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3906
4716
|
if (gpu) {
|
|
3907
4717
|
gpu.seatProgram.uniforms.uFocusFloor.value = value;
|
|
3908
4718
|
gpu.solidProgram.uniforms.uFocusFloor.value = value;
|
|
4719
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = value;
|
|
3909
4720
|
}
|
|
3910
4721
|
focusedFloor = value;
|
|
3911
4722
|
if (index !== null) {
|