@seatlayer/core 0.29.0 → 0.30.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/README.md +102 -22
- package/dist/chunk-FGS67GT3.js +1252 -0
- package/dist/chunk-FGS67GT3.js.map +1 -0
- package/dist/index.cjs +572 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +269 -909
- package/dist/index.js.map +1 -1
- package/dist/{types-B-tpUFqz.d.cts → types-CG2pEDoI.d.cts} +56 -0
- package/dist/{types-B-tpUFqz.d.ts → types-CG2pEDoI.d.ts} +56 -0
- package/dist/view3d/index.cjs +2315 -99
- package/dist/view3d/index.cjs.map +1 -1
- package/dist/view3d/index.d.cts +236 -27
- package/dist/view3d/index.d.ts +236 -27
- package/dist/view3d/index.js +1912 -98
- package/dist/view3d/index.js.map +1 -1
- package/package.json +5 -3
- package/dist/chunk-5MLADB2N.js +0 -53
- package/dist/chunk-5MLADB2N.js.map +0 -1
package/dist/view3d/index.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CHART_UNITS_PER_METRE,
|
|
2
3
|
METRES_PER_CHART_UNIT,
|
|
3
4
|
SEATED_EYE_HEIGHT_M,
|
|
5
|
+
accessibilityRingColor,
|
|
6
|
+
distanceToPolyline,
|
|
7
|
+
pointInPolygonWithHoles,
|
|
8
|
+
resolveSection,
|
|
4
9
|
sectionGeometry
|
|
5
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-FGS67GT3.js";
|
|
6
11
|
|
|
7
12
|
// src/view3d/index.ts
|
|
8
13
|
import { Quat as Quat2, Vec3 as Vec33 } from "ogl";
|
|
@@ -23,15 +28,6 @@ var SEAT_STATE_COLORS = {
|
|
|
23
28
|
selected: [0.24, 0.74, 1],
|
|
24
29
|
dimmed: [0.28, 0.32, 0.37]
|
|
25
30
|
};
|
|
26
|
-
function seatStateColorLUT() {
|
|
27
|
-
const out = [];
|
|
28
|
-
for (const s of SEAT_STATES) out.push(...SEAT_STATE_COLORS[s]);
|
|
29
|
-
return out;
|
|
30
|
-
}
|
|
31
|
-
function seatStateColorByIndex(index) {
|
|
32
|
-
const state = SEAT_STATES[index] ?? "available";
|
|
33
|
-
return SEAT_STATE_COLORS[state];
|
|
34
|
-
}
|
|
35
31
|
var STRUCTURE = {
|
|
36
32
|
ground: [0.07, 0.085, 0.11],
|
|
37
33
|
tierTop: [0.24, 0.28, 0.34],
|
|
@@ -42,7 +38,13 @@ var STRUCTURE = {
|
|
|
42
38
|
decorTop: [0.22, 0.25, 0.29],
|
|
43
39
|
decorWall: [0.15, 0.17, 0.2],
|
|
44
40
|
gaTop: [0.24, 0.28, 0.33],
|
|
45
|
-
gaWall: [0.16, 0.19, 0.23]
|
|
41
|
+
gaWall: [0.16, 0.19, 0.23],
|
|
42
|
+
/** Exhibition / trade-show booth stand. */
|
|
43
|
+
boothTop: [0.3, 0.33, 0.38],
|
|
44
|
+
boothWall: [0.2, 0.23, 0.27],
|
|
45
|
+
/** Banquet table top — warmer, so a laid table reads apart from structure. */
|
|
46
|
+
tableTop: [0.38, 0.34, 0.29],
|
|
47
|
+
tableWall: [0.24, 0.21, 0.18]
|
|
46
48
|
};
|
|
47
49
|
var BACKGROUND = {
|
|
48
50
|
top: [0.05, 0.06, 0.08],
|
|
@@ -77,6 +79,8 @@ function computeDpr() {
|
|
|
77
79
|
}
|
|
78
80
|
var GLContext = class {
|
|
79
81
|
constructor(container, opts) {
|
|
82
|
+
/** Current clear colour, so a context restore repaints the themed background. */
|
|
83
|
+
this.clearRgb = [0, 0, 0];
|
|
80
84
|
this.container = container;
|
|
81
85
|
this.canvas = document.createElement("canvas");
|
|
82
86
|
this.canvas.style.display = "block";
|
|
@@ -94,7 +98,8 @@ var GLContext = class {
|
|
|
94
98
|
webgl: 2
|
|
95
99
|
});
|
|
96
100
|
this.gl = this.renderer.gl;
|
|
97
|
-
this.
|
|
101
|
+
this.clearRgb = [BACKGROUND.top[0], BACKGROUND.top[1], BACKGROUND.top[2]];
|
|
102
|
+
this.gl.clearColor(this.clearRgb[0], this.clearRgb[1], this.clearRgb[2], 1);
|
|
98
103
|
container.appendChild(this.canvas);
|
|
99
104
|
this.lostHandler = (e) => {
|
|
100
105
|
e.preventDefault();
|
|
@@ -105,6 +110,21 @@ var GLContext = class {
|
|
|
105
110
|
this.canvas.addEventListener("webglcontextrestored", this.restoredHandler, false);
|
|
106
111
|
this.resize();
|
|
107
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Repaint the clear colour from the chart's theme.
|
|
115
|
+
*
|
|
116
|
+
* The clear shows for one frame before the background triangle draws, and on
|
|
117
|
+
* any frame the scene does not cover — so leaving it at the library default
|
|
118
|
+
* flashes SeatLayer grey into a white-labelled venue.
|
|
119
|
+
*/
|
|
120
|
+
setClearColor(rgb) {
|
|
121
|
+
this.clearRgb = [rgb[0], rgb[1], rgb[2]];
|
|
122
|
+
this.gl.clearColor(rgb[0], rgb[1], rgb[2], 1);
|
|
123
|
+
}
|
|
124
|
+
/** The clear colour currently set (the pick pass restores through this). */
|
|
125
|
+
get clearColor() {
|
|
126
|
+
return this.clearRgb;
|
|
127
|
+
}
|
|
108
128
|
/** Match the drawing buffer to the container's CSS box. */
|
|
109
129
|
resize() {
|
|
110
130
|
const w = Math.max(1, this.container.clientWidth || this.canvas.clientWidth || 1);
|
|
@@ -253,14 +273,14 @@ var OrbitCamera = class {
|
|
|
253
273
|
* then the damped `update()` eases it up into the 3/4 architectural angle and
|
|
254
274
|
* dollies in — the venue "stands up" instead of teleporting (~600ms).
|
|
255
275
|
*/
|
|
256
|
-
frame(bounds, intro = false) {
|
|
276
|
+
frame(bounds, intro = false, stageAzimuth) {
|
|
257
277
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
258
278
|
const r = Math.max(1, bounds.radius);
|
|
259
279
|
const halfV = this.fovY * DEG / 2;
|
|
260
280
|
const aspect = this.camera.aspect || 1;
|
|
261
281
|
const halfH = Math.atan(Math.tan(halfV) * aspect);
|
|
262
282
|
const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
|
|
263
|
-
this.azT = -30 * DEG;
|
|
283
|
+
this.azT = stageAzimuth ?? -30 * DEG;
|
|
264
284
|
this.polT = 55 * DEG;
|
|
265
285
|
this.distT = fit * FRAME_MARGIN;
|
|
266
286
|
this.minDist = Math.max(2, r * 0.12);
|
|
@@ -279,6 +299,24 @@ var OrbitCamera = class {
|
|
|
279
299
|
setAspect(aspect) {
|
|
280
300
|
this.camera.perspective({ aspect });
|
|
281
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Damped return to the framed overview from wherever the camera is now (a
|
|
304
|
+
* seat, behind the shell, anywhere): re-pivot on the venue centre without
|
|
305
|
+
* moving the camera, then glide targets back to the 3/4 architectural pose.
|
|
306
|
+
*/
|
|
307
|
+
frameSoft(bounds, stageAzimuth) {
|
|
308
|
+
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
309
|
+
this.syncFromCamera();
|
|
310
|
+
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
311
|
+
const r = Math.max(1, bounds.radius);
|
|
312
|
+
const halfV = this.fovY * DEG / 2;
|
|
313
|
+
const aspect = this.camera.aspect || 1;
|
|
314
|
+
const halfH = Math.atan(Math.tan(halfV) * aspect);
|
|
315
|
+
const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
|
|
316
|
+
this.azT = stageAzimuth ?? this.azimuth;
|
|
317
|
+
this.polT = 55 * DEG;
|
|
318
|
+
this.distT = fit * FRAME_MARGIN;
|
|
319
|
+
}
|
|
282
320
|
/** Damp toward targets; returns true while still moving. */
|
|
283
321
|
update() {
|
|
284
322
|
const da = this.azT - this.azimuth;
|
|
@@ -397,18 +435,47 @@ function computeSeatLod(distance, radius) {
|
|
|
397
435
|
|
|
398
436
|
// src/view3d/scene/geometry.ts
|
|
399
437
|
import earcut from "earcut";
|
|
438
|
+
import polygonClipping from "polygon-clipping";
|
|
400
439
|
var M = METRES_PER_CHART_UNIT;
|
|
440
|
+
var MIN_TRI_ALTITUDE_M = 2e-3;
|
|
441
|
+
function isDegenerate(p0, p1, p2) {
|
|
442
|
+
const e0 = Math.hypot(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
|
|
443
|
+
const e1 = Math.hypot(p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]);
|
|
444
|
+
const e2 = Math.hypot(p0[0] - p2[0], p0[1] - p2[1], p0[2] - p2[2]);
|
|
445
|
+
const longest = Math.max(e0, e1, e2);
|
|
446
|
+
if (longest < 1e-9) return true;
|
|
447
|
+
const s = (e0 + e1 + e2) / 2;
|
|
448
|
+
const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
|
|
449
|
+
return 2 * area / longest < MIN_TRI_ALTITUDE_M;
|
|
450
|
+
}
|
|
401
451
|
var MeshBuilder = class {
|
|
402
452
|
constructor() {
|
|
403
453
|
this.pos = [];
|
|
404
454
|
this.nor = [];
|
|
405
455
|
this.col = [];
|
|
456
|
+
this.flr = [];
|
|
457
|
+
/** Floor index stamped onto every triangle emitted from now on. */
|
|
458
|
+
this.currentFloor = 0;
|
|
459
|
+
}
|
|
460
|
+
/** Stamp subsequent triangles as belonging to `index`. */
|
|
461
|
+
setFloor(index) {
|
|
462
|
+
this.currentFloor = index;
|
|
406
463
|
}
|
|
407
464
|
/** One triangle with a shared (flat) normal and per-vertex colours. */
|
|
408
465
|
tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
|
|
466
|
+
if (isDegenerate(p0, p1, p2)) return;
|
|
409
467
|
this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
|
|
410
468
|
this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
|
|
411
469
|
this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
|
|
470
|
+
this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
|
|
471
|
+
}
|
|
472
|
+
/** One triangle with independent per-vertex normals (smooth shading). */
|
|
473
|
+
triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
|
|
474
|
+
if (isDegenerate(p0, p1, p2)) return;
|
|
475
|
+
this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
|
|
476
|
+
this.nor.push(n0[0], n0[1], n0[2], n1[0], n1[1], n1[2], n2[0], n2[1], n2[2]);
|
|
477
|
+
this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
|
|
478
|
+
this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
|
|
412
479
|
}
|
|
413
480
|
get vertexCount() {
|
|
414
481
|
return this.pos.length / 3;
|
|
@@ -418,6 +485,7 @@ var MeshBuilder = class {
|
|
|
418
485
|
position: new Float32Array(this.pos),
|
|
419
486
|
normal: new Float32Array(this.nor),
|
|
420
487
|
color: new Float32Array(this.col),
|
|
488
|
+
floor: new Float32Array(this.flr),
|
|
421
489
|
count: this.pos.length / 3
|
|
422
490
|
};
|
|
423
491
|
}
|
|
@@ -472,33 +540,158 @@ function signedArea(pts) {
|
|
|
472
540
|
function toCCW(pts) {
|
|
473
541
|
return signedArea(pts) < 0 ? [...pts].reverse() : pts;
|
|
474
542
|
}
|
|
475
|
-
|
|
543
|
+
var MAX_CAP_SPLIT_DEPTH = 3;
|
|
544
|
+
function capDeviation(a, b, c, topY) {
|
|
545
|
+
const ya = topY(a), yb = topY(b), yc = topY(c);
|
|
546
|
+
const edge = (p, q, yp, yq) => Math.abs(topY({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }) - (yp + yq) / 2);
|
|
547
|
+
const cx3 = (a.x + b.x + c.x) / 3, cy3 = (a.y + b.y + c.y) / 3;
|
|
548
|
+
const yCen = (ya + yb + yc) / 3;
|
|
549
|
+
let worst = Math.max(
|
|
550
|
+
edge(a, b, ya, yb),
|
|
551
|
+
edge(b, c, yb, yc),
|
|
552
|
+
edge(c, a, yc, ya),
|
|
553
|
+
Math.abs(topY({ x: cx3, y: cy3 }) - yCen)
|
|
554
|
+
);
|
|
555
|
+
const probe = (px, py, yp) => {
|
|
556
|
+
const d = Math.abs(topY({ x: (px + cx3) / 2, y: (py + cy3) / 2 }) - (yp + yCen) / 2);
|
|
557
|
+
if (d > worst) worst = d;
|
|
558
|
+
};
|
|
559
|
+
probe((a.x + b.x) / 2, (a.y + b.y) / 2, (ya + yb) / 2);
|
|
560
|
+
probe((b.x + c.x) / 2, (b.y + c.y) / 2, (yb + yc) / 2);
|
|
561
|
+
probe((c.x + a.x) / 2, (c.y + a.y) / 2, (yc + ya) / 2);
|
|
562
|
+
return worst;
|
|
563
|
+
}
|
|
564
|
+
function maxDevAtDepth(a, b, c, topY, depth) {
|
|
565
|
+
if (depth <= 0) return capDeviation(a, b, c, topY);
|
|
566
|
+
const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
567
|
+
const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
|
|
568
|
+
const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
|
|
569
|
+
return Math.max(
|
|
570
|
+
maxDevAtDepth(a, ab, ca, topY, depth - 1),
|
|
571
|
+
maxDevAtDepth(ab, b, bc, topY, depth - 1),
|
|
572
|
+
maxDevAtDepth(ca, bc, c, topY, depth - 1),
|
|
573
|
+
maxDevAtDepth(ab, bc, ca, topY, depth - 1)
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
function emitCapUniform(builder, a, b, c, topY, colTop, depth, topN) {
|
|
577
|
+
if (depth > 0) {
|
|
578
|
+
const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
579
|
+
const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
|
|
580
|
+
const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
|
|
581
|
+
emitCapUniform(builder, a, ab, ca, topY, colTop, depth - 1, topN);
|
|
582
|
+
emitCapUniform(builder, ab, b, bc, topY, colTop, depth - 1, topN);
|
|
583
|
+
emitCapUniform(builder, ca, bc, c, topY, colTop, depth - 1, topN);
|
|
584
|
+
emitCapUniform(builder, ab, bc, ca, topY, colTop, depth - 1, topN);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const at = [a.x * M, topY(a), a.y * M];
|
|
588
|
+
const bt = [b.x * M, topY(b), b.y * M];
|
|
589
|
+
const ct = [c.x * M, topY(c), c.y * M];
|
|
590
|
+
if (topN) {
|
|
591
|
+
builder.triN(at, bt, ct, topN(a), topN(b), topN(c), colTop);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
let n = faceNormal(at, bt, ct);
|
|
595
|
+
if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
|
|
596
|
+
builder.tri(at, bt, ct, n, colTop);
|
|
597
|
+
}
|
|
598
|
+
var MAX_RING_SPLIT_DEPTH = 8;
|
|
599
|
+
function densifyRing(ring, topY, maxError) {
|
|
600
|
+
if (!Number.isFinite(maxError)) return ring;
|
|
601
|
+
const out = [];
|
|
602
|
+
const emit = (p, q, yp, yq, depth) => {
|
|
603
|
+
if (depth > 0) {
|
|
604
|
+
const mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
|
605
|
+
const ym = topY(mid);
|
|
606
|
+
if (Math.abs(ym - (yp + yq) / 2) > maxError) {
|
|
607
|
+
emit(p, mid, yp, ym, depth - 1);
|
|
608
|
+
emit(mid, q, ym, yq, depth - 1);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
out.push(p);
|
|
613
|
+
};
|
|
614
|
+
for (let i = 0; i < ring.length; i++) {
|
|
615
|
+
const p = ring[i], q = ring[(i + 1) % ring.length];
|
|
616
|
+
emit(p, q, topY(p), topY(q), MAX_RING_SPLIT_DEPTH);
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
function dedupeRing(ring, eps) {
|
|
621
|
+
if (ring.length < 3) return ring;
|
|
622
|
+
const out = [];
|
|
623
|
+
for (const p of ring) {
|
|
624
|
+
const last = out[out.length - 1];
|
|
625
|
+
if (last && Math.hypot(p.x - last.x, p.y - last.y) < eps) continue;
|
|
626
|
+
out.push(p);
|
|
627
|
+
}
|
|
628
|
+
while (out.length > 2 && Math.hypot(out[0].x - out[out.length - 1].x, out[0].y - out[out.length - 1].y) < eps) {
|
|
629
|
+
out.pop();
|
|
630
|
+
}
|
|
631
|
+
if (out.length < 3) return ring;
|
|
632
|
+
const keep = [];
|
|
633
|
+
for (let i = 0; i < out.length; i++) {
|
|
634
|
+
const prev = keep.length ? keep[keep.length - 1] : out[(i - 1 + out.length) % out.length];
|
|
635
|
+
const p = out[i];
|
|
636
|
+
const next = out[(i + 1) % out.length];
|
|
637
|
+
const ax = next.x - prev.x, ay = next.y - prev.y;
|
|
638
|
+
const len = Math.hypot(ax, ay);
|
|
639
|
+
if (len > eps) {
|
|
640
|
+
const cross = Math.abs((p.x - prev.x) * ay - (p.y - prev.y) * ax) / len;
|
|
641
|
+
if (cross < eps) continue;
|
|
642
|
+
}
|
|
643
|
+
keep.push(p);
|
|
644
|
+
}
|
|
645
|
+
return keep.length >= 3 ? keep : out;
|
|
646
|
+
}
|
|
647
|
+
var RING_EPS_U = 1e-3 * CHART_UNITS_PER_METRE * 0.1;
|
|
648
|
+
function extrudePrism(builder, outlineIn, holesIn, topY, bottomY, colTop, colWall, ao, maxCapError = Infinity, topNormal) {
|
|
476
649
|
if (!outlineIn || outlineIn.length < 3) return;
|
|
650
|
+
if (maxCapError === void 0) maxCapError = Infinity;
|
|
477
651
|
if (Math.abs(signedArea(outlineIn)) < 1e-4) return;
|
|
478
|
-
const outline = toCCW(outlineIn);
|
|
479
|
-
const holes = holesIn?.map((h) => toCCW(h)).filter((h) => h.length >= 3 && Math.abs(signedArea(h)) >= 1e-4);
|
|
652
|
+
const outline = dedupeRing(densifyRing(dedupeRing(toCCW(outlineIn), RING_EPS_U), topY, maxCapError), RING_EPS_U);
|
|
653
|
+
const holes = holesIn?.map((h) => dedupeRing(densifyRing(dedupeRing(toCCW(h), RING_EPS_U), topY, maxCapError), RING_EPS_U)).filter((h) => h.length >= 3 && Math.abs(signedArea(h)) >= 1e-4);
|
|
480
654
|
const { pts, tris } = triangulate(outline, holes);
|
|
481
655
|
const cTop = [colTop[0] * ao.top, colTop[1] * ao.top, colTop[2] * ao.top];
|
|
482
656
|
const cBot = [colTop[0] * ao.bottomCap, colTop[1] * ao.bottomCap, colTop[2] * ao.bottomCap];
|
|
483
657
|
const cWallTop = [colWall[0] * ao.top, colWall[1] * ao.top, colWall[2] * ao.top];
|
|
484
658
|
const cWallBot = [colWall[0] * ao.wallBottom, colWall[1] * ao.wallBottom, colWall[2] * ao.wallBottom];
|
|
659
|
+
let capDepth = 0;
|
|
660
|
+
if (Number.isFinite(maxCapError)) {
|
|
661
|
+
for (; capDepth < MAX_CAP_SPLIT_DEPTH; capDepth++) {
|
|
662
|
+
let worst = 0;
|
|
663
|
+
for (let i = 0; i < tris.length; i += 3) {
|
|
664
|
+
const d = maxDevAtDepth(pts[tris[i]], pts[tris[i + 1]], pts[tris[i + 2]], topY, capDepth);
|
|
665
|
+
if (d > worst) worst = d;
|
|
666
|
+
}
|
|
667
|
+
if (worst <= maxCapError) break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
485
670
|
for (let i = 0; i < tris.length; i += 3) {
|
|
486
671
|
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
487
|
-
|
|
488
|
-
const bt = [b.x * M, topY(b), b.y * M];
|
|
489
|
-
const ct = [c.x * M, topY(c), c.y * M];
|
|
490
|
-
let n = faceNormal(at, bt, ct);
|
|
491
|
-
if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
|
|
492
|
-
builder.tri(at, bt, ct, n, cTop);
|
|
672
|
+
emitCapUniform(builder, a, b, c, topY, cTop, capDepth, topNormal);
|
|
493
673
|
const ab = [a.x * M, bottomY, a.y * M];
|
|
494
674
|
const bb = [b.x * M, bottomY, b.y * M];
|
|
495
675
|
const cb = [c.x * M, bottomY, c.y * M];
|
|
496
676
|
builder.tri(ab, cb, bb, [0, -1, 0], cBot);
|
|
497
677
|
}
|
|
678
|
+
const splitRing = (ring) => {
|
|
679
|
+
if (capDepth <= 0) return ring;
|
|
680
|
+
const n = 1 << capDepth;
|
|
681
|
+
const out = [];
|
|
682
|
+
for (let i = 0; i < ring.length; i++) {
|
|
683
|
+
const a = ring[i], b = ring[(i + 1) % ring.length];
|
|
684
|
+
for (let k = 0; k < n; k++) {
|
|
685
|
+
const t = k / n;
|
|
686
|
+
out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
};
|
|
498
691
|
const oc = centroid(outline);
|
|
499
|
-
const rings = [{ ring: outline, flip: false }];
|
|
692
|
+
const rings = [{ ring: splitRing(outline), flip: false }];
|
|
500
693
|
if (holes) {
|
|
501
|
-
for (const h of holes) if (h.length >= 3) rings.push({ ring: h, flip: true });
|
|
694
|
+
for (const h of holes) if (h.length >= 3) rings.push({ ring: splitRing(h), flip: true });
|
|
502
695
|
}
|
|
503
696
|
for (const { ring, flip } of rings) {
|
|
504
697
|
for (let i = 0; i < ring.length; i++) {
|
|
@@ -534,14 +727,74 @@ function mergeMeshData(parts) {
|
|
|
534
727
|
const position = new Float32Array(total * 3);
|
|
535
728
|
const normal = new Float32Array(total * 3);
|
|
536
729
|
const color = new Float32Array(total * 3);
|
|
730
|
+
const floor = new Float32Array(total);
|
|
537
731
|
let off = 0;
|
|
538
732
|
for (const p of parts) {
|
|
539
733
|
position.set(p.position, off * 3);
|
|
540
734
|
normal.set(p.normal, off * 3);
|
|
541
735
|
color.set(p.color, off * 3);
|
|
736
|
+
floor.set(p.floor, off);
|
|
542
737
|
off += p.count;
|
|
543
738
|
}
|
|
544
|
-
return { position, normal, color, count: total };
|
|
739
|
+
return { position, normal, color, floor, count: total };
|
|
740
|
+
}
|
|
741
|
+
function outsetRing(ring, d, miterLimit = 2.5) {
|
|
742
|
+
if (d <= 0 || ring.length < 3) return ring;
|
|
743
|
+
const r = toCCW(ring);
|
|
744
|
+
const n = r.length;
|
|
745
|
+
const nrm = [];
|
|
746
|
+
for (let i = 0; i < n; i++) {
|
|
747
|
+
const a = r[i], b = r[(i + 1) % n];
|
|
748
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
749
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
750
|
+
nrm.push({ x: dy / len, y: -dx / len });
|
|
751
|
+
}
|
|
752
|
+
const out = [];
|
|
753
|
+
for (let i = 0; i < n; i++) {
|
|
754
|
+
const p = r[i];
|
|
755
|
+
const nPrev = nrm[(i - 1 + n) % n];
|
|
756
|
+
const nCur = nrm[i];
|
|
757
|
+
let mx = nPrev.x + nCur.x, my = nPrev.y + nCur.y;
|
|
758
|
+
const ml = Math.hypot(mx, my);
|
|
759
|
+
const bevel = () => {
|
|
760
|
+
out.push([p.x + nPrev.x * d, p.y + nPrev.y * d]);
|
|
761
|
+
out.push([p.x + nCur.x * d, p.y + nCur.y * d]);
|
|
762
|
+
};
|
|
763
|
+
if (ml < 1e-9) {
|
|
764
|
+
bevel();
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
mx /= ml;
|
|
768
|
+
my /= ml;
|
|
769
|
+
const cosHalf = mx * nPrev.x + my * nPrev.y;
|
|
770
|
+
const scale2 = 1 / Math.max(cosHalf, 1e-6);
|
|
771
|
+
if (!Number.isFinite(scale2) || scale2 > miterLimit) {
|
|
772
|
+
bevel();
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
out.push([p.x + mx * d * scale2, p.y + my * d * scale2]);
|
|
776
|
+
}
|
|
777
|
+
if (out.length < 3) return ring;
|
|
778
|
+
try {
|
|
779
|
+
const merged = polygonClipping.union([[...out, out[0]]]);
|
|
780
|
+
let best = null, bestArea = -Infinity;
|
|
781
|
+
for (const poly of merged) {
|
|
782
|
+
const area = Math.abs(signedArea(poly[0].map(([x, y]) => ({ x, y }))));
|
|
783
|
+
if (area > bestArea) {
|
|
784
|
+
bestArea = area;
|
|
785
|
+
best = poly[0];
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (!best) return ring;
|
|
789
|
+
const pts = best.map(([x, y]) => ({ x, y }));
|
|
790
|
+
if (pts.length > 1) {
|
|
791
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
792
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
793
|
+
}
|
|
794
|
+
return pts.length >= 3 ? pts : ring;
|
|
795
|
+
} catch {
|
|
796
|
+
return ring;
|
|
797
|
+
}
|
|
545
798
|
}
|
|
546
799
|
function ellipsePolygon(cx, cy, rx, ry, seg = 28) {
|
|
547
800
|
const out = [];
|
|
@@ -561,29 +814,107 @@ function rectPolygon(x, y, w, h) {
|
|
|
561
814
|
}
|
|
562
815
|
|
|
563
816
|
// src/view3d/scene/seatInstances.ts
|
|
564
|
-
var
|
|
817
|
+
var SEAT_DOT_RADIUS_M = 0.22;
|
|
818
|
+
var SEAT_PITCH_FRACTION = 0.42;
|
|
819
|
+
function nearestNeighbourSpacing(seats) {
|
|
820
|
+
const n = seats.length;
|
|
821
|
+
const out = new Float64Array(n);
|
|
822
|
+
if (n < 2) {
|
|
823
|
+
out.fill(Infinity);
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
827
|
+
for (const s of seats) {
|
|
828
|
+
if (s.x < minX) minX = s.x;
|
|
829
|
+
if (s.y < minY) minY = s.y;
|
|
830
|
+
if (s.x > maxX) maxX = s.x;
|
|
831
|
+
if (s.y > maxY) maxY = s.y;
|
|
832
|
+
}
|
|
833
|
+
const w = Math.max(maxX - minX, 1e-6), h = Math.max(maxY - minY, 1e-6);
|
|
834
|
+
const cell = Math.max(Math.sqrt(w * h / n), 1e-6);
|
|
835
|
+
const cols = Math.max(1, Math.ceil(w / cell) + 1);
|
|
836
|
+
const rows = Math.max(1, Math.ceil(h / cell) + 1);
|
|
837
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
838
|
+
const cellOf = (x, y) => {
|
|
839
|
+
const cx = Math.min(cols - 1, Math.max(0, Math.floor((x - minX) / cell)));
|
|
840
|
+
const cy = Math.min(rows - 1, Math.max(0, Math.floor((y - minY) / cell)));
|
|
841
|
+
return cy * cols + cx;
|
|
842
|
+
};
|
|
843
|
+
for (let i = 0; i < n; i++) {
|
|
844
|
+
const k = cellOf(seats[i].x, seats[i].y);
|
|
845
|
+
const b = buckets.get(k);
|
|
846
|
+
if (b) b.push(i);
|
|
847
|
+
else buckets.set(k, [i]);
|
|
848
|
+
}
|
|
849
|
+
for (let i = 0; i < n; i++) {
|
|
850
|
+
const s = seats[i];
|
|
851
|
+
const cx = Math.min(cols - 1, Math.max(0, Math.floor((s.x - minX) / cell)));
|
|
852
|
+
const cy = Math.min(rows - 1, Math.max(0, Math.floor((s.y - minY) / cell)));
|
|
853
|
+
let best = Infinity;
|
|
854
|
+
for (let r = 1; r <= 4; r++) {
|
|
855
|
+
for (let gy = cy - r; gy <= cy + r; gy++) {
|
|
856
|
+
if (gy < 0 || gy >= rows) continue;
|
|
857
|
+
for (let gx = cx - r; gx <= cx + r; gx++) {
|
|
858
|
+
if (gx < 0 || gx >= cols) continue;
|
|
859
|
+
if (r > 1 && Math.abs(gy - cy) < r && Math.abs(gx - cx) < r) continue;
|
|
860
|
+
const b = buckets.get(gy * cols + gx);
|
|
861
|
+
if (!b) continue;
|
|
862
|
+
for (const j of b) {
|
|
863
|
+
if (j === i) continue;
|
|
864
|
+
const d = Math.hypot(seats[j].x - s.x, seats[j].y - s.y);
|
|
865
|
+
if (d < best) best = d;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (Number.isFinite(best) && best <= r * cell) break;
|
|
870
|
+
}
|
|
871
|
+
out[i] = best;
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
565
875
|
function seatSurfaceY(seat) {
|
|
566
876
|
const eye = seat.eyeHeightM;
|
|
567
|
-
if (Number.isFinite(eye))
|
|
568
|
-
|
|
569
|
-
}
|
|
570
|
-
return SEAT_SURFACE_LIFT_M;
|
|
877
|
+
if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
|
|
878
|
+
return 0;
|
|
571
879
|
}
|
|
572
|
-
function buildSeatInstances(seats, initial) {
|
|
880
|
+
function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
573
881
|
const count = seats.length;
|
|
574
882
|
const iPosition = new Float32Array(count * 3);
|
|
575
883
|
const iState = new Float32Array(count);
|
|
884
|
+
const iMaxRadius = new Float32Array(count);
|
|
885
|
+
const iRing = new Float32Array(count * 3);
|
|
576
886
|
const idToIndex = /* @__PURE__ */ new Map();
|
|
887
|
+
const spacing = nearestNeighbourSpacing(seats);
|
|
577
888
|
for (let i = 0; i < count; i++) {
|
|
578
889
|
const seat = seats[i];
|
|
890
|
+
const resolved = surfaces?.seatPitchU(i);
|
|
891
|
+
const pitchM = (resolved ?? spacing[i]) * M;
|
|
892
|
+
iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
|
|
579
893
|
iPosition[i * 3] = seat.x * M;
|
|
580
|
-
iPosition[i * 3 + 1] = seatSurfaceY(seat);
|
|
894
|
+
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
581
895
|
iPosition[i * 3 + 2] = seat.y * M;
|
|
582
896
|
iState[i] = seatStateIndex(initial ? initial(seat) : "available");
|
|
897
|
+
if (seat.accessibility?.length) {
|
|
898
|
+
const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
|
|
899
|
+
if (rgb) {
|
|
900
|
+
iRing[i * 3] = rgb[0];
|
|
901
|
+
iRing[i * 3 + 1] = rgb[1];
|
|
902
|
+
iRing[i * 3 + 2] = rgb[2];
|
|
903
|
+
}
|
|
904
|
+
}
|
|
583
905
|
idToIndex.set(seat.id, i);
|
|
584
906
|
}
|
|
585
|
-
return {
|
|
907
|
+
return {
|
|
908
|
+
count,
|
|
909
|
+
iPosition,
|
|
910
|
+
iState,
|
|
911
|
+
iMaxRadius,
|
|
912
|
+
iRing,
|
|
913
|
+
idToIndex,
|
|
914
|
+
iFloor: seatFloor ?? new Float32Array(count)
|
|
915
|
+
};
|
|
586
916
|
}
|
|
917
|
+
var RUN_MERGE_GAP = 64;
|
|
587
918
|
function applySeatStates(data, updates) {
|
|
588
919
|
const changed = [];
|
|
589
920
|
for (const u of updates) {
|
|
@@ -603,7 +934,7 @@ function applySeatStates(data, updates) {
|
|
|
603
934
|
for (let i = 1; i < changed.length; i++) {
|
|
604
935
|
const idx = changed[i];
|
|
605
936
|
if (idx === prev) continue;
|
|
606
|
-
if (idx
|
|
937
|
+
if (idx <= prev + RUN_MERGE_GAP) {
|
|
607
938
|
prev = idx;
|
|
608
939
|
continue;
|
|
609
940
|
}
|
|
@@ -615,6 +946,788 @@ function applySeatStates(data, updates) {
|
|
|
615
946
|
return runs;
|
|
616
947
|
}
|
|
617
948
|
|
|
949
|
+
// src/view3d/theme.ts
|
|
950
|
+
var BACKGROUND_INFLUENCE = 0.15;
|
|
951
|
+
var BG_TOP_SCALE = 0.9;
|
|
952
|
+
var BG_BOTTOM_SCALE = 1.76;
|
|
953
|
+
var SEAT_SCALE_MIN = 0.7;
|
|
954
|
+
var SEAT_SCALE_MAX = 1.6;
|
|
955
|
+
function defaultTheme3D() {
|
|
956
|
+
return {
|
|
957
|
+
background: { top: [...BACKGROUND.top], bottom: [...BACKGROUND.bottom] },
|
|
958
|
+
structure: STRUCTURE,
|
|
959
|
+
seatStates: { ...SEAT_STATE_COLORS },
|
|
960
|
+
seatScale: 1
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
function resolveTheme3D(theme) {
|
|
964
|
+
const base = defaultTheme3D();
|
|
965
|
+
if (!theme) return base;
|
|
966
|
+
const bg = hexToRgb(theme.background);
|
|
967
|
+
if (bg) {
|
|
968
|
+
base.background = {
|
|
969
|
+
top: scaleRgb(bg, BG_TOP_SCALE),
|
|
970
|
+
bottom: scaleRgb(bg, BG_BOTTOM_SCALE)
|
|
971
|
+
};
|
|
972
|
+
const rebased = {};
|
|
973
|
+
for (const [key, value] of Object.entries(STRUCTURE)) {
|
|
974
|
+
rebased[key] = mix(value, bg, BACKGROUND_INFLUENCE);
|
|
975
|
+
}
|
|
976
|
+
base.structure = rebased;
|
|
977
|
+
}
|
|
978
|
+
const selection = hexToRgb(theme.selectionColor) ?? hexToRgb(theme.accent);
|
|
979
|
+
if (selection) base.seatStates = { ...base.seatStates, selected: selection };
|
|
980
|
+
const scale2 = theme.seatScale;
|
|
981
|
+
if (typeof scale2 === "number" && Number.isFinite(scale2)) {
|
|
982
|
+
base.seatScale = Math.min(SEAT_SCALE_MAX, Math.max(SEAT_SCALE_MIN, scale2));
|
|
983
|
+
}
|
|
984
|
+
return base;
|
|
985
|
+
}
|
|
986
|
+
function themeSeatColorLUT(theme, order) {
|
|
987
|
+
const out = [];
|
|
988
|
+
for (const s of order) out.push(...theme.seatStates[s]);
|
|
989
|
+
return out;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// src/view3d/labels.ts
|
|
993
|
+
var ZONE_MIN_DISTANCE = 1.15;
|
|
994
|
+
var SECTION_MAX_DISTANCE = 2.2;
|
|
995
|
+
var NEAR_MAX_DISTANCE = 0.85;
|
|
996
|
+
function visibleLabelKinds(distance, venueRadius) {
|
|
997
|
+
const r = Math.max(1e-6, venueRadius);
|
|
998
|
+
const d = distance / r;
|
|
999
|
+
const out = /* @__PURE__ */ new Set();
|
|
1000
|
+
if (d >= ZONE_MIN_DISTANCE) out.add("zone");
|
|
1001
|
+
if (d <= SECTION_MAX_DISTANCE) out.add("section");
|
|
1002
|
+
if (d <= NEAR_MAX_DISTANCE) {
|
|
1003
|
+
out.add("annotation");
|
|
1004
|
+
out.add("booth");
|
|
1005
|
+
}
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
function projectToScreen(viewProjection, p, width, height) {
|
|
1009
|
+
const m = viewProjection;
|
|
1010
|
+
const x = p[0], y = p[1], z = p[2];
|
|
1011
|
+
const cx = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
1012
|
+
const cy = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
1013
|
+
const cz = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
1014
|
+
const cw = m[3] * x + m[7] * y + m[11] * z + m[15];
|
|
1015
|
+
if (!(cw > 1e-6)) return { x: 0, y: 0, depth: Infinity, visible: false };
|
|
1016
|
+
const ndcX = cx / cw, ndcY = cy / cw, ndcZ = cz / cw;
|
|
1017
|
+
const inside = ndcX >= -1.05 && ndcX <= 1.05 && ndcY >= -1.05 && ndcY <= 1.05 && ndcZ <= 1;
|
|
1018
|
+
return {
|
|
1019
|
+
x: (ndcX * 0.5 + 0.5) * width,
|
|
1020
|
+
y: (1 - (ndcY * 0.5 + 0.5)) * height,
|
|
1021
|
+
depth: ndcZ,
|
|
1022
|
+
visible: inside
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
function cullOverlapping(items, separationX, separationY = separationX) {
|
|
1026
|
+
const kept = [];
|
|
1027
|
+
const ordered = [...items].sort((a, b) => a.screen.depth - b.screen.depth);
|
|
1028
|
+
for (const item of ordered) {
|
|
1029
|
+
let clash = false;
|
|
1030
|
+
for (const k of kept) {
|
|
1031
|
+
const dx = Math.abs(item.screen.x - k.screen.x);
|
|
1032
|
+
const dy = Math.abs(item.screen.y - k.screen.y);
|
|
1033
|
+
if (dx < separationX && dy < separationY) {
|
|
1034
|
+
clash = true;
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (!clash) kept.push(item);
|
|
1039
|
+
}
|
|
1040
|
+
return kept;
|
|
1041
|
+
}
|
|
1042
|
+
function centroidOf(points) {
|
|
1043
|
+
if (!points.length) return null;
|
|
1044
|
+
let x = 0, y = 0;
|
|
1045
|
+
for (const p of points) {
|
|
1046
|
+
x += p.x;
|
|
1047
|
+
y += p.y;
|
|
1048
|
+
}
|
|
1049
|
+
return { x: x / points.length, y: y / points.length };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/view3d/scene/sceneModel.ts
|
|
1053
|
+
import polygonClipping3 from "polygon-clipping";
|
|
1054
|
+
|
|
1055
|
+
// src/core/rake.ts
|
|
1056
|
+
var BLEND_ROWS = 3;
|
|
1057
|
+
var BLEND_DISTANCE_RATIO = 2.2;
|
|
1058
|
+
var GRAD_STEP = 0.5;
|
|
1059
|
+
function radialRake(focal) {
|
|
1060
|
+
return {
|
|
1061
|
+
kind: "radial",
|
|
1062
|
+
rowCount: 0,
|
|
1063
|
+
rows: [],
|
|
1064
|
+
nearestRow: () => -1,
|
|
1065
|
+
depthAt: (x, y) => Math.hypot(x - focal.x, y - focal.y),
|
|
1066
|
+
gradientAt: (x, y) => {
|
|
1067
|
+
const dx = x - focal.x, dy = y - focal.y;
|
|
1068
|
+
const d = Math.hypot(dx, dy);
|
|
1069
|
+
return d < 1e-9 ? [0, 0] : [dx / d, dy / d];
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
function fitRow(points, focal) {
|
|
1074
|
+
if (!points.length) return null;
|
|
1075
|
+
let cx = 0, cy = 0, depth = 0;
|
|
1076
|
+
for (const p of points) {
|
|
1077
|
+
cx += p.x;
|
|
1078
|
+
cy += p.y;
|
|
1079
|
+
depth += Math.hypot(p.x - focal.x, p.y - focal.y);
|
|
1080
|
+
}
|
|
1081
|
+
const n = points.length;
|
|
1082
|
+
cx /= n;
|
|
1083
|
+
cy /= n;
|
|
1084
|
+
depth /= n;
|
|
1085
|
+
let ax = points[0], far = -1;
|
|
1086
|
+
for (const p of points) {
|
|
1087
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1088
|
+
if (d > far) {
|
|
1089
|
+
far = d;
|
|
1090
|
+
ax = p;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
let dx = ax.x - cx, dy = ax.y - cy;
|
|
1094
|
+
const len = Math.hypot(dx, dy);
|
|
1095
|
+
if (len > 1e-9) {
|
|
1096
|
+
dx /= len;
|
|
1097
|
+
dy /= len;
|
|
1098
|
+
} else {
|
|
1099
|
+
dx = 1;
|
|
1100
|
+
dy = 0;
|
|
1101
|
+
}
|
|
1102
|
+
const pts = [...points].sort((p, q) => (p.x - cx) * dx + (p.y - cy) * dy - ((q.x - cx) * dx + (q.y - cy) * dy));
|
|
1103
|
+
let radius = 0;
|
|
1104
|
+
for (const p of pts) {
|
|
1105
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1106
|
+
if (d > radius) radius = d;
|
|
1107
|
+
}
|
|
1108
|
+
return { pts, cx, cy, radius, depth };
|
|
1109
|
+
}
|
|
1110
|
+
function distToRow(r, x, y) {
|
|
1111
|
+
const pts = r.pts;
|
|
1112
|
+
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1113
|
+
let best = Infinity;
|
|
1114
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1115
|
+
const a = pts[i], b = pts[i + 1];
|
|
1116
|
+
const vx = b.x - a.x, vy = b.y - a.y;
|
|
1117
|
+
const len2 = vx * vx + vy * vy;
|
|
1118
|
+
let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
|
|
1119
|
+
if (t < 0) t = 0;
|
|
1120
|
+
else if (t > 1) t = 1;
|
|
1121
|
+
const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
|
|
1122
|
+
if (d < best) best = d;
|
|
1123
|
+
}
|
|
1124
|
+
return best;
|
|
1125
|
+
}
|
|
1126
|
+
var CANDIDATE_ROWS = 8;
|
|
1127
|
+
function sampleRows(rows, x, y) {
|
|
1128
|
+
const candD = new Array(CANDIDATE_ROWS).fill(Infinity);
|
|
1129
|
+
const candI = new Array(CANDIDATE_ROWS).fill(-1);
|
|
1130
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1131
|
+
const r = rows[i];
|
|
1132
|
+
const lower = Math.hypot(x - r.cx, y - r.cy) - r.radius;
|
|
1133
|
+
for (let k = 0; k < CANDIDATE_ROWS; k++) {
|
|
1134
|
+
if (lower < candD[k]) {
|
|
1135
|
+
for (let j = CANDIDATE_ROWS - 1; j > k; j--) {
|
|
1136
|
+
candD[j] = candD[j - 1];
|
|
1137
|
+
candI[j] = candI[j - 1];
|
|
1138
|
+
}
|
|
1139
|
+
candD[k] = lower;
|
|
1140
|
+
candI[k] = i;
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
const bestD = new Array(BLEND_ROWS).fill(Infinity);
|
|
1146
|
+
const bestI = new Array(BLEND_ROWS).fill(-1);
|
|
1147
|
+
for (const i of candI) {
|
|
1148
|
+
if (i < 0) continue;
|
|
1149
|
+
const d = distToRow(rows[i], x, y);
|
|
1150
|
+
for (let k = 0; k < BLEND_ROWS; k++) {
|
|
1151
|
+
if (d < bestD[k]) {
|
|
1152
|
+
for (let j = BLEND_ROWS - 1; j > k; j--) {
|
|
1153
|
+
bestD[j] = bestD[j - 1];
|
|
1154
|
+
bestI[j] = bestI[j - 1];
|
|
1155
|
+
}
|
|
1156
|
+
bestD[k] = d;
|
|
1157
|
+
bestI[k] = i;
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (bestI[0] < 0) return 0;
|
|
1163
|
+
if (bestD[0] < 1e-6) return rows[bestI[0]].depth;
|
|
1164
|
+
const cutoff = bestD[0] * BLEND_DISTANCE_RATIO;
|
|
1165
|
+
let num = 0, den = 0;
|
|
1166
|
+
for (let k = 0; k < BLEND_ROWS; k++) {
|
|
1167
|
+
const i = bestI[k];
|
|
1168
|
+
if (i < 0 || bestD[k] > cutoff) continue;
|
|
1169
|
+
const w = 1 / (bestD[k] * bestD[k]);
|
|
1170
|
+
num += w * rows[i].depth;
|
|
1171
|
+
den += w;
|
|
1172
|
+
}
|
|
1173
|
+
return den > 0 ? num / den : rows[bestI[0]].depth;
|
|
1174
|
+
}
|
|
1175
|
+
function rowsRake(rows) {
|
|
1176
|
+
const depthAt = (x, y) => sampleRows(rows, x, y);
|
|
1177
|
+
const nearestRow = (x, y) => {
|
|
1178
|
+
let best = Infinity, bestI = -1;
|
|
1179
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1180
|
+
const r = rows[i];
|
|
1181
|
+
if (Math.hypot(x - r.cx, y - r.cy) - r.radius >= best) continue;
|
|
1182
|
+
const d = distToRow(r, x, y);
|
|
1183
|
+
if (d < best) {
|
|
1184
|
+
best = d;
|
|
1185
|
+
bestI = i;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return bestI;
|
|
1189
|
+
};
|
|
1190
|
+
return {
|
|
1191
|
+
kind: "rows",
|
|
1192
|
+
rowCount: rows.length,
|
|
1193
|
+
rows,
|
|
1194
|
+
nearestRow,
|
|
1195
|
+
depthAt,
|
|
1196
|
+
gradientAt: (x, y) => {
|
|
1197
|
+
const gx = (depthAt(x + GRAD_STEP, y) - depthAt(x - GRAD_STEP, y)) / (2 * GRAD_STEP);
|
|
1198
|
+
const gy = (depthAt(x, y + GRAD_STEP) - depthAt(x, y - GRAD_STEP)) / (2 * GRAD_STEP);
|
|
1199
|
+
const len = Math.hypot(gx, gy);
|
|
1200
|
+
return len < 1e-9 ? [0, 0] : [gx / len, gy / len];
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
function buildSectionRake(rows, focal) {
|
|
1205
|
+
const fits = [];
|
|
1206
|
+
for (const r of rows) {
|
|
1207
|
+
const f = fitRow(r.points, focal);
|
|
1208
|
+
if (f) fits.push(f);
|
|
1209
|
+
}
|
|
1210
|
+
if (fits.length < 2) return radialRake(focal);
|
|
1211
|
+
fits.sort((a, b) => a.depth - b.depth);
|
|
1212
|
+
return rowsRake(fits);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
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
|
+
// src/view3d/scene/deckBands.ts
|
|
1401
|
+
import polygonClipping2 from "polygon-clipping";
|
|
1402
|
+
import earcut2 from "earcut";
|
|
1403
|
+
var FRONT_REACH = 0.58;
|
|
1404
|
+
var MIN_RISER_M = 0.01;
|
|
1405
|
+
var MIN_REACH_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1406
|
+
var BACK_REACH = 0.5;
|
|
1407
|
+
var END_REACH = 0.6;
|
|
1408
|
+
function rowNormals(pts, focal) {
|
|
1409
|
+
const n = pts.length;
|
|
1410
|
+
const raw = [];
|
|
1411
|
+
for (let i = 0; i < n; i++) {
|
|
1412
|
+
const a = pts[Math.max(0, i - 1)];
|
|
1413
|
+
const b = pts[Math.min(n - 1, i + 1)];
|
|
1414
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
1415
|
+
const len = Math.hypot(dx, dy);
|
|
1416
|
+
if (len < 1e-9) {
|
|
1417
|
+
raw.push([0, 0]);
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
dx /= len;
|
|
1421
|
+
dy /= len;
|
|
1422
|
+
raw.push([-dy, dx]);
|
|
1423
|
+
}
|
|
1424
|
+
let vote = 0;
|
|
1425
|
+
for (let i = 0; i < n; i++) {
|
|
1426
|
+
vote += (pts[i].x - focal.x) * raw[i][0] + (pts[i].y - focal.y) * raw[i][1];
|
|
1427
|
+
}
|
|
1428
|
+
const flip = vote < 0;
|
|
1429
|
+
return flip ? raw.map(([x, y]) => [-x, -y]) : raw;
|
|
1430
|
+
}
|
|
1431
|
+
function extendEnds(pts, by) {
|
|
1432
|
+
if (pts.length < 2 || by <= 0) return [...pts];
|
|
1433
|
+
const out = [...pts];
|
|
1434
|
+
const dir = (p, q) => {
|
|
1435
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
1436
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
1437
|
+
return { x: dx / len, y: dy / len };
|
|
1438
|
+
};
|
|
1439
|
+
const head = dir(out[1], out[0]);
|
|
1440
|
+
const tail = dir(out[out.length - 2], out[out.length - 1]);
|
|
1441
|
+
out.unshift({ x: out[0].x + head.x * by, y: out[0].y + head.y * by });
|
|
1442
|
+
out.push({
|
|
1443
|
+
x: out[out.length - 1].x + tail.x * by,
|
|
1444
|
+
y: out[out.length - 1].y + tail.y * by
|
|
1445
|
+
});
|
|
1446
|
+
return out;
|
|
1447
|
+
}
|
|
1448
|
+
function distToPolyline(pts, x, y) {
|
|
1449
|
+
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1450
|
+
let best = Infinity;
|
|
1451
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1452
|
+
const a = pts[i], b = pts[i + 1];
|
|
1453
|
+
const vx = b.x - a.x, vy = b.y - a.y;
|
|
1454
|
+
const len2 = vx * vx + vy * vy;
|
|
1455
|
+
let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
|
|
1456
|
+
if (t < 0) t = 0;
|
|
1457
|
+
else if (t > 1) t = 1;
|
|
1458
|
+
const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
|
|
1459
|
+
if (d < best) best = d;
|
|
1460
|
+
}
|
|
1461
|
+
return best;
|
|
1462
|
+
}
|
|
1463
|
+
function neighbourhoods(rows) {
|
|
1464
|
+
const probesOf = (pts) => {
|
|
1465
|
+
const n = pts.length;
|
|
1466
|
+
if (n <= 2) return [...pts];
|
|
1467
|
+
return [pts[Math.floor(n * 0.25)], pts[Math.floor(n * 0.5)], pts[Math.floor(n * 0.75)]];
|
|
1468
|
+
};
|
|
1469
|
+
const out = [];
|
|
1470
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1471
|
+
const probes = probesOf(rows[i].pts);
|
|
1472
|
+
const pitches = [];
|
|
1473
|
+
let bestFrontD = Infinity, belowY = null;
|
|
1474
|
+
for (const c of probes) {
|
|
1475
|
+
let nearest = Infinity;
|
|
1476
|
+
for (let j = 0; j < rows.length; j++) {
|
|
1477
|
+
if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
|
|
1478
|
+
const d = distToPolyline(rows[j].pts, c.x, c.y);
|
|
1479
|
+
if (d < nearest) nearest = d;
|
|
1480
|
+
if (rows[j].depth < rows[i].depth && d < bestFrontD) {
|
|
1481
|
+
bestFrontD = d;
|
|
1482
|
+
belowY = rows[j].y;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (Number.isFinite(nearest) && nearest > 0) pitches.push(nearest);
|
|
1486
|
+
}
|
|
1487
|
+
pitches.sort((a, b) => a - b);
|
|
1488
|
+
const pitch = pitches.length ? pitches[Math.floor(pitches.length / 2)] : 1;
|
|
1489
|
+
out.push({ pitch, belowY });
|
|
1490
|
+
}
|
|
1491
|
+
return out;
|
|
1492
|
+
}
|
|
1493
|
+
function ribbonOf(rows, i, nbrs, focal) {
|
|
1494
|
+
const row = rows[i];
|
|
1495
|
+
const rowPts = row.pts.length >= 2 ? [...row.pts] : row.pts.length === 1 ? (() => {
|
|
1496
|
+
const p = row.pts[0];
|
|
1497
|
+
let dx = p.x - focal.x, dy = p.y - focal.y;
|
|
1498
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
1499
|
+
dx /= len;
|
|
1500
|
+
dy /= len;
|
|
1501
|
+
const h = Math.max(nbrs[i].pitch, 1e-3) * 0.5;
|
|
1502
|
+
return [{ x: p.x + dy * h, y: p.y - dx * h }, { x: p.x - dy * h, y: p.y + dx * h }];
|
|
1503
|
+
})() : [];
|
|
1504
|
+
if (rowPts.length < 2) return null;
|
|
1505
|
+
let seatSpan = 0, spanN = 0;
|
|
1506
|
+
for (let k = 1; k < rowPts.length; k++) {
|
|
1507
|
+
const d = Math.hypot(rowPts[k].x - rowPts[k - 1].x, rowPts[k].y - rowPts[k - 1].y);
|
|
1508
|
+
if (d > 1e-6) {
|
|
1509
|
+
seatSpan += d;
|
|
1510
|
+
spanN++;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const spacing = spanN > 0 ? seatSpan / spanN : 0;
|
|
1514
|
+
const pts = extendEnds(rowPts, Math.max(spacing * END_REACH, MIN_REACH_U));
|
|
1515
|
+
return {
|
|
1516
|
+
pts,
|
|
1517
|
+
nrm: rowNormals(pts, focal),
|
|
1518
|
+
front: Math.max(nbrs[i].pitch * FRONT_REACH, MIN_REACH_U),
|
|
1519
|
+
back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
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
|
+
var FOOTPRINT_MARGIN_U = 1.5;
|
|
1581
|
+
function simplifyRun(pts, tol) {
|
|
1582
|
+
if (pts.length < 3) return pts;
|
|
1583
|
+
const a = pts[0], b = pts[pts.length - 1];
|
|
1584
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
1585
|
+
const len = Math.hypot(dx, dy);
|
|
1586
|
+
let worst = -1, worstI = -1;
|
|
1587
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
1588
|
+
const p = pts[i];
|
|
1589
|
+
const d = len > 1e-12 ? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / len : Math.hypot(p.x - a.x, p.y - a.y);
|
|
1590
|
+
if (d > worst) {
|
|
1591
|
+
worst = d;
|
|
1592
|
+
worstI = i;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (worst <= tol || worstI < 0) return [a, b];
|
|
1596
|
+
const left = simplifyRun(pts.slice(0, worstI + 1), tol);
|
|
1597
|
+
const right = simplifyRun(pts.slice(worstI), tol);
|
|
1598
|
+
return [...left.slice(0, -1), ...right];
|
|
1599
|
+
}
|
|
1600
|
+
function simplifyRing(ring, tol) {
|
|
1601
|
+
if (ring.length < 4) return ring;
|
|
1602
|
+
const out = simplifyRun([...ring, ring[0]], tol);
|
|
1603
|
+
out.pop();
|
|
1604
|
+
return out.length >= 3 ? out : ring;
|
|
1605
|
+
}
|
|
1606
|
+
function pointInRing(ring, x, y) {
|
|
1607
|
+
let inside = false;
|
|
1608
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
1609
|
+
const a = ring[i], b = ring[j];
|
|
1610
|
+
if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
1611
|
+
}
|
|
1612
|
+
return inside;
|
|
1613
|
+
}
|
|
1614
|
+
var ClipTest = class {
|
|
1615
|
+
constructor(rings) {
|
|
1616
|
+
this.minX = Infinity;
|
|
1617
|
+
this.minY = Infinity;
|
|
1618
|
+
this.maxX = -Infinity;
|
|
1619
|
+
this.maxY = -Infinity;
|
|
1620
|
+
this.rings = rings;
|
|
1621
|
+
for (const r of rings) {
|
|
1622
|
+
for (const p of r) {
|
|
1623
|
+
if (p.x < this.minX) this.minX = p.x;
|
|
1624
|
+
if (p.y < this.minY) this.minY = p.y;
|
|
1625
|
+
if (p.x > this.maxX) this.maxX = p.x;
|
|
1626
|
+
if (p.y > this.maxY) this.maxY = p.y;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
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;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (all) return true;
|
|
1644
|
+
}
|
|
1645
|
+
return false;
|
|
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
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
function emitDeckBands(builder, rows, focal, landingY, colors, clip) {
|
|
1689
|
+
if (rows.length < 2) return;
|
|
1690
|
+
const nbrs = neighbourhoods(rows);
|
|
1691
|
+
const UP = [0, 1, 0];
|
|
1692
|
+
const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
|
|
1693
|
+
const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
|
|
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
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
618
1731
|
// src/view3d/scene/sceneModel.ts
|
|
619
1732
|
function floorUnits(doc) {
|
|
620
1733
|
if (doc.floors?.length) {
|
|
@@ -627,8 +1740,6 @@ function floorUnits(doc) {
|
|
|
627
1740
|
return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
|
|
628
1741
|
}
|
|
629
1742
|
var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
|
|
630
|
-
var DECK_DROP_M = 0.28;
|
|
631
|
-
var MAX_TIER_RISE_M = 25;
|
|
632
1743
|
function tintTop(fill, neutral) {
|
|
633
1744
|
if (!fill) return neutral;
|
|
634
1745
|
const muted = scaleRgb(desaturate(fill, 0.4), 0.62);
|
|
@@ -665,31 +1776,229 @@ function resolveSectionFills(doc, seats) {
|
|
|
665
1776
|
function sectionFill(section, byLogical) {
|
|
666
1777
|
return hexToRgb(section.color) ?? byLogical.get(section.logicalSectionId ?? section.id) ?? null;
|
|
667
1778
|
}
|
|
668
|
-
function
|
|
1779
|
+
function bboxOfRing(ring) {
|
|
1780
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1781
|
+
for (const p of ring) {
|
|
1782
|
+
if (p.x < minX) minX = p.x;
|
|
1783
|
+
if (p.y < minY) minY = p.y;
|
|
1784
|
+
if (p.x > maxX) maxX = p.x;
|
|
1785
|
+
if (p.y > maxY) maxY = p.y;
|
|
1786
|
+
}
|
|
1787
|
+
return { minX, minY, maxX, maxY };
|
|
1788
|
+
}
|
|
1789
|
+
function boxesOverlap(a, b) {
|
|
1790
|
+
return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
|
|
1791
|
+
}
|
|
1792
|
+
function paddedFootprint(section, siblings) {
|
|
1793
|
+
const __tp = performance.now();
|
|
1794
|
+
const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
|
|
1795
|
+
__t("outsetRing", __tp);
|
|
1796
|
+
const box = bboxOfRing(padded);
|
|
1797
|
+
const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
|
|
1798
|
+
if (!others.length) return [padded];
|
|
1799
|
+
const toRing = (pts) => {
|
|
1800
|
+
const r = pts.map((p) => [p.x, p.y]);
|
|
1801
|
+
r.push(r[0]);
|
|
1802
|
+
return r;
|
|
1803
|
+
};
|
|
1804
|
+
try {
|
|
1805
|
+
const diff = polygonClipping3.difference([toRing(padded)], ...others.map((o) => [toRing(o.outline)]));
|
|
1806
|
+
const out = [];
|
|
1807
|
+
for (const poly of diff) {
|
|
1808
|
+
if (!poly.length) continue;
|
|
1809
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
1810
|
+
if (pts.length > 1) {
|
|
1811
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
1812
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
1813
|
+
}
|
|
1814
|
+
if (pts.length >= 3) out.push(pts);
|
|
1815
|
+
}
|
|
1816
|
+
return out.length ? out : [padded];
|
|
1817
|
+
} catch {
|
|
1818
|
+
return [padded];
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
function clipFootprints(blocks, allowed) {
|
|
1822
|
+
if (!allowed.length) return blocks;
|
|
1823
|
+
const toRing = (pts) => {
|
|
1824
|
+
const r = pts.map((p) => [p.x, p.y]);
|
|
1825
|
+
r.push(r[0]);
|
|
1826
|
+
return r;
|
|
1827
|
+
};
|
|
1828
|
+
const allowedPolys = allowed.map((a) => [toRing(a)]);
|
|
1829
|
+
const out = [];
|
|
1830
|
+
for (const b of blocks) {
|
|
1831
|
+
let pieces;
|
|
1832
|
+
try {
|
|
1833
|
+
pieces = polygonClipping3.intersection([toRing(b.outline)], ...[allowedPolys.flat()]);
|
|
1834
|
+
} catch {
|
|
1835
|
+
out.push(b);
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
for (const poly of pieces) {
|
|
1839
|
+
if (!poly.length) continue;
|
|
1840
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
1841
|
+
if (pts.length > 1) {
|
|
1842
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
1843
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
1844
|
+
}
|
|
1845
|
+
if (pts.length >= 3) out.push({ outline: pts, holes: [], topY: b.topY });
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return out.length ? out : blocks;
|
|
1849
|
+
}
|
|
1850
|
+
function buildTier(builder, section, unit, fill, surface, claimed, siblings, S) {
|
|
669
1851
|
if (!section.outline || section.outline.length < 3) return;
|
|
670
|
-
const geo = sectionGeometry(section, { floorBaseHeightM: unit.baseHeightM });
|
|
671
1852
|
const bottomY = unit.baseHeightM;
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1853
|
+
const colTop = tintTop(fill, S.tierTop);
|
|
1854
|
+
if (!surface) return;
|
|
1855
|
+
const topY = (p) => surface.deckAt(p.x, p.y);
|
|
1856
|
+
const footprint = paddedFootprint(section, siblings);
|
|
1857
|
+
const outline = footprint[0] ?? section.outline;
|
|
1858
|
+
if (surface.rowLevels.length >= 2) {
|
|
1859
|
+
const __tf = performance.now();
|
|
1860
|
+
const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
|
|
1861
|
+
__t("deckFootprints", __tf);
|
|
1862
|
+
const capUp = () => [0, 1, 0];
|
|
1863
|
+
for (const b of blocks) {
|
|
1864
|
+
extrudePrism(
|
|
1865
|
+
builder,
|
|
1866
|
+
b.outline,
|
|
1867
|
+
b.holes,
|
|
1868
|
+
() => b.topY,
|
|
1869
|
+
bottomY,
|
|
1870
|
+
colTop,
|
|
1871
|
+
S.tierWall,
|
|
1872
|
+
AO,
|
|
1873
|
+
void 0,
|
|
1874
|
+
capUp
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
if (!blocks.length) {
|
|
1878
|
+
extrudePrism(
|
|
1879
|
+
builder,
|
|
1880
|
+
outline,
|
|
1881
|
+
section.holes,
|
|
1882
|
+
() => surface.landingY,
|
|
1883
|
+
bottomY,
|
|
1884
|
+
colTop,
|
|
1885
|
+
S.tierWall,
|
|
1886
|
+
AO
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
const __tb = performance.now();
|
|
1890
|
+
emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
|
|
1891
|
+
tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
|
|
1892
|
+
// Risers read as the structure they are, a shade below their tread, which
|
|
1893
|
+
// is what makes the stepping legible from a low angle.
|
|
1894
|
+
riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
|
|
1895
|
+
}, footprint.length === 1 ? outline : footprint.flat());
|
|
1896
|
+
__t("emitDeckBands", __tb);
|
|
678
1897
|
return;
|
|
679
1898
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1899
|
+
const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
|
|
1900
|
+
const topN = (p) => surface.normalAt(p.x, p.y);
|
|
1901
|
+
for (const ring of claimed.subtract(outline)) {
|
|
1902
|
+
extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
var ClaimedArea = class {
|
|
1906
|
+
constructor() {
|
|
1907
|
+
this.rings = [];
|
|
1908
|
+
this.boxes = [];
|
|
1909
|
+
}
|
|
1910
|
+
/** `ring` minus everything claimed so far; then claim what is returned. */
|
|
1911
|
+
subtract(ring) {
|
|
1912
|
+
const closed = ring.map((p) => [p.x, p.y]);
|
|
1913
|
+
if (closed.length < 3) return [];
|
|
1914
|
+
closed.push(closed[0]);
|
|
1915
|
+
const box = bboxOfRing(ring);
|
|
1916
|
+
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
|
|
1917
|
+
let pieces = [[closed]];
|
|
1918
|
+
if (overlapping.length) {
|
|
1919
|
+
try {
|
|
1920
|
+
const diff = polygonClipping3.difference([closed], ...overlapping);
|
|
1921
|
+
pieces = diff.map((poly) => poly.map((r) => r.map(([x, y]) => [x, y])));
|
|
1922
|
+
} catch {
|
|
1923
|
+
pieces = [[closed]];
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
this.rings.push([closed]);
|
|
1927
|
+
this.boxes.push(box);
|
|
1928
|
+
const out = [];
|
|
1929
|
+
for (const poly of pieces) {
|
|
1930
|
+
if (!poly.length) continue;
|
|
1931
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
1932
|
+
if (pts.length > 1) {
|
|
1933
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
1934
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
1935
|
+
}
|
|
1936
|
+
if (pts.length >= 3) out.push(pts);
|
|
1937
|
+
}
|
|
1938
|
+
return out;
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
var BOOTH_HEIGHT_M = 2.4;
|
|
1942
|
+
var ZONE_LABEL_LIFT_M = 6;
|
|
1943
|
+
var SECTION_LABEL_LIFT_M = 2.2;
|
|
1944
|
+
var ANNOTATION_LIFT_M = 0.1;
|
|
1945
|
+
var BOOTH_LABEL_LIFT_M = 0.3;
|
|
1946
|
+
var TABLE_HEIGHT_M = 0.75;
|
|
1947
|
+
function boothPolygon(booth) {
|
|
1948
|
+
if (booth.points && booth.points.length >= 3) return booth.points;
|
|
1949
|
+
const { center, width, height, rotation } = booth;
|
|
1950
|
+
if (!width || !height) return null;
|
|
1951
|
+
const a = (rotation ?? 0) * Math.PI / 180;
|
|
1952
|
+
const cos = Math.cos(a), sin = Math.sin(a);
|
|
1953
|
+
const hw = width / 2, hh = height / 2;
|
|
1954
|
+
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
|
|
1955
|
+
x: center.x + lx * cos - ly * sin,
|
|
1956
|
+
y: center.y + lx * sin + ly * cos
|
|
1957
|
+
}));
|
|
1958
|
+
}
|
|
1959
|
+
function buildBooth(builder, booth, base, fill, S) {
|
|
1960
|
+
const poly = boothPolygon(booth);
|
|
1961
|
+
if (!poly) return;
|
|
1962
|
+
extrudePrism(
|
|
1963
|
+
builder,
|
|
1964
|
+
poly,
|
|
1965
|
+
void 0,
|
|
1966
|
+
() => base + BOOTH_HEIGHT_M,
|
|
1967
|
+
base,
|
|
1968
|
+
tintTop(fill, S.boothTop),
|
|
1969
|
+
S.boothWall,
|
|
1970
|
+
AO
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
function tablePolygon(table) {
|
|
1974
|
+
if (table.shape === "round") {
|
|
1975
|
+
const r = table.radius;
|
|
1976
|
+
if (!r) return null;
|
|
1977
|
+
return ellipsePolygon(table.center.x, table.center.y, r, r, 24);
|
|
1978
|
+
}
|
|
1979
|
+
const { width, height, rotation, center } = table;
|
|
1980
|
+
if (!width || !height) return null;
|
|
1981
|
+
const a = (rotation ?? 0) * Math.PI / 180;
|
|
1982
|
+
const cos = Math.cos(a), sin = Math.sin(a);
|
|
1983
|
+
const hw = width / 2, hh = height / 2;
|
|
1984
|
+
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
|
|
1985
|
+
x: center.x + lx * cos - ly * sin,
|
|
1986
|
+
y: center.y + lx * sin + ly * cos
|
|
1987
|
+
}));
|
|
1988
|
+
}
|
|
1989
|
+
function buildTable(builder, table, base, fill, S) {
|
|
1990
|
+
const poly = tablePolygon(table);
|
|
1991
|
+
if (!poly) return;
|
|
1992
|
+
extrudePrism(
|
|
1993
|
+
builder,
|
|
1994
|
+
poly,
|
|
1995
|
+
void 0,
|
|
1996
|
+
() => base + TABLE_HEIGHT_M,
|
|
1997
|
+
base,
|
|
1998
|
+
tintTop(fill, S.tableTop),
|
|
1999
|
+
S.tableWall,
|
|
2000
|
+
AO
|
|
2001
|
+
);
|
|
693
2002
|
}
|
|
694
2003
|
function shapePolygon(shape) {
|
|
695
2004
|
if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
|
|
@@ -703,19 +2012,19 @@ function shapePolygon(shape) {
|
|
|
703
2012
|
}
|
|
704
2013
|
return null;
|
|
705
2014
|
}
|
|
706
|
-
function buildShape(builder, shape, base) {
|
|
2015
|
+
function buildShape(builder, shape, base, S) {
|
|
707
2016
|
const poly = shapePolygon(shape);
|
|
708
2017
|
if (!poly) return;
|
|
709
2018
|
const isStage = shape.role === "stage";
|
|
710
2019
|
const height = isStage ? base + 1 : base + 0.25;
|
|
711
|
-
const colTop = isStage ?
|
|
712
|
-
const colWall = isStage ?
|
|
2020
|
+
const colTop = isStage ? S.stageTop : S.decorTop;
|
|
2021
|
+
const colWall = isStage ? S.stageWall : S.decorWall;
|
|
713
2022
|
extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
|
|
714
2023
|
}
|
|
715
|
-
function buildGa(builder, ga, base, fill) {
|
|
2024
|
+
function buildGa(builder, ga, base, fill, S) {
|
|
716
2025
|
if (!ga.points || ga.points.length < 3) return;
|
|
717
|
-
const colTop = tintTop(fill,
|
|
718
|
-
extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop,
|
|
2026
|
+
const colTop = tintTop(fill, S.gaTop);
|
|
2027
|
+
extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, S.gaWall, AO);
|
|
719
2028
|
}
|
|
720
2029
|
function chartFootprint(units, seats) {
|
|
721
2030
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
@@ -731,6 +2040,13 @@ function chartFootprint(units, seats) {
|
|
|
731
2040
|
if (o.type === "section") for (const p of o.outline) acc(p.x, p.y);
|
|
732
2041
|
else if (o.type === "shape" && o.points) for (const p of o.points) acc(p.x, p.y);
|
|
733
2042
|
else if (o.type === "gaArea") for (const p of o.points) acc(p.x, p.y);
|
|
2043
|
+
else if (o.type === "booth") {
|
|
2044
|
+
const b = boothPolygon(o);
|
|
2045
|
+
if (b) for (const p of b) acc(p.x, p.y);
|
|
2046
|
+
} else if (o.type === "table") {
|
|
2047
|
+
const t = tablePolygon(o);
|
|
2048
|
+
if (t) for (const p of t) acc(p.x, p.y);
|
|
2049
|
+
}
|
|
734
2050
|
}
|
|
735
2051
|
}
|
|
736
2052
|
if (!Number.isFinite(minX)) {
|
|
@@ -741,41 +2057,318 @@ function chartFootprint(units, seats) {
|
|
|
741
2057
|
}
|
|
742
2058
|
return { minX, minY, maxX, maxY };
|
|
743
2059
|
}
|
|
2060
|
+
var __PROF = {};
|
|
2061
|
+
var __t = (k, t0) => {
|
|
2062
|
+
__PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
|
|
2063
|
+
};
|
|
744
2064
|
function buildSceneModel(input) {
|
|
2065
|
+
for (const k of Object.keys(__PROF)) delete __PROF[k];
|
|
745
2066
|
const { doc, seats } = input;
|
|
2067
|
+
const theme = resolveTheme3D(doc.theme);
|
|
2068
|
+
const S = theme.structure;
|
|
746
2069
|
const units = floorUnits(doc);
|
|
747
2070
|
const builder = new MeshBuilder();
|
|
748
2071
|
const fp = chartFootprint(units, seats);
|
|
749
2072
|
const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
|
|
750
2073
|
const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
|
|
751
|
-
extrudePrism(builder, groundPoly, void 0, () => 0, -0.4,
|
|
2074
|
+
extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
|
|
752
2075
|
const sectionFills = resolveSectionFills(doc, seats);
|
|
753
2076
|
const catColor = /* @__PURE__ */ new Map();
|
|
754
2077
|
for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
|
|
755
|
-
|
|
2078
|
+
const __t0 = performance.now();
|
|
2079
|
+
const surfaces = buildVenueSurfaces(units, seats);
|
|
2080
|
+
__t("surfaces", __t0);
|
|
2081
|
+
const seatFloor = new Float32Array(seats.length);
|
|
2082
|
+
for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
|
|
2083
|
+
const unit = units[unitIndex];
|
|
2084
|
+
builder.setFloor(unitIndex);
|
|
2085
|
+
const claimed = new ClaimedArea();
|
|
2086
|
+
const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
|
|
756
2087
|
for (const o of unit.objects) {
|
|
757
|
-
if (o.type === "section")
|
|
758
|
-
|
|
759
|
-
|
|
2088
|
+
if (o.type === "section") {
|
|
2089
|
+
const t = performance.now();
|
|
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);
|
|
2093
|
+
else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2094
|
+
else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2095
|
+
else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
760
2096
|
}
|
|
761
2097
|
}
|
|
2098
|
+
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
2099
|
+
const __tm = performance.now();
|
|
762
2100
|
const solids = mergeMeshData([builder.build()]);
|
|
763
|
-
|
|
2101
|
+
__t("meshBuild+merge", __tm);
|
|
2102
|
+
const __ts = performance.now();
|
|
2103
|
+
const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
|
|
2104
|
+
__t("seatInstances", __ts);
|
|
2105
|
+
const zoneDefs = doc.zones ?? [];
|
|
2106
|
+
const zones = [];
|
|
2107
|
+
if (zoneDefs.length) {
|
|
2108
|
+
const sectionZone = /* @__PURE__ */ new Map();
|
|
2109
|
+
for (const unit of units) {
|
|
2110
|
+
for (const o of unit.objects) {
|
|
2111
|
+
if (o.type === "section" && o.zone) sectionZone.set(o.id, o.zone);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2115
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2116
|
+
const s = seats[i];
|
|
2117
|
+
const owner = surfaces.seatOwner[i];
|
|
2118
|
+
const zid = s.zoneId ?? (owner ? sectionZone.get(owner) : void 0);
|
|
2119
|
+
if (!zid) continue;
|
|
2120
|
+
let a = acc.get(zid);
|
|
2121
|
+
if (!a) {
|
|
2122
|
+
a = { n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 };
|
|
2123
|
+
acc.set(zid, a);
|
|
2124
|
+
}
|
|
2125
|
+
a.n++;
|
|
2126
|
+
if (s.x < a.minX) a.minX = s.x;
|
|
2127
|
+
if (s.y < a.minY) a.minY = s.y;
|
|
2128
|
+
if (s.x > a.maxX) a.maxX = s.x;
|
|
2129
|
+
if (s.y > a.maxY) a.maxY = s.y;
|
|
2130
|
+
a.sumY += surfaces.seatDeckY(i);
|
|
2131
|
+
}
|
|
2132
|
+
for (const z of zoneDefs) {
|
|
2133
|
+
const a = acc.get(z.id);
|
|
2134
|
+
const sectionIds = [];
|
|
2135
|
+
for (const [secId, zid] of sectionZone) if (zid === z.id) sectionIds.push(secId);
|
|
2136
|
+
if (!a || a.n === 0) {
|
|
2137
|
+
zones.push({
|
|
2138
|
+
id: z.id,
|
|
2139
|
+
label: z.label,
|
|
2140
|
+
color: hexToRgb(z.color),
|
|
2141
|
+
sectionIds,
|
|
2142
|
+
seatCount: 0,
|
|
2143
|
+
center: [0, 0, 0],
|
|
2144
|
+
radius: 0,
|
|
2145
|
+
focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
|
|
2146
|
+
});
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
zones.push({
|
|
2150
|
+
id: z.id,
|
|
2151
|
+
label: z.label,
|
|
2152
|
+
color: hexToRgb(z.color),
|
|
2153
|
+
sectionIds,
|
|
2154
|
+
seatCount: a.n,
|
|
2155
|
+
center: [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M],
|
|
2156
|
+
radius: 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1,
|
|
2157
|
+
// A zone may face its own point (`ZoneDef.focalPoint`); the documented
|
|
2158
|
+
// fallback is the floor/chart focal.
|
|
2159
|
+
focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
const labels = [];
|
|
2164
|
+
for (const z of zones) {
|
|
2165
|
+
if (z.seatCount === 0) continue;
|
|
2166
|
+
labels.push({
|
|
2167
|
+
id: `zone:${z.id}`,
|
|
2168
|
+
kind: "zone",
|
|
2169
|
+
text: z.label,
|
|
2170
|
+
anchor: [z.center[0], z.center[1] + ZONE_LABEL_LIFT_M, z.center[2]],
|
|
2171
|
+
color: doc.zones?.find((d) => d.id === z.id)?.color
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
{
|
|
2175
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2176
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2177
|
+
const owner = surfaces.seatOwner[i];
|
|
2178
|
+
if (!owner) continue;
|
|
2179
|
+
let a = acc.get(owner);
|
|
2180
|
+
if (!a) {
|
|
2181
|
+
a = { n: 0, x: 0, y: 0, deck: 0 };
|
|
2182
|
+
acc.set(owner, a);
|
|
2183
|
+
}
|
|
2184
|
+
a.n++;
|
|
2185
|
+
a.x += seats[i].x;
|
|
2186
|
+
a.y += seats[i].y;
|
|
2187
|
+
a.deck += surfaces.seatDeckY(i);
|
|
2188
|
+
}
|
|
2189
|
+
for (const unit of units) {
|
|
2190
|
+
for (const o of unit.objects) {
|
|
2191
|
+
if (o.type !== "section") continue;
|
|
2192
|
+
const a = acc.get(o.id);
|
|
2193
|
+
if (!a || a.n === 0) continue;
|
|
2194
|
+
labels.push({
|
|
2195
|
+
id: `section:${o.id}`,
|
|
2196
|
+
kind: "section",
|
|
2197
|
+
// The buyer-facing name wins over the technical one, as it does in 2D.
|
|
2198
|
+
text: o.displayLabel || o.label || o.id,
|
|
2199
|
+
anchor: [a.x / a.n * M, a.deck / a.n + SECTION_LABEL_LIFT_M, a.y / a.n * M]
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
for (const unit of units) {
|
|
2205
|
+
for (const o of unit.objects) {
|
|
2206
|
+
if (o.type === "text") {
|
|
2207
|
+
if (!o.text) continue;
|
|
2208
|
+
labels.push({
|
|
2209
|
+
id: `text:${o.id}`,
|
|
2210
|
+
kind: "annotation",
|
|
2211
|
+
text: o.text,
|
|
2212
|
+
anchor: [o.position.x * M, unit.baseHeightM + ANNOTATION_LIFT_M, o.position.y * M],
|
|
2213
|
+
color: o.color,
|
|
2214
|
+
rotation: o.rotation
|
|
2215
|
+
});
|
|
2216
|
+
} else if (o.type === "booth") {
|
|
2217
|
+
const poly = boothPolygon(o);
|
|
2218
|
+
if (!poly) continue;
|
|
2219
|
+
const c = centroidOf(poly);
|
|
2220
|
+
if (!c) continue;
|
|
2221
|
+
labels.push({
|
|
2222
|
+
id: `booth:${o.id}`,
|
|
2223
|
+
kind: "booth",
|
|
2224
|
+
text: o.displayLabel || o.label || o.id,
|
|
2225
|
+
anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M]
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const floors = [];
|
|
2231
|
+
{
|
|
2232
|
+
const sectionFloor = /* @__PURE__ */ new Map();
|
|
2233
|
+
for (let ui = 0; ui < units.length; ui++) {
|
|
2234
|
+
for (const o of units[ui].objects) if (o.type === "section") sectionFloor.set(o.id, ui);
|
|
2235
|
+
}
|
|
2236
|
+
const acc = units.map(() => ({ n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 }));
|
|
2237
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2238
|
+
const owner = surfaces.seatOwner[i];
|
|
2239
|
+
const ui = owner !== null ? sectionFloor.get(owner) : void 0;
|
|
2240
|
+
if (ui === void 0) continue;
|
|
2241
|
+
const a = acc[ui];
|
|
2242
|
+
const s = seats[i];
|
|
2243
|
+
a.n++;
|
|
2244
|
+
if (s.x < a.minX) a.minX = s.x;
|
|
2245
|
+
if (s.y < a.minY) a.minY = s.y;
|
|
2246
|
+
if (s.x > a.maxX) a.maxX = s.x;
|
|
2247
|
+
if (s.y > a.maxY) a.maxY = s.y;
|
|
2248
|
+
a.sumY += surfaces.seatDeckY(i);
|
|
2249
|
+
seatFloor[i] = ui;
|
|
2250
|
+
}
|
|
2251
|
+
for (let ui = 0; ui < units.length; ui++) {
|
|
2252
|
+
const a = acc[ui];
|
|
2253
|
+
const f = doc.floors?.[ui];
|
|
2254
|
+
floors.push({
|
|
2255
|
+
index: ui,
|
|
2256
|
+
id: f?.id ?? `floor-${ui}`,
|
|
2257
|
+
label: f?.name ?? (units.length > 1 ? `Floor ${ui + 1}` : "Venue"),
|
|
2258
|
+
seatCount: a.n,
|
|
2259
|
+
center: a.n ? [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M] : [0, 0, 0],
|
|
2260
|
+
radius: a.n ? 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1 : 0
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
764
2264
|
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
765
2265
|
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
766
2266
|
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
767
|
-
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
768
2267
|
return {
|
|
769
2268
|
solids,
|
|
770
2269
|
seats: seatData,
|
|
771
2270
|
bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
|
|
772
|
-
stateColorLUT:
|
|
2271
|
+
stateColorLUT: themeSeatColorLUT(theme, SEAT_STATES),
|
|
2272
|
+
theme,
|
|
773
2273
|
seatCount: seats.length,
|
|
774
2274
|
// Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
|
|
775
|
-
focalWorld: [focal.x * M, 1.5, focal.y * M]
|
|
2275
|
+
focalWorld: [focal.x * M, 1.5, focal.y * M],
|
|
2276
|
+
zones,
|
|
2277
|
+
labels,
|
|
2278
|
+
floors
|
|
776
2279
|
};
|
|
777
2280
|
}
|
|
778
2281
|
|
|
2282
|
+
// src/view3d/labelOverlay.ts
|
|
2283
|
+
var SEPARATION_X_PX = 88;
|
|
2284
|
+
var SEPARATION_Y_PX = 20;
|
|
2285
|
+
var KIND_STYLE = {
|
|
2286
|
+
zone: { size: 15, weight: "600", opacity: 0.95 },
|
|
2287
|
+
section: { size: 12, weight: "500", opacity: 0.88 },
|
|
2288
|
+
booth: { size: 11, weight: "500", opacity: 0.85 },
|
|
2289
|
+
annotation: { size: 11, weight: "400", opacity: 0.75 }
|
|
2290
|
+
};
|
|
2291
|
+
var LabelOverlay = class {
|
|
2292
|
+
constructor(container, opts = {}) {
|
|
2293
|
+
this.nodes = /* @__PURE__ */ new Map();
|
|
2294
|
+
this.labels = [];
|
|
2295
|
+
this.opts = opts;
|
|
2296
|
+
this.root = document.createElement("div");
|
|
2297
|
+
this.root.setAttribute("data-view3d-labels", "");
|
|
2298
|
+
const s = this.root.style;
|
|
2299
|
+
s.position = "absolute";
|
|
2300
|
+
s.inset = "0";
|
|
2301
|
+
s.pointerEvents = "none";
|
|
2302
|
+
s.overflow = "hidden";
|
|
2303
|
+
if (opts.fontFamily) s.fontFamily = opts.fontFamily;
|
|
2304
|
+
container.appendChild(this.root);
|
|
2305
|
+
}
|
|
2306
|
+
setLabels(labels) {
|
|
2307
|
+
this.labels = labels;
|
|
2308
|
+
for (const [id, node] of this.nodes) {
|
|
2309
|
+
if (!labels.some((l) => l.id === id)) {
|
|
2310
|
+
node.remove();
|
|
2311
|
+
this.nodes.delete(id);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Reposition every label for the current camera.
|
|
2317
|
+
*
|
|
2318
|
+
* `viewProjection` is column-major, as OGL supplies it.
|
|
2319
|
+
*/
|
|
2320
|
+
update(viewProjection, width, height, cameraDistance, venueRadius) {
|
|
2321
|
+
if (!this.labels.length) return;
|
|
2322
|
+
const kinds = visibleLabelKinds(cameraDistance, venueRadius);
|
|
2323
|
+
const candidates = [];
|
|
2324
|
+
for (const label of this.labels) {
|
|
2325
|
+
if (!kinds.has(label.kind)) continue;
|
|
2326
|
+
const screen = projectToScreen(viewProjection, label.anchor, width, height);
|
|
2327
|
+
if (!screen.visible) continue;
|
|
2328
|
+
candidates.push({ label, screen });
|
|
2329
|
+
}
|
|
2330
|
+
const kept = cullOverlapping(candidates, SEPARATION_X_PX, SEPARATION_Y_PX);
|
|
2331
|
+
const keptIds = new Set(kept.map((k) => k.label.id));
|
|
2332
|
+
for (const { label, screen } of kept) {
|
|
2333
|
+
const node = this.nodeFor(label);
|
|
2334
|
+
const st = node.style;
|
|
2335
|
+
st.display = "";
|
|
2336
|
+
st.transform = `translate(-50%, -50%) translate(${screen.x.toFixed(1)}px, ${screen.y.toFixed(1)}px)`;
|
|
2337
|
+
}
|
|
2338
|
+
for (const [id, node] of this.nodes) {
|
|
2339
|
+
if (!keptIds.has(id)) node.style.display = "none";
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
nodeFor(label) {
|
|
2343
|
+
let node = this.nodes.get(label.id);
|
|
2344
|
+
if (node) return node;
|
|
2345
|
+
node = document.createElement("div");
|
|
2346
|
+
node.textContent = label.text;
|
|
2347
|
+
node.setAttribute("data-label-kind", label.kind);
|
|
2348
|
+
const style = KIND_STYLE[label.kind];
|
|
2349
|
+
const s = node.style;
|
|
2350
|
+
s.position = "absolute";
|
|
2351
|
+
s.left = "0";
|
|
2352
|
+
s.top = "0";
|
|
2353
|
+
s.whiteSpace = "nowrap";
|
|
2354
|
+
s.fontSize = `${style.size}px`;
|
|
2355
|
+
s.fontWeight = style.weight;
|
|
2356
|
+
s.opacity = String(style.opacity);
|
|
2357
|
+
s.color = label.color ?? this.opts.ink ?? "#e8edf5";
|
|
2358
|
+
s.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
|
|
2359
|
+
s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
|
|
2360
|
+
if (label.kind === "zone") s.textTransform = "uppercase";
|
|
2361
|
+
s.display = "none";
|
|
2362
|
+
this.root.appendChild(node);
|
|
2363
|
+
this.nodes.set(label.id, node);
|
|
2364
|
+
return node;
|
|
2365
|
+
}
|
|
2366
|
+
dispose() {
|
|
2367
|
+
this.root.remove();
|
|
2368
|
+
this.nodes.clear();
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
|
|
779
2372
|
// src/view3d/scene/build.ts
|
|
780
2373
|
import { Geometry, Mesh, Transform } from "ogl";
|
|
781
2374
|
|
|
@@ -788,16 +2381,29 @@ precision highp float;
|
|
|
788
2381
|
in vec3 position;
|
|
789
2382
|
in vec3 normal;
|
|
790
2383
|
in vec3 color;
|
|
2384
|
+
in float floorIndex;
|
|
2385
|
+
uniform mat4 modelMatrix;
|
|
2386
|
+
uniform float uFocusFloor; // -1 = show every floor
|
|
791
2387
|
uniform mat4 modelViewMatrix;
|
|
792
2388
|
uniform mat4 projectionMatrix;
|
|
793
2389
|
uniform mat3 normalMatrix;
|
|
794
2390
|
out vec3 vColor;
|
|
2391
|
+
out vec3 vNormalWorld;
|
|
795
2392
|
out vec3 vNormalView;
|
|
796
2393
|
out vec3 vPosView;
|
|
2394
|
+
out float vDim;
|
|
797
2395
|
void main() {
|
|
2396
|
+
// Per-floor isolation without splitting the merged mesh into a draw call per
|
|
2397
|
+
// floor: a floor that is not the focused one is dimmed, not hidden, so the
|
|
2398
|
+
// buyer keeps the whole venue as context while looking at one level.
|
|
2399
|
+
vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
798
2400
|
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
|
799
2401
|
vPosView = mv.xyz;
|
|
800
2402
|
vNormalView = normalize(normalMatrix * normal);
|
|
2403
|
+
// World normal drives the key + hemisphere so the lighting stays welded to the
|
|
2404
|
+
// venue as the camera orbits (the scene has no non-uniform scale, so mat3 of
|
|
2405
|
+
// the model matrix is the correct normal transform).
|
|
2406
|
+
vNormalWorld = normalize(mat3(modelMatrix) * normal);
|
|
801
2407
|
vColor = color;
|
|
802
2408
|
gl_Position = projectionMatrix * mv;
|
|
803
2409
|
}`
|
|
@@ -807,18 +2413,26 @@ var SOLID_FRAG = (
|
|
|
807
2413
|
`#version 300 es
|
|
808
2414
|
precision highp float;
|
|
809
2415
|
in vec3 vColor;
|
|
2416
|
+
in vec3 vNormalWorld;
|
|
810
2417
|
in vec3 vNormalView;
|
|
811
2418
|
in vec3 vPosView;
|
|
2419
|
+
in float vDim;
|
|
2420
|
+
uniform vec3 uKeyDir; // WORLD space, unit, points at the light
|
|
812
2421
|
out vec4 fragColor;
|
|
813
2422
|
void main() {
|
|
814
|
-
vec3 N = normalize(
|
|
2423
|
+
vec3 N = normalize(vNormalWorld);
|
|
815
2424
|
vec3 V = normalize(-vPosView);
|
|
816
|
-
float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
2425
|
+
float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
|
|
2426
|
+
float key = max(dot(N, uKeyDir), 0.0); // fixed key \u2014 does not orbit with you
|
|
2427
|
+
// Low opposite fill so faces turned away from the key keep their form instead
|
|
2428
|
+
// of crushing to a single flat value.
|
|
2429
|
+
vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
|
|
2430
|
+
float fill = max(dot(N, fillDir), 0.0);
|
|
2431
|
+
vec3 base = vColor * (0.52 + 0.34 * hemi) + vColor * key * 0.34 + vColor * fill * 0.10;
|
|
2432
|
+
float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
|
|
2433
|
+
base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained (view-dependent by design)
|
|
2434
|
+
// Unfocused floors fall back toward the background rather than vanishing.
|
|
2435
|
+
base = mix(base, base * 0.45, vDim);
|
|
822
2436
|
fragColor = vec4(base, 1.0);
|
|
823
2437
|
}`
|
|
824
2438
|
);
|
|
@@ -829,22 +2443,45 @@ precision highp float;
|
|
|
829
2443
|
in vec2 position; // quad corner in [-1,1]
|
|
830
2444
|
in vec3 iOffset; // per-instance world position
|
|
831
2445
|
in vec3 iColor; // per-instance state colour (resolved CPU-side)
|
|
2446
|
+
in float iMaxRadius; // per-instance world-radius ceiling (seat pitch derived)
|
|
2447
|
+
in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
|
|
2448
|
+
in float iFloor; // owning floor index
|
|
832
2449
|
uniform mat4 modelViewMatrix;
|
|
833
2450
|
uniform mat4 projectionMatrix;
|
|
834
2451
|
uniform float uSeatRadius;
|
|
835
2452
|
uniform float uSeatScale;
|
|
836
2453
|
uniform float uMinPixels;
|
|
837
2454
|
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
2455
|
+
uniform float uFocusFloor; // -1 = show every floor
|
|
838
2456
|
out vec2 vUv;
|
|
839
2457
|
out vec3 vColor;
|
|
2458
|
+
out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
|
|
2459
|
+
out vec3 vRing;
|
|
2460
|
+
out float vDim;
|
|
840
2461
|
void main() {
|
|
841
2462
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
842
2463
|
float depth = max(-mv.z, 0.001);
|
|
843
2464
|
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
844
|
-
|
|
2465
|
+
// Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
|
|
2466
|
+
// unbounded growth is what merges neighbouring rows into one mass at range.
|
|
2467
|
+
float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
|
|
2468
|
+
// How much of the requested pixel floor the dot could actually afford. Below 1
|
|
2469
|
+
// it is losing legibility to distance, and the fragment stage dissolves it
|
|
2470
|
+
// toward the tier top rather than letting a sub-pixel dot alias and shimmer.
|
|
2471
|
+
vBudget = minR > 0.0 ? clamp(r / minR, 0.0, 1.0) : 1.0;
|
|
845
2472
|
mv.xy += position * r; // camera-facing billboard
|
|
2473
|
+
// Seat the dot ON the deck instead of centring it in the deck. iOffset is the
|
|
2474
|
+
// exact surface point, so half the billboard would otherwise sit below the cap
|
|
2475
|
+
// and be clipped by it \u2014 and because uMinPixels grows r with distance, no
|
|
2476
|
+
// constant world-space lift can prevent that at every range. Offsetting by r
|
|
2477
|
+
// along the screen projection of WORLD up is self-correcting: it is full at a
|
|
2478
|
+
// grazing view (where slicing happens) and vanishes looking straight down
|
|
2479
|
+
// (where the dot must stay centred on its seat).
|
|
2480
|
+
mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
|
|
846
2481
|
vUv = position;
|
|
847
2482
|
vColor = iColor;
|
|
2483
|
+
vRing = iRing;
|
|
2484
|
+
vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
848
2485
|
gl_Position = projectionMatrix * mv;
|
|
849
2486
|
}`
|
|
850
2487
|
);
|
|
@@ -854,6 +2491,9 @@ var SEAT_FRAG = (
|
|
|
854
2491
|
precision highp float;
|
|
855
2492
|
in vec2 vUv;
|
|
856
2493
|
in vec3 vColor;
|
|
2494
|
+
in float vBudget;
|
|
2495
|
+
in vec3 vRing;
|
|
2496
|
+
in float vDim;
|
|
857
2497
|
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
858
2498
|
uniform vec3 uFadeColor;
|
|
859
2499
|
out vec4 fragColor;
|
|
@@ -864,6 +2504,20 @@ void main() {
|
|
|
864
2504
|
float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
|
|
865
2505
|
vec3 c = vColor * shade;
|
|
866
2506
|
c = mix(c, uFadeColor, uSeatFade);
|
|
2507
|
+
// Accommodation ring \u2014 the 3D echo of the coloured ring 2D draws around every
|
|
2508
|
+
// accessible seat. Painted INSIDE the dot's own radius rather than outside it,
|
|
2509
|
+
// so an accessible seat still respects the row-pitch ceiling and cannot grow
|
|
2510
|
+
// into its neighbour just for carrying a ring.
|
|
2511
|
+
float ringMask = step(0.001, dot(vRing, vRing));
|
|
2512
|
+
float ring = smoothstep(0.58, 0.70, d) * (1.0 - smoothstep(0.90, 1.0, d));
|
|
2513
|
+
c = mix(c, vRing, ring * ringMask * 0.95);
|
|
2514
|
+
// A dot that can no longer afford its pixel floor dissolves instead of
|
|
2515
|
+
// aliasing; the tier cap underneath already carries the section's category
|
|
2516
|
+
// tint, so the block reads as coloured seating rather than empty concrete.
|
|
2517
|
+
alpha *= smoothstep(0.35, 1.0, vBudget);
|
|
2518
|
+
// Seats on an unfocused floor recede with their structure.
|
|
2519
|
+
c = mix(c, uFadeColor, vDim * 0.75);
|
|
2520
|
+
alpha *= mix(1.0, 0.30, vDim);
|
|
867
2521
|
fragColor = vec4(c, alpha);
|
|
868
2522
|
}`
|
|
869
2523
|
);
|
|
@@ -899,6 +2553,7 @@ var SEAT_PICK_VERT = (
|
|
|
899
2553
|
precision highp float;
|
|
900
2554
|
in vec2 position;
|
|
901
2555
|
in vec3 iOffset;
|
|
2556
|
+
in float iMaxRadius;
|
|
902
2557
|
uniform mat4 modelViewMatrix;
|
|
903
2558
|
uniform mat4 projectionMatrix;
|
|
904
2559
|
uniform float uSeatRadius;
|
|
@@ -913,8 +2568,10 @@ void main() {
|
|
|
913
2568
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
914
2569
|
float depth = max(-mv.z, 0.001);
|
|
915
2570
|
float minR = uMinPixels * depth * uPixelToWorld;
|
|
916
|
-
float r = max(uSeatRadius * uSeatScale, minR);
|
|
2571
|
+
float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
|
|
917
2572
|
mv.xy += position * r;
|
|
2573
|
+
// Must match SEAT_VERT exactly, or the hit mask drifts off the drawn dot.
|
|
2574
|
+
mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
|
|
918
2575
|
vUv = position;
|
|
919
2576
|
gl_Position = projectionMatrix * mv;
|
|
920
2577
|
}`
|
|
@@ -958,7 +2615,7 @@ function createSeatPickProgram(gl) {
|
|
|
958
2615
|
depthWrite: true,
|
|
959
2616
|
cullFace: false,
|
|
960
2617
|
uniforms: {
|
|
961
|
-
uSeatRadius: { value:
|
|
2618
|
+
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
962
2619
|
uSeatScale: { value: 1 },
|
|
963
2620
|
uMinPixels: { value: 2.5 },
|
|
964
2621
|
uPixelToWorld: { value: 2e-3 }
|
|
@@ -985,7 +2642,13 @@ function createSolidProgram(gl) {
|
|
|
985
2642
|
fragment: SOLID_FRAG,
|
|
986
2643
|
cullFace: false,
|
|
987
2644
|
depthTest: true,
|
|
988
|
-
depthWrite: true
|
|
2645
|
+
depthWrite: true,
|
|
2646
|
+
uniforms: {
|
|
2647
|
+
// High and off-axis, in world space: reads as a house rig rather than a
|
|
2648
|
+
// headlamp welded to the camera.
|
|
2649
|
+
uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
|
|
2650
|
+
uFocusFloor: { value: -1 }
|
|
2651
|
+
}
|
|
989
2652
|
});
|
|
990
2653
|
}
|
|
991
2654
|
function createSeatProgram(gl) {
|
|
@@ -997,11 +2660,12 @@ function createSeatProgram(gl) {
|
|
|
997
2660
|
depthWrite: false,
|
|
998
2661
|
cullFace: false,
|
|
999
2662
|
uniforms: {
|
|
1000
|
-
uSeatRadius: { value:
|
|
2663
|
+
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
1001
2664
|
uSeatScale: { value: 1 },
|
|
1002
2665
|
uMinPixels: { value: 2.5 },
|
|
1003
2666
|
uPixelToWorld: { value: 2e-3 },
|
|
1004
2667
|
uSeatFade: { value: 0 },
|
|
2668
|
+
uFocusFloor: { value: -1 },
|
|
1005
2669
|
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
|
|
1006
2670
|
}
|
|
1007
2671
|
});
|
|
@@ -1021,9 +2685,9 @@ function createBackgroundProgram(gl, top, bottom) {
|
|
|
1021
2685
|
}
|
|
1022
2686
|
|
|
1023
2687
|
// src/view3d/scene/build.ts
|
|
1024
|
-
function writeSeatColors(iColor, iState, start, count) {
|
|
2688
|
+
function writeSeatColors(iColor, iState, start, count, states) {
|
|
1025
2689
|
for (let i = start; i < start + count; i++) {
|
|
1026
|
-
const c =
|
|
2690
|
+
const c = states[iState[i]] ?? states[0];
|
|
1027
2691
|
iColor[i * 3] = c[0];
|
|
1028
2692
|
iColor[i * 3 + 1] = c[1];
|
|
1029
2693
|
iColor[i * 3 + 2] = c[2];
|
|
@@ -1035,14 +2699,15 @@ function buildGpuScene(gl, model) {
|
|
|
1035
2699
|
const main = new Transform();
|
|
1036
2700
|
const background = new Transform();
|
|
1037
2701
|
const bgGeo = new Geometry(gl, { position: { size: 2, data: BG_TRI } });
|
|
1038
|
-
const bgProg = createBackgroundProgram(gl,
|
|
2702
|
+
const bgProg = createBackgroundProgram(gl, model.theme.background.top, model.theme.background.bottom);
|
|
1039
2703
|
const bgMesh = new Mesh(gl, { geometry: bgGeo, program: bgProg });
|
|
1040
2704
|
bgMesh.frustumCulled = false;
|
|
1041
2705
|
bgMesh.setParent(background);
|
|
1042
2706
|
const solidGeo = new Geometry(gl, {
|
|
1043
2707
|
position: { size: 3, data: model.solids.position },
|
|
1044
2708
|
normal: { size: 3, data: model.solids.normal },
|
|
1045
|
-
color: { size: 3, data: model.solids.color }
|
|
2709
|
+
color: { size: 3, data: model.solids.color },
|
|
2710
|
+
floorIndex: { size: 1, data: model.solids.floor }
|
|
1046
2711
|
});
|
|
1047
2712
|
const solidProg = createSolidProgram(gl);
|
|
1048
2713
|
const solidMesh = new Mesh(gl, { geometry: solidGeo, program: solidProg });
|
|
@@ -1050,11 +2715,18 @@ function buildGpuScene(gl, model) {
|
|
|
1050
2715
|
solidMesh.setParent(main);
|
|
1051
2716
|
const seatProg = createSeatProgram(gl);
|
|
1052
2717
|
const iColor = new Float32Array(model.seats.count * 3);
|
|
1053
|
-
|
|
2718
|
+
const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
|
|
2719
|
+
writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
|
|
1054
2720
|
const seatGeo = new Geometry(gl, {
|
|
1055
2721
|
position: { size: 2, data: SEAT_QUAD },
|
|
1056
2722
|
iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
|
|
1057
|
-
iColor: { size: 3, data: iColor, instanced: 1 }
|
|
2723
|
+
iColor: { size: 3, data: iColor, instanced: 1 },
|
|
2724
|
+
// Per-seat world-radius ceiling: what stops distant rows merging into one
|
|
2725
|
+
// mass when the shader grows a dot to hold its minimum pixel size.
|
|
2726
|
+
iMaxRadius: { size: 1, data: model.seats.iMaxRadius, instanced: 1 },
|
|
2727
|
+
// Accommodation ring colour; (0,0,0) means the seat carries no access type.
|
|
2728
|
+
iRing: { size: 3, data: model.seats.iRing, instanced: 1 },
|
|
2729
|
+
iFloor: { size: 1, data: model.seats.iFloor, instanced: 1 }
|
|
1058
2730
|
});
|
|
1059
2731
|
const seatMesh = new Mesh(gl, { geometry: seatGeo, program: seatProg });
|
|
1060
2732
|
seatMesh.frustumCulled = false;
|
|
@@ -1064,12 +2736,13 @@ function buildGpuScene(gl, model) {
|
|
|
1064
2736
|
main,
|
|
1065
2737
|
background,
|
|
1066
2738
|
seatProgram: seatProg,
|
|
2739
|
+
solidProgram: solidProg,
|
|
1067
2740
|
seatGeometry: seatGeo,
|
|
1068
2741
|
solidGeometry: solidGeo,
|
|
1069
2742
|
drawCalls: 3,
|
|
1070
2743
|
uploadSeatStateRuns(runs) {
|
|
1071
2744
|
if (!runs.length) return;
|
|
1072
|
-
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length);
|
|
2745
|
+
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
|
|
1073
2746
|
const buffer = colorAttr.buffer;
|
|
1074
2747
|
if (!buffer) {
|
|
1075
2748
|
colorAttr.needsUpdate = true;
|
|
@@ -1137,6 +2810,8 @@ var PickPipeline = class {
|
|
|
1137
2810
|
this.seatScene = new Transform2();
|
|
1138
2811
|
this.solidScene = new Transform2();
|
|
1139
2812
|
this.target = null;
|
|
2813
|
+
/** Display clear colour to restore after the pick pass (theme-dependent). */
|
|
2814
|
+
this.restoreClear = [0, 0, 0];
|
|
1140
2815
|
this.renderer = renderer;
|
|
1141
2816
|
this.gl = renderer.gl;
|
|
1142
2817
|
this.maxIndex = seatCount;
|
|
@@ -1149,6 +2824,10 @@ var PickPipeline = class {
|
|
|
1149
2824
|
solidMesh.frustumCulled = false;
|
|
1150
2825
|
solidMesh.setParent(this.solidScene);
|
|
1151
2826
|
}
|
|
2827
|
+
/** Follow the theme's background when the scene is (re)built. */
|
|
2828
|
+
setRestoreClear(rgb) {
|
|
2829
|
+
this.restoreClear = [rgb[0], rgb[1], rgb[2]];
|
|
2830
|
+
}
|
|
1152
2831
|
/** Match the display seat sizing so the pick mask lines up with the dots. */
|
|
1153
2832
|
syncFromSeatProgram(seatProgram) {
|
|
1154
2833
|
for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
|
|
@@ -1189,7 +2868,7 @@ var PickPipeline = class {
|
|
|
1189
2868
|
const boxH = Math.max(1, Math.min(bh, py + radius + 1) - y0);
|
|
1190
2869
|
gl.enable(gl.SCISSOR_TEST);
|
|
1191
2870
|
gl.scissor(x0, y0, boxW, boxH);
|
|
1192
|
-
const [br, bg, bb] =
|
|
2871
|
+
const [br, bg, bb] = this.restoreClear;
|
|
1193
2872
|
gl.clearColor(0, 0, 0, 1);
|
|
1194
2873
|
this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
|
|
1195
2874
|
this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
|
|
@@ -1642,9 +3321,15 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1642
3321
|
const prefetch = /* @__PURE__ */ new Map();
|
|
1643
3322
|
let flightGen = 0;
|
|
1644
3323
|
let reducedForced = null;
|
|
3324
|
+
let focusedFloor = -1;
|
|
1645
3325
|
const rebuildGpu = () => {
|
|
1646
3326
|
gpu = buildGpuScene(glctx.gl, model);
|
|
1647
3327
|
pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
|
|
3328
|
+
glctx.setClearColor(model.theme.background.top);
|
|
3329
|
+
pick.setRestoreClear(model.theme.background.top);
|
|
3330
|
+
gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
|
|
3331
|
+
gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
3332
|
+
gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
1648
3333
|
};
|
|
1649
3334
|
const glctx = new GLContext(container, {
|
|
1650
3335
|
onContextLost: () => {
|
|
@@ -1667,8 +3352,21 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1667
3352
|
// first real drag/wheel/pinch (not the intro ease)
|
|
1668
3353
|
);
|
|
1669
3354
|
orbit.setAspect(glctx.aspect);
|
|
1670
|
-
|
|
3355
|
+
const stageAzimuth = (() => {
|
|
3356
|
+
const dx = model.focalWorld[0] - model.bounds.center[0];
|
|
3357
|
+
const dz = model.focalWorld[2] - model.bounds.center[2];
|
|
3358
|
+
return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
|
|
3359
|
+
})();
|
|
3360
|
+
orbit.frame(model.bounds, true, stageAzimuth);
|
|
1671
3361
|
const cinematic = new Cinematic(orbit.camera);
|
|
3362
|
+
if (!container.style.position || container.style.position === "static") {
|
|
3363
|
+
container.style.position = "relative";
|
|
3364
|
+
}
|
|
3365
|
+
const labelOverlay = new LabelOverlay(container, {
|
|
3366
|
+
fontFamily: input.doc.theme?.fontFamily,
|
|
3367
|
+
ink: input.doc.theme?.textColor
|
|
3368
|
+
});
|
|
3369
|
+
labelOverlay.setLabels(model.labels);
|
|
1672
3370
|
rebuildGpu();
|
|
1673
3371
|
const loop = new RenderLoop(() => {
|
|
1674
3372
|
if (contextLost || !gpu || frozen) return false;
|
|
@@ -1681,6 +3379,13 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1681
3379
|
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
|
|
1682
3380
|
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
1683
3381
|
glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
|
|
3382
|
+
labelOverlay.update(
|
|
3383
|
+
orbit.camera.projectionViewMatrix,
|
|
3384
|
+
glctx.canvas.clientWidth || 1,
|
|
3385
|
+
glctx.canvas.clientHeight || 1,
|
|
3386
|
+
orbit.currentDistance,
|
|
3387
|
+
model.bounds.radius
|
|
3388
|
+
);
|
|
1684
3389
|
return moving;
|
|
1685
3390
|
});
|
|
1686
3391
|
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
|
|
@@ -1728,6 +3433,72 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1728
3433
|
orbit.camera.fov = FOV_END;
|
|
1729
3434
|
orbit.camera.updateProjectionMatrix();
|
|
1730
3435
|
};
|
|
3436
|
+
let arriveChip = null;
|
|
3437
|
+
const removeArriveChip = () => {
|
|
3438
|
+
arriveChip?.remove();
|
|
3439
|
+
arriveChip = null;
|
|
3440
|
+
};
|
|
3441
|
+
const showArriveChip = (seatId, gen) => {
|
|
3442
|
+
removeArriveChip();
|
|
3443
|
+
if (disposed || !opts.getSeatView) return;
|
|
3444
|
+
const chip = document.createElement("button");
|
|
3445
|
+
chip.type = "button";
|
|
3446
|
+
chip.textContent = "\u25C9 View in 360\xB0";
|
|
3447
|
+
chip.setAttribute("aria-label", `Open the 360\xB0 view from seat ${seatId}`);
|
|
3448
|
+
Object.assign(chip.style, {
|
|
3449
|
+
position: "absolute",
|
|
3450
|
+
left: "50%",
|
|
3451
|
+
bottom: "18px",
|
|
3452
|
+
transform: "translateX(-50%)",
|
|
3453
|
+
minHeight: "44px",
|
|
3454
|
+
padding: "10px 18px",
|
|
3455
|
+
borderRadius: "999px",
|
|
3456
|
+
background: "rgba(12,18,32,0.78)",
|
|
3457
|
+
color: "#eef1f8",
|
|
3458
|
+
border: "1px solid rgba(150,165,205,0.4)",
|
|
3459
|
+
backdropFilter: "blur(6px)",
|
|
3460
|
+
font: "600 13px/1 inherit",
|
|
3461
|
+
cursor: "pointer",
|
|
3462
|
+
zIndex: "4"
|
|
3463
|
+
});
|
|
3464
|
+
chip.addEventListener("click", () => {
|
|
3465
|
+
if (disposed || gen !== flightGen) {
|
|
3466
|
+
removeArriveChip();
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3469
|
+
removeArriveChip();
|
|
3470
|
+
void openPanorama(seatId, 400, gen);
|
|
3471
|
+
});
|
|
3472
|
+
container.appendChild(chip);
|
|
3473
|
+
arriveChip = chip;
|
|
3474
|
+
};
|
|
3475
|
+
const overviewChip = document.createElement("button");
|
|
3476
|
+
overviewChip.type = "button";
|
|
3477
|
+
overviewChip.textContent = "\u2302 Overview";
|
|
3478
|
+
overviewChip.setAttribute("aria-label", "Return to the venue overview");
|
|
3479
|
+
Object.assign(overviewChip.style, {
|
|
3480
|
+
position: "absolute",
|
|
3481
|
+
right: "14px",
|
|
3482
|
+
bottom: "18px",
|
|
3483
|
+
minHeight: "40px",
|
|
3484
|
+
padding: "8px 14px",
|
|
3485
|
+
borderRadius: "999px",
|
|
3486
|
+
background: "rgba(12,18,32,0.72)",
|
|
3487
|
+
color: "#c9d4ea",
|
|
3488
|
+
border: "1px solid rgba(150,165,205,0.35)",
|
|
3489
|
+
backdropFilter: "blur(6px)",
|
|
3490
|
+
font: "600 12.5px/1 inherit",
|
|
3491
|
+
cursor: "pointer",
|
|
3492
|
+
zIndex: "4"
|
|
3493
|
+
});
|
|
3494
|
+
overviewChip.addEventListener("click", () => {
|
|
3495
|
+
if (disposed || frozen) return;
|
|
3496
|
+
cancelFlight();
|
|
3497
|
+
removeArriveChip();
|
|
3498
|
+
orbit.frameSoft(model.bounds, stageAzimuth);
|
|
3499
|
+
loop.requestRender();
|
|
3500
|
+
});
|
|
3501
|
+
container.appendChild(overviewChip);
|
|
1731
3502
|
const openPanorama = async (seatId, fadeMs, gen) => {
|
|
1732
3503
|
const viewPromise = ensureSeatView(seatId);
|
|
1733
3504
|
if (!viewPromise) {
|
|
@@ -1748,6 +3519,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1748
3519
|
return;
|
|
1749
3520
|
}
|
|
1750
3521
|
if (disposed || gen !== flightGen) return;
|
|
3522
|
+
removeArriveChip();
|
|
1751
3523
|
panorama = mountPanorama(container, view, {
|
|
1752
3524
|
fadeMs,
|
|
1753
3525
|
seatLabel: seatId,
|
|
@@ -1757,6 +3529,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1757
3529
|
analytics.panoramaClosed();
|
|
1758
3530
|
orbit.resumeAfterFlight(model.focalWorld);
|
|
1759
3531
|
loop.requestRender();
|
|
3532
|
+
showArriveChip(seatId, flightGen);
|
|
1760
3533
|
}
|
|
1761
3534
|
});
|
|
1762
3535
|
analytics.panoramaOpened();
|
|
@@ -1778,6 +3551,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1778
3551
|
}
|
|
1779
3552
|
frozen = false;
|
|
1780
3553
|
const gen = ++flightGen;
|
|
3554
|
+
removeArriveChip();
|
|
1781
3555
|
const seatEye = seatEyeWorld(idx);
|
|
1782
3556
|
const focal = model.focalWorld;
|
|
1783
3557
|
const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
|
|
@@ -1786,9 +3560,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1786
3560
|
placeCameraFinal(finalPos, focal);
|
|
1787
3561
|
loop.requestRender();
|
|
1788
3562
|
analytics.cinematicSkipped();
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
3563
|
+
if (!disposed) orbit.syncFromCamera();
|
|
3564
|
+
showArriveChip(seatId, gen);
|
|
3565
|
+
return Promise.resolve();
|
|
1792
3566
|
}
|
|
1793
3567
|
const startQuat = new Quat2().copy(orbit.camera.quaternion);
|
|
1794
3568
|
const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
|
|
@@ -1796,7 +3570,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1796
3570
|
return cinematic.start(waypoints, startQuat, endQuat).then(() => {
|
|
1797
3571
|
if (disposed || gen !== flightGen) return;
|
|
1798
3572
|
analytics.cinematicPlayed(FLIGHT_DURATION_MS);
|
|
1799
|
-
|
|
3573
|
+
orbit.resumeAfterFlight(model.focalWorld);
|
|
3574
|
+
loop.requestRender();
|
|
3575
|
+
showArriveChip(seatId, gen);
|
|
1800
3576
|
});
|
|
1801
3577
|
};
|
|
1802
3578
|
let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
|
|
@@ -1872,13 +3648,51 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1872
3648
|
loseContextForTest() {
|
|
1873
3649
|
glctx.simulateContextLossCycle();
|
|
1874
3650
|
},
|
|
3651
|
+
floors() {
|
|
3652
|
+
return model.floors;
|
|
3653
|
+
},
|
|
3654
|
+
focusFloor(index) {
|
|
3655
|
+
if (index !== null && !model.floors.some((f) => f.index === index)) return false;
|
|
3656
|
+
const value = index ?? -1;
|
|
3657
|
+
if (gpu) {
|
|
3658
|
+
gpu.seatProgram.uniforms.uFocusFloor.value = value;
|
|
3659
|
+
gpu.solidProgram.uniforms.uFocusFloor.value = value;
|
|
3660
|
+
}
|
|
3661
|
+
focusedFloor = value;
|
|
3662
|
+
if (index !== null) {
|
|
3663
|
+
const f = model.floors[index];
|
|
3664
|
+
if (f.seatCount > 0) {
|
|
3665
|
+
cinematic.cancel();
|
|
3666
|
+
orbit.frame({ center: f.center, radius: f.radius * 1.25 });
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
loop.requestRender();
|
|
3670
|
+
return true;
|
|
3671
|
+
},
|
|
3672
|
+
zones() {
|
|
3673
|
+
return model.zones;
|
|
3674
|
+
},
|
|
3675
|
+
focusZone(zoneId) {
|
|
3676
|
+
const zone = model.zones.find((z) => z.id === zoneId);
|
|
3677
|
+
if (!zone || zone.seatCount === 0) return false;
|
|
3678
|
+
cinematic.cancel();
|
|
3679
|
+
const dx = zone.focalWorld[0] - zone.center[0];
|
|
3680
|
+
const dz = zone.focalWorld[2] - zone.center[2];
|
|
3681
|
+
const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
|
|
3682
|
+
orbit.frame({ center: zone.center, radius: zone.radius * 1.25 }, false, azimuth);
|
|
3683
|
+
loop.requestRender();
|
|
3684
|
+
return true;
|
|
3685
|
+
},
|
|
1875
3686
|
setReducedMotionForTest(value) {
|
|
1876
3687
|
reducedForced = value;
|
|
1877
3688
|
},
|
|
1878
3689
|
dispose() {
|
|
1879
3690
|
disposed = true;
|
|
3691
|
+
removeArriveChip();
|
|
3692
|
+
overviewChip.remove();
|
|
1880
3693
|
cancelFlight();
|
|
1881
3694
|
loop.stop();
|
|
3695
|
+
labelOverlay.dispose();
|
|
1882
3696
|
ro?.disconnect();
|
|
1883
3697
|
if (panorama) {
|
|
1884
3698
|
panorama.dispose();
|