@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.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";
|
|
@@ -448,12 +657,42 @@ function isDegenerate(p0, p1, p2) {
|
|
|
448
657
|
const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
|
|
449
658
|
return 2 * area / longest < MIN_TRI_ALTITUDE_M;
|
|
450
659
|
}
|
|
660
|
+
var F32Buffer = class {
|
|
661
|
+
constructor(initial = 1 << 14) {
|
|
662
|
+
this.len = 0;
|
|
663
|
+
this.buf = new Float32Array(initial);
|
|
664
|
+
}
|
|
665
|
+
push3(a, b, c) {
|
|
666
|
+
if (this.len + 3 > this.buf.length) this.grow(this.len + 3);
|
|
667
|
+
this.buf[this.len++] = a;
|
|
668
|
+
this.buf[this.len++] = b;
|
|
669
|
+
this.buf[this.len++] = c;
|
|
670
|
+
}
|
|
671
|
+
push1(a) {
|
|
672
|
+
if (this.len + 1 > this.buf.length) this.grow(this.len + 1);
|
|
673
|
+
this.buf[this.len++] = a;
|
|
674
|
+
}
|
|
675
|
+
grow(need) {
|
|
676
|
+
let cap = this.buf.length * 2;
|
|
677
|
+
while (cap < need) cap *= 2;
|
|
678
|
+
const next = new Float32Array(cap);
|
|
679
|
+
next.set(this.buf.subarray(0, this.len));
|
|
680
|
+
this.buf = next;
|
|
681
|
+
}
|
|
682
|
+
get length() {
|
|
683
|
+
return this.len;
|
|
684
|
+
}
|
|
685
|
+
/** A copy trimmed to the used length. */
|
|
686
|
+
toArray() {
|
|
687
|
+
return this.buf.slice(0, this.len);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
451
690
|
var MeshBuilder = class {
|
|
452
691
|
constructor() {
|
|
453
|
-
this.pos =
|
|
454
|
-
this.nor =
|
|
455
|
-
this.col =
|
|
456
|
-
this.flr =
|
|
692
|
+
this.pos = new F32Buffer();
|
|
693
|
+
this.nor = new F32Buffer();
|
|
694
|
+
this.col = new F32Buffer();
|
|
695
|
+
this.flr = new F32Buffer();
|
|
457
696
|
/** Floor index stamped onto every triangle emitted from now on. */
|
|
458
697
|
this.currentFloor = 0;
|
|
459
698
|
}
|
|
@@ -464,28 +703,44 @@ var MeshBuilder = class {
|
|
|
464
703
|
/** One triangle with a shared (flat) normal and per-vertex colours. */
|
|
465
704
|
tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
|
|
466
705
|
if (isDegenerate(p0, p1, p2)) return;
|
|
467
|
-
this.pos.
|
|
468
|
-
this.
|
|
469
|
-
this.
|
|
470
|
-
this.
|
|
706
|
+
this.pos.push3(p0[0], p0[1], p0[2]);
|
|
707
|
+
this.pos.push3(p1[0], p1[1], p1[2]);
|
|
708
|
+
this.pos.push3(p2[0], p2[1], p2[2]);
|
|
709
|
+
this.nor.push3(n[0], n[1], n[2]);
|
|
710
|
+
this.nor.push3(n[0], n[1], n[2]);
|
|
711
|
+
this.nor.push3(n[0], n[1], n[2]);
|
|
712
|
+
this.col.push3(c0[0], c0[1], c0[2]);
|
|
713
|
+
this.col.push3(c1[0], c1[1], c1[2]);
|
|
714
|
+
this.col.push3(c2[0], c2[1], c2[2]);
|
|
715
|
+
this.flr.push1(this.currentFloor);
|
|
716
|
+
this.flr.push1(this.currentFloor);
|
|
717
|
+
this.flr.push1(this.currentFloor);
|
|
471
718
|
}
|
|
472
719
|
/** One triangle with independent per-vertex normals (smooth shading). */
|
|
473
720
|
triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
|
|
474
721
|
if (isDegenerate(p0, p1, p2)) return;
|
|
475
|
-
this.pos.
|
|
476
|
-
this.
|
|
477
|
-
this.
|
|
478
|
-
this.
|
|
722
|
+
this.pos.push3(p0[0], p0[1], p0[2]);
|
|
723
|
+
this.pos.push3(p1[0], p1[1], p1[2]);
|
|
724
|
+
this.pos.push3(p2[0], p2[1], p2[2]);
|
|
725
|
+
this.nor.push3(n0[0], n0[1], n0[2]);
|
|
726
|
+
this.nor.push3(n1[0], n1[1], n1[2]);
|
|
727
|
+
this.nor.push3(n2[0], n2[1], n2[2]);
|
|
728
|
+
this.col.push3(c0[0], c0[1], c0[2]);
|
|
729
|
+
this.col.push3(c1[0], c1[1], c1[2]);
|
|
730
|
+
this.col.push3(c2[0], c2[1], c2[2]);
|
|
731
|
+
this.flr.push1(this.currentFloor);
|
|
732
|
+
this.flr.push1(this.currentFloor);
|
|
733
|
+
this.flr.push1(this.currentFloor);
|
|
479
734
|
}
|
|
480
735
|
get vertexCount() {
|
|
481
736
|
return this.pos.length / 3;
|
|
482
737
|
}
|
|
483
738
|
build() {
|
|
484
739
|
return {
|
|
485
|
-
position:
|
|
486
|
-
normal:
|
|
487
|
-
color:
|
|
488
|
-
floor:
|
|
740
|
+
position: this.pos.toArray(),
|
|
741
|
+
normal: this.nor.toArray(),
|
|
742
|
+
color: this.col.toArray(),
|
|
743
|
+
floor: this.flr.toArray(),
|
|
489
744
|
count: this.pos.length / 3
|
|
490
745
|
};
|
|
491
746
|
}
|
|
@@ -813,6 +1068,152 @@ function rectPolygon(x, y, w, h) {
|
|
|
813
1068
|
];
|
|
814
1069
|
}
|
|
815
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
|
+
|
|
816
1217
|
// src/view3d/scene/seatInstances.ts
|
|
817
1218
|
var SEAT_DOT_RADIUS_M = 0.22;
|
|
818
1219
|
var SEAT_PITCH_FRACTION = 0.42;
|
|
@@ -882,6 +1283,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
882
1283
|
const iPosition = new Float32Array(count * 3);
|
|
883
1284
|
const iState = new Float32Array(count);
|
|
884
1285
|
const iMaxRadius = new Float32Array(count);
|
|
1286
|
+
const iChairWidth = new Float32Array(count);
|
|
885
1287
|
const iRing = new Float32Array(count * 3);
|
|
886
1288
|
const idToIndex = /* @__PURE__ */ new Map();
|
|
887
1289
|
const spacing = nearestNeighbourSpacing(seats);
|
|
@@ -890,6 +1292,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
890
1292
|
const resolved = surfaces?.seatPitchU(i);
|
|
891
1293
|
const pitchM = (resolved ?? spacing[i]) * M;
|
|
892
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);
|
|
893
1296
|
iPosition[i * 3] = seat.x * M;
|
|
894
1297
|
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
895
1298
|
iPosition[i * 3 + 2] = seat.y * M;
|
|
@@ -911,7 +1314,9 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
911
1314
|
iMaxRadius,
|
|
912
1315
|
iRing,
|
|
913
1316
|
idToIndex,
|
|
914
|
-
|
|
1317
|
+
iChairWidth,
|
|
1318
|
+
iFloor: seatFloor ?? new Float32Array(count),
|
|
1319
|
+
iYaw: new Float32Array(count)
|
|
915
1320
|
};
|
|
916
1321
|
}
|
|
917
1322
|
var RUN_MERGE_GAP = 64;
|
|
@@ -993,16 +1398,23 @@ function themeSeatColorLUT(theme, order) {
|
|
|
993
1398
|
var ZONE_MIN_DISTANCE = 1.15;
|
|
994
1399
|
var SECTION_MAX_DISTANCE = 2.2;
|
|
995
1400
|
var NEAR_MAX_DISTANCE = 0.85;
|
|
1401
|
+
var ROW_MAX_DISTANCE = 0.5;
|
|
1402
|
+
var SEAT_MAX_DISTANCE = 0.26;
|
|
996
1403
|
function visibleLabelKinds(distance, venueRadius) {
|
|
997
1404
|
const r = Math.max(1e-6, venueRadius);
|
|
998
1405
|
const d = distance / r;
|
|
999
1406
|
const out = /* @__PURE__ */ new Set();
|
|
1000
1407
|
if (d >= ZONE_MIN_DISTANCE) out.add("zone");
|
|
1001
|
-
if (d <= SECTION_MAX_DISTANCE)
|
|
1408
|
+
if (d <= SECTION_MAX_DISTANCE) {
|
|
1409
|
+
out.add("section");
|
|
1410
|
+
out.add("ga");
|
|
1411
|
+
}
|
|
1002
1412
|
if (d <= NEAR_MAX_DISTANCE) {
|
|
1003
1413
|
out.add("annotation");
|
|
1004
1414
|
out.add("booth");
|
|
1005
1415
|
}
|
|
1416
|
+
if (d <= ROW_MAX_DISTANCE) out.add("row");
|
|
1417
|
+
if (d <= SEAT_MAX_DISTANCE) out.add("seat");
|
|
1006
1418
|
return out;
|
|
1007
1419
|
}
|
|
1008
1420
|
function projectToScreen(viewProjection, p, width, height) {
|
|
@@ -1039,6 +1451,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
|
|
|
1039
1451
|
}
|
|
1040
1452
|
return kept;
|
|
1041
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
|
+
}
|
|
1042
1476
|
function centroidOf(points) {
|
|
1043
1477
|
if (!points.length) return null;
|
|
1044
1478
|
let x = 0, y = 0;
|
|
@@ -1212,191 +1646,6 @@ function buildSectionRake(rows, focal) {
|
|
|
1212
1646
|
return rowsRake(fits);
|
|
1213
1647
|
}
|
|
1214
1648
|
|
|
1215
|
-
// src/view3d/scene/surface.ts
|
|
1216
|
-
var FLAT_SLAB_TOP_M = 0.05;
|
|
1217
|
-
var CAP_MAX_ERROR_M = 0.05;
|
|
1218
|
-
var SEAT_CLEARANCE_M = 0.15;
|
|
1219
|
-
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1220
|
-
function bboxOf(pts) {
|
|
1221
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1222
|
-
for (const p of pts) {
|
|
1223
|
-
if (p.x < minX) minX = p.x;
|
|
1224
|
-
if (p.y < minY) minY = p.y;
|
|
1225
|
-
if (p.x > maxX) maxX = p.x;
|
|
1226
|
-
if (p.y > maxY) maxY = p.y;
|
|
1227
|
-
}
|
|
1228
|
-
return { minX, minY, maxX, maxY };
|
|
1229
|
-
}
|
|
1230
|
-
var MAX_TIER_RISE_M = 25;
|
|
1231
|
-
function buildVenueSurfaces(units, seats) {
|
|
1232
|
-
const bySection = /* @__PURE__ */ new Map();
|
|
1233
|
-
const seatOwner = new Array(seats.length).fill(null);
|
|
1234
|
-
const seatDeck = new Float64Array(seats.length);
|
|
1235
|
-
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
1236
|
-
const seatPitch = new Array(seats.length).fill(void 0);
|
|
1237
|
-
const acc = /* @__PURE__ */ new Map();
|
|
1238
|
-
const boxes = [];
|
|
1239
|
-
for (const unit of units) {
|
|
1240
|
-
for (const o of unit.objects) {
|
|
1241
|
-
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1242
|
-
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
1243
|
-
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
1244
|
-
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1248
|
-
const s = seats[i];
|
|
1249
|
-
for (const b of boxes) {
|
|
1250
|
-
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
1251
|
-
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
1252
|
-
seatOwner[i] = b.id;
|
|
1253
|
-
const a = acc.get(b.id);
|
|
1254
|
-
a.hasSeats = true;
|
|
1255
|
-
const f = s.focalPoint ?? b.unit.focal;
|
|
1256
|
-
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
1257
|
-
if (d < a.frontU) a.frontU = d;
|
|
1258
|
-
a.seatIndices.push(i);
|
|
1259
|
-
const rowKey = s.rowId || `__seat-${i}`;
|
|
1260
|
-
const arr = a.rows.get(rowKey);
|
|
1261
|
-
if (arr) arr.push({ x: s.x, y: s.y });
|
|
1262
|
-
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
1263
|
-
break;
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
for (const [id, a] of acc) {
|
|
1267
|
-
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
1268
|
-
const bottomY = a.unit.baseHeightM;
|
|
1269
|
-
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
1270
|
-
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
1271
|
-
const kind = a.section.surfaceKind;
|
|
1272
|
-
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
1273
|
-
const rake = buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal);
|
|
1274
|
-
let frontU = Infinity;
|
|
1275
|
-
if (a.hasSeats) {
|
|
1276
|
-
for (const pts of a.rows.values()) {
|
|
1277
|
-
for (const p of pts) {
|
|
1278
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1279
|
-
if (d < frontU) frontU = d;
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
1284
|
-
frontU = Infinity;
|
|
1285
|
-
for (const p of a.section.outline) {
|
|
1286
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1287
|
-
if (d < frontU) frontU = d;
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
const flatTop = bottomY + FLAT_SLAB_TOP_M;
|
|
1291
|
-
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
1292
|
-
const levelFor = (depthU) => {
|
|
1293
|
-
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
1294
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1295
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1296
|
-
};
|
|
1297
|
-
const levelForBlockDepth = (blockDepthU) => {
|
|
1298
|
-
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
1299
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1300
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1301
|
-
};
|
|
1302
|
-
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
1303
|
-
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
1304
|
-
pts: r.pts,
|
|
1305
|
-
y: levelForBlockDepth(r.blockDepth),
|
|
1306
|
-
depth: r.blockDepth,
|
|
1307
|
-
blockId: r.blockId
|
|
1308
|
-
}));
|
|
1309
|
-
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
1310
|
-
const rowBounds = rowLevels.map((r) => {
|
|
1311
|
-
let cx = 0, cy = 0;
|
|
1312
|
-
for (const p of r.pts) {
|
|
1313
|
-
cx += p.x;
|
|
1314
|
-
cy += p.y;
|
|
1315
|
-
}
|
|
1316
|
-
const n = r.pts.length || 1;
|
|
1317
|
-
cx /= n;
|
|
1318
|
-
cy /= n;
|
|
1319
|
-
let rad = 0;
|
|
1320
|
-
for (const p of r.pts) {
|
|
1321
|
-
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1322
|
-
if (d > rad) rad = d;
|
|
1323
|
-
}
|
|
1324
|
-
return { cx, cy, rad };
|
|
1325
|
-
});
|
|
1326
|
-
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
1327
|
-
let best = Infinity, bestY = landingY;
|
|
1328
|
-
for (let i = 0; i < rowLevels.length; i++) {
|
|
1329
|
-
const b = rowBounds[i];
|
|
1330
|
-
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
1331
|
-
const d = distanceToPolyline(rowLevels[i].pts, x, y);
|
|
1332
|
-
if (d < best) {
|
|
1333
|
-
best = d;
|
|
1334
|
-
bestY = rowLevels[i].y;
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
return bestY;
|
|
1338
|
-
} : (x, y) => levelFor(rake.depthAt(x, y));
|
|
1339
|
-
const UP = [0, 1, 0];
|
|
1340
|
-
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
1341
|
-
const d = rake.depthAt(x, y);
|
|
1342
|
-
if (d <= frontU) return UP;
|
|
1343
|
-
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
1344
|
-
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
1345
|
-
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
1346
|
-
const [gx, gy] = rake.gradientAt(x, y);
|
|
1347
|
-
if (gx === 0 && gy === 0) return UP;
|
|
1348
|
-
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
1349
|
-
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
1350
|
-
};
|
|
1351
|
-
if (!flat) {
|
|
1352
|
-
for (const r of structure.rows) {
|
|
1353
|
-
const y = levelForBlockDepth(r.blockDepth);
|
|
1354
|
-
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
for (const r of structure.rows) {
|
|
1358
|
-
const gaps = [];
|
|
1359
|
-
for (let k = 1; k < r.pts.length; k++) {
|
|
1360
|
-
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
1361
|
-
if (d > 1e-6) gaps.push(d);
|
|
1362
|
-
}
|
|
1363
|
-
gaps.sort((x, y) => x - y);
|
|
1364
|
-
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
1365
|
-
let across = Infinity;
|
|
1366
|
-
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
1367
|
-
if (probe) {
|
|
1368
|
-
for (const other of structure.rows) {
|
|
1369
|
-
if (other === r || other.blockId !== r.blockId) continue;
|
|
1370
|
-
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
1371
|
-
if (d > 1e-6 && d < across) across = d;
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
1375
|
-
if (Number.isFinite(pitch) && pitch > 0) {
|
|
1376
|
-
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
1380
|
-
}
|
|
1381
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1382
|
-
const ownerId = seatOwner[i];
|
|
1383
|
-
const s = seats[i];
|
|
1384
|
-
if (ownerId) {
|
|
1385
|
-
const own = seatRowLevel[i];
|
|
1386
|
-
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
1387
|
-
continue;
|
|
1388
|
-
}
|
|
1389
|
-
const eye = s.eyeHeightM;
|
|
1390
|
-
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1391
|
-
}
|
|
1392
|
-
return {
|
|
1393
|
-
bySection,
|
|
1394
|
-
seatOwner,
|
|
1395
|
-
seatDeckY: (i) => seatDeck[i],
|
|
1396
|
-
seatPitchU: (i) => seatPitch[i]
|
|
1397
|
-
};
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
1649
|
// src/view3d/scene/deckBands.ts
|
|
1401
1650
|
import polygonClipping2 from "polygon-clipping";
|
|
1402
1651
|
import earcut2 from "earcut";
|
|
@@ -1445,22 +1694,100 @@ function extendEnds(pts, by) {
|
|
|
1445
1694
|
});
|
|
1446
1695
|
return out;
|
|
1447
1696
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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
|
+
}
|
|
1759
|
+
function distToPolyline(pts, x, y) {
|
|
1760
|
+
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1761
|
+
let best = Infinity;
|
|
1762
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1763
|
+
const a = pts[i], b = pts[i + 1];
|
|
1764
|
+
const vx = b.x - a.x, vy = b.y - a.y;
|
|
1765
|
+
const len2 = vx * vx + vy * vy;
|
|
1766
|
+
let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
|
|
1767
|
+
if (t < 0) t = 0;
|
|
1768
|
+
else if (t > 1) t = 1;
|
|
1769
|
+
const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
|
|
1459
1770
|
if (d < best) best = d;
|
|
1460
1771
|
}
|
|
1461
1772
|
return best;
|
|
1462
1773
|
}
|
|
1463
|
-
function
|
|
1774
|
+
function rowNeighbourhoods(rows) {
|
|
1775
|
+
const bounds = rows.map((r) => {
|
|
1776
|
+
let cx = 0, cy = 0;
|
|
1777
|
+
for (const p of r.pts) {
|
|
1778
|
+
cx += p.x;
|
|
1779
|
+
cy += p.y;
|
|
1780
|
+
}
|
|
1781
|
+
const n = r.pts.length || 1;
|
|
1782
|
+
cx /= n;
|
|
1783
|
+
cy /= n;
|
|
1784
|
+
let rad = 0;
|
|
1785
|
+
for (const p of r.pts) {
|
|
1786
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1787
|
+
if (d > rad) rad = d;
|
|
1788
|
+
}
|
|
1789
|
+
return { cx, cy, rad };
|
|
1790
|
+
});
|
|
1464
1791
|
const probesOf = (pts) => {
|
|
1465
1792
|
const n = pts.length;
|
|
1466
1793
|
if (n <= 2) return [...pts];
|
|
@@ -1475,6 +1802,9 @@ function neighbourhoods(rows) {
|
|
|
1475
1802
|
let nearest = Infinity;
|
|
1476
1803
|
for (let j = 0; j < rows.length; j++) {
|
|
1477
1804
|
if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
|
|
1805
|
+
const b = bounds[j];
|
|
1806
|
+
const lower = Math.hypot(c.x - b.cx, c.y - b.cy) - b.rad;
|
|
1807
|
+
if (lower >= nearest && lower >= bestFrontD) continue;
|
|
1478
1808
|
const d = distToPolyline(rows[j].pts, c.x, c.y);
|
|
1479
1809
|
if (d < nearest) nearest = d;
|
|
1480
1810
|
if (rows[j].depth < rows[i].depth && d < bestFrontD) {
|
|
@@ -1519,65 +1849,8 @@ function ribbonOf(rows, i, nbrs, focal) {
|
|
|
1519
1849
|
back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
|
|
1520
1850
|
};
|
|
1521
1851
|
}
|
|
1522
|
-
function deckFootprints(rows, focal) {
|
|
1523
|
-
if (rows.length < 2) return [];
|
|
1524
|
-
const nbrs = neighbourhoods(rows);
|
|
1525
|
-
const rings = [];
|
|
1526
|
-
const ribbons = [];
|
|
1527
|
-
for (let i = 0; i < rows.length; i++) {
|
|
1528
|
-
const r = ribbonOf(rows, i, nbrs, focal);
|
|
1529
|
-
ribbons.push(r);
|
|
1530
|
-
if (!r) continue;
|
|
1531
|
-
const f = [];
|
|
1532
|
-
const b = [];
|
|
1533
|
-
const rf = r.front + FOOTPRINT_MARGIN_U;
|
|
1534
|
-
const rb = r.back + FOOTPRINT_MARGIN_U;
|
|
1535
|
-
for (let k = 0; k < r.pts.length; k++) {
|
|
1536
|
-
const p = r.pts[k], n = r.nrm[k];
|
|
1537
|
-
f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
|
|
1538
|
-
b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
|
|
1539
|
-
}
|
|
1540
|
-
const ring = [...f, ...b.reverse()];
|
|
1541
|
-
if (ring.length < 3) continue;
|
|
1542
|
-
ring.push(ring[0]);
|
|
1543
|
-
rings.push([ring]);
|
|
1544
|
-
}
|
|
1545
|
-
if (!rings.length) return [];
|
|
1546
|
-
let merged;
|
|
1547
|
-
try {
|
|
1548
|
-
merged = polygonClipping2.union(rings[0], ...rings.slice(1));
|
|
1549
|
-
} catch {
|
|
1550
|
-
return [];
|
|
1551
|
-
}
|
|
1552
|
-
const out = [];
|
|
1553
|
-
for (const poly of merged) {
|
|
1554
|
-
if (!poly.length || poly[0].length < 4) continue;
|
|
1555
|
-
const toPts = (ring) => {
|
|
1556
|
-
const pts = ring.map(([x, y]) => ({ x, y }));
|
|
1557
|
-
const first = pts[0], last = pts[pts.length - 1];
|
|
1558
|
-
if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
|
|
1559
|
-
return pts;
|
|
1560
|
-
};
|
|
1561
|
-
const outline = toPts(poly[0]);
|
|
1562
|
-
if (outline.length < 3) continue;
|
|
1563
|
-
let topY = Infinity;
|
|
1564
|
-
for (let i = 0; i < rows.length; i++) {
|
|
1565
|
-
const r = ribbons[i];
|
|
1566
|
-
if (!r) continue;
|
|
1567
|
-
const mid = r.pts[Math.floor(r.pts.length / 2)];
|
|
1568
|
-
if (pointInRing(outline, mid.x, mid.y) && rows[i].y < topY) topY = rows[i].y;
|
|
1569
|
-
}
|
|
1570
|
-
if (!Number.isFinite(topY)) continue;
|
|
1571
|
-
out.push({
|
|
1572
|
-
outline: simplifyRing(outline, FOOTPRINT_TOLERANCE_U),
|
|
1573
|
-
holes: poly.slice(1).map(toPts).map((h) => simplifyRing(h, FOOTPRINT_TOLERANCE_U)).filter((h) => h.length >= 3),
|
|
1574
|
-
topY
|
|
1575
|
-
});
|
|
1576
|
-
}
|
|
1577
|
-
return out;
|
|
1578
|
-
}
|
|
1579
|
-
var FOOTPRINT_TOLERANCE_U = 0.3;
|
|
1580
1852
|
var FOOTPRINT_MARGIN_U = 1.5;
|
|
1853
|
+
var FOOTPRINT_TOLERANCE_U = 0.3;
|
|
1581
1854
|
function simplifyRun(pts, tol) {
|
|
1582
1855
|
if (pts.length < 3) return pts;
|
|
1583
1856
|
const a = pts[0], b = pts[pts.length - 1];
|
|
@@ -1603,6 +1876,109 @@ function simplifyRing(ring, tol) {
|
|
|
1603
1876
|
out.pop();
|
|
1604
1877
|
return out.length >= 3 ? out : ring;
|
|
1605
1878
|
}
|
|
1879
|
+
function deckFootprints(rows, focal, shared) {
|
|
1880
|
+
if (rows.length < 2) return [];
|
|
1881
|
+
const nbrs = shared ?? rowNeighbourhoods(rows);
|
|
1882
|
+
const byBlock = /* @__PURE__ */ new Map();
|
|
1883
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1884
|
+
const a = byBlock.get(rows[i].blockId);
|
|
1885
|
+
if (a) a.push(i);
|
|
1886
|
+
else byBlock.set(rows[i].blockId, [i]);
|
|
1887
|
+
}
|
|
1888
|
+
const out = [];
|
|
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;
|
|
1897
|
+
indices.sort((a, b) => rows[a].depth - rows[b].depth);
|
|
1898
|
+
const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
|
|
1899
|
+
const usable = ribbons.filter((r) => r !== null);
|
|
1900
|
+
if (!usable.length) continue;
|
|
1901
|
+
const frontOf = (r, k) => ({
|
|
1902
|
+
x: r.pts[k].x - r.nrm[k][0] * (r.front + FOOTPRINT_MARGIN_U),
|
|
1903
|
+
y: r.pts[k].y - r.nrm[k][1] * (r.front + FOOTPRINT_MARGIN_U)
|
|
1904
|
+
});
|
|
1905
|
+
const backOf = (r, k) => ({
|
|
1906
|
+
x: r.pts[k].x + r.nrm[k][0] * (r.back + FOOTPRINT_MARGIN_U),
|
|
1907
|
+
y: r.pts[k].y + r.nrm[k][1] * (r.back + FOOTPRINT_MARGIN_U)
|
|
1908
|
+
});
|
|
1909
|
+
const first = usable[0], last = usable[usable.length - 1];
|
|
1910
|
+
const ring = [];
|
|
1911
|
+
for (let k = 0; k < first.pts.length; k++) ring.push(frontOf(first, k));
|
|
1912
|
+
for (const r of usable) ring.push(backOf(r, r.pts.length - 1));
|
|
1913
|
+
for (let k = last.pts.length - 1; k >= 0; k--) ring.push(backOf(last, k));
|
|
1914
|
+
for (let i = usable.length - 1; i >= 0; i--) ring.push(frontOf(usable[i], 0));
|
|
1915
|
+
const deduped = dedupeAdjacent(ring);
|
|
1916
|
+
let topY = Infinity;
|
|
1917
|
+
for (const i of indices) if (rows[i].y < topY) topY = rows[i].y;
|
|
1918
|
+
if (!Number.isFinite(topY)) continue;
|
|
1919
|
+
const covers = deduped.length >= 3 && Math.abs(ringArea(deduped)) > 1e-6 && usable.every((r) => {
|
|
1920
|
+
const mid = r.pts[Math.floor(r.pts.length / 2)];
|
|
1921
|
+
return pointInRing(deduped, mid.x, mid.y);
|
|
1922
|
+
});
|
|
1923
|
+
if (covers) {
|
|
1924
|
+
out.push({ outline: simplifyRing(deduped, FOOTPRINT_TOLERANCE_U), holes: [], topY });
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
for (const poly of unionRibbons(usable)) out.push({ outline: poly, holes: [], topY });
|
|
1928
|
+
}
|
|
1929
|
+
return out;
|
|
1930
|
+
}
|
|
1931
|
+
function unionRibbons(ribbons) {
|
|
1932
|
+
const rings = [];
|
|
1933
|
+
for (const r of ribbons) {
|
|
1934
|
+
const f = [];
|
|
1935
|
+
const b = [];
|
|
1936
|
+
const rf = r.front + FOOTPRINT_MARGIN_U;
|
|
1937
|
+
const rb = r.back + FOOTPRINT_MARGIN_U;
|
|
1938
|
+
for (let k = 0; k < r.pts.length; k++) {
|
|
1939
|
+
const p = r.pts[k], n = r.nrm[k];
|
|
1940
|
+
f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
|
|
1941
|
+
b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
|
|
1942
|
+
}
|
|
1943
|
+
const ring = [...f, ...b.reverse()];
|
|
1944
|
+
if (ring.length < 3) continue;
|
|
1945
|
+
ring.push(ring[0]);
|
|
1946
|
+
rings.push([ring]);
|
|
1947
|
+
}
|
|
1948
|
+
if (!rings.length) return [];
|
|
1949
|
+
try {
|
|
1950
|
+
const merged = polygonClipping2.union(rings[0], ...rings.slice(1));
|
|
1951
|
+
const out = [];
|
|
1952
|
+
for (const poly of merged) {
|
|
1953
|
+
if (!poly.length || poly[0].length < 4) continue;
|
|
1954
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
1955
|
+
const first = pts[0], last = pts[pts.length - 1];
|
|
1956
|
+
if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
|
|
1957
|
+
if (pts.length >= 3) out.push(simplifyRing(pts, FOOTPRINT_TOLERANCE_U));
|
|
1958
|
+
}
|
|
1959
|
+
return out;
|
|
1960
|
+
} catch {
|
|
1961
|
+
return [];
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
function ringArea(ring) {
|
|
1965
|
+
let a = 0;
|
|
1966
|
+
for (let i = 0, n = ring.length; i < n; i++) {
|
|
1967
|
+
const p = ring[i], q = ring[(i + 1) % n];
|
|
1968
|
+
a += p.x * q.y - q.x * p.y;
|
|
1969
|
+
}
|
|
1970
|
+
return a / 2;
|
|
1971
|
+
}
|
|
1972
|
+
function dedupeAdjacent(ring) {
|
|
1973
|
+
const out = [];
|
|
1974
|
+
for (const p of ring) {
|
|
1975
|
+
const last = out[out.length - 1];
|
|
1976
|
+
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 1e-9) continue;
|
|
1977
|
+
out.push(p);
|
|
1978
|
+
}
|
|
1979
|
+
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();
|
|
1980
|
+
return out;
|
|
1981
|
+
}
|
|
1606
1982
|
function pointInRing(ring, x, y) {
|
|
1607
1983
|
let inside = false;
|
|
1608
1984
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
@@ -1626,106 +2002,351 @@ var ClipTest = class {
|
|
|
1626
2002
|
if (p.y > this.maxY) this.maxY = p.y;
|
|
1627
2003
|
}
|
|
1628
2004
|
}
|
|
1629
|
-
}
|
|
1630
|
-
/** True when every point lies strictly inside ONE ring of the clip region. */
|
|
1631
|
-
containsAll(pts) {
|
|
1632
|
-
for (const p of pts) {
|
|
1633
|
-
if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
|
|
1634
|
-
}
|
|
1635
|
-
for (const ring of this.rings) {
|
|
1636
|
-
let all = true;
|
|
1637
|
-
for (const p of pts) {
|
|
1638
|
-
if (!pointInRing(ring, p.x, p.y)) {
|
|
1639
|
-
all = false;
|
|
1640
|
-
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;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
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
|
+
}
|
|
2308
|
+
}
|
|
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);
|
|
2314
|
+
}
|
|
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;
|
|
1641
2324
|
}
|
|
1642
2325
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
};
|
|
1648
|
-
function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
|
|
1649
|
-
if (clipTest.containsAll(quad)) {
|
|
1650
|
-
const UPF = [0, 1, 0];
|
|
1651
|
-
const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
|
|
1652
|
-
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);
|
|
1653
|
-
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);
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
const ring = quad.map((p) => [p.x, p.y]);
|
|
1657
|
-
ring.push(ring[0]);
|
|
1658
|
-
let pieces;
|
|
1659
|
-
try {
|
|
1660
|
-
pieces = polygonClipping2.intersection([ring], clipRing);
|
|
1661
|
-
} catch {
|
|
1662
|
-
return;
|
|
1663
|
-
}
|
|
1664
|
-
const UP = [0, 1, 0];
|
|
1665
|
-
for (const poly of pieces) {
|
|
1666
|
-
if (!poly.length || poly[0].length < 4) continue;
|
|
1667
|
-
const outer = poly[0];
|
|
1668
|
-
const flat = [];
|
|
1669
|
-
const pts = [];
|
|
1670
|
-
for (let i = 0; i < outer.length - 1; i++) {
|
|
1671
|
-
flat.push(outer[i][0], outer[i][1]);
|
|
1672
|
-
pts.push([outer[i][0], outer[i][1]]);
|
|
1673
|
-
}
|
|
1674
|
-
if (pts.length < 3) continue;
|
|
1675
|
-
const tris = earcut2(flat, void 0, 2);
|
|
1676
|
-
for (let i = 0; i < tris.length; i += 3) {
|
|
1677
|
-
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
1678
|
-
builder.tri(
|
|
1679
|
-
[a[0] * M, y, a[1] * M],
|
|
1680
|
-
[b[0] * M, y, b[1] * M],
|
|
1681
|
-
[c[0] * M, y, c[1] * M],
|
|
1682
|
-
UP,
|
|
1683
|
-
color
|
|
1684
|
-
);
|
|
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;
|
|
2329
|
+
}
|
|
1685
2330
|
}
|
|
2331
|
+
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
1686
2332
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
for (let i = 0; i < rows.length; i++) {
|
|
1695
|
-
const row = rows[i];
|
|
1696
|
-
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
1697
|
-
if (!rib) continue;
|
|
1698
|
-
const { pts, nrm, front, back } = rib;
|
|
1699
|
-
const belowY = nbrs[i].belowY ?? landingY;
|
|
1700
|
-
for (let k = 0; k + 1 < pts.length; k++) {
|
|
1701
|
-
const p = pts[k], q = pts[k + 1];
|
|
1702
|
-
const np = nrm[k], nq = nrm[k + 1];
|
|
1703
|
-
if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
|
|
1704
|
-
if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
|
|
1705
|
-
const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
|
|
1706
|
-
const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
|
|
1707
|
-
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
1708
|
-
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
1709
|
-
if (clipRing && clipTest) {
|
|
1710
|
-
emitClippedQuad(builder, clipRing, clipTest, [
|
|
1711
|
-
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
1712
|
-
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
1713
|
-
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
1714
|
-
{ x: p.x + np[0] * back, y: p.y + np[1] * back }
|
|
1715
|
-
], row.y, colors.tread);
|
|
1716
|
-
} else {
|
|
1717
|
-
builder.tri(pF, qF, qB, UP, colors.tread);
|
|
1718
|
-
builder.tri(pF, qB, pB, UP, colors.tread);
|
|
1719
|
-
}
|
|
1720
|
-
if (row.y > belowY + MIN_RISER_M) {
|
|
1721
|
-
const pFd = [pF[0], belowY, pF[2]];
|
|
1722
|
-
const qFd = [qF[0], belowY, qF[2]];
|
|
1723
|
-
const rn = [-np[0], 0, -np[1]];
|
|
1724
|
-
builder.tri(pF, qF, qFd, rn, colors.riser);
|
|
1725
|
-
builder.tri(pF, qFd, pFd, rn, colors.riser);
|
|
1726
|
-
}
|
|
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;
|
|
1727
2340
|
}
|
|
2341
|
+
const eye = s.eyeHeightM;
|
|
2342
|
+
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1728
2343
|
}
|
|
2344
|
+
return {
|
|
2345
|
+
bySection,
|
|
2346
|
+
seatOwner,
|
|
2347
|
+
seatDeckY: (i) => seatDeck[i],
|
|
2348
|
+
seatPitchU: (i) => seatPitch[i]
|
|
2349
|
+
};
|
|
1729
2350
|
}
|
|
1730
2351
|
|
|
1731
2352
|
// src/view3d/scene/sceneModel.ts
|
|
@@ -1790,9 +2411,7 @@ function boxesOverlap(a, b) {
|
|
|
1790
2411
|
return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
|
|
1791
2412
|
}
|
|
1792
2413
|
function paddedFootprint(section, siblings) {
|
|
1793
|
-
const __tp = performance.now();
|
|
1794
2414
|
const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
|
|
1795
|
-
__t("outsetRing", __tp);
|
|
1796
2415
|
const box = bboxOfRing(padded);
|
|
1797
2416
|
const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
|
|
1798
2417
|
if (!others.length) return [padded];
|
|
@@ -1856,9 +2475,8 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
|
|
|
1856
2475
|
const footprint = paddedFootprint(section, siblings);
|
|
1857
2476
|
const outline = footprint[0] ?? section.outline;
|
|
1858
2477
|
if (surface.rowLevels.length >= 2) {
|
|
1859
|
-
const
|
|
1860
|
-
const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
|
|
1861
|
-
__t("deckFootprints", __tf);
|
|
2478
|
+
const nbrs = rowNeighbourhoods(surface.rowLevels);
|
|
2479
|
+
const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal, nbrs), footprint);
|
|
1862
2480
|
const capUp = () => [0, 1, 0];
|
|
1863
2481
|
for (const b of blocks) {
|
|
1864
2482
|
extrudePrism(
|
|
@@ -1886,34 +2504,49 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
|
|
|
1886
2504
|
AO
|
|
1887
2505
|
);
|
|
1888
2506
|
}
|
|
1889
|
-
const __tb = performance.now();
|
|
1890
2507
|
emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
|
|
1891
2508
|
tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
|
|
1892
2509
|
// Risers read as the structure they are, a shade below their tread, which
|
|
1893
2510
|
// is what makes the stepping legible from a low angle.
|
|
1894
2511
|
riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
|
|
1895
|
-
}, footprint.length === 1 ? outline : footprint.flat());
|
|
1896
|
-
__t("emitDeckBands", __tb);
|
|
2512
|
+
}, footprint.length === 1 ? outline : footprint.flat(), nbrs);
|
|
1897
2513
|
return;
|
|
1898
2514
|
}
|
|
1899
2515
|
const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
|
|
1900
2516
|
const topN = (p) => surface.normalAt(p.x, p.y);
|
|
1901
|
-
|
|
2517
|
+
const level = surface.flat ? surface.landingY : void 0;
|
|
2518
|
+
for (const ring of claimed.subtract(outline, level)) {
|
|
1902
2519
|
extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
|
|
1903
2520
|
}
|
|
1904
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
|
+
}
|
|
1905
2526
|
var ClaimedArea = class {
|
|
1906
2527
|
constructor() {
|
|
1907
2528
|
this.rings = [];
|
|
1908
2529
|
this.boxes = [];
|
|
2530
|
+
/** Constant deck height of each claim, or undefined when it is not level. */
|
|
2531
|
+
this.levels = [];
|
|
1909
2532
|
}
|
|
1910
|
-
/**
|
|
1911
|
-
|
|
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) {
|
|
1912
2545
|
const closed = ring.map((p) => [p.x, p.y]);
|
|
1913
2546
|
if (closed.length < 3) return [];
|
|
1914
2547
|
closed.push(closed[0]);
|
|
1915
2548
|
const box = bboxOfRing(ring);
|
|
1916
|
-
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]));
|
|
1917
2550
|
let pieces = [[closed]];
|
|
1918
2551
|
if (overlapping.length) {
|
|
1919
2552
|
try {
|
|
@@ -1925,6 +2558,7 @@ var ClaimedArea = class {
|
|
|
1925
2558
|
}
|
|
1926
2559
|
this.rings.push([closed]);
|
|
1927
2560
|
this.boxes.push(box);
|
|
2561
|
+
this.levels.push(level);
|
|
1928
2562
|
const out = [];
|
|
1929
2563
|
for (const poly of pieces) {
|
|
1930
2564
|
if (!poly.length) continue;
|
|
@@ -1943,7 +2577,12 @@ var ZONE_LABEL_LIFT_M = 6;
|
|
|
1943
2577
|
var SECTION_LABEL_LIFT_M = 2.2;
|
|
1944
2578
|
var ANNOTATION_LIFT_M = 0.1;
|
|
1945
2579
|
var BOOTH_LABEL_LIFT_M = 0.3;
|
|
2580
|
+
var GA_LABEL_LIFT_M = 1.8;
|
|
2581
|
+
var ROW_LABEL_LIFT_M = 0.9;
|
|
2582
|
+
var SEAT_LABEL_LIFT_M = 0.55;
|
|
2583
|
+
var SEAT_LABEL_MAX = 6e3;
|
|
1946
2584
|
var TABLE_HEIGHT_M = 0.75;
|
|
2585
|
+
var DECOR_PLATE_M = 0.15;
|
|
1947
2586
|
function boothPolygon(booth) {
|
|
1948
2587
|
if (booth.points && booth.points.length >= 3) return booth.points;
|
|
1949
2588
|
const { center, width, height, rotation } = booth;
|
|
@@ -2017,10 +2656,63 @@ function buildShape(builder, shape, base, S) {
|
|
|
2017
2656
|
if (!poly) return;
|
|
2018
2657
|
const isStage = shape.role === "stage";
|
|
2019
2658
|
const height = isStage ? base + 1 : base + 0.25;
|
|
2020
|
-
const colTop = isStage ? S.stageTop : S.decorTop;
|
|
2659
|
+
const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
|
|
2021
2660
|
const colWall = isStage ? S.stageWall : S.decorWall;
|
|
2022
2661
|
extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
|
|
2023
2662
|
}
|
|
2663
|
+
function decorPolygon(decor) {
|
|
2664
|
+
const { x, y, width, height } = decor;
|
|
2665
|
+
if (!width || !height) return null;
|
|
2666
|
+
const cx = x + width / 2, cy = y + height / 2;
|
|
2667
|
+
const a = (decor.rotation ?? 0) * Math.PI / 180;
|
|
2668
|
+
const cos = Math.cos(a), sin = Math.sin(a);
|
|
2669
|
+
const hw = width / 2, hh = height / 2;
|
|
2670
|
+
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
|
|
2671
|
+
x: cx + lx * cos - ly * sin,
|
|
2672
|
+
y: cy + lx * sin + ly * cos
|
|
2673
|
+
}));
|
|
2674
|
+
}
|
|
2675
|
+
function decorPlatePaint(href) {
|
|
2676
|
+
if (!href || !href.startsWith("data:image/svg+xml")) return null;
|
|
2677
|
+
let svg = href.slice(href.indexOf(",") + 1);
|
|
2678
|
+
if (/;base64/i.test(href.slice(0, href.indexOf(",")))) {
|
|
2679
|
+
try {
|
|
2680
|
+
svg = atob(svg);
|
|
2681
|
+
} catch {
|
|
2682
|
+
return null;
|
|
2683
|
+
}
|
|
2684
|
+
} else {
|
|
2685
|
+
try {
|
|
2686
|
+
svg = decodeURIComponent(svg);
|
|
2687
|
+
} catch {
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
const covering = svg.match(/<(?:rect|path)\b[^>]*\bfill="([^"]+)"/i);
|
|
2691
|
+
if (!covering) return null;
|
|
2692
|
+
const paint = covering[1].trim();
|
|
2693
|
+
if (paint === "none" || paint === "transparent") return null;
|
|
2694
|
+
const gradient = paint.match(/^url\(#([^)]+)\)$/);
|
|
2695
|
+
if (!gradient) return hexToRgb(paint);
|
|
2696
|
+
const def = svg.match(new RegExp(`<(?:linear|radial)Gradient\\b[^>]*\\bid="${gradient[1]}"[^>]*>([\\s\\S]*?)</(?:linear|radial)Gradient>`, "i"));
|
|
2697
|
+
if (!def) return null;
|
|
2698
|
+
const stops = [];
|
|
2699
|
+
for (const m of def[1].matchAll(/stop-color="([^"]+)"/gi)) {
|
|
2700
|
+
const c = hexToRgb(m[1].trim());
|
|
2701
|
+
if (c) stops.push(c);
|
|
2702
|
+
}
|
|
2703
|
+
if (!stops.length) return null;
|
|
2704
|
+
return stops.reduce((acc, c, i) => mix(acc, c, 1 / (i + 1)), stops[0]);
|
|
2705
|
+
}
|
|
2706
|
+
function buildDecorImage(builder, decor, base, S) {
|
|
2707
|
+
if (decor.layer === "foreground") return;
|
|
2708
|
+
const poly = decorPolygon(decor);
|
|
2709
|
+
if (!poly) return;
|
|
2710
|
+
const top = base + DECOR_PLATE_M;
|
|
2711
|
+
let paint = tintTop(decorPlatePaint(decor.href), S.decorTop);
|
|
2712
|
+
const opacity = Math.max(0, Math.min(1, decor.opacity ?? 1));
|
|
2713
|
+
if (opacity < 1) paint = mix(S.ground, paint, opacity);
|
|
2714
|
+
extrudePrism(builder, poly, void 0, () => top, base, paint, mix(paint, S.decorWall, 0.5), AO);
|
|
2715
|
+
}
|
|
2024
2716
|
function buildGa(builder, ga, base, fill, S) {
|
|
2025
2717
|
if (!ga.points || ga.points.length < 3) return;
|
|
2026
2718
|
const colTop = tintTop(fill, S.gaTop);
|
|
@@ -2046,6 +2738,9 @@ function chartFootprint(units, seats) {
|
|
|
2046
2738
|
} else if (o.type === "table") {
|
|
2047
2739
|
const t = tablePolygon(o);
|
|
2048
2740
|
if (t) for (const p of t) acc(p.x, p.y);
|
|
2741
|
+
} else if (o.type === "decorImage") {
|
|
2742
|
+
const d = decorPolygon(o);
|
|
2743
|
+
if (d) for (const p of d) acc(p.x, p.y);
|
|
2049
2744
|
}
|
|
2050
2745
|
}
|
|
2051
2746
|
}
|
|
@@ -2057,12 +2752,7 @@ function chartFootprint(units, seats) {
|
|
|
2057
2752
|
}
|
|
2058
2753
|
return { minX, minY, maxX, maxY };
|
|
2059
2754
|
}
|
|
2060
|
-
var __PROF = {};
|
|
2061
|
-
var __t = (k, t0) => {
|
|
2062
|
-
__PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
|
|
2063
|
-
};
|
|
2064
2755
|
function buildSceneModel(input) {
|
|
2065
|
-
for (const k of Object.keys(__PROF)) delete __PROF[k];
|
|
2066
2756
|
const { doc, seats } = input;
|
|
2067
2757
|
const theme = resolveTheme3D(doc.theme);
|
|
2068
2758
|
const S = theme.structure;
|
|
@@ -2075,9 +2765,7 @@ function buildSceneModel(input) {
|
|
|
2075
2765
|
const sectionFills = resolveSectionFills(doc, seats);
|
|
2076
2766
|
const catColor = /* @__PURE__ */ new Map();
|
|
2077
2767
|
for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
|
|
2078
|
-
const __t0 = performance.now();
|
|
2079
2768
|
const surfaces = buildVenueSurfaces(units, seats);
|
|
2080
|
-
__t("surfaces", __t0);
|
|
2081
2769
|
const seatFloor = new Float32Array(seats.length);
|
|
2082
2770
|
for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
|
|
2083
2771
|
const unit = units[unitIndex];
|
|
@@ -2085,23 +2773,17 @@ function buildSceneModel(input) {
|
|
|
2085
2773
|
const claimed = new ClaimedArea();
|
|
2086
2774
|
const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
|
|
2087
2775
|
for (const o of unit.objects) {
|
|
2088
|
-
if (o.type === "section")
|
|
2089
|
-
|
|
2090
|
-
buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
|
|
2091
|
-
__t("buildTier", t);
|
|
2092
|
-
} else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
|
|
2776
|
+
if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
|
|
2777
|
+
else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
|
|
2093
2778
|
else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2094
2779
|
else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2095
2780
|
else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2781
|
+
else if (o.type === "decorImage") buildDecorImage(builder, o, unit.baseHeightM, S);
|
|
2096
2782
|
}
|
|
2097
2783
|
}
|
|
2098
2784
|
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
2099
|
-
const __tm = performance.now();
|
|
2100
2785
|
const solids = mergeMeshData([builder.build()]);
|
|
2101
|
-
__t("meshBuild+merge", __tm);
|
|
2102
|
-
const __ts = performance.now();
|
|
2103
2786
|
const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
|
|
2104
|
-
__t("seatInstances", __ts);
|
|
2105
2787
|
const zoneDefs = doc.zones ?? [];
|
|
2106
2788
|
const zones = [];
|
|
2107
2789
|
if (zoneDefs.length) {
|
|
@@ -2161,6 +2843,7 @@ function buildSceneModel(input) {
|
|
|
2161
2843
|
}
|
|
2162
2844
|
}
|
|
2163
2845
|
const labels = [];
|
|
2846
|
+
const sections = [];
|
|
2164
2847
|
for (const z of zones) {
|
|
2165
2848
|
if (z.seatCount === 0) continue;
|
|
2166
2849
|
labels.push({
|
|
@@ -2173,6 +2856,7 @@ function buildSceneModel(input) {
|
|
|
2173
2856
|
}
|
|
2174
2857
|
{
|
|
2175
2858
|
const acc = /* @__PURE__ */ new Map();
|
|
2859
|
+
const ext = /* @__PURE__ */ new Map();
|
|
2176
2860
|
for (let i = 0; i < seats.length; i++) {
|
|
2177
2861
|
const owner = surfaces.seatOwner[i];
|
|
2178
2862
|
if (!owner) continue;
|
|
@@ -2185,18 +2869,87 @@ function buildSceneModel(input) {
|
|
|
2185
2869
|
a.x += seats[i].x;
|
|
2186
2870
|
a.y += seats[i].y;
|
|
2187
2871
|
a.deck += surfaces.seatDeckY(i);
|
|
2872
|
+
let e = ext.get(owner);
|
|
2873
|
+
if (!e) {
|
|
2874
|
+
e = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
|
2875
|
+
ext.set(owner, e);
|
|
2876
|
+
}
|
|
2877
|
+
if (seats[i].x < e.minX) e.minX = seats[i].x;
|
|
2878
|
+
if (seats[i].x > e.maxX) e.maxX = seats[i].x;
|
|
2879
|
+
if (seats[i].y < e.minY) e.minY = seats[i].y;
|
|
2880
|
+
if (seats[i].y > e.maxY) e.maxY = seats[i].y;
|
|
2188
2881
|
}
|
|
2189
2882
|
for (const unit of units) {
|
|
2190
2883
|
for (const o of unit.objects) {
|
|
2191
2884
|
if (o.type !== "section") continue;
|
|
2192
2885
|
const a = acc.get(o.id);
|
|
2193
2886
|
if (!a || a.n === 0) continue;
|
|
2887
|
+
const e = ext.get(o.id);
|
|
2888
|
+
const centre = [a.x / a.n * M, a.deck / a.n, a.y / a.n * M];
|
|
2889
|
+
sections.push({
|
|
2890
|
+
id: o.id,
|
|
2891
|
+
label: o.displayLabel || o.label || o.id,
|
|
2892
|
+
seatCount: a.n,
|
|
2893
|
+
center: centre,
|
|
2894
|
+
// Half-diagonal of the seat extent — the same fit the zones use.
|
|
2895
|
+
radius: Math.max(1, Math.hypot(e.maxX - e.minX, e.maxY - e.minY) * 0.5 * M),
|
|
2896
|
+
// A section faces its floor's focal, which is what the camera should
|
|
2897
|
+
// look along so the seats present their fronts.
|
|
2898
|
+
focalWorld: [unit.focal.x * M, unit.baseHeightM + 1.5, unit.focal.y * M]
|
|
2899
|
+
});
|
|
2194
2900
|
labels.push({
|
|
2195
2901
|
id: `section:${o.id}`,
|
|
2196
2902
|
kind: "section",
|
|
2197
2903
|
// The buyer-facing name wins over the technical one, as it does in 2D.
|
|
2198
2904
|
text: o.displayLabel || o.label || o.id,
|
|
2199
|
-
anchor: [
|
|
2905
|
+
anchor: [centre[0], centre[1] + SECTION_LABEL_LIFT_M, centre[2]]
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
{
|
|
2911
|
+
for (const unit of units) {
|
|
2912
|
+
for (const o of unit.objects) {
|
|
2913
|
+
if (o.type !== "gaArea") continue;
|
|
2914
|
+
const c = centroidOf(o.points);
|
|
2915
|
+
if (!c) continue;
|
|
2916
|
+
const name = o.displayLabel || o.label || o.id;
|
|
2917
|
+
labels.push({
|
|
2918
|
+
id: `ga:${o.id}`,
|
|
2919
|
+
kind: "ga",
|
|
2920
|
+
text: o.capacity > 0 ? `${name} \xB7 ${o.capacity.toLocaleString()}` : name,
|
|
2921
|
+
anchor: [c.x * M, unit.baseHeightM + GA_LABEL_LIFT_M, c.y * M]
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
const rowEnds = /* @__PURE__ */ new Map();
|
|
2926
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2927
|
+
const s = seats[i];
|
|
2928
|
+
const far = Math.hypot(s.x - focal.x, s.y - focal.y);
|
|
2929
|
+
const cur = rowEnds.get(s.rowId);
|
|
2930
|
+
if (!cur || far > cur.far) rowEnds.set(s.rowId, { seat: s, index: i, far });
|
|
2931
|
+
}
|
|
2932
|
+
for (const [rowId, end] of rowEnds) {
|
|
2933
|
+
const cut = end.seat.label ? end.seat.label.lastIndexOf("-") : -1;
|
|
2934
|
+
const text = cut > 0 ? end.seat.label.slice(0, cut) : rowId;
|
|
2935
|
+
if (!text) continue;
|
|
2936
|
+
labels.push({
|
|
2937
|
+
id: `row:${rowId}`,
|
|
2938
|
+
kind: "row",
|
|
2939
|
+
text,
|
|
2940
|
+
anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
if (seats.length <= SEAT_LABEL_MAX) {
|
|
2944
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2945
|
+
const s = seats[i];
|
|
2946
|
+
const text = s.displayLabel || s.label;
|
|
2947
|
+
if (!text) continue;
|
|
2948
|
+
labels.push({
|
|
2949
|
+
id: `seat:${s.id}`,
|
|
2950
|
+
kind: "seat",
|
|
2951
|
+
text,
|
|
2952
|
+
anchor: [s.x * M, surfaces.seatDeckY(i) + SEAT_LABEL_LIFT_M, s.y * M]
|
|
2200
2953
|
});
|
|
2201
2954
|
}
|
|
2202
2955
|
}
|
|
@@ -2261,6 +3014,15 @@ function buildSceneModel(input) {
|
|
|
2261
3014
|
});
|
|
2262
3015
|
}
|
|
2263
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
|
+
);
|
|
2264
3026
|
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
2265
3027
|
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
2266
3028
|
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
@@ -2274,6 +3036,7 @@ function buildSceneModel(input) {
|
|
|
2274
3036
|
// Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
|
|
2275
3037
|
focalWorld: [focal.x * M, 1.5, focal.y * M],
|
|
2276
3038
|
zones,
|
|
3039
|
+
sections,
|
|
2277
3040
|
labels,
|
|
2278
3041
|
floors
|
|
2279
3042
|
};
|
|
@@ -2285,8 +3048,23 @@ var SEPARATION_Y_PX = 20;
|
|
|
2285
3048
|
var KIND_STYLE = {
|
|
2286
3049
|
zone: { size: 15, weight: "600", opacity: 0.95 },
|
|
2287
3050
|
section: { size: 12, weight: "500", opacity: 0.88 },
|
|
3051
|
+
ga: { size: 12, weight: "500", opacity: 0.88 },
|
|
2288
3052
|
booth: { size: 11, weight: "500", opacity: 0.85 },
|
|
2289
|
-
annotation: { size: 11, weight: "400", opacity: 0.75 }
|
|
3053
|
+
annotation: { size: 11, weight: "400", opacity: 0.75 },
|
|
3054
|
+
// Row and seat identity are quieter than the structure they sit inside: at
|
|
3055
|
+
// this range the venue is already understood and the label is a detail, so it
|
|
3056
|
+
// must not compete with the seating it is printed over.
|
|
3057
|
+
row: { size: 10.5, weight: "600", opacity: 0.8 },
|
|
3058
|
+
seat: { size: 9.5, weight: "500", opacity: 0.72 }
|
|
3059
|
+
};
|
|
3060
|
+
var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
|
|
3061
|
+
var DENSE_SEPARATION = {
|
|
3062
|
+
row: { x: 62, y: 16 },
|
|
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 }
|
|
2290
3068
|
};
|
|
2291
3069
|
var LabelOverlay = class {
|
|
2292
3070
|
constructor(container, opts = {}) {
|
|
@@ -2317,7 +3095,7 @@ var LabelOverlay = class {
|
|
|
2317
3095
|
*
|
|
2318
3096
|
* `viewProjection` is column-major, as OGL supplies it.
|
|
2319
3097
|
*/
|
|
2320
|
-
update(viewProjection, width, height, cameraDistance, venueRadius) {
|
|
3098
|
+
update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
|
|
2321
3099
|
if (!this.labels.length) return;
|
|
2322
3100
|
const kinds = visibleLabelKinds(cameraDistance, venueRadius);
|
|
2323
3101
|
const candidates = [];
|
|
@@ -2325,9 +3103,26 @@ var LabelOverlay = class {
|
|
|
2325
3103
|
if (!kinds.has(label.kind)) continue;
|
|
2326
3104
|
const screen = projectToScreen(viewProjection, label.anchor, width, height);
|
|
2327
3105
|
if (!screen.visible) continue;
|
|
2328
|
-
|
|
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) });
|
|
2329
3112
|
}
|
|
2330
|
-
const
|
|
3113
|
+
const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
|
|
3114
|
+
const kept = [
|
|
3115
|
+
...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
|
|
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(
|
|
3120
|
+
candidates.filter((c) => c.label.kind === kind),
|
|
3121
|
+
DENSE_SEPARATION[kind].x,
|
|
3122
|
+
DENSE_SEPARATION[kind].y,
|
|
3123
|
+
DENSE_LABEL_BUDGET[kind]
|
|
3124
|
+
))
|
|
3125
|
+
];
|
|
2331
3126
|
const keptIds = new Set(kept.map((k) => k.label.id));
|
|
2332
3127
|
for (const { label, screen } of kept) {
|
|
2333
3128
|
const node = this.nodeFor(label);
|
|
@@ -2374,6 +3169,13 @@ import { Geometry, Mesh, Transform } from "ogl";
|
|
|
2374
3169
|
|
|
2375
3170
|
// src/view3d/scene/materials.ts
|
|
2376
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
|
+
);
|
|
2377
3179
|
var SOLID_VERT = (
|
|
2378
3180
|
/* glsl */
|
|
2379
3181
|
`#version 300 es
|
|
@@ -2453,14 +3255,23 @@ uniform float uSeatScale;
|
|
|
2453
3255
|
uniform float uMinPixels;
|
|
2454
3256
|
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
2455
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
|
|
2456
3260
|
out vec2 vUv;
|
|
2457
3261
|
out vec3 vColor;
|
|
2458
3262
|
out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
|
|
2459
3263
|
out vec3 vRing;
|
|
2460
3264
|
out float vDim;
|
|
3265
|
+
out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
|
|
3266
|
+
${CHAIR_WEIGHT_GLSL}
|
|
2461
3267
|
void main() {
|
|
2462
3268
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
2463
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);
|
|
2464
3275
|
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
2465
3276
|
// Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
|
|
2466
3277
|
// unbounded growth is what merges neighbouring rows into one mass at range.
|
|
@@ -2494,6 +3305,7 @@ in vec3 vColor;
|
|
|
2494
3305
|
in float vBudget;
|
|
2495
3306
|
in vec3 vRing;
|
|
2496
3307
|
in float vDim;
|
|
3308
|
+
in float vDotWeight;
|
|
2497
3309
|
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
2498
3310
|
uniform vec3 uFadeColor;
|
|
2499
3311
|
out vec4 fragColor;
|
|
@@ -2518,9 +3330,131 @@ void main() {
|
|
|
2518
3330
|
// Seats on an unfocused floor recede with their structure.
|
|
2519
3331
|
c = mix(c, uFadeColor, vDim * 0.75);
|
|
2520
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;
|
|
2521
3338
|
fragColor = vec4(c, alpha);
|
|
2522
3339
|
}`
|
|
2523
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
|
+
);
|
|
2524
3458
|
var BG_VERT = (
|
|
2525
3459
|
/* glsl */
|
|
2526
3460
|
`#version 300 es
|
|
@@ -2617,7 +3551,7 @@ function createSeatPickProgram(gl) {
|
|
|
2617
3551
|
uniforms: {
|
|
2618
3552
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
2619
3553
|
uSeatScale: { value: 1 },
|
|
2620
|
-
uMinPixels: { value:
|
|
3554
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
2621
3555
|
uPixelToWorld: { value: 2e-3 }
|
|
2622
3556
|
}
|
|
2623
3557
|
});
|
|
@@ -2662,11 +3596,32 @@ function createSeatProgram(gl) {
|
|
|
2662
3596
|
uniforms: {
|
|
2663
3597
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
2664
3598
|
uSeatScale: { value: 1 },
|
|
2665
|
-
uMinPixels: { value:
|
|
3599
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
2666
3600
|
uPixelToWorld: { value: 2e-3 },
|
|
2667
3601
|
uSeatFade: { value: 0 },
|
|
2668
3602
|
uFocusFloor: { value: -1 },
|
|
2669
|
-
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 }
|
|
2670
3625
|
}
|
|
2671
3626
|
});
|
|
2672
3627
|
}
|
|
@@ -2732,16 +3687,90 @@ function buildGpuScene(gl, model) {
|
|
|
2732
3687
|
seatMesh.frustumCulled = false;
|
|
2733
3688
|
if (model.seats.count > 0) seatMesh.setParent(main);
|
|
2734
3689
|
const colorAttr = seatGeo.attributes.iColor;
|
|
2735
|
-
|
|
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 = {
|
|
2736
3727
|
main,
|
|
2737
3728
|
background,
|
|
2738
3729
|
seatProgram: seatProg,
|
|
2739
3730
|
solidProgram: solidProg,
|
|
3731
|
+
chairProgram: chairProg,
|
|
2740
3732
|
seatGeometry: seatGeo,
|
|
2741
3733
|
solidGeometry: solidGeo,
|
|
2742
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
|
+
},
|
|
2743
3771
|
uploadSeatStateRuns(runs) {
|
|
2744
3772
|
if (!runs.length) return;
|
|
3773
|
+
if (nearCount) writeChairColors();
|
|
2745
3774
|
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
|
|
2746
3775
|
const buffer = colorAttr.buffer;
|
|
2747
3776
|
if (!buffer) {
|
|
@@ -2761,8 +3790,11 @@ function buildGpuScene(gl, model) {
|
|
|
2761
3790
|
solidProg.remove();
|
|
2762
3791
|
seatGeo.remove();
|
|
2763
3792
|
seatProg.remove();
|
|
3793
|
+
chairGeo.remove();
|
|
3794
|
+
chairProg.remove();
|
|
2764
3795
|
}
|
|
2765
3796
|
};
|
|
3797
|
+
return scene;
|
|
2766
3798
|
}
|
|
2767
3799
|
|
|
2768
3800
|
// src/view3d/pick/pickPipeline.ts
|
|
@@ -3330,6 +4362,28 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3330
4362
|
gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
|
|
3331
4363
|
gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
3332
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);
|
|
3333
4387
|
};
|
|
3334
4388
|
const glctx = new GLContext(container, {
|
|
3335
4389
|
onContextLost: () => {
|
|
@@ -3376,7 +4430,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3376
4430
|
const u = gpu.seatProgram.uniforms;
|
|
3377
4431
|
u.uSeatScale.value = lod.scale;
|
|
3378
4432
|
u.uSeatFade.value = lod.fade;
|
|
4433
|
+
u.uMinPixels.value = lod.minPixels;
|
|
3379
4434
|
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
|
|
4435
|
+
updateNearField();
|
|
3380
4436
|
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
3381
4437
|
glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
|
|
3382
4438
|
labelOverlay.update(
|
|
@@ -3384,7 +4440,10 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3384
4440
|
glctx.canvas.clientWidth || 1,
|
|
3385
4441
|
glctx.canvas.clientHeight || 1,
|
|
3386
4442
|
orbit.currentDistance,
|
|
3387
|
-
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]
|
|
3388
4447
|
);
|
|
3389
4448
|
return moving;
|
|
3390
4449
|
});
|
|
@@ -3657,6 +4716,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3657
4716
|
if (gpu) {
|
|
3658
4717
|
gpu.seatProgram.uniforms.uFocusFloor.value = value;
|
|
3659
4718
|
gpu.solidProgram.uniforms.uFocusFloor.value = value;
|
|
4719
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = value;
|
|
3660
4720
|
}
|
|
3661
4721
|
focusedFloor = value;
|
|
3662
4722
|
if (index !== null) {
|
|
@@ -3683,6 +4743,20 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3683
4743
|
loop.requestRender();
|
|
3684
4744
|
return true;
|
|
3685
4745
|
},
|
|
4746
|
+
sections() {
|
|
4747
|
+
return model.sections;
|
|
4748
|
+
},
|
|
4749
|
+
focusSection(sectionId) {
|
|
4750
|
+
const sec = model.sections.find((s) => s.id === sectionId);
|
|
4751
|
+
if (!sec || sec.seatCount === 0) return false;
|
|
4752
|
+
cinematic.cancel();
|
|
4753
|
+
const dx = sec.focalWorld[0] - sec.center[0];
|
|
4754
|
+
const dz = sec.focalWorld[2] - sec.center[2];
|
|
4755
|
+
const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
|
|
4756
|
+
orbit.frame({ center: sec.center, radius: sec.radius * 1.45 }, false, azimuth);
|
|
4757
|
+
loop.requestRender();
|
|
4758
|
+
return true;
|
|
4759
|
+
},
|
|
3686
4760
|
setReducedMotionForTest(value) {
|
|
3687
4761
|
reducedForced = value;
|
|
3688
4762
|
},
|