@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.cjs
CHANGED
|
@@ -52,15 +52,6 @@ var SEAT_STATE_COLORS = {
|
|
|
52
52
|
selected: [0.24, 0.74, 1],
|
|
53
53
|
dimmed: [0.28, 0.32, 0.37]
|
|
54
54
|
};
|
|
55
|
-
function seatStateColorLUT() {
|
|
56
|
-
const out = [];
|
|
57
|
-
for (const s of SEAT_STATES) out.push(...SEAT_STATE_COLORS[s]);
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
function seatStateColorByIndex(index) {
|
|
61
|
-
const state = SEAT_STATES[index] ?? "available";
|
|
62
|
-
return SEAT_STATE_COLORS[state];
|
|
63
|
-
}
|
|
64
55
|
var STRUCTURE = {
|
|
65
56
|
ground: [0.07, 0.085, 0.11],
|
|
66
57
|
tierTop: [0.24, 0.28, 0.34],
|
|
@@ -71,7 +62,13 @@ var STRUCTURE = {
|
|
|
71
62
|
decorTop: [0.22, 0.25, 0.29],
|
|
72
63
|
decorWall: [0.15, 0.17, 0.2],
|
|
73
64
|
gaTop: [0.24, 0.28, 0.33],
|
|
74
|
-
gaWall: [0.16, 0.19, 0.23]
|
|
65
|
+
gaWall: [0.16, 0.19, 0.23],
|
|
66
|
+
/** Exhibition / trade-show booth stand. */
|
|
67
|
+
boothTop: [0.3, 0.33, 0.38],
|
|
68
|
+
boothWall: [0.2, 0.23, 0.27],
|
|
69
|
+
/** Banquet table top — warmer, so a laid table reads apart from structure. */
|
|
70
|
+
tableTop: [0.38, 0.34, 0.29],
|
|
71
|
+
tableWall: [0.24, 0.21, 0.18]
|
|
75
72
|
};
|
|
76
73
|
var BACKGROUND = {
|
|
77
74
|
top: [0.05, 0.06, 0.08],
|
|
@@ -106,6 +103,8 @@ function computeDpr() {
|
|
|
106
103
|
}
|
|
107
104
|
var GLContext = class {
|
|
108
105
|
constructor(container, opts) {
|
|
106
|
+
/** Current clear colour, so a context restore repaints the themed background. */
|
|
107
|
+
this.clearRgb = [0, 0, 0];
|
|
109
108
|
this.container = container;
|
|
110
109
|
this.canvas = document.createElement("canvas");
|
|
111
110
|
this.canvas.style.display = "block";
|
|
@@ -123,7 +122,8 @@ var GLContext = class {
|
|
|
123
122
|
webgl: 2
|
|
124
123
|
});
|
|
125
124
|
this.gl = this.renderer.gl;
|
|
126
|
-
this.
|
|
125
|
+
this.clearRgb = [BACKGROUND.top[0], BACKGROUND.top[1], BACKGROUND.top[2]];
|
|
126
|
+
this.gl.clearColor(this.clearRgb[0], this.clearRgb[1], this.clearRgb[2], 1);
|
|
127
127
|
container.appendChild(this.canvas);
|
|
128
128
|
this.lostHandler = (e) => {
|
|
129
129
|
e.preventDefault();
|
|
@@ -134,6 +134,21 @@ var GLContext = class {
|
|
|
134
134
|
this.canvas.addEventListener("webglcontextrestored", this.restoredHandler, false);
|
|
135
135
|
this.resize();
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Repaint the clear colour from the chart's theme.
|
|
139
|
+
*
|
|
140
|
+
* The clear shows for one frame before the background triangle draws, and on
|
|
141
|
+
* any frame the scene does not cover — so leaving it at the library default
|
|
142
|
+
* flashes SeatLayer grey into a white-labelled venue.
|
|
143
|
+
*/
|
|
144
|
+
setClearColor(rgb) {
|
|
145
|
+
this.clearRgb = [rgb[0], rgb[1], rgb[2]];
|
|
146
|
+
this.gl.clearColor(rgb[0], rgb[1], rgb[2], 1);
|
|
147
|
+
}
|
|
148
|
+
/** The clear colour currently set (the pick pass restores through this). */
|
|
149
|
+
get clearColor() {
|
|
150
|
+
return this.clearRgb;
|
|
151
|
+
}
|
|
137
152
|
/** Match the drawing buffer to the container's CSS box. */
|
|
138
153
|
resize() {
|
|
139
154
|
const w = Math.max(1, this.container.clientWidth || this.canvas.clientWidth || 1);
|
|
@@ -282,14 +297,14 @@ var OrbitCamera = class {
|
|
|
282
297
|
* then the damped `update()` eases it up into the 3/4 architectural angle and
|
|
283
298
|
* dollies in — the venue "stands up" instead of teleporting (~600ms).
|
|
284
299
|
*/
|
|
285
|
-
frame(bounds, intro = false) {
|
|
300
|
+
frame(bounds, intro = false, stageAzimuth) {
|
|
286
301
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
287
302
|
const r = Math.max(1, bounds.radius);
|
|
288
303
|
const halfV = this.fovY * DEG / 2;
|
|
289
304
|
const aspect = this.camera.aspect || 1;
|
|
290
305
|
const halfH = Math.atan(Math.tan(halfV) * aspect);
|
|
291
306
|
const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
|
|
292
|
-
this.azT = -30 * DEG;
|
|
307
|
+
this.azT = stageAzimuth ?? -30 * DEG;
|
|
293
308
|
this.polT = 55 * DEG;
|
|
294
309
|
this.distT = fit * FRAME_MARGIN;
|
|
295
310
|
this.minDist = Math.max(2, r * 0.12);
|
|
@@ -308,6 +323,24 @@ var OrbitCamera = class {
|
|
|
308
323
|
setAspect(aspect) {
|
|
309
324
|
this.camera.perspective({ aspect });
|
|
310
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Damped return to the framed overview from wherever the camera is now (a
|
|
328
|
+
* seat, behind the shell, anywhere): re-pivot on the venue centre without
|
|
329
|
+
* moving the camera, then glide targets back to the 3/4 architectural pose.
|
|
330
|
+
*/
|
|
331
|
+
frameSoft(bounds, stageAzimuth) {
|
|
332
|
+
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
333
|
+
this.syncFromCamera();
|
|
334
|
+
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
335
|
+
const r = Math.max(1, bounds.radius);
|
|
336
|
+
const halfV = this.fovY * DEG / 2;
|
|
337
|
+
const aspect = this.camera.aspect || 1;
|
|
338
|
+
const halfH = Math.atan(Math.tan(halfV) * aspect);
|
|
339
|
+
const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
|
|
340
|
+
this.azT = stageAzimuth ?? this.azimuth;
|
|
341
|
+
this.polT = 55 * DEG;
|
|
342
|
+
this.distT = fit * FRAME_MARGIN;
|
|
343
|
+
}
|
|
311
344
|
/** Damp toward targets; returns true while still moving. */
|
|
312
345
|
update() {
|
|
313
346
|
const da = this.azT - this.azimuth;
|
|
@@ -424,6 +457,46 @@ function computeSeatLod(distance, radius) {
|
|
|
424
457
|
};
|
|
425
458
|
}
|
|
426
459
|
|
|
460
|
+
// src/core/types.ts
|
|
461
|
+
var ACCESSIBILITY_TYPES = [
|
|
462
|
+
{ key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
|
|
463
|
+
{ key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
|
|
464
|
+
{ key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
|
|
465
|
+
{ key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
|
|
466
|
+
{ key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
|
|
467
|
+
{ key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
|
|
468
|
+
{ key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
|
|
469
|
+
{ key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
|
|
470
|
+
];
|
|
471
|
+
var ACCESSIBILITY_LABEL = new Map(ACCESSIBILITY_TYPES.map((a) => [a.key, a]));
|
|
472
|
+
var ACCESSIBILITY_RING_COLOR = {
|
|
473
|
+
wheelchair: "#3b82f6",
|
|
474
|
+
companion: "#8b5cf6",
|
|
475
|
+
"semi-ambulatory": "#0ea5e9",
|
|
476
|
+
hearing: "#14b8a6",
|
|
477
|
+
cart: "#7c3aed",
|
|
478
|
+
"sign-language": "#f59e0b",
|
|
479
|
+
"plus-size": "#ec4899",
|
|
480
|
+
"lift-armrest": "#22c55e"
|
|
481
|
+
};
|
|
482
|
+
function accessibilityRingColor(types) {
|
|
483
|
+
const primary = types?.[0];
|
|
484
|
+
return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
|
|
485
|
+
}
|
|
486
|
+
var SURROUNDINGS_SHAPE_ROLES = [
|
|
487
|
+
"reference-focal",
|
|
488
|
+
"bar",
|
|
489
|
+
"entrance",
|
|
490
|
+
"exit",
|
|
491
|
+
"restroom",
|
|
492
|
+
"screen",
|
|
493
|
+
"sound",
|
|
494
|
+
"concession",
|
|
495
|
+
"coat",
|
|
496
|
+
"wall"
|
|
497
|
+
];
|
|
498
|
+
var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
|
|
499
|
+
|
|
427
500
|
// src/core/units.ts
|
|
428
501
|
var METRES_PER_CHART_UNIT = 0.55 / 24;
|
|
429
502
|
var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
|
|
@@ -462,18 +535,47 @@ function sectionGeometry(section, context = {}) {
|
|
|
462
535
|
|
|
463
536
|
// src/view3d/scene/geometry.ts
|
|
464
537
|
var import_earcut = __toESM(require("earcut"), 1);
|
|
538
|
+
var import_polygon_clipping = __toESM(require("polygon-clipping"), 1);
|
|
465
539
|
var M = METRES_PER_CHART_UNIT;
|
|
540
|
+
var MIN_TRI_ALTITUDE_M = 2e-3;
|
|
541
|
+
function isDegenerate(p0, p1, p2) {
|
|
542
|
+
const e0 = Math.hypot(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
|
|
543
|
+
const e1 = Math.hypot(p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]);
|
|
544
|
+
const e2 = Math.hypot(p0[0] - p2[0], p0[1] - p2[1], p0[2] - p2[2]);
|
|
545
|
+
const longest = Math.max(e0, e1, e2);
|
|
546
|
+
if (longest < 1e-9) return true;
|
|
547
|
+
const s = (e0 + e1 + e2) / 2;
|
|
548
|
+
const area = Math.sqrt(Math.max(0, s * (s - e0) * (s - e1) * (s - e2)));
|
|
549
|
+
return 2 * area / longest < MIN_TRI_ALTITUDE_M;
|
|
550
|
+
}
|
|
466
551
|
var MeshBuilder = class {
|
|
467
552
|
constructor() {
|
|
468
553
|
this.pos = [];
|
|
469
554
|
this.nor = [];
|
|
470
555
|
this.col = [];
|
|
556
|
+
this.flr = [];
|
|
557
|
+
/** Floor index stamped onto every triangle emitted from now on. */
|
|
558
|
+
this.currentFloor = 0;
|
|
559
|
+
}
|
|
560
|
+
/** Stamp subsequent triangles as belonging to `index`. */
|
|
561
|
+
setFloor(index) {
|
|
562
|
+
this.currentFloor = index;
|
|
471
563
|
}
|
|
472
564
|
/** One triangle with a shared (flat) normal and per-vertex colours. */
|
|
473
565
|
tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
|
|
566
|
+
if (isDegenerate(p0, p1, p2)) return;
|
|
474
567
|
this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
|
|
475
568
|
this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
|
|
476
569
|
this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
|
|
570
|
+
this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
|
|
571
|
+
}
|
|
572
|
+
/** One triangle with independent per-vertex normals (smooth shading). */
|
|
573
|
+
triN(p0, p1, p2, n0, n1, n2, c0, c1 = c0, c2 = c0) {
|
|
574
|
+
if (isDegenerate(p0, p1, p2)) return;
|
|
575
|
+
this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
|
|
576
|
+
this.nor.push(n0[0], n0[1], n0[2], n1[0], n1[1], n1[2], n2[0], n2[1], n2[2]);
|
|
577
|
+
this.col.push(c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2]);
|
|
578
|
+
this.flr.push(this.currentFloor, this.currentFloor, this.currentFloor);
|
|
477
579
|
}
|
|
478
580
|
get vertexCount() {
|
|
479
581
|
return this.pos.length / 3;
|
|
@@ -483,6 +585,7 @@ var MeshBuilder = class {
|
|
|
483
585
|
position: new Float32Array(this.pos),
|
|
484
586
|
normal: new Float32Array(this.nor),
|
|
485
587
|
color: new Float32Array(this.col),
|
|
588
|
+
floor: new Float32Array(this.flr),
|
|
486
589
|
count: this.pos.length / 3
|
|
487
590
|
};
|
|
488
591
|
}
|
|
@@ -537,33 +640,158 @@ function signedArea(pts) {
|
|
|
537
640
|
function toCCW(pts) {
|
|
538
641
|
return signedArea(pts) < 0 ? [...pts].reverse() : pts;
|
|
539
642
|
}
|
|
540
|
-
|
|
643
|
+
var MAX_CAP_SPLIT_DEPTH = 3;
|
|
644
|
+
function capDeviation(a, b, c, topY) {
|
|
645
|
+
const ya = topY(a), yb = topY(b), yc = topY(c);
|
|
646
|
+
const edge = (p, q, yp, yq) => Math.abs(topY({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }) - (yp + yq) / 2);
|
|
647
|
+
const cx3 = (a.x + b.x + c.x) / 3, cy3 = (a.y + b.y + c.y) / 3;
|
|
648
|
+
const yCen = (ya + yb + yc) / 3;
|
|
649
|
+
let worst = Math.max(
|
|
650
|
+
edge(a, b, ya, yb),
|
|
651
|
+
edge(b, c, yb, yc),
|
|
652
|
+
edge(c, a, yc, ya),
|
|
653
|
+
Math.abs(topY({ x: cx3, y: cy3 }) - yCen)
|
|
654
|
+
);
|
|
655
|
+
const probe = (px, py, yp) => {
|
|
656
|
+
const d = Math.abs(topY({ x: (px + cx3) / 2, y: (py + cy3) / 2 }) - (yp + yCen) / 2);
|
|
657
|
+
if (d > worst) worst = d;
|
|
658
|
+
};
|
|
659
|
+
probe((a.x + b.x) / 2, (a.y + b.y) / 2, (ya + yb) / 2);
|
|
660
|
+
probe((b.x + c.x) / 2, (b.y + c.y) / 2, (yb + yc) / 2);
|
|
661
|
+
probe((c.x + a.x) / 2, (c.y + a.y) / 2, (yc + ya) / 2);
|
|
662
|
+
return worst;
|
|
663
|
+
}
|
|
664
|
+
function maxDevAtDepth(a, b, c, topY, depth) {
|
|
665
|
+
if (depth <= 0) return capDeviation(a, b, c, topY);
|
|
666
|
+
const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
667
|
+
const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
|
|
668
|
+
const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
|
|
669
|
+
return Math.max(
|
|
670
|
+
maxDevAtDepth(a, ab, ca, topY, depth - 1),
|
|
671
|
+
maxDevAtDepth(ab, b, bc, topY, depth - 1),
|
|
672
|
+
maxDevAtDepth(ca, bc, c, topY, depth - 1),
|
|
673
|
+
maxDevAtDepth(ab, bc, ca, topY, depth - 1)
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
function emitCapUniform(builder, a, b, c, topY, colTop, depth, topN) {
|
|
677
|
+
if (depth > 0) {
|
|
678
|
+
const ab = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
679
|
+
const bc = { x: (b.x + c.x) / 2, y: (b.y + c.y) / 2 };
|
|
680
|
+
const ca = { x: (c.x + a.x) / 2, y: (c.y + a.y) / 2 };
|
|
681
|
+
emitCapUniform(builder, a, ab, ca, topY, colTop, depth - 1, topN);
|
|
682
|
+
emitCapUniform(builder, ab, b, bc, topY, colTop, depth - 1, topN);
|
|
683
|
+
emitCapUniform(builder, ca, bc, c, topY, colTop, depth - 1, topN);
|
|
684
|
+
emitCapUniform(builder, ab, bc, ca, topY, colTop, depth - 1, topN);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const at = [a.x * M, topY(a), a.y * M];
|
|
688
|
+
const bt = [b.x * M, topY(b), b.y * M];
|
|
689
|
+
const ct = [c.x * M, topY(c), c.y * M];
|
|
690
|
+
if (topN) {
|
|
691
|
+
builder.triN(at, bt, ct, topN(a), topN(b), topN(c), colTop);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
let n = faceNormal(at, bt, ct);
|
|
695
|
+
if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
|
|
696
|
+
builder.tri(at, bt, ct, n, colTop);
|
|
697
|
+
}
|
|
698
|
+
var MAX_RING_SPLIT_DEPTH = 8;
|
|
699
|
+
function densifyRing(ring, topY, maxError) {
|
|
700
|
+
if (!Number.isFinite(maxError)) return ring;
|
|
701
|
+
const out = [];
|
|
702
|
+
const emit = (p, q, yp, yq, depth) => {
|
|
703
|
+
if (depth > 0) {
|
|
704
|
+
const mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
|
705
|
+
const ym = topY(mid);
|
|
706
|
+
if (Math.abs(ym - (yp + yq) / 2) > maxError) {
|
|
707
|
+
emit(p, mid, yp, ym, depth - 1);
|
|
708
|
+
emit(mid, q, ym, yq, depth - 1);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
out.push(p);
|
|
713
|
+
};
|
|
714
|
+
for (let i = 0; i < ring.length; i++) {
|
|
715
|
+
const p = ring[i], q = ring[(i + 1) % ring.length];
|
|
716
|
+
emit(p, q, topY(p), topY(q), MAX_RING_SPLIT_DEPTH);
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
function dedupeRing(ring, eps) {
|
|
721
|
+
if (ring.length < 3) return ring;
|
|
722
|
+
const out = [];
|
|
723
|
+
for (const p of ring) {
|
|
724
|
+
const last = out[out.length - 1];
|
|
725
|
+
if (last && Math.hypot(p.x - last.x, p.y - last.y) < eps) continue;
|
|
726
|
+
out.push(p);
|
|
727
|
+
}
|
|
728
|
+
while (out.length > 2 && Math.hypot(out[0].x - out[out.length - 1].x, out[0].y - out[out.length - 1].y) < eps) {
|
|
729
|
+
out.pop();
|
|
730
|
+
}
|
|
731
|
+
if (out.length < 3) return ring;
|
|
732
|
+
const keep = [];
|
|
733
|
+
for (let i = 0; i < out.length; i++) {
|
|
734
|
+
const prev = keep.length ? keep[keep.length - 1] : out[(i - 1 + out.length) % out.length];
|
|
735
|
+
const p = out[i];
|
|
736
|
+
const next = out[(i + 1) % out.length];
|
|
737
|
+
const ax = next.x - prev.x, ay = next.y - prev.y;
|
|
738
|
+
const len = Math.hypot(ax, ay);
|
|
739
|
+
if (len > eps) {
|
|
740
|
+
const cross = Math.abs((p.x - prev.x) * ay - (p.y - prev.y) * ax) / len;
|
|
741
|
+
if (cross < eps) continue;
|
|
742
|
+
}
|
|
743
|
+
keep.push(p);
|
|
744
|
+
}
|
|
745
|
+
return keep.length >= 3 ? keep : out;
|
|
746
|
+
}
|
|
747
|
+
var RING_EPS_U = 1e-3 * CHART_UNITS_PER_METRE * 0.1;
|
|
748
|
+
function extrudePrism(builder, outlineIn, holesIn, topY, bottomY, colTop, colWall, ao, maxCapError = Infinity, topNormal) {
|
|
541
749
|
if (!outlineIn || outlineIn.length < 3) return;
|
|
750
|
+
if (maxCapError === void 0) maxCapError = Infinity;
|
|
542
751
|
if (Math.abs(signedArea(outlineIn)) < 1e-4) return;
|
|
543
|
-
const outline = toCCW(outlineIn);
|
|
544
|
-
const holes = holesIn?.map((h) => toCCW(h)).filter((h) => h.length >= 3 && Math.abs(signedArea(h)) >= 1e-4);
|
|
752
|
+
const outline = dedupeRing(densifyRing(dedupeRing(toCCW(outlineIn), RING_EPS_U), topY, maxCapError), RING_EPS_U);
|
|
753
|
+
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);
|
|
545
754
|
const { pts, tris } = triangulate(outline, holes);
|
|
546
755
|
const cTop = [colTop[0] * ao.top, colTop[1] * ao.top, colTop[2] * ao.top];
|
|
547
756
|
const cBot = [colTop[0] * ao.bottomCap, colTop[1] * ao.bottomCap, colTop[2] * ao.bottomCap];
|
|
548
757
|
const cWallTop = [colWall[0] * ao.top, colWall[1] * ao.top, colWall[2] * ao.top];
|
|
549
758
|
const cWallBot = [colWall[0] * ao.wallBottom, colWall[1] * ao.wallBottom, colWall[2] * ao.wallBottom];
|
|
759
|
+
let capDepth = 0;
|
|
760
|
+
if (Number.isFinite(maxCapError)) {
|
|
761
|
+
for (; capDepth < MAX_CAP_SPLIT_DEPTH; capDepth++) {
|
|
762
|
+
let worst = 0;
|
|
763
|
+
for (let i = 0; i < tris.length; i += 3) {
|
|
764
|
+
const d = maxDevAtDepth(pts[tris[i]], pts[tris[i + 1]], pts[tris[i + 2]], topY, capDepth);
|
|
765
|
+
if (d > worst) worst = d;
|
|
766
|
+
}
|
|
767
|
+
if (worst <= maxCapError) break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
550
770
|
for (let i = 0; i < tris.length; i += 3) {
|
|
551
771
|
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
552
|
-
|
|
553
|
-
const bt = [b.x * M, topY(b), b.y * M];
|
|
554
|
-
const ct = [c.x * M, topY(c), c.y * M];
|
|
555
|
-
let n = faceNormal(at, bt, ct);
|
|
556
|
-
if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
|
|
557
|
-
builder.tri(at, bt, ct, n, cTop);
|
|
772
|
+
emitCapUniform(builder, a, b, c, topY, cTop, capDepth, topNormal);
|
|
558
773
|
const ab = [a.x * M, bottomY, a.y * M];
|
|
559
774
|
const bb = [b.x * M, bottomY, b.y * M];
|
|
560
775
|
const cb = [c.x * M, bottomY, c.y * M];
|
|
561
776
|
builder.tri(ab, cb, bb, [0, -1, 0], cBot);
|
|
562
777
|
}
|
|
778
|
+
const splitRing = (ring) => {
|
|
779
|
+
if (capDepth <= 0) return ring;
|
|
780
|
+
const n = 1 << capDepth;
|
|
781
|
+
const out = [];
|
|
782
|
+
for (let i = 0; i < ring.length; i++) {
|
|
783
|
+
const a = ring[i], b = ring[(i + 1) % ring.length];
|
|
784
|
+
for (let k = 0; k < n; k++) {
|
|
785
|
+
const t = k / n;
|
|
786
|
+
out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
};
|
|
563
791
|
const oc = centroid(outline);
|
|
564
|
-
const rings = [{ ring: outline, flip: false }];
|
|
792
|
+
const rings = [{ ring: splitRing(outline), flip: false }];
|
|
565
793
|
if (holes) {
|
|
566
|
-
for (const h of holes) if (h.length >= 3) rings.push({ ring: h, flip: true });
|
|
794
|
+
for (const h of holes) if (h.length >= 3) rings.push({ ring: splitRing(h), flip: true });
|
|
567
795
|
}
|
|
568
796
|
for (const { ring, flip } of rings) {
|
|
569
797
|
for (let i = 0; i < ring.length; i++) {
|
|
@@ -599,14 +827,74 @@ function mergeMeshData(parts) {
|
|
|
599
827
|
const position = new Float32Array(total * 3);
|
|
600
828
|
const normal = new Float32Array(total * 3);
|
|
601
829
|
const color = new Float32Array(total * 3);
|
|
830
|
+
const floor = new Float32Array(total);
|
|
602
831
|
let off = 0;
|
|
603
832
|
for (const p of parts) {
|
|
604
833
|
position.set(p.position, off * 3);
|
|
605
834
|
normal.set(p.normal, off * 3);
|
|
606
835
|
color.set(p.color, off * 3);
|
|
836
|
+
floor.set(p.floor, off);
|
|
607
837
|
off += p.count;
|
|
608
838
|
}
|
|
609
|
-
return { position, normal, color, count: total };
|
|
839
|
+
return { position, normal, color, floor, count: total };
|
|
840
|
+
}
|
|
841
|
+
function outsetRing(ring, d, miterLimit = 2.5) {
|
|
842
|
+
if (d <= 0 || ring.length < 3) return ring;
|
|
843
|
+
const r = toCCW(ring);
|
|
844
|
+
const n = r.length;
|
|
845
|
+
const nrm = [];
|
|
846
|
+
for (let i = 0; i < n; i++) {
|
|
847
|
+
const a = r[i], b = r[(i + 1) % n];
|
|
848
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
849
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
850
|
+
nrm.push({ x: dy / len, y: -dx / len });
|
|
851
|
+
}
|
|
852
|
+
const out = [];
|
|
853
|
+
for (let i = 0; i < n; i++) {
|
|
854
|
+
const p = r[i];
|
|
855
|
+
const nPrev = nrm[(i - 1 + n) % n];
|
|
856
|
+
const nCur = nrm[i];
|
|
857
|
+
let mx = nPrev.x + nCur.x, my = nPrev.y + nCur.y;
|
|
858
|
+
const ml = Math.hypot(mx, my);
|
|
859
|
+
const bevel = () => {
|
|
860
|
+
out.push([p.x + nPrev.x * d, p.y + nPrev.y * d]);
|
|
861
|
+
out.push([p.x + nCur.x * d, p.y + nCur.y * d]);
|
|
862
|
+
};
|
|
863
|
+
if (ml < 1e-9) {
|
|
864
|
+
bevel();
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
mx /= ml;
|
|
868
|
+
my /= ml;
|
|
869
|
+
const cosHalf = mx * nPrev.x + my * nPrev.y;
|
|
870
|
+
const scale2 = 1 / Math.max(cosHalf, 1e-6);
|
|
871
|
+
if (!Number.isFinite(scale2) || scale2 > miterLimit) {
|
|
872
|
+
bevel();
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
out.push([p.x + mx * d * scale2, p.y + my * d * scale2]);
|
|
876
|
+
}
|
|
877
|
+
if (out.length < 3) return ring;
|
|
878
|
+
try {
|
|
879
|
+
const merged = import_polygon_clipping.default.union([[...out, out[0]]]);
|
|
880
|
+
let best = null, bestArea = -Infinity;
|
|
881
|
+
for (const poly of merged) {
|
|
882
|
+
const area = Math.abs(signedArea(poly[0].map(([x, y]) => ({ x, y }))));
|
|
883
|
+
if (area > bestArea) {
|
|
884
|
+
bestArea = area;
|
|
885
|
+
best = poly[0];
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (!best) return ring;
|
|
889
|
+
const pts = best.map(([x, y]) => ({ x, y }));
|
|
890
|
+
if (pts.length > 1) {
|
|
891
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
892
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
893
|
+
}
|
|
894
|
+
return pts.length >= 3 ? pts : ring;
|
|
895
|
+
} catch {
|
|
896
|
+
return ring;
|
|
897
|
+
}
|
|
610
898
|
}
|
|
611
899
|
function ellipsePolygon(cx, cy, rx, ry, seg = 28) {
|
|
612
900
|
const out = [];
|
|
@@ -626,29 +914,107 @@ function rectPolygon(x, y, w, h) {
|
|
|
626
914
|
}
|
|
627
915
|
|
|
628
916
|
// src/view3d/scene/seatInstances.ts
|
|
629
|
-
var
|
|
917
|
+
var SEAT_DOT_RADIUS_M = 0.22;
|
|
918
|
+
var SEAT_PITCH_FRACTION = 0.42;
|
|
919
|
+
function nearestNeighbourSpacing(seats) {
|
|
920
|
+
const n = seats.length;
|
|
921
|
+
const out = new Float64Array(n);
|
|
922
|
+
if (n < 2) {
|
|
923
|
+
out.fill(Infinity);
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
927
|
+
for (const s of seats) {
|
|
928
|
+
if (s.x < minX) minX = s.x;
|
|
929
|
+
if (s.y < minY) minY = s.y;
|
|
930
|
+
if (s.x > maxX) maxX = s.x;
|
|
931
|
+
if (s.y > maxY) maxY = s.y;
|
|
932
|
+
}
|
|
933
|
+
const w = Math.max(maxX - minX, 1e-6), h = Math.max(maxY - minY, 1e-6);
|
|
934
|
+
const cell = Math.max(Math.sqrt(w * h / n), 1e-6);
|
|
935
|
+
const cols = Math.max(1, Math.ceil(w / cell) + 1);
|
|
936
|
+
const rows = Math.max(1, Math.ceil(h / cell) + 1);
|
|
937
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
938
|
+
const cellOf = (x, y) => {
|
|
939
|
+
const cx = Math.min(cols - 1, Math.max(0, Math.floor((x - minX) / cell)));
|
|
940
|
+
const cy = Math.min(rows - 1, Math.max(0, Math.floor((y - minY) / cell)));
|
|
941
|
+
return cy * cols + cx;
|
|
942
|
+
};
|
|
943
|
+
for (let i = 0; i < n; i++) {
|
|
944
|
+
const k = cellOf(seats[i].x, seats[i].y);
|
|
945
|
+
const b = buckets.get(k);
|
|
946
|
+
if (b) b.push(i);
|
|
947
|
+
else buckets.set(k, [i]);
|
|
948
|
+
}
|
|
949
|
+
for (let i = 0; i < n; i++) {
|
|
950
|
+
const s = seats[i];
|
|
951
|
+
const cx = Math.min(cols - 1, Math.max(0, Math.floor((s.x - minX) / cell)));
|
|
952
|
+
const cy = Math.min(rows - 1, Math.max(0, Math.floor((s.y - minY) / cell)));
|
|
953
|
+
let best = Infinity;
|
|
954
|
+
for (let r = 1; r <= 4; r++) {
|
|
955
|
+
for (let gy = cy - r; gy <= cy + r; gy++) {
|
|
956
|
+
if (gy < 0 || gy >= rows) continue;
|
|
957
|
+
for (let gx = cx - r; gx <= cx + r; gx++) {
|
|
958
|
+
if (gx < 0 || gx >= cols) continue;
|
|
959
|
+
if (r > 1 && Math.abs(gy - cy) < r && Math.abs(gx - cx) < r) continue;
|
|
960
|
+
const b = buckets.get(gy * cols + gx);
|
|
961
|
+
if (!b) continue;
|
|
962
|
+
for (const j of b) {
|
|
963
|
+
if (j === i) continue;
|
|
964
|
+
const d = Math.hypot(seats[j].x - s.x, seats[j].y - s.y);
|
|
965
|
+
if (d < best) best = d;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (Number.isFinite(best) && best <= r * cell) break;
|
|
970
|
+
}
|
|
971
|
+
out[i] = best;
|
|
972
|
+
}
|
|
973
|
+
return out;
|
|
974
|
+
}
|
|
630
975
|
function seatSurfaceY(seat) {
|
|
631
976
|
const eye = seat.eyeHeightM;
|
|
632
|
-
if (Number.isFinite(eye))
|
|
633
|
-
|
|
634
|
-
}
|
|
635
|
-
return SEAT_SURFACE_LIFT_M;
|
|
977
|
+
if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
|
|
978
|
+
return 0;
|
|
636
979
|
}
|
|
637
|
-
function buildSeatInstances(seats, initial) {
|
|
980
|
+
function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
638
981
|
const count = seats.length;
|
|
639
982
|
const iPosition = new Float32Array(count * 3);
|
|
640
983
|
const iState = new Float32Array(count);
|
|
984
|
+
const iMaxRadius = new Float32Array(count);
|
|
985
|
+
const iRing = new Float32Array(count * 3);
|
|
641
986
|
const idToIndex = /* @__PURE__ */ new Map();
|
|
987
|
+
const spacing = nearestNeighbourSpacing(seats);
|
|
642
988
|
for (let i = 0; i < count; i++) {
|
|
643
989
|
const seat = seats[i];
|
|
990
|
+
const resolved = surfaces?.seatPitchU(i);
|
|
991
|
+
const pitchM = (resolved ?? spacing[i]) * M;
|
|
992
|
+
iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
|
|
644
993
|
iPosition[i * 3] = seat.x * M;
|
|
645
|
-
iPosition[i * 3 + 1] = seatSurfaceY(seat);
|
|
994
|
+
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
646
995
|
iPosition[i * 3 + 2] = seat.y * M;
|
|
647
996
|
iState[i] = seatStateIndex(initial ? initial(seat) : "available");
|
|
997
|
+
if (seat.accessibility?.length) {
|
|
998
|
+
const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
|
|
999
|
+
if (rgb) {
|
|
1000
|
+
iRing[i * 3] = rgb[0];
|
|
1001
|
+
iRing[i * 3 + 1] = rgb[1];
|
|
1002
|
+
iRing[i * 3 + 2] = rgb[2];
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
648
1005
|
idToIndex.set(seat.id, i);
|
|
649
1006
|
}
|
|
650
|
-
return {
|
|
1007
|
+
return {
|
|
1008
|
+
count,
|
|
1009
|
+
iPosition,
|
|
1010
|
+
iState,
|
|
1011
|
+
iMaxRadius,
|
|
1012
|
+
iRing,
|
|
1013
|
+
idToIndex,
|
|
1014
|
+
iFloor: seatFloor ?? new Float32Array(count)
|
|
1015
|
+
};
|
|
651
1016
|
}
|
|
1017
|
+
var RUN_MERGE_GAP = 64;
|
|
652
1018
|
function applySeatStates(data, updates) {
|
|
653
1019
|
const changed = [];
|
|
654
1020
|
for (const u of updates) {
|
|
@@ -668,7 +1034,7 @@ function applySeatStates(data, updates) {
|
|
|
668
1034
|
for (let i = 1; i < changed.length; i++) {
|
|
669
1035
|
const idx = changed[i];
|
|
670
1036
|
if (idx === prev) continue;
|
|
671
|
-
if (idx
|
|
1037
|
+
if (idx <= prev + RUN_MERGE_GAP) {
|
|
672
1038
|
prev = idx;
|
|
673
1039
|
continue;
|
|
674
1040
|
}
|
|
@@ -680,6 +1046,1155 @@ function applySeatStates(data, updates) {
|
|
|
680
1046
|
return runs;
|
|
681
1047
|
}
|
|
682
1048
|
|
|
1049
|
+
// src/view3d/theme.ts
|
|
1050
|
+
var BACKGROUND_INFLUENCE = 0.15;
|
|
1051
|
+
var BG_TOP_SCALE = 0.9;
|
|
1052
|
+
var BG_BOTTOM_SCALE = 1.76;
|
|
1053
|
+
var SEAT_SCALE_MIN = 0.7;
|
|
1054
|
+
var SEAT_SCALE_MAX = 1.6;
|
|
1055
|
+
function defaultTheme3D() {
|
|
1056
|
+
return {
|
|
1057
|
+
background: { top: [...BACKGROUND.top], bottom: [...BACKGROUND.bottom] },
|
|
1058
|
+
structure: STRUCTURE,
|
|
1059
|
+
seatStates: { ...SEAT_STATE_COLORS },
|
|
1060
|
+
seatScale: 1
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
function resolveTheme3D(theme) {
|
|
1064
|
+
const base = defaultTheme3D();
|
|
1065
|
+
if (!theme) return base;
|
|
1066
|
+
const bg = hexToRgb(theme.background);
|
|
1067
|
+
if (bg) {
|
|
1068
|
+
base.background = {
|
|
1069
|
+
top: scaleRgb(bg, BG_TOP_SCALE),
|
|
1070
|
+
bottom: scaleRgb(bg, BG_BOTTOM_SCALE)
|
|
1071
|
+
};
|
|
1072
|
+
const rebased = {};
|
|
1073
|
+
for (const [key, value] of Object.entries(STRUCTURE)) {
|
|
1074
|
+
rebased[key] = mix(value, bg, BACKGROUND_INFLUENCE);
|
|
1075
|
+
}
|
|
1076
|
+
base.structure = rebased;
|
|
1077
|
+
}
|
|
1078
|
+
const selection = hexToRgb(theme.selectionColor) ?? hexToRgb(theme.accent);
|
|
1079
|
+
if (selection) base.seatStates = { ...base.seatStates, selected: selection };
|
|
1080
|
+
const scale2 = theme.seatScale;
|
|
1081
|
+
if (typeof scale2 === "number" && Number.isFinite(scale2)) {
|
|
1082
|
+
base.seatScale = Math.min(SEAT_SCALE_MAX, Math.max(SEAT_SCALE_MIN, scale2));
|
|
1083
|
+
}
|
|
1084
|
+
return base;
|
|
1085
|
+
}
|
|
1086
|
+
function themeSeatColorLUT(theme, order) {
|
|
1087
|
+
const out = [];
|
|
1088
|
+
for (const s of order) out.push(...theme.seatStates[s]);
|
|
1089
|
+
return out;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// src/view3d/labels.ts
|
|
1093
|
+
var ZONE_MIN_DISTANCE = 1.15;
|
|
1094
|
+
var SECTION_MAX_DISTANCE = 2.2;
|
|
1095
|
+
var NEAR_MAX_DISTANCE = 0.85;
|
|
1096
|
+
function visibleLabelKinds(distance, venueRadius) {
|
|
1097
|
+
const r = Math.max(1e-6, venueRadius);
|
|
1098
|
+
const d = distance / r;
|
|
1099
|
+
const out = /* @__PURE__ */ new Set();
|
|
1100
|
+
if (d >= ZONE_MIN_DISTANCE) out.add("zone");
|
|
1101
|
+
if (d <= SECTION_MAX_DISTANCE) out.add("section");
|
|
1102
|
+
if (d <= NEAR_MAX_DISTANCE) {
|
|
1103
|
+
out.add("annotation");
|
|
1104
|
+
out.add("booth");
|
|
1105
|
+
}
|
|
1106
|
+
return out;
|
|
1107
|
+
}
|
|
1108
|
+
function projectToScreen(viewProjection, p, width, height) {
|
|
1109
|
+
const m = viewProjection;
|
|
1110
|
+
const x = p[0], y = p[1], z = p[2];
|
|
1111
|
+
const cx = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
1112
|
+
const cy = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
1113
|
+
const cz = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
1114
|
+
const cw = m[3] * x + m[7] * y + m[11] * z + m[15];
|
|
1115
|
+
if (!(cw > 1e-6)) return { x: 0, y: 0, depth: Infinity, visible: false };
|
|
1116
|
+
const ndcX = cx / cw, ndcY = cy / cw, ndcZ = cz / cw;
|
|
1117
|
+
const inside = ndcX >= -1.05 && ndcX <= 1.05 && ndcY >= -1.05 && ndcY <= 1.05 && ndcZ <= 1;
|
|
1118
|
+
return {
|
|
1119
|
+
x: (ndcX * 0.5 + 0.5) * width,
|
|
1120
|
+
y: (1 - (ndcY * 0.5 + 0.5)) * height,
|
|
1121
|
+
depth: ndcZ,
|
|
1122
|
+
visible: inside
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
function cullOverlapping(items, separationX, separationY = separationX) {
|
|
1126
|
+
const kept = [];
|
|
1127
|
+
const ordered = [...items].sort((a, b) => a.screen.depth - b.screen.depth);
|
|
1128
|
+
for (const item of ordered) {
|
|
1129
|
+
let clash = false;
|
|
1130
|
+
for (const k of kept) {
|
|
1131
|
+
const dx = Math.abs(item.screen.x - k.screen.x);
|
|
1132
|
+
const dy = Math.abs(item.screen.y - k.screen.y);
|
|
1133
|
+
if (dx < separationX && dy < separationY) {
|
|
1134
|
+
clash = true;
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (!clash) kept.push(item);
|
|
1139
|
+
}
|
|
1140
|
+
return kept;
|
|
1141
|
+
}
|
|
1142
|
+
function centroidOf(points) {
|
|
1143
|
+
if (!points.length) return null;
|
|
1144
|
+
let x = 0, y = 0;
|
|
1145
|
+
for (const p of points) {
|
|
1146
|
+
x += p.x;
|
|
1147
|
+
y += p.y;
|
|
1148
|
+
}
|
|
1149
|
+
return { x: x / points.length, y: y / points.length };
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// src/view3d/scene/sceneModel.ts
|
|
1153
|
+
var import_polygon_clipping3 = __toESM(require("polygon-clipping"), 1);
|
|
1154
|
+
|
|
1155
|
+
// src/core/venueStructure.ts
|
|
1156
|
+
var BLOCK_LINK_RATIO = 1.8;
|
|
1157
|
+
function probesOf(pts) {
|
|
1158
|
+
const n = pts.length;
|
|
1159
|
+
if (n <= 3) return [...pts];
|
|
1160
|
+
return [pts[Math.floor(n * 0.25)], pts[Math.floor(n * 0.5)], pts[Math.floor(n * 0.75)]];
|
|
1161
|
+
}
|
|
1162
|
+
function distanceToPolyline(pts, x, y) {
|
|
1163
|
+
if (!pts.length) return Infinity;
|
|
1164
|
+
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1165
|
+
let best = Infinity;
|
|
1166
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1167
|
+
const a = pts[i], b = pts[i + 1];
|
|
1168
|
+
const vx = b.x - a.x, vy = b.y - a.y;
|
|
1169
|
+
const len2 = vx * vx + vy * vy;
|
|
1170
|
+
let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
|
|
1171
|
+
if (t < 0) t = 0;
|
|
1172
|
+
else if (t > 1) t = 1;
|
|
1173
|
+
const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
|
|
1174
|
+
if (d < best) best = d;
|
|
1175
|
+
}
|
|
1176
|
+
return best;
|
|
1177
|
+
}
|
|
1178
|
+
function boundOf(pts) {
|
|
1179
|
+
let cx = 0, cy = 0;
|
|
1180
|
+
for (const p of pts) {
|
|
1181
|
+
cx += p.x;
|
|
1182
|
+
cy += p.y;
|
|
1183
|
+
}
|
|
1184
|
+
const n = pts.length || 1;
|
|
1185
|
+
cx /= n;
|
|
1186
|
+
cy /= n;
|
|
1187
|
+
let r = 0;
|
|
1188
|
+
for (const p of pts) {
|
|
1189
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1190
|
+
if (d > r) r = d;
|
|
1191
|
+
}
|
|
1192
|
+
return { cx, cy, r };
|
|
1193
|
+
}
|
|
1194
|
+
function boundGap(a, b) {
|
|
1195
|
+
return Math.hypot(a.cx - b.cx, a.cy - b.cy) - a.r - b.r;
|
|
1196
|
+
}
|
|
1197
|
+
function rowGap(a, b) {
|
|
1198
|
+
let best = Infinity;
|
|
1199
|
+
for (const p of probesOf(a)) {
|
|
1200
|
+
const d = distanceToPolyline(b, p.x, p.y);
|
|
1201
|
+
if (d < best) best = d;
|
|
1202
|
+
}
|
|
1203
|
+
for (const p of probesOf(b)) {
|
|
1204
|
+
const d = distanceToPolyline(a, p.x, p.y);
|
|
1205
|
+
if (d < best) best = d;
|
|
1206
|
+
}
|
|
1207
|
+
return best;
|
|
1208
|
+
}
|
|
1209
|
+
function orderAlongRow(pts, indices) {
|
|
1210
|
+
if (pts.length < 3) return { pts, seatIndices: indices };
|
|
1211
|
+
let cx = 0, cy = 0;
|
|
1212
|
+
for (const p of pts) {
|
|
1213
|
+
cx += p.x;
|
|
1214
|
+
cy += p.y;
|
|
1215
|
+
}
|
|
1216
|
+
cx /= pts.length;
|
|
1217
|
+
cy /= pts.length;
|
|
1218
|
+
let far = -1, ax = pts[0];
|
|
1219
|
+
for (const p of pts) {
|
|
1220
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1221
|
+
if (d > far) {
|
|
1222
|
+
far = d;
|
|
1223
|
+
ax = p;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
let dx = ax.x - cx, dy = ax.y - cy;
|
|
1227
|
+
const len = Math.hypot(dx, dy);
|
|
1228
|
+
if (len > 1e-9) {
|
|
1229
|
+
dx /= len;
|
|
1230
|
+
dy /= len;
|
|
1231
|
+
} else {
|
|
1232
|
+
dx = 1;
|
|
1233
|
+
dy = 0;
|
|
1234
|
+
}
|
|
1235
|
+
const order = pts.map((p, i) => ({ p, i, t: (p.x - cx) * dx + (p.y - cy) * dy })).sort((u, v) => u.t - v.t);
|
|
1236
|
+
return { pts: order.map((o) => o.p), seatIndices: order.map((o) => indices[o.i]) };
|
|
1237
|
+
}
|
|
1238
|
+
function fitCircle(pts) {
|
|
1239
|
+
const n = pts.length;
|
|
1240
|
+
if (n < 3) return null;
|
|
1241
|
+
let mx = 0, my = 0;
|
|
1242
|
+
for (const p of pts) {
|
|
1243
|
+
mx += p.x;
|
|
1244
|
+
my += p.y;
|
|
1245
|
+
}
|
|
1246
|
+
mx /= n;
|
|
1247
|
+
my /= n;
|
|
1248
|
+
let suu = 0, suv = 0, svv = 0, suuu = 0, svvv = 0, suvv = 0, svuu = 0;
|
|
1249
|
+
for (const p of pts) {
|
|
1250
|
+
const u = p.x - mx, v = p.y - my;
|
|
1251
|
+
suu += u * u;
|
|
1252
|
+
svv += v * v;
|
|
1253
|
+
suv += u * v;
|
|
1254
|
+
suuu += u * u * u;
|
|
1255
|
+
svvv += v * v * v;
|
|
1256
|
+
suvv += u * v * v;
|
|
1257
|
+
svuu += v * u * u;
|
|
1258
|
+
}
|
|
1259
|
+
const det = suu * svv - suv * suv;
|
|
1260
|
+
if (Math.abs(det) < 1e-9) return null;
|
|
1261
|
+
const b1 = (suuu + suvv) / 2;
|
|
1262
|
+
const b2 = (svvv + svuu) / 2;
|
|
1263
|
+
const uc = (b1 * svv - b2 * suv) / det;
|
|
1264
|
+
const vc = (b2 * suu - b1 * suv) / det;
|
|
1265
|
+
const cx = uc + mx, cy = vc + my;
|
|
1266
|
+
if (!Number.isFinite(cx) || !Number.isFinite(cy)) return null;
|
|
1267
|
+
return { cx, cy };
|
|
1268
|
+
}
|
|
1269
|
+
var CIRCLE_ROW_SPREAD_LIMIT = 0.15;
|
|
1270
|
+
function fitBlockAxis(group) {
|
|
1271
|
+
if (group.length < 2) return null;
|
|
1272
|
+
const cents = group.map((r) => {
|
|
1273
|
+
let x = 0, y = 0;
|
|
1274
|
+
for (const p of r.pts) {
|
|
1275
|
+
x += p.x;
|
|
1276
|
+
y += p.y;
|
|
1277
|
+
}
|
|
1278
|
+
return { x: x / r.pts.length, y: y / r.pts.length };
|
|
1279
|
+
});
|
|
1280
|
+
let ox = 0, oy = 0;
|
|
1281
|
+
for (const c of cents) {
|
|
1282
|
+
ox += c.x;
|
|
1283
|
+
oy += c.y;
|
|
1284
|
+
}
|
|
1285
|
+
ox /= cents.length;
|
|
1286
|
+
oy /= cents.length;
|
|
1287
|
+
let sxx = 0, sxy = 0, syy = 0;
|
|
1288
|
+
for (const c of cents) {
|
|
1289
|
+
const dx = c.x - ox, dy = c.y - oy;
|
|
1290
|
+
sxx += dx * dx;
|
|
1291
|
+
sxy += dx * dy;
|
|
1292
|
+
syy += dy * dy;
|
|
1293
|
+
}
|
|
1294
|
+
const tr = sxx + syy;
|
|
1295
|
+
const det = sxx * syy - sxy * sxy;
|
|
1296
|
+
const lambda = tr / 2 + Math.sqrt(Math.max(0, tr * tr / 4 - det));
|
|
1297
|
+
let ax, ay;
|
|
1298
|
+
if (Math.abs(sxy) > 1e-12) {
|
|
1299
|
+
ax = lambda - syy;
|
|
1300
|
+
ay = sxy;
|
|
1301
|
+
} else if (sxx >= syy) {
|
|
1302
|
+
ax = 1;
|
|
1303
|
+
ay = 0;
|
|
1304
|
+
} else {
|
|
1305
|
+
ax = 0;
|
|
1306
|
+
ay = 1;
|
|
1307
|
+
}
|
|
1308
|
+
const len = Math.hypot(ax, ay);
|
|
1309
|
+
if (!(len > 1e-12)) return null;
|
|
1310
|
+
ax /= len;
|
|
1311
|
+
ay /= len;
|
|
1312
|
+
let lo = Infinity, hi = -Infinity;
|
|
1313
|
+
const widths = [];
|
|
1314
|
+
for (const r of group) {
|
|
1315
|
+
let rlo = Infinity, rhi = -Infinity;
|
|
1316
|
+
for (const p of r.pts) {
|
|
1317
|
+
const t = p.x * ax + p.y * ay;
|
|
1318
|
+
if (t < rlo) rlo = t;
|
|
1319
|
+
if (t > rhi) rhi = t;
|
|
1320
|
+
if (t < lo) lo = t;
|
|
1321
|
+
if (t > hi) hi = t;
|
|
1322
|
+
}
|
|
1323
|
+
widths.push(rhi - rlo);
|
|
1324
|
+
}
|
|
1325
|
+
const range = hi - lo;
|
|
1326
|
+
if (!(range > 1e-9)) return null;
|
|
1327
|
+
widths.sort((a, b) => a - b);
|
|
1328
|
+
const p90 = widths[Math.min(widths.length - 1, Math.floor(widths.length * 0.9))];
|
|
1329
|
+
return p90 / range <= AXIS_ROW_WIDTH_LIMIT ? { x: ax, y: ay } : null;
|
|
1330
|
+
}
|
|
1331
|
+
var AXIS_ROW_WIDTH_LIMIT = 0.35;
|
|
1332
|
+
function resolveSection(sectionId, seats, seatIndices, focal) {
|
|
1333
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1334
|
+
for (const i of seatIndices) {
|
|
1335
|
+
const s = seats[i];
|
|
1336
|
+
const key = s.rowId || `__seat-${i}`;
|
|
1337
|
+
let g = groups.get(key);
|
|
1338
|
+
if (!g) {
|
|
1339
|
+
g = { pts: [], idx: [] };
|
|
1340
|
+
groups.set(key, g);
|
|
1341
|
+
}
|
|
1342
|
+
g.pts.push({ x: s.x, y: s.y });
|
|
1343
|
+
g.idx.push(i);
|
|
1344
|
+
}
|
|
1345
|
+
if (!groups.size) return { sectionId, rows: [], blockCount: 0 };
|
|
1346
|
+
const rows = [];
|
|
1347
|
+
for (const [id, g] of groups) {
|
|
1348
|
+
const ordered = orderAlongRow(g.pts, g.idx);
|
|
1349
|
+
let sum = 0;
|
|
1350
|
+
for (const p of ordered.pts) sum += Math.hypot(p.x - focal.x, p.y - focal.y);
|
|
1351
|
+
rows.push({
|
|
1352
|
+
id,
|
|
1353
|
+
pts: ordered.pts,
|
|
1354
|
+
seatIndices: ordered.seatIndices,
|
|
1355
|
+
blockId: -1,
|
|
1356
|
+
ordinal: 0,
|
|
1357
|
+
blockDepth: 0,
|
|
1358
|
+
focalDistance: sum / ordered.pts.length
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
const bounds = rows.map((r) => boundOf(r.pts));
|
|
1362
|
+
const nearest = [];
|
|
1363
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1364
|
+
let best = Infinity;
|
|
1365
|
+
for (let j = 0; j < rows.length; j++) {
|
|
1366
|
+
if (i === j) continue;
|
|
1367
|
+
if (boundGap(bounds[i], bounds[j]) >= best) continue;
|
|
1368
|
+
const d = rowGap(rows[i].pts, rows[j].pts);
|
|
1369
|
+
if (d < best) best = d;
|
|
1370
|
+
}
|
|
1371
|
+
if (Number.isFinite(best)) nearest.push(best);
|
|
1372
|
+
}
|
|
1373
|
+
nearest.sort((a, b) => a - b);
|
|
1374
|
+
const typicalGap = nearest.length ? nearest[Math.floor(nearest.length / 2)] : 1;
|
|
1375
|
+
const linkDistance = Math.max(typicalGap * BLOCK_LINK_RATIO, 1e-6);
|
|
1376
|
+
const adjacency = rows.map(() => []);
|
|
1377
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1378
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
1379
|
+
if (boundGap(bounds[i], bounds[j]) > linkDistance) continue;
|
|
1380
|
+
if (rowGap(rows[i].pts, rows[j].pts) <= linkDistance) {
|
|
1381
|
+
adjacency[i].push(j);
|
|
1382
|
+
adjacency[j].push(i);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
let blockCount = 0;
|
|
1387
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1388
|
+
if (rows[i].blockId !== -1) continue;
|
|
1389
|
+
const id = blockCount++;
|
|
1390
|
+
const stack = [i];
|
|
1391
|
+
rows[i].blockId = id;
|
|
1392
|
+
while (stack.length) {
|
|
1393
|
+
const k = stack.pop();
|
|
1394
|
+
for (const n of adjacency[k]) {
|
|
1395
|
+
if (rows[n].blockId !== -1) continue;
|
|
1396
|
+
rows[n].blockId = id;
|
|
1397
|
+
stack.push(n);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
const byBlock = /* @__PURE__ */ new Map();
|
|
1402
|
+
for (const r of rows) {
|
|
1403
|
+
const a = byBlock.get(r.blockId) ?? [];
|
|
1404
|
+
a.push(r);
|
|
1405
|
+
byBlock.set(r.blockId, a);
|
|
1406
|
+
}
|
|
1407
|
+
const centres = [];
|
|
1408
|
+
for (const r of rows) {
|
|
1409
|
+
if (r.pts.length < 4) continue;
|
|
1410
|
+
const c = fitCircle(r.pts);
|
|
1411
|
+
if (!c) continue;
|
|
1412
|
+
if (!Number.isFinite(c.cx) || !Number.isFinite(c.cy)) continue;
|
|
1413
|
+
centres.push({ x: c.cx, y: c.cy });
|
|
1414
|
+
}
|
|
1415
|
+
const centre = centres.length >= 2 ? (() => {
|
|
1416
|
+
const xs = centres.map((c) => c.x).sort((a, b) => a - b);
|
|
1417
|
+
const ys = centres.map((c) => c.y).sort((a, b) => a - b);
|
|
1418
|
+
const mid = Math.floor(centres.length / 2);
|
|
1419
|
+
return { cx: xs[mid], cy: ys[mid] };
|
|
1420
|
+
})() : null;
|
|
1421
|
+
if (centre) {
|
|
1422
|
+
const radiusOf = (p) => Math.hypot(p.x - centre.cx, p.y - centre.cy);
|
|
1423
|
+
let lo = Infinity, hi = -Infinity;
|
|
1424
|
+
const spreads = [];
|
|
1425
|
+
for (const r of rows) {
|
|
1426
|
+
let rlo = Infinity, rhi = -Infinity;
|
|
1427
|
+
for (const p of r.pts) {
|
|
1428
|
+
const d = radiusOf(p);
|
|
1429
|
+
if (d < rlo) rlo = d;
|
|
1430
|
+
if (d > rhi) rhi = d;
|
|
1431
|
+
}
|
|
1432
|
+
spreads.push(rhi - rlo);
|
|
1433
|
+
if (rlo < lo) lo = rlo;
|
|
1434
|
+
if (rhi > hi) hi = rhi;
|
|
1435
|
+
}
|
|
1436
|
+
const range = hi - lo;
|
|
1437
|
+
spreads.sort((a, b) => a - b);
|
|
1438
|
+
const p90 = spreads[Math.min(spreads.length - 1, Math.floor(spreads.length * 0.9))];
|
|
1439
|
+
if (range > 1e-9 && p90 / range <= CIRCLE_ROW_SPREAD_LIMIT) {
|
|
1440
|
+
const withRadius = rows.map((r) => {
|
|
1441
|
+
let sum = 0;
|
|
1442
|
+
for (const p of r.pts) sum += radiusOf(p);
|
|
1443
|
+
return { row: r, radius: sum / r.pts.length };
|
|
1444
|
+
});
|
|
1445
|
+
let minR = Infinity;
|
|
1446
|
+
for (const w of withRadius) if (w.radius < minR) minR = w.radius;
|
|
1447
|
+
withRadius.sort((a, b) => a.radius - b.radius);
|
|
1448
|
+
let ordinal = -1, lastRadius = -Infinity;
|
|
1449
|
+
const tolerance = range / Math.max(1, withRadius.length) * 0.5;
|
|
1450
|
+
const ordered = [];
|
|
1451
|
+
for (const w of withRadius) {
|
|
1452
|
+
if (w.radius - lastRadius > tolerance) {
|
|
1453
|
+
ordinal++;
|
|
1454
|
+
lastRadius = w.radius;
|
|
1455
|
+
}
|
|
1456
|
+
w.row.ordinal = ordinal;
|
|
1457
|
+
w.row.blockDepth = w.radius - minR;
|
|
1458
|
+
ordered.push(w.row);
|
|
1459
|
+
}
|
|
1460
|
+
return { sectionId, rows: ordered, blockCount };
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const out = [];
|
|
1464
|
+
for (const [, group] of byBlock) {
|
|
1465
|
+
const axis = fitBlockAxis(group);
|
|
1466
|
+
if (axis) {
|
|
1467
|
+
const key = (r) => {
|
|
1468
|
+
let sum = 0;
|
|
1469
|
+
for (const p of r.pts) sum += p.x * axis.x + p.y * axis.y;
|
|
1470
|
+
return sum / r.pts.length;
|
|
1471
|
+
};
|
|
1472
|
+
group.sort((a, b) => key(a) - key(b));
|
|
1473
|
+
let depth = 0;
|
|
1474
|
+
for (let i = 0; i < group.length; i++) {
|
|
1475
|
+
if (i > 0) depth += rowGap(group[i - 1].pts, group[i].pts);
|
|
1476
|
+
group[i].ordinal = i;
|
|
1477
|
+
group[i].blockDepth = depth;
|
|
1478
|
+
out.push(group[i]);
|
|
1479
|
+
}
|
|
1480
|
+
} else {
|
|
1481
|
+
let front = Infinity;
|
|
1482
|
+
for (const r of group) if (r.focalDistance < front) front = r.focalDistance;
|
|
1483
|
+
group.sort((a, b) => a.focalDistance - b.focalDistance);
|
|
1484
|
+
for (let i = 0; i < group.length; i++) {
|
|
1485
|
+
group[i].ordinal = i;
|
|
1486
|
+
group[i].blockDepth = Math.max(0, group[i].focalDistance - front);
|
|
1487
|
+
out.push(group[i]);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return { sectionId, rows: out, blockCount };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// src/core/layout.ts
|
|
1495
|
+
var DEG2 = Math.PI / 180;
|
|
1496
|
+
function pointInPolygon(p, poly) {
|
|
1497
|
+
let inside = false;
|
|
1498
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
1499
|
+
const xi = poly[i].x;
|
|
1500
|
+
const yi = poly[i].y;
|
|
1501
|
+
const xj = poly[j].x;
|
|
1502
|
+
const yj = poly[j].y;
|
|
1503
|
+
const hit = yi > p.y !== yj > p.y && p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi;
|
|
1504
|
+
if (hit) inside = !inside;
|
|
1505
|
+
}
|
|
1506
|
+
return inside;
|
|
1507
|
+
}
|
|
1508
|
+
function pointOnPolygonBoundary(p, poly) {
|
|
1509
|
+
return poly.some((start, index) => {
|
|
1510
|
+
const end = poly[(index + 1) % poly.length];
|
|
1511
|
+
const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
|
|
1512
|
+
if (Math.abs(cross) > 1e-7) return false;
|
|
1513
|
+
const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
|
|
1514
|
+
const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
|
|
1515
|
+
return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
function pointInPolygonWithHoles(p, outer, holes) {
|
|
1519
|
+
return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/core/rake.ts
|
|
1523
|
+
var BLEND_ROWS = 3;
|
|
1524
|
+
var BLEND_DISTANCE_RATIO = 2.2;
|
|
1525
|
+
var GRAD_STEP = 0.5;
|
|
1526
|
+
function radialRake(focal) {
|
|
1527
|
+
return {
|
|
1528
|
+
kind: "radial",
|
|
1529
|
+
rowCount: 0,
|
|
1530
|
+
rows: [],
|
|
1531
|
+
nearestRow: () => -1,
|
|
1532
|
+
depthAt: (x, y) => Math.hypot(x - focal.x, y - focal.y),
|
|
1533
|
+
gradientAt: (x, y) => {
|
|
1534
|
+
const dx = x - focal.x, dy = y - focal.y;
|
|
1535
|
+
const d = Math.hypot(dx, dy);
|
|
1536
|
+
return d < 1e-9 ? [0, 0] : [dx / d, dy / d];
|
|
1537
|
+
}
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
function fitRow(points, focal) {
|
|
1541
|
+
if (!points.length) return null;
|
|
1542
|
+
let cx = 0, cy = 0, depth = 0;
|
|
1543
|
+
for (const p of points) {
|
|
1544
|
+
cx += p.x;
|
|
1545
|
+
cy += p.y;
|
|
1546
|
+
depth += Math.hypot(p.x - focal.x, p.y - focal.y);
|
|
1547
|
+
}
|
|
1548
|
+
const n = points.length;
|
|
1549
|
+
cx /= n;
|
|
1550
|
+
cy /= n;
|
|
1551
|
+
depth /= n;
|
|
1552
|
+
let ax = points[0], far = -1;
|
|
1553
|
+
for (const p of points) {
|
|
1554
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1555
|
+
if (d > far) {
|
|
1556
|
+
far = d;
|
|
1557
|
+
ax = p;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
let dx = ax.x - cx, dy = ax.y - cy;
|
|
1561
|
+
const len = Math.hypot(dx, dy);
|
|
1562
|
+
if (len > 1e-9) {
|
|
1563
|
+
dx /= len;
|
|
1564
|
+
dy /= len;
|
|
1565
|
+
} else {
|
|
1566
|
+
dx = 1;
|
|
1567
|
+
dy = 0;
|
|
1568
|
+
}
|
|
1569
|
+
const pts = [...points].sort((p, q) => (p.x - cx) * dx + (p.y - cy) * dy - ((q.x - cx) * dx + (q.y - cy) * dy));
|
|
1570
|
+
let radius = 0;
|
|
1571
|
+
for (const p of pts) {
|
|
1572
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1573
|
+
if (d > radius) radius = d;
|
|
1574
|
+
}
|
|
1575
|
+
return { pts, cx, cy, radius, depth };
|
|
1576
|
+
}
|
|
1577
|
+
function distToRow(r, x, y) {
|
|
1578
|
+
const pts = r.pts;
|
|
1579
|
+
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1580
|
+
let best = Infinity;
|
|
1581
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1582
|
+
const a = pts[i], b = pts[i + 1];
|
|
1583
|
+
const vx = b.x - a.x, vy = b.y - a.y;
|
|
1584
|
+
const len2 = vx * vx + vy * vy;
|
|
1585
|
+
let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
|
|
1586
|
+
if (t < 0) t = 0;
|
|
1587
|
+
else if (t > 1) t = 1;
|
|
1588
|
+
const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
|
|
1589
|
+
if (d < best) best = d;
|
|
1590
|
+
}
|
|
1591
|
+
return best;
|
|
1592
|
+
}
|
|
1593
|
+
var CANDIDATE_ROWS = 8;
|
|
1594
|
+
function sampleRows(rows, x, y) {
|
|
1595
|
+
const candD = new Array(CANDIDATE_ROWS).fill(Infinity);
|
|
1596
|
+
const candI = new Array(CANDIDATE_ROWS).fill(-1);
|
|
1597
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1598
|
+
const r = rows[i];
|
|
1599
|
+
const lower = Math.hypot(x - r.cx, y - r.cy) - r.radius;
|
|
1600
|
+
for (let k = 0; k < CANDIDATE_ROWS; k++) {
|
|
1601
|
+
if (lower < candD[k]) {
|
|
1602
|
+
for (let j = CANDIDATE_ROWS - 1; j > k; j--) {
|
|
1603
|
+
candD[j] = candD[j - 1];
|
|
1604
|
+
candI[j] = candI[j - 1];
|
|
1605
|
+
}
|
|
1606
|
+
candD[k] = lower;
|
|
1607
|
+
candI[k] = i;
|
|
1608
|
+
break;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
const bestD = new Array(BLEND_ROWS).fill(Infinity);
|
|
1613
|
+
const bestI = new Array(BLEND_ROWS).fill(-1);
|
|
1614
|
+
for (const i of candI) {
|
|
1615
|
+
if (i < 0) continue;
|
|
1616
|
+
const d = distToRow(rows[i], x, y);
|
|
1617
|
+
for (let k = 0; k < BLEND_ROWS; k++) {
|
|
1618
|
+
if (d < bestD[k]) {
|
|
1619
|
+
for (let j = BLEND_ROWS - 1; j > k; j--) {
|
|
1620
|
+
bestD[j] = bestD[j - 1];
|
|
1621
|
+
bestI[j] = bestI[j - 1];
|
|
1622
|
+
}
|
|
1623
|
+
bestD[k] = d;
|
|
1624
|
+
bestI[k] = i;
|
|
1625
|
+
break;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (bestI[0] < 0) return 0;
|
|
1630
|
+
if (bestD[0] < 1e-6) return rows[bestI[0]].depth;
|
|
1631
|
+
const cutoff = bestD[0] * BLEND_DISTANCE_RATIO;
|
|
1632
|
+
let num = 0, den = 0;
|
|
1633
|
+
for (let k = 0; k < BLEND_ROWS; k++) {
|
|
1634
|
+
const i = bestI[k];
|
|
1635
|
+
if (i < 0 || bestD[k] > cutoff) continue;
|
|
1636
|
+
const w = 1 / (bestD[k] * bestD[k]);
|
|
1637
|
+
num += w * rows[i].depth;
|
|
1638
|
+
den += w;
|
|
1639
|
+
}
|
|
1640
|
+
return den > 0 ? num / den : rows[bestI[0]].depth;
|
|
1641
|
+
}
|
|
1642
|
+
function rowsRake(rows) {
|
|
1643
|
+
const depthAt = (x, y) => sampleRows(rows, x, y);
|
|
1644
|
+
const nearestRow = (x, y) => {
|
|
1645
|
+
let best = Infinity, bestI = -1;
|
|
1646
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1647
|
+
const r = rows[i];
|
|
1648
|
+
if (Math.hypot(x - r.cx, y - r.cy) - r.radius >= best) continue;
|
|
1649
|
+
const d = distToRow(r, x, y);
|
|
1650
|
+
if (d < best) {
|
|
1651
|
+
best = d;
|
|
1652
|
+
bestI = i;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
return bestI;
|
|
1656
|
+
};
|
|
1657
|
+
return {
|
|
1658
|
+
kind: "rows",
|
|
1659
|
+
rowCount: rows.length,
|
|
1660
|
+
rows,
|
|
1661
|
+
nearestRow,
|
|
1662
|
+
depthAt,
|
|
1663
|
+
gradientAt: (x, y) => {
|
|
1664
|
+
const gx = (depthAt(x + GRAD_STEP, y) - depthAt(x - GRAD_STEP, y)) / (2 * GRAD_STEP);
|
|
1665
|
+
const gy = (depthAt(x, y + GRAD_STEP) - depthAt(x, y - GRAD_STEP)) / (2 * GRAD_STEP);
|
|
1666
|
+
const len = Math.hypot(gx, gy);
|
|
1667
|
+
return len < 1e-9 ? [0, 0] : [gx / len, gy / len];
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
function buildSectionRake(rows, focal) {
|
|
1672
|
+
const fits = [];
|
|
1673
|
+
for (const r of rows) {
|
|
1674
|
+
const f = fitRow(r.points, focal);
|
|
1675
|
+
if (f) fits.push(f);
|
|
1676
|
+
}
|
|
1677
|
+
if (fits.length < 2) return radialRake(focal);
|
|
1678
|
+
fits.sort((a, b) => a.depth - b.depth);
|
|
1679
|
+
return rowsRake(fits);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// src/view3d/scene/surface.ts
|
|
1683
|
+
var FLAT_SLAB_TOP_M = 0.05;
|
|
1684
|
+
var CAP_MAX_ERROR_M = 0.05;
|
|
1685
|
+
var SEAT_CLEARANCE_M = 0.15;
|
|
1686
|
+
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1687
|
+
function bboxOf(pts) {
|
|
1688
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1689
|
+
for (const p of pts) {
|
|
1690
|
+
if (p.x < minX) minX = p.x;
|
|
1691
|
+
if (p.y < minY) minY = p.y;
|
|
1692
|
+
if (p.x > maxX) maxX = p.x;
|
|
1693
|
+
if (p.y > maxY) maxY = p.y;
|
|
1694
|
+
}
|
|
1695
|
+
return { minX, minY, maxX, maxY };
|
|
1696
|
+
}
|
|
1697
|
+
var MAX_TIER_RISE_M = 25;
|
|
1698
|
+
function buildVenueSurfaces(units, seats) {
|
|
1699
|
+
const bySection = /* @__PURE__ */ new Map();
|
|
1700
|
+
const seatOwner = new Array(seats.length).fill(null);
|
|
1701
|
+
const seatDeck = new Float64Array(seats.length);
|
|
1702
|
+
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
1703
|
+
const seatPitch = new Array(seats.length).fill(void 0);
|
|
1704
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1705
|
+
const boxes = [];
|
|
1706
|
+
for (const unit of units) {
|
|
1707
|
+
for (const o of unit.objects) {
|
|
1708
|
+
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1709
|
+
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
1710
|
+
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
1711
|
+
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
for (let i = 0; i < seats.length; i++) {
|
|
1715
|
+
const s = seats[i];
|
|
1716
|
+
for (const b of boxes) {
|
|
1717
|
+
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
1718
|
+
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
1719
|
+
seatOwner[i] = b.id;
|
|
1720
|
+
const a = acc.get(b.id);
|
|
1721
|
+
a.hasSeats = true;
|
|
1722
|
+
const f = s.focalPoint ?? b.unit.focal;
|
|
1723
|
+
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
1724
|
+
if (d < a.frontU) a.frontU = d;
|
|
1725
|
+
a.seatIndices.push(i);
|
|
1726
|
+
const rowKey = s.rowId || `__seat-${i}`;
|
|
1727
|
+
const arr = a.rows.get(rowKey);
|
|
1728
|
+
if (arr) arr.push({ x: s.x, y: s.y });
|
|
1729
|
+
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
1730
|
+
break;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
for (const [id, a] of acc) {
|
|
1734
|
+
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
1735
|
+
const bottomY = a.unit.baseHeightM;
|
|
1736
|
+
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
1737
|
+
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
1738
|
+
const kind = a.section.surfaceKind;
|
|
1739
|
+
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
1740
|
+
const rake = buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal);
|
|
1741
|
+
let frontU = Infinity;
|
|
1742
|
+
if (a.hasSeats) {
|
|
1743
|
+
for (const pts of a.rows.values()) {
|
|
1744
|
+
for (const p of pts) {
|
|
1745
|
+
const d = rake.depthAt(p.x, p.y);
|
|
1746
|
+
if (d < frontU) frontU = d;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
1751
|
+
frontU = Infinity;
|
|
1752
|
+
for (const p of a.section.outline) {
|
|
1753
|
+
const d = rake.depthAt(p.x, p.y);
|
|
1754
|
+
if (d < frontU) frontU = d;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const flatTop = bottomY + FLAT_SLAB_TOP_M;
|
|
1758
|
+
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
1759
|
+
const levelFor = (depthU) => {
|
|
1760
|
+
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
1761
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1762
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
1763
|
+
};
|
|
1764
|
+
const levelForBlockDepth = (blockDepthU) => {
|
|
1765
|
+
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
1766
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1767
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
1768
|
+
};
|
|
1769
|
+
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
1770
|
+
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
1771
|
+
pts: r.pts,
|
|
1772
|
+
y: levelForBlockDepth(r.blockDepth),
|
|
1773
|
+
depth: r.blockDepth,
|
|
1774
|
+
blockId: r.blockId
|
|
1775
|
+
}));
|
|
1776
|
+
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
1777
|
+
const rowBounds = rowLevels.map((r) => {
|
|
1778
|
+
let cx = 0, cy = 0;
|
|
1779
|
+
for (const p of r.pts) {
|
|
1780
|
+
cx += p.x;
|
|
1781
|
+
cy += p.y;
|
|
1782
|
+
}
|
|
1783
|
+
const n = r.pts.length || 1;
|
|
1784
|
+
cx /= n;
|
|
1785
|
+
cy /= n;
|
|
1786
|
+
let rad = 0;
|
|
1787
|
+
for (const p of r.pts) {
|
|
1788
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1789
|
+
if (d > rad) rad = d;
|
|
1790
|
+
}
|
|
1791
|
+
return { cx, cy, rad };
|
|
1792
|
+
});
|
|
1793
|
+
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
1794
|
+
let best = Infinity, bestY = landingY;
|
|
1795
|
+
for (let i = 0; i < rowLevels.length; i++) {
|
|
1796
|
+
const b = rowBounds[i];
|
|
1797
|
+
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
1798
|
+
const d = distanceToPolyline(rowLevels[i].pts, x, y);
|
|
1799
|
+
if (d < best) {
|
|
1800
|
+
best = d;
|
|
1801
|
+
bestY = rowLevels[i].y;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
return bestY;
|
|
1805
|
+
} : (x, y) => levelFor(rake.depthAt(x, y));
|
|
1806
|
+
const UP = [0, 1, 0];
|
|
1807
|
+
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
1808
|
+
const d = rake.depthAt(x, y);
|
|
1809
|
+
if (d <= frontU) return UP;
|
|
1810
|
+
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
1811
|
+
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
1812
|
+
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
1813
|
+
const [gx, gy] = rake.gradientAt(x, y);
|
|
1814
|
+
if (gx === 0 && gy === 0) return UP;
|
|
1815
|
+
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
1816
|
+
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
1817
|
+
};
|
|
1818
|
+
if (!flat) {
|
|
1819
|
+
for (const r of structure.rows) {
|
|
1820
|
+
const y = levelForBlockDepth(r.blockDepth);
|
|
1821
|
+
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
for (const r of structure.rows) {
|
|
1825
|
+
const gaps = [];
|
|
1826
|
+
for (let k = 1; k < r.pts.length; k++) {
|
|
1827
|
+
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
1828
|
+
if (d > 1e-6) gaps.push(d);
|
|
1829
|
+
}
|
|
1830
|
+
gaps.sort((x, y) => x - y);
|
|
1831
|
+
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
1832
|
+
let across = Infinity;
|
|
1833
|
+
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
1834
|
+
if (probe) {
|
|
1835
|
+
for (const other of structure.rows) {
|
|
1836
|
+
if (other === r || other.blockId !== r.blockId) continue;
|
|
1837
|
+
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
1838
|
+
if (d > 1e-6 && d < across) across = d;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
1842
|
+
if (Number.isFinite(pitch) && pitch > 0) {
|
|
1843
|
+
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
1847
|
+
}
|
|
1848
|
+
for (let i = 0; i < seats.length; i++) {
|
|
1849
|
+
const ownerId = seatOwner[i];
|
|
1850
|
+
const s = seats[i];
|
|
1851
|
+
if (ownerId) {
|
|
1852
|
+
const own = seatRowLevel[i];
|
|
1853
|
+
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
const eye = s.eyeHeightM;
|
|
1857
|
+
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1858
|
+
}
|
|
1859
|
+
return {
|
|
1860
|
+
bySection,
|
|
1861
|
+
seatOwner,
|
|
1862
|
+
seatDeckY: (i) => seatDeck[i],
|
|
1863
|
+
seatPitchU: (i) => seatPitch[i]
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// src/view3d/scene/deckBands.ts
|
|
1868
|
+
var import_polygon_clipping2 = __toESM(require("polygon-clipping"), 1);
|
|
1869
|
+
var import_earcut2 = __toESM(require("earcut"), 1);
|
|
1870
|
+
var FRONT_REACH = 0.58;
|
|
1871
|
+
var MIN_RISER_M = 0.01;
|
|
1872
|
+
var MIN_REACH_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1873
|
+
var BACK_REACH = 0.5;
|
|
1874
|
+
var END_REACH = 0.6;
|
|
1875
|
+
function rowNormals(pts, focal) {
|
|
1876
|
+
const n = pts.length;
|
|
1877
|
+
const raw = [];
|
|
1878
|
+
for (let i = 0; i < n; i++) {
|
|
1879
|
+
const a = pts[Math.max(0, i - 1)];
|
|
1880
|
+
const b = pts[Math.min(n - 1, i + 1)];
|
|
1881
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
1882
|
+
const len = Math.hypot(dx, dy);
|
|
1883
|
+
if (len < 1e-9) {
|
|
1884
|
+
raw.push([0, 0]);
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
dx /= len;
|
|
1888
|
+
dy /= len;
|
|
1889
|
+
raw.push([-dy, dx]);
|
|
1890
|
+
}
|
|
1891
|
+
let vote = 0;
|
|
1892
|
+
for (let i = 0; i < n; i++) {
|
|
1893
|
+
vote += (pts[i].x - focal.x) * raw[i][0] + (pts[i].y - focal.y) * raw[i][1];
|
|
1894
|
+
}
|
|
1895
|
+
const flip = vote < 0;
|
|
1896
|
+
return flip ? raw.map(([x, y]) => [-x, -y]) : raw;
|
|
1897
|
+
}
|
|
1898
|
+
function extendEnds(pts, by) {
|
|
1899
|
+
if (pts.length < 2 || by <= 0) return [...pts];
|
|
1900
|
+
const out = [...pts];
|
|
1901
|
+
const dir = (p, q) => {
|
|
1902
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
1903
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
1904
|
+
return { x: dx / len, y: dy / len };
|
|
1905
|
+
};
|
|
1906
|
+
const head = dir(out[1], out[0]);
|
|
1907
|
+
const tail = dir(out[out.length - 2], out[out.length - 1]);
|
|
1908
|
+
out.unshift({ x: out[0].x + head.x * by, y: out[0].y + head.y * by });
|
|
1909
|
+
out.push({
|
|
1910
|
+
x: out[out.length - 1].x + tail.x * by,
|
|
1911
|
+
y: out[out.length - 1].y + tail.y * by
|
|
1912
|
+
});
|
|
1913
|
+
return out;
|
|
1914
|
+
}
|
|
1915
|
+
function distToPolyline(pts, x, y) {
|
|
1916
|
+
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1917
|
+
let best = Infinity;
|
|
1918
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1919
|
+
const a = pts[i], b = pts[i + 1];
|
|
1920
|
+
const vx = b.x - a.x, vy = b.y - a.y;
|
|
1921
|
+
const len2 = vx * vx + vy * vy;
|
|
1922
|
+
let t = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
|
|
1923
|
+
if (t < 0) t = 0;
|
|
1924
|
+
else if (t > 1) t = 1;
|
|
1925
|
+
const d = Math.hypot(x - (a.x + t * vx), y - (a.y + t * vy));
|
|
1926
|
+
if (d < best) best = d;
|
|
1927
|
+
}
|
|
1928
|
+
return best;
|
|
1929
|
+
}
|
|
1930
|
+
function neighbourhoods(rows) {
|
|
1931
|
+
const probesOf2 = (pts) => {
|
|
1932
|
+
const n = pts.length;
|
|
1933
|
+
if (n <= 2) return [...pts];
|
|
1934
|
+
return [pts[Math.floor(n * 0.25)], pts[Math.floor(n * 0.5)], pts[Math.floor(n * 0.75)]];
|
|
1935
|
+
};
|
|
1936
|
+
const out = [];
|
|
1937
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1938
|
+
const probes = probesOf2(rows[i].pts);
|
|
1939
|
+
const pitches = [];
|
|
1940
|
+
let bestFrontD = Infinity, belowY = null;
|
|
1941
|
+
for (const c of probes) {
|
|
1942
|
+
let nearest = Infinity;
|
|
1943
|
+
for (let j = 0; j < rows.length; j++) {
|
|
1944
|
+
if (j === i || Math.abs(rows[j].y - rows[i].y) < 1e-3) continue;
|
|
1945
|
+
const d = distToPolyline(rows[j].pts, c.x, c.y);
|
|
1946
|
+
if (d < nearest) nearest = d;
|
|
1947
|
+
if (rows[j].depth < rows[i].depth && d < bestFrontD) {
|
|
1948
|
+
bestFrontD = d;
|
|
1949
|
+
belowY = rows[j].y;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
if (Number.isFinite(nearest) && nearest > 0) pitches.push(nearest);
|
|
1953
|
+
}
|
|
1954
|
+
pitches.sort((a, b) => a - b);
|
|
1955
|
+
const pitch = pitches.length ? pitches[Math.floor(pitches.length / 2)] : 1;
|
|
1956
|
+
out.push({ pitch, belowY });
|
|
1957
|
+
}
|
|
1958
|
+
return out;
|
|
1959
|
+
}
|
|
1960
|
+
function ribbonOf(rows, i, nbrs, focal) {
|
|
1961
|
+
const row = rows[i];
|
|
1962
|
+
const rowPts = row.pts.length >= 2 ? [...row.pts] : row.pts.length === 1 ? (() => {
|
|
1963
|
+
const p = row.pts[0];
|
|
1964
|
+
let dx = p.x - focal.x, dy = p.y - focal.y;
|
|
1965
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
1966
|
+
dx /= len;
|
|
1967
|
+
dy /= len;
|
|
1968
|
+
const h = Math.max(nbrs[i].pitch, 1e-3) * 0.5;
|
|
1969
|
+
return [{ x: p.x + dy * h, y: p.y - dx * h }, { x: p.x - dy * h, y: p.y + dx * h }];
|
|
1970
|
+
})() : [];
|
|
1971
|
+
if (rowPts.length < 2) return null;
|
|
1972
|
+
let seatSpan = 0, spanN = 0;
|
|
1973
|
+
for (let k = 1; k < rowPts.length; k++) {
|
|
1974
|
+
const d = Math.hypot(rowPts[k].x - rowPts[k - 1].x, rowPts[k].y - rowPts[k - 1].y);
|
|
1975
|
+
if (d > 1e-6) {
|
|
1976
|
+
seatSpan += d;
|
|
1977
|
+
spanN++;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
const spacing = spanN > 0 ? seatSpan / spanN : 0;
|
|
1981
|
+
const pts = extendEnds(rowPts, Math.max(spacing * END_REACH, MIN_REACH_U));
|
|
1982
|
+
return {
|
|
1983
|
+
pts,
|
|
1984
|
+
nrm: rowNormals(pts, focal),
|
|
1985
|
+
front: Math.max(nbrs[i].pitch * FRONT_REACH, MIN_REACH_U),
|
|
1986
|
+
back: Math.max(nbrs[i].pitch * BACK_REACH, MIN_REACH_U)
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
function deckFootprints(rows, focal) {
|
|
1990
|
+
if (rows.length < 2) return [];
|
|
1991
|
+
const nbrs = neighbourhoods(rows);
|
|
1992
|
+
const rings = [];
|
|
1993
|
+
const ribbons = [];
|
|
1994
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1995
|
+
const r = ribbonOf(rows, i, nbrs, focal);
|
|
1996
|
+
ribbons.push(r);
|
|
1997
|
+
if (!r) continue;
|
|
1998
|
+
const f = [];
|
|
1999
|
+
const b = [];
|
|
2000
|
+
const rf = r.front + FOOTPRINT_MARGIN_U;
|
|
2001
|
+
const rb = r.back + FOOTPRINT_MARGIN_U;
|
|
2002
|
+
for (let k = 0; k < r.pts.length; k++) {
|
|
2003
|
+
const p = r.pts[k], n = r.nrm[k];
|
|
2004
|
+
f.push([p.x - n[0] * rf, p.y - n[1] * rf]);
|
|
2005
|
+
b.push([p.x + n[0] * rb, p.y + n[1] * rb]);
|
|
2006
|
+
}
|
|
2007
|
+
const ring = [...f, ...b.reverse()];
|
|
2008
|
+
if (ring.length < 3) continue;
|
|
2009
|
+
ring.push(ring[0]);
|
|
2010
|
+
rings.push([ring]);
|
|
2011
|
+
}
|
|
2012
|
+
if (!rings.length) return [];
|
|
2013
|
+
let merged;
|
|
2014
|
+
try {
|
|
2015
|
+
merged = import_polygon_clipping2.default.union(rings[0], ...rings.slice(1));
|
|
2016
|
+
} catch {
|
|
2017
|
+
return [];
|
|
2018
|
+
}
|
|
2019
|
+
const out = [];
|
|
2020
|
+
for (const poly of merged) {
|
|
2021
|
+
if (!poly.length || poly[0].length < 4) continue;
|
|
2022
|
+
const toPts = (ring) => {
|
|
2023
|
+
const pts = ring.map(([x, y]) => ({ x, y }));
|
|
2024
|
+
const first = pts[0], last = pts[pts.length - 1];
|
|
2025
|
+
if (pts.length > 1 && Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop();
|
|
2026
|
+
return pts;
|
|
2027
|
+
};
|
|
2028
|
+
const outline = toPts(poly[0]);
|
|
2029
|
+
if (outline.length < 3) continue;
|
|
2030
|
+
let topY = Infinity;
|
|
2031
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2032
|
+
const r = ribbons[i];
|
|
2033
|
+
if (!r) continue;
|
|
2034
|
+
const mid = r.pts[Math.floor(r.pts.length / 2)];
|
|
2035
|
+
if (pointInRing(outline, mid.x, mid.y) && rows[i].y < topY) topY = rows[i].y;
|
|
2036
|
+
}
|
|
2037
|
+
if (!Number.isFinite(topY)) continue;
|
|
2038
|
+
out.push({
|
|
2039
|
+
outline: simplifyRing(outline, FOOTPRINT_TOLERANCE_U),
|
|
2040
|
+
holes: poly.slice(1).map(toPts).map((h) => simplifyRing(h, FOOTPRINT_TOLERANCE_U)).filter((h) => h.length >= 3),
|
|
2041
|
+
topY
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
return out;
|
|
2045
|
+
}
|
|
2046
|
+
var FOOTPRINT_TOLERANCE_U = 0.3;
|
|
2047
|
+
var FOOTPRINT_MARGIN_U = 1.5;
|
|
2048
|
+
function simplifyRun(pts, tol) {
|
|
2049
|
+
if (pts.length < 3) return pts;
|
|
2050
|
+
const a = pts[0], b = pts[pts.length - 1];
|
|
2051
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
2052
|
+
const len = Math.hypot(dx, dy);
|
|
2053
|
+
let worst = -1, worstI = -1;
|
|
2054
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
2055
|
+
const p = pts[i];
|
|
2056
|
+
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);
|
|
2057
|
+
if (d > worst) {
|
|
2058
|
+
worst = d;
|
|
2059
|
+
worstI = i;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
if (worst <= tol || worstI < 0) return [a, b];
|
|
2063
|
+
const left = simplifyRun(pts.slice(0, worstI + 1), tol);
|
|
2064
|
+
const right = simplifyRun(pts.slice(worstI), tol);
|
|
2065
|
+
return [...left.slice(0, -1), ...right];
|
|
2066
|
+
}
|
|
2067
|
+
function simplifyRing(ring, tol) {
|
|
2068
|
+
if (ring.length < 4) return ring;
|
|
2069
|
+
const out = simplifyRun([...ring, ring[0]], tol);
|
|
2070
|
+
out.pop();
|
|
2071
|
+
return out.length >= 3 ? out : ring;
|
|
2072
|
+
}
|
|
2073
|
+
function pointInRing(ring, x, y) {
|
|
2074
|
+
let inside = false;
|
|
2075
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
2076
|
+
const a = ring[i], b = ring[j];
|
|
2077
|
+
if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
2078
|
+
}
|
|
2079
|
+
return inside;
|
|
2080
|
+
}
|
|
2081
|
+
var ClipTest = class {
|
|
2082
|
+
constructor(rings) {
|
|
2083
|
+
this.minX = Infinity;
|
|
2084
|
+
this.minY = Infinity;
|
|
2085
|
+
this.maxX = -Infinity;
|
|
2086
|
+
this.maxY = -Infinity;
|
|
2087
|
+
this.rings = rings;
|
|
2088
|
+
for (const r of rings) {
|
|
2089
|
+
for (const p of r) {
|
|
2090
|
+
if (p.x < this.minX) this.minX = p.x;
|
|
2091
|
+
if (p.y < this.minY) this.minY = p.y;
|
|
2092
|
+
if (p.x > this.maxX) this.maxX = p.x;
|
|
2093
|
+
if (p.y > this.maxY) this.maxY = p.y;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
/** True when every point lies strictly inside ONE ring of the clip region. */
|
|
2098
|
+
containsAll(pts) {
|
|
2099
|
+
for (const p of pts) {
|
|
2100
|
+
if (p.x < this.minX || p.x > this.maxX || p.y < this.minY || p.y > this.maxY) return false;
|
|
2101
|
+
}
|
|
2102
|
+
for (const ring of this.rings) {
|
|
2103
|
+
let all = true;
|
|
2104
|
+
for (const p of pts) {
|
|
2105
|
+
if (!pointInRing(ring, p.x, p.y)) {
|
|
2106
|
+
all = false;
|
|
2107
|
+
break;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
if (all) return true;
|
|
2111
|
+
}
|
|
2112
|
+
return false;
|
|
2113
|
+
}
|
|
2114
|
+
};
|
|
2115
|
+
function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
|
|
2116
|
+
if (clipTest.containsAll(quad)) {
|
|
2117
|
+
const UPF = [0, 1, 0];
|
|
2118
|
+
const a = quad[0], b = quad[1], c = quad[2], d = quad[3];
|
|
2119
|
+
builder.tri([a.x * M, y, a.y * M], [b.x * M, y, b.y * M], [c.x * M, y, c.y * M], UPF, color);
|
|
2120
|
+
builder.tri([a.x * M, y, a.y * M], [c.x * M, y, c.y * M], [d.x * M, y, d.y * M], UPF, color);
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
const ring = quad.map((p) => [p.x, p.y]);
|
|
2124
|
+
ring.push(ring[0]);
|
|
2125
|
+
let pieces;
|
|
2126
|
+
try {
|
|
2127
|
+
pieces = import_polygon_clipping2.default.intersection([ring], clipRing);
|
|
2128
|
+
} catch {
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
const UP = [0, 1, 0];
|
|
2132
|
+
for (const poly of pieces) {
|
|
2133
|
+
if (!poly.length || poly[0].length < 4) continue;
|
|
2134
|
+
const outer = poly[0];
|
|
2135
|
+
const flat = [];
|
|
2136
|
+
const pts = [];
|
|
2137
|
+
for (let i = 0; i < outer.length - 1; i++) {
|
|
2138
|
+
flat.push(outer[i][0], outer[i][1]);
|
|
2139
|
+
pts.push([outer[i][0], outer[i][1]]);
|
|
2140
|
+
}
|
|
2141
|
+
if (pts.length < 3) continue;
|
|
2142
|
+
const tris = (0, import_earcut2.default)(flat, void 0, 2);
|
|
2143
|
+
for (let i = 0; i < tris.length; i += 3) {
|
|
2144
|
+
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
2145
|
+
builder.tri(
|
|
2146
|
+
[a[0] * M, y, a[1] * M],
|
|
2147
|
+
[b[0] * M, y, b[1] * M],
|
|
2148
|
+
[c[0] * M, y, c[1] * M],
|
|
2149
|
+
UP,
|
|
2150
|
+
color
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
function emitDeckBands(builder, rows, focal, landingY, colors, clip) {
|
|
2156
|
+
if (rows.length < 2) return;
|
|
2157
|
+
const nbrs = neighbourhoods(rows);
|
|
2158
|
+
const UP = [0, 1, 0];
|
|
2159
|
+
const clipRing = clip && clip.length >= 3 ? [[...clip.map((p) => [p.x, p.y]), [clip[0].x, clip[0].y]]] : null;
|
|
2160
|
+
const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
|
|
2161
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2162
|
+
const row = rows[i];
|
|
2163
|
+
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
2164
|
+
if (!rib) continue;
|
|
2165
|
+
const { pts, nrm, front, back } = rib;
|
|
2166
|
+
const belowY = nbrs[i].belowY ?? landingY;
|
|
2167
|
+
for (let k = 0; k + 1 < pts.length; k++) {
|
|
2168
|
+
const p = pts[k], q = pts[k + 1];
|
|
2169
|
+
const np = nrm[k], nq = nrm[k + 1];
|
|
2170
|
+
if (Math.hypot(q.x - p.x, q.y - p.y) < 1e-6) continue;
|
|
2171
|
+
if (np[0] === 0 && np[1] === 0 || nq[0] === 0 && nq[1] === 0) continue;
|
|
2172
|
+
const pF = [(p.x - np[0] * front) * M, row.y, (p.y - np[1] * front) * M];
|
|
2173
|
+
const qF = [(q.x - nq[0] * front) * M, row.y, (q.y - nq[1] * front) * M];
|
|
2174
|
+
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
2175
|
+
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
2176
|
+
if (clipRing && clipTest) {
|
|
2177
|
+
emitClippedQuad(builder, clipRing, clipTest, [
|
|
2178
|
+
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
2179
|
+
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
2180
|
+
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
2181
|
+
{ x: p.x + np[0] * back, y: p.y + np[1] * back }
|
|
2182
|
+
], row.y, colors.tread);
|
|
2183
|
+
} else {
|
|
2184
|
+
builder.tri(pF, qF, qB, UP, colors.tread);
|
|
2185
|
+
builder.tri(pF, qB, pB, UP, colors.tread);
|
|
2186
|
+
}
|
|
2187
|
+
if (row.y > belowY + MIN_RISER_M) {
|
|
2188
|
+
const pFd = [pF[0], belowY, pF[2]];
|
|
2189
|
+
const qFd = [qF[0], belowY, qF[2]];
|
|
2190
|
+
const rn = [-np[0], 0, -np[1]];
|
|
2191
|
+
builder.tri(pF, qF, qFd, rn, colors.riser);
|
|
2192
|
+
builder.tri(pF, qFd, pFd, rn, colors.riser);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
683
2198
|
// src/view3d/scene/sceneModel.ts
|
|
684
2199
|
function floorUnits(doc) {
|
|
685
2200
|
if (doc.floors?.length) {
|
|
@@ -692,8 +2207,6 @@ function floorUnits(doc) {
|
|
|
692
2207
|
return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
|
|
693
2208
|
}
|
|
694
2209
|
var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
|
|
695
|
-
var DECK_DROP_M = 0.28;
|
|
696
|
-
var MAX_TIER_RISE_M = 25;
|
|
697
2210
|
function tintTop(fill, neutral) {
|
|
698
2211
|
if (!fill) return neutral;
|
|
699
2212
|
const muted = scaleRgb(desaturate(fill, 0.4), 0.62);
|
|
@@ -730,31 +2243,229 @@ function resolveSectionFills(doc, seats) {
|
|
|
730
2243
|
function sectionFill(section, byLogical) {
|
|
731
2244
|
return hexToRgb(section.color) ?? byLogical.get(section.logicalSectionId ?? section.id) ?? null;
|
|
732
2245
|
}
|
|
733
|
-
function
|
|
2246
|
+
function bboxOfRing(ring) {
|
|
2247
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2248
|
+
for (const p of ring) {
|
|
2249
|
+
if (p.x < minX) minX = p.x;
|
|
2250
|
+
if (p.y < minY) minY = p.y;
|
|
2251
|
+
if (p.x > maxX) maxX = p.x;
|
|
2252
|
+
if (p.y > maxY) maxY = p.y;
|
|
2253
|
+
}
|
|
2254
|
+
return { minX, minY, maxX, maxY };
|
|
2255
|
+
}
|
|
2256
|
+
function boxesOverlap(a, b) {
|
|
2257
|
+
return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
|
|
2258
|
+
}
|
|
2259
|
+
function paddedFootprint(section, siblings) {
|
|
2260
|
+
const __tp = performance.now();
|
|
2261
|
+
const padded = outsetRing(section.outline, SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE);
|
|
2262
|
+
__t("outsetRing", __tp);
|
|
2263
|
+
const box = bboxOfRing(padded);
|
|
2264
|
+
const others = siblings.filter((o) => o !== section && o.outline && o.outline.length >= 3 && boxesOverlap(box, bboxOfRing(o.outline)));
|
|
2265
|
+
if (!others.length) return [padded];
|
|
2266
|
+
const toRing = (pts) => {
|
|
2267
|
+
const r = pts.map((p) => [p.x, p.y]);
|
|
2268
|
+
r.push(r[0]);
|
|
2269
|
+
return r;
|
|
2270
|
+
};
|
|
2271
|
+
try {
|
|
2272
|
+
const diff = import_polygon_clipping3.default.difference([toRing(padded)], ...others.map((o) => [toRing(o.outline)]));
|
|
2273
|
+
const out = [];
|
|
2274
|
+
for (const poly of diff) {
|
|
2275
|
+
if (!poly.length) continue;
|
|
2276
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
2277
|
+
if (pts.length > 1) {
|
|
2278
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
2279
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
2280
|
+
}
|
|
2281
|
+
if (pts.length >= 3) out.push(pts);
|
|
2282
|
+
}
|
|
2283
|
+
return out.length ? out : [padded];
|
|
2284
|
+
} catch {
|
|
2285
|
+
return [padded];
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
function clipFootprints(blocks, allowed) {
|
|
2289
|
+
if (!allowed.length) return blocks;
|
|
2290
|
+
const toRing = (pts) => {
|
|
2291
|
+
const r = pts.map((p) => [p.x, p.y]);
|
|
2292
|
+
r.push(r[0]);
|
|
2293
|
+
return r;
|
|
2294
|
+
};
|
|
2295
|
+
const allowedPolys = allowed.map((a) => [toRing(a)]);
|
|
2296
|
+
const out = [];
|
|
2297
|
+
for (const b of blocks) {
|
|
2298
|
+
let pieces;
|
|
2299
|
+
try {
|
|
2300
|
+
pieces = import_polygon_clipping3.default.intersection([toRing(b.outline)], ...[allowedPolys.flat()]);
|
|
2301
|
+
} catch {
|
|
2302
|
+
out.push(b);
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
for (const poly of pieces) {
|
|
2306
|
+
if (!poly.length) continue;
|
|
2307
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
2308
|
+
if (pts.length > 1) {
|
|
2309
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
2310
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
2311
|
+
}
|
|
2312
|
+
if (pts.length >= 3) out.push({ outline: pts, holes: [], topY: b.topY });
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
return out.length ? out : blocks;
|
|
2316
|
+
}
|
|
2317
|
+
function buildTier(builder, section, unit, fill, surface, claimed, siblings, S) {
|
|
734
2318
|
if (!section.outline || section.outline.length < 3) return;
|
|
735
|
-
const geo = sectionGeometry(section, { floorBaseHeightM: unit.baseHeightM });
|
|
736
2319
|
const bottomY = unit.baseHeightM;
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
2320
|
+
const colTop = tintTop(fill, S.tierTop);
|
|
2321
|
+
if (!surface) return;
|
|
2322
|
+
const topY = (p) => surface.deckAt(p.x, p.y);
|
|
2323
|
+
const footprint = paddedFootprint(section, siblings);
|
|
2324
|
+
const outline = footprint[0] ?? section.outline;
|
|
2325
|
+
if (surface.rowLevels.length >= 2) {
|
|
2326
|
+
const __tf = performance.now();
|
|
2327
|
+
const blocks = clipFootprints(deckFootprints(surface.rowLevels, unit.focal), footprint);
|
|
2328
|
+
__t("deckFootprints", __tf);
|
|
2329
|
+
const capUp = () => [0, 1, 0];
|
|
2330
|
+
for (const b of blocks) {
|
|
2331
|
+
extrudePrism(
|
|
2332
|
+
builder,
|
|
2333
|
+
b.outline,
|
|
2334
|
+
b.holes,
|
|
2335
|
+
() => b.topY,
|
|
2336
|
+
bottomY,
|
|
2337
|
+
colTop,
|
|
2338
|
+
S.tierWall,
|
|
2339
|
+
AO,
|
|
2340
|
+
void 0,
|
|
2341
|
+
capUp
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
if (!blocks.length) {
|
|
2345
|
+
extrudePrism(
|
|
2346
|
+
builder,
|
|
2347
|
+
outline,
|
|
2348
|
+
section.holes,
|
|
2349
|
+
() => surface.landingY,
|
|
2350
|
+
bottomY,
|
|
2351
|
+
colTop,
|
|
2352
|
+
S.tierWall,
|
|
2353
|
+
AO
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
const __tb = performance.now();
|
|
2357
|
+
emitDeckBands(builder, surface.rowLevels, unit.focal, surface.landingY, {
|
|
2358
|
+
tread: [colTop[0] * AO.top, colTop[1] * AO.top, colTop[2] * AO.top],
|
|
2359
|
+
// Risers read as the structure they are, a shade below their tread, which
|
|
2360
|
+
// is what makes the stepping legible from a low angle.
|
|
2361
|
+
riser: [colTop[0] * 0.72, colTop[1] * 0.72, colTop[2] * 0.72]
|
|
2362
|
+
}, footprint.length === 1 ? outline : footprint.flat());
|
|
2363
|
+
__t("emitDeckBands", __tb);
|
|
743
2364
|
return;
|
|
744
2365
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
2366
|
+
const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
|
|
2367
|
+
const topN = (p) => surface.normalAt(p.x, p.y);
|
|
2368
|
+
for (const ring of claimed.subtract(outline)) {
|
|
2369
|
+
extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
var ClaimedArea = class {
|
|
2373
|
+
constructor() {
|
|
2374
|
+
this.rings = [];
|
|
2375
|
+
this.boxes = [];
|
|
2376
|
+
}
|
|
2377
|
+
/** `ring` minus everything claimed so far; then claim what is returned. */
|
|
2378
|
+
subtract(ring) {
|
|
2379
|
+
const closed = ring.map((p) => [p.x, p.y]);
|
|
2380
|
+
if (closed.length < 3) return [];
|
|
2381
|
+
closed.push(closed[0]);
|
|
2382
|
+
const box = bboxOfRing(ring);
|
|
2383
|
+
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
|
|
2384
|
+
let pieces = [[closed]];
|
|
2385
|
+
if (overlapping.length) {
|
|
2386
|
+
try {
|
|
2387
|
+
const diff = import_polygon_clipping3.default.difference([closed], ...overlapping);
|
|
2388
|
+
pieces = diff.map((poly) => poly.map((r) => r.map(([x, y]) => [x, y])));
|
|
2389
|
+
} catch {
|
|
2390
|
+
pieces = [[closed]];
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
this.rings.push([closed]);
|
|
2394
|
+
this.boxes.push(box);
|
|
2395
|
+
const out = [];
|
|
2396
|
+
for (const poly of pieces) {
|
|
2397
|
+
if (!poly.length) continue;
|
|
2398
|
+
const pts = poly[0].map(([x, y]) => ({ x, y }));
|
|
2399
|
+
if (pts.length > 1) {
|
|
2400
|
+
const f = pts[0], l = pts[pts.length - 1];
|
|
2401
|
+
if (Math.abs(f.x - l.x) < 1e-9 && Math.abs(f.y - l.y) < 1e-9) pts.pop();
|
|
2402
|
+
}
|
|
2403
|
+
if (pts.length >= 3) out.push(pts);
|
|
2404
|
+
}
|
|
2405
|
+
return out;
|
|
2406
|
+
}
|
|
2407
|
+
};
|
|
2408
|
+
var BOOTH_HEIGHT_M = 2.4;
|
|
2409
|
+
var ZONE_LABEL_LIFT_M = 6;
|
|
2410
|
+
var SECTION_LABEL_LIFT_M = 2.2;
|
|
2411
|
+
var ANNOTATION_LIFT_M = 0.1;
|
|
2412
|
+
var BOOTH_LABEL_LIFT_M = 0.3;
|
|
2413
|
+
var TABLE_HEIGHT_M = 0.75;
|
|
2414
|
+
function boothPolygon(booth) {
|
|
2415
|
+
if (booth.points && booth.points.length >= 3) return booth.points;
|
|
2416
|
+
const { center, width, height, rotation } = booth;
|
|
2417
|
+
if (!width || !height) return null;
|
|
2418
|
+
const a = (rotation ?? 0) * Math.PI / 180;
|
|
2419
|
+
const cos = Math.cos(a), sin = Math.sin(a);
|
|
2420
|
+
const hw = width / 2, hh = height / 2;
|
|
2421
|
+
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
|
|
2422
|
+
x: center.x + lx * cos - ly * sin,
|
|
2423
|
+
y: center.y + lx * sin + ly * cos
|
|
2424
|
+
}));
|
|
2425
|
+
}
|
|
2426
|
+
function buildBooth(builder, booth, base, fill, S) {
|
|
2427
|
+
const poly = boothPolygon(booth);
|
|
2428
|
+
if (!poly) return;
|
|
2429
|
+
extrudePrism(
|
|
2430
|
+
builder,
|
|
2431
|
+
poly,
|
|
2432
|
+
void 0,
|
|
2433
|
+
() => base + BOOTH_HEIGHT_M,
|
|
2434
|
+
base,
|
|
2435
|
+
tintTop(fill, S.boothTop),
|
|
2436
|
+
S.boothWall,
|
|
2437
|
+
AO
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
2440
|
+
function tablePolygon(table) {
|
|
2441
|
+
if (table.shape === "round") {
|
|
2442
|
+
const r = table.radius;
|
|
2443
|
+
if (!r) return null;
|
|
2444
|
+
return ellipsePolygon(table.center.x, table.center.y, r, r, 24);
|
|
2445
|
+
}
|
|
2446
|
+
const { width, height, rotation, center } = table;
|
|
2447
|
+
if (!width || !height) return null;
|
|
2448
|
+
const a = (rotation ?? 0) * Math.PI / 180;
|
|
2449
|
+
const cos = Math.cos(a), sin = Math.sin(a);
|
|
2450
|
+
const hw = width / 2, hh = height / 2;
|
|
2451
|
+
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
|
|
2452
|
+
x: center.x + lx * cos - ly * sin,
|
|
2453
|
+
y: center.y + lx * sin + ly * cos
|
|
2454
|
+
}));
|
|
2455
|
+
}
|
|
2456
|
+
function buildTable(builder, table, base, fill, S) {
|
|
2457
|
+
const poly = tablePolygon(table);
|
|
2458
|
+
if (!poly) return;
|
|
2459
|
+
extrudePrism(
|
|
2460
|
+
builder,
|
|
2461
|
+
poly,
|
|
2462
|
+
void 0,
|
|
2463
|
+
() => base + TABLE_HEIGHT_M,
|
|
2464
|
+
base,
|
|
2465
|
+
tintTop(fill, S.tableTop),
|
|
2466
|
+
S.tableWall,
|
|
2467
|
+
AO
|
|
2468
|
+
);
|
|
758
2469
|
}
|
|
759
2470
|
function shapePolygon(shape) {
|
|
760
2471
|
if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
|
|
@@ -768,19 +2479,19 @@ function shapePolygon(shape) {
|
|
|
768
2479
|
}
|
|
769
2480
|
return null;
|
|
770
2481
|
}
|
|
771
|
-
function buildShape(builder, shape, base) {
|
|
2482
|
+
function buildShape(builder, shape, base, S) {
|
|
772
2483
|
const poly = shapePolygon(shape);
|
|
773
2484
|
if (!poly) return;
|
|
774
2485
|
const isStage = shape.role === "stage";
|
|
775
2486
|
const height = isStage ? base + 1 : base + 0.25;
|
|
776
|
-
const colTop = isStage ?
|
|
777
|
-
const colWall = isStage ?
|
|
2487
|
+
const colTop = isStage ? S.stageTop : S.decorTop;
|
|
2488
|
+
const colWall = isStage ? S.stageWall : S.decorWall;
|
|
778
2489
|
extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
|
|
779
2490
|
}
|
|
780
|
-
function buildGa(builder, ga, base, fill) {
|
|
2491
|
+
function buildGa(builder, ga, base, fill, S) {
|
|
781
2492
|
if (!ga.points || ga.points.length < 3) return;
|
|
782
|
-
const colTop = tintTop(fill,
|
|
783
|
-
extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop,
|
|
2493
|
+
const colTop = tintTop(fill, S.gaTop);
|
|
2494
|
+
extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, S.gaWall, AO);
|
|
784
2495
|
}
|
|
785
2496
|
function chartFootprint(units, seats) {
|
|
786
2497
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
@@ -796,6 +2507,13 @@ function chartFootprint(units, seats) {
|
|
|
796
2507
|
if (o.type === "section") for (const p of o.outline) acc(p.x, p.y);
|
|
797
2508
|
else if (o.type === "shape" && o.points) for (const p of o.points) acc(p.x, p.y);
|
|
798
2509
|
else if (o.type === "gaArea") for (const p of o.points) acc(p.x, p.y);
|
|
2510
|
+
else if (o.type === "booth") {
|
|
2511
|
+
const b = boothPolygon(o);
|
|
2512
|
+
if (b) for (const p of b) acc(p.x, p.y);
|
|
2513
|
+
} else if (o.type === "table") {
|
|
2514
|
+
const t = tablePolygon(o);
|
|
2515
|
+
if (t) for (const p of t) acc(p.x, p.y);
|
|
2516
|
+
}
|
|
799
2517
|
}
|
|
800
2518
|
}
|
|
801
2519
|
if (!Number.isFinite(minX)) {
|
|
@@ -806,41 +2524,318 @@ function chartFootprint(units, seats) {
|
|
|
806
2524
|
}
|
|
807
2525
|
return { minX, minY, maxX, maxY };
|
|
808
2526
|
}
|
|
2527
|
+
var __PROF = {};
|
|
2528
|
+
var __t = (k, t0) => {
|
|
2529
|
+
__PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
|
|
2530
|
+
};
|
|
809
2531
|
function buildSceneModel(input) {
|
|
2532
|
+
for (const k of Object.keys(__PROF)) delete __PROF[k];
|
|
810
2533
|
const { doc, seats } = input;
|
|
2534
|
+
const theme = resolveTheme3D(doc.theme);
|
|
2535
|
+
const S = theme.structure;
|
|
811
2536
|
const units = floorUnits(doc);
|
|
812
2537
|
const builder = new MeshBuilder();
|
|
813
2538
|
const fp = chartFootprint(units, seats);
|
|
814
2539
|
const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
|
|
815
2540
|
const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
|
|
816
|
-
extrudePrism(builder, groundPoly, void 0, () => 0, -0.4,
|
|
2541
|
+
extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
|
|
817
2542
|
const sectionFills = resolveSectionFills(doc, seats);
|
|
818
2543
|
const catColor = /* @__PURE__ */ new Map();
|
|
819
2544
|
for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
|
|
820
|
-
|
|
2545
|
+
const __t0 = performance.now();
|
|
2546
|
+
const surfaces = buildVenueSurfaces(units, seats);
|
|
2547
|
+
__t("surfaces", __t0);
|
|
2548
|
+
const seatFloor = new Float32Array(seats.length);
|
|
2549
|
+
for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
|
|
2550
|
+
const unit = units[unitIndex];
|
|
2551
|
+
builder.setFloor(unitIndex);
|
|
2552
|
+
const claimed = new ClaimedArea();
|
|
2553
|
+
const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
|
|
821
2554
|
for (const o of unit.objects) {
|
|
822
|
-
if (o.type === "section")
|
|
823
|
-
|
|
824
|
-
|
|
2555
|
+
if (o.type === "section") {
|
|
2556
|
+
const t = performance.now();
|
|
2557
|
+
buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
|
|
2558
|
+
__t("buildTier", t);
|
|
2559
|
+
} else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
|
|
2560
|
+
else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2561
|
+
else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2562
|
+
else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
825
2563
|
}
|
|
826
2564
|
}
|
|
2565
|
+
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
2566
|
+
const __tm = performance.now();
|
|
827
2567
|
const solids = mergeMeshData([builder.build()]);
|
|
828
|
-
|
|
2568
|
+
__t("meshBuild+merge", __tm);
|
|
2569
|
+
const __ts = performance.now();
|
|
2570
|
+
const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
|
|
2571
|
+
__t("seatInstances", __ts);
|
|
2572
|
+
const zoneDefs = doc.zones ?? [];
|
|
2573
|
+
const zones = [];
|
|
2574
|
+
if (zoneDefs.length) {
|
|
2575
|
+
const sectionZone = /* @__PURE__ */ new Map();
|
|
2576
|
+
for (const unit of units) {
|
|
2577
|
+
for (const o of unit.objects) {
|
|
2578
|
+
if (o.type === "section" && o.zone) sectionZone.set(o.id, o.zone);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2582
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2583
|
+
const s = seats[i];
|
|
2584
|
+
const owner = surfaces.seatOwner[i];
|
|
2585
|
+
const zid = s.zoneId ?? (owner ? sectionZone.get(owner) : void 0);
|
|
2586
|
+
if (!zid) continue;
|
|
2587
|
+
let a = acc.get(zid);
|
|
2588
|
+
if (!a) {
|
|
2589
|
+
a = { n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 };
|
|
2590
|
+
acc.set(zid, a);
|
|
2591
|
+
}
|
|
2592
|
+
a.n++;
|
|
2593
|
+
if (s.x < a.minX) a.minX = s.x;
|
|
2594
|
+
if (s.y < a.minY) a.minY = s.y;
|
|
2595
|
+
if (s.x > a.maxX) a.maxX = s.x;
|
|
2596
|
+
if (s.y > a.maxY) a.maxY = s.y;
|
|
2597
|
+
a.sumY += surfaces.seatDeckY(i);
|
|
2598
|
+
}
|
|
2599
|
+
for (const z of zoneDefs) {
|
|
2600
|
+
const a = acc.get(z.id);
|
|
2601
|
+
const sectionIds = [];
|
|
2602
|
+
for (const [secId, zid] of sectionZone) if (zid === z.id) sectionIds.push(secId);
|
|
2603
|
+
if (!a || a.n === 0) {
|
|
2604
|
+
zones.push({
|
|
2605
|
+
id: z.id,
|
|
2606
|
+
label: z.label,
|
|
2607
|
+
color: hexToRgb(z.color),
|
|
2608
|
+
sectionIds,
|
|
2609
|
+
seatCount: 0,
|
|
2610
|
+
center: [0, 0, 0],
|
|
2611
|
+
radius: 0,
|
|
2612
|
+
focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
|
|
2613
|
+
});
|
|
2614
|
+
continue;
|
|
2615
|
+
}
|
|
2616
|
+
zones.push({
|
|
2617
|
+
id: z.id,
|
|
2618
|
+
label: z.label,
|
|
2619
|
+
color: hexToRgb(z.color),
|
|
2620
|
+
sectionIds,
|
|
2621
|
+
seatCount: a.n,
|
|
2622
|
+
center: [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M],
|
|
2623
|
+
radius: 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1,
|
|
2624
|
+
// A zone may face its own point (`ZoneDef.focalPoint`); the documented
|
|
2625
|
+
// fallback is the floor/chart focal.
|
|
2626
|
+
focalWorld: [(z.focalPoint ?? focal).x * M, 1.5, (z.focalPoint ?? focal).y * M]
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
const labels = [];
|
|
2631
|
+
for (const z of zones) {
|
|
2632
|
+
if (z.seatCount === 0) continue;
|
|
2633
|
+
labels.push({
|
|
2634
|
+
id: `zone:${z.id}`,
|
|
2635
|
+
kind: "zone",
|
|
2636
|
+
text: z.label,
|
|
2637
|
+
anchor: [z.center[0], z.center[1] + ZONE_LABEL_LIFT_M, z.center[2]],
|
|
2638
|
+
color: doc.zones?.find((d) => d.id === z.id)?.color
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
{
|
|
2642
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2643
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2644
|
+
const owner = surfaces.seatOwner[i];
|
|
2645
|
+
if (!owner) continue;
|
|
2646
|
+
let a = acc.get(owner);
|
|
2647
|
+
if (!a) {
|
|
2648
|
+
a = { n: 0, x: 0, y: 0, deck: 0 };
|
|
2649
|
+
acc.set(owner, a);
|
|
2650
|
+
}
|
|
2651
|
+
a.n++;
|
|
2652
|
+
a.x += seats[i].x;
|
|
2653
|
+
a.y += seats[i].y;
|
|
2654
|
+
a.deck += surfaces.seatDeckY(i);
|
|
2655
|
+
}
|
|
2656
|
+
for (const unit of units) {
|
|
2657
|
+
for (const o of unit.objects) {
|
|
2658
|
+
if (o.type !== "section") continue;
|
|
2659
|
+
const a = acc.get(o.id);
|
|
2660
|
+
if (!a || a.n === 0) continue;
|
|
2661
|
+
labels.push({
|
|
2662
|
+
id: `section:${o.id}`,
|
|
2663
|
+
kind: "section",
|
|
2664
|
+
// The buyer-facing name wins over the technical one, as it does in 2D.
|
|
2665
|
+
text: o.displayLabel || o.label || o.id,
|
|
2666
|
+
anchor: [a.x / a.n * M, a.deck / a.n + SECTION_LABEL_LIFT_M, a.y / a.n * M]
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
for (const unit of units) {
|
|
2672
|
+
for (const o of unit.objects) {
|
|
2673
|
+
if (o.type === "text") {
|
|
2674
|
+
if (!o.text) continue;
|
|
2675
|
+
labels.push({
|
|
2676
|
+
id: `text:${o.id}`,
|
|
2677
|
+
kind: "annotation",
|
|
2678
|
+
text: o.text,
|
|
2679
|
+
anchor: [o.position.x * M, unit.baseHeightM + ANNOTATION_LIFT_M, o.position.y * M],
|
|
2680
|
+
color: o.color,
|
|
2681
|
+
rotation: o.rotation
|
|
2682
|
+
});
|
|
2683
|
+
} else if (o.type === "booth") {
|
|
2684
|
+
const poly = boothPolygon(o);
|
|
2685
|
+
if (!poly) continue;
|
|
2686
|
+
const c = centroidOf(poly);
|
|
2687
|
+
if (!c) continue;
|
|
2688
|
+
labels.push({
|
|
2689
|
+
id: `booth:${o.id}`,
|
|
2690
|
+
kind: "booth",
|
|
2691
|
+
text: o.displayLabel || o.label || o.id,
|
|
2692
|
+
anchor: [c.x * M, unit.baseHeightM + BOOTH_HEIGHT_M + BOOTH_LABEL_LIFT_M, c.y * M]
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
const floors = [];
|
|
2698
|
+
{
|
|
2699
|
+
const sectionFloor = /* @__PURE__ */ new Map();
|
|
2700
|
+
for (let ui = 0; ui < units.length; ui++) {
|
|
2701
|
+
for (const o of units[ui].objects) if (o.type === "section") sectionFloor.set(o.id, ui);
|
|
2702
|
+
}
|
|
2703
|
+
const acc = units.map(() => ({ n: 0, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, sumY: 0 }));
|
|
2704
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2705
|
+
const owner = surfaces.seatOwner[i];
|
|
2706
|
+
const ui = owner !== null ? sectionFloor.get(owner) : void 0;
|
|
2707
|
+
if (ui === void 0) continue;
|
|
2708
|
+
const a = acc[ui];
|
|
2709
|
+
const s = seats[i];
|
|
2710
|
+
a.n++;
|
|
2711
|
+
if (s.x < a.minX) a.minX = s.x;
|
|
2712
|
+
if (s.y < a.minY) a.minY = s.y;
|
|
2713
|
+
if (s.x > a.maxX) a.maxX = s.x;
|
|
2714
|
+
if (s.y > a.maxY) a.maxY = s.y;
|
|
2715
|
+
a.sumY += surfaces.seatDeckY(i);
|
|
2716
|
+
seatFloor[i] = ui;
|
|
2717
|
+
}
|
|
2718
|
+
for (let ui = 0; ui < units.length; ui++) {
|
|
2719
|
+
const a = acc[ui];
|
|
2720
|
+
const f = doc.floors?.[ui];
|
|
2721
|
+
floors.push({
|
|
2722
|
+
index: ui,
|
|
2723
|
+
id: f?.id ?? `floor-${ui}`,
|
|
2724
|
+
label: f?.name ?? (units.length > 1 ? `Floor ${ui + 1}` : "Venue"),
|
|
2725
|
+
seatCount: a.n,
|
|
2726
|
+
center: a.n ? [(a.minX + a.maxX) / 2 * M, a.sumY / a.n, (a.minY + a.maxY) / 2 * M] : [0, 0, 0],
|
|
2727
|
+
radius: a.n ? 0.5 * Math.hypot((a.maxX - a.minX) * M, (a.maxY - a.minY) * M) || 1 : 0
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
829
2731
|
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
830
2732
|
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
831
2733
|
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
832
|
-
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
833
2734
|
return {
|
|
834
2735
|
solids,
|
|
835
2736
|
seats: seatData,
|
|
836
2737
|
bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
|
|
837
|
-
stateColorLUT:
|
|
2738
|
+
stateColorLUT: themeSeatColorLUT(theme, SEAT_STATES),
|
|
2739
|
+
theme,
|
|
838
2740
|
seatCount: seats.length,
|
|
839
2741
|
// Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
|
|
840
|
-
focalWorld: [focal.x * M, 1.5, focal.y * M]
|
|
2742
|
+
focalWorld: [focal.x * M, 1.5, focal.y * M],
|
|
2743
|
+
zones,
|
|
2744
|
+
labels,
|
|
2745
|
+
floors
|
|
841
2746
|
};
|
|
842
2747
|
}
|
|
843
2748
|
|
|
2749
|
+
// src/view3d/labelOverlay.ts
|
|
2750
|
+
var SEPARATION_X_PX = 88;
|
|
2751
|
+
var SEPARATION_Y_PX = 20;
|
|
2752
|
+
var KIND_STYLE = {
|
|
2753
|
+
zone: { size: 15, weight: "600", opacity: 0.95 },
|
|
2754
|
+
section: { size: 12, weight: "500", opacity: 0.88 },
|
|
2755
|
+
booth: { size: 11, weight: "500", opacity: 0.85 },
|
|
2756
|
+
annotation: { size: 11, weight: "400", opacity: 0.75 }
|
|
2757
|
+
};
|
|
2758
|
+
var LabelOverlay = class {
|
|
2759
|
+
constructor(container, opts = {}) {
|
|
2760
|
+
this.nodes = /* @__PURE__ */ new Map();
|
|
2761
|
+
this.labels = [];
|
|
2762
|
+
this.opts = opts;
|
|
2763
|
+
this.root = document.createElement("div");
|
|
2764
|
+
this.root.setAttribute("data-view3d-labels", "");
|
|
2765
|
+
const s = this.root.style;
|
|
2766
|
+
s.position = "absolute";
|
|
2767
|
+
s.inset = "0";
|
|
2768
|
+
s.pointerEvents = "none";
|
|
2769
|
+
s.overflow = "hidden";
|
|
2770
|
+
if (opts.fontFamily) s.fontFamily = opts.fontFamily;
|
|
2771
|
+
container.appendChild(this.root);
|
|
2772
|
+
}
|
|
2773
|
+
setLabels(labels) {
|
|
2774
|
+
this.labels = labels;
|
|
2775
|
+
for (const [id, node] of this.nodes) {
|
|
2776
|
+
if (!labels.some((l) => l.id === id)) {
|
|
2777
|
+
node.remove();
|
|
2778
|
+
this.nodes.delete(id);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Reposition every label for the current camera.
|
|
2784
|
+
*
|
|
2785
|
+
* `viewProjection` is column-major, as OGL supplies it.
|
|
2786
|
+
*/
|
|
2787
|
+
update(viewProjection, width, height, cameraDistance, venueRadius) {
|
|
2788
|
+
if (!this.labels.length) return;
|
|
2789
|
+
const kinds = visibleLabelKinds(cameraDistance, venueRadius);
|
|
2790
|
+
const candidates = [];
|
|
2791
|
+
for (const label of this.labels) {
|
|
2792
|
+
if (!kinds.has(label.kind)) continue;
|
|
2793
|
+
const screen = projectToScreen(viewProjection, label.anchor, width, height);
|
|
2794
|
+
if (!screen.visible) continue;
|
|
2795
|
+
candidates.push({ label, screen });
|
|
2796
|
+
}
|
|
2797
|
+
const kept = cullOverlapping(candidates, SEPARATION_X_PX, SEPARATION_Y_PX);
|
|
2798
|
+
const keptIds = new Set(kept.map((k) => k.label.id));
|
|
2799
|
+
for (const { label, screen } of kept) {
|
|
2800
|
+
const node = this.nodeFor(label);
|
|
2801
|
+
const st = node.style;
|
|
2802
|
+
st.display = "";
|
|
2803
|
+
st.transform = `translate(-50%, -50%) translate(${screen.x.toFixed(1)}px, ${screen.y.toFixed(1)}px)`;
|
|
2804
|
+
}
|
|
2805
|
+
for (const [id, node] of this.nodes) {
|
|
2806
|
+
if (!keptIds.has(id)) node.style.display = "none";
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
nodeFor(label) {
|
|
2810
|
+
let node = this.nodes.get(label.id);
|
|
2811
|
+
if (node) return node;
|
|
2812
|
+
node = document.createElement("div");
|
|
2813
|
+
node.textContent = label.text;
|
|
2814
|
+
node.setAttribute("data-label-kind", label.kind);
|
|
2815
|
+
const style = KIND_STYLE[label.kind];
|
|
2816
|
+
const s = node.style;
|
|
2817
|
+
s.position = "absolute";
|
|
2818
|
+
s.left = "0";
|
|
2819
|
+
s.top = "0";
|
|
2820
|
+
s.whiteSpace = "nowrap";
|
|
2821
|
+
s.fontSize = `${style.size}px`;
|
|
2822
|
+
s.fontWeight = style.weight;
|
|
2823
|
+
s.opacity = String(style.opacity);
|
|
2824
|
+
s.color = label.color ?? this.opts.ink ?? "#e8edf5";
|
|
2825
|
+
s.textShadow = "0 1px 3px rgba(0,0,0,0.85), 0 0 8px rgba(0,0,0,0.55)";
|
|
2826
|
+
s.letterSpacing = label.kind === "zone" ? "0.08em" : "0.02em";
|
|
2827
|
+
if (label.kind === "zone") s.textTransform = "uppercase";
|
|
2828
|
+
s.display = "none";
|
|
2829
|
+
this.root.appendChild(node);
|
|
2830
|
+
this.nodes.set(label.id, node);
|
|
2831
|
+
return node;
|
|
2832
|
+
}
|
|
2833
|
+
dispose() {
|
|
2834
|
+
this.root.remove();
|
|
2835
|
+
this.nodes.clear();
|
|
2836
|
+
}
|
|
2837
|
+
};
|
|
2838
|
+
|
|
844
2839
|
// src/view3d/scene/build.ts
|
|
845
2840
|
var import_ogl4 = require("ogl");
|
|
846
2841
|
|
|
@@ -853,16 +2848,29 @@ precision highp float;
|
|
|
853
2848
|
in vec3 position;
|
|
854
2849
|
in vec3 normal;
|
|
855
2850
|
in vec3 color;
|
|
2851
|
+
in float floorIndex;
|
|
2852
|
+
uniform mat4 modelMatrix;
|
|
2853
|
+
uniform float uFocusFloor; // -1 = show every floor
|
|
856
2854
|
uniform mat4 modelViewMatrix;
|
|
857
2855
|
uniform mat4 projectionMatrix;
|
|
858
2856
|
uniform mat3 normalMatrix;
|
|
859
2857
|
out vec3 vColor;
|
|
2858
|
+
out vec3 vNormalWorld;
|
|
860
2859
|
out vec3 vNormalView;
|
|
861
2860
|
out vec3 vPosView;
|
|
2861
|
+
out float vDim;
|
|
862
2862
|
void main() {
|
|
2863
|
+
// Per-floor isolation without splitting the merged mesh into a draw call per
|
|
2864
|
+
// floor: a floor that is not the focused one is dimmed, not hidden, so the
|
|
2865
|
+
// buyer keeps the whole venue as context while looking at one level.
|
|
2866
|
+
vDim = (uFocusFloor < -0.5 || abs(floorIndex - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
863
2867
|
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
|
864
2868
|
vPosView = mv.xyz;
|
|
865
2869
|
vNormalView = normalize(normalMatrix * normal);
|
|
2870
|
+
// World normal drives the key + hemisphere so the lighting stays welded to the
|
|
2871
|
+
// venue as the camera orbits (the scene has no non-uniform scale, so mat3 of
|
|
2872
|
+
// the model matrix is the correct normal transform).
|
|
2873
|
+
vNormalWorld = normalize(mat3(modelMatrix) * normal);
|
|
866
2874
|
vColor = color;
|
|
867
2875
|
gl_Position = projectionMatrix * mv;
|
|
868
2876
|
}`
|
|
@@ -872,18 +2880,26 @@ var SOLID_FRAG = (
|
|
|
872
2880
|
`#version 300 es
|
|
873
2881
|
precision highp float;
|
|
874
2882
|
in vec3 vColor;
|
|
2883
|
+
in vec3 vNormalWorld;
|
|
875
2884
|
in vec3 vNormalView;
|
|
876
2885
|
in vec3 vPosView;
|
|
2886
|
+
in float vDim;
|
|
2887
|
+
uniform vec3 uKeyDir; // WORLD space, unit, points at the light
|
|
877
2888
|
out vec4 fragColor;
|
|
878
2889
|
void main() {
|
|
879
|
-
vec3 N = normalize(
|
|
2890
|
+
vec3 N = normalize(vNormalWorld);
|
|
880
2891
|
vec3 V = normalize(-vPosView);
|
|
881
|
-
float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
2892
|
+
float hemi = 0.5 + 0.5 * N.y; // sky/ground gradient about WORLD up
|
|
2893
|
+
float key = max(dot(N, uKeyDir), 0.0); // fixed key \u2014 does not orbit with you
|
|
2894
|
+
// Low opposite fill so faces turned away from the key keep their form instead
|
|
2895
|
+
// of crushing to a single flat value.
|
|
2896
|
+
vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
|
|
2897
|
+
float fill = max(dot(N, fillDir), 0.0);
|
|
2898
|
+
vec3 base = vColor * (0.52 + 0.34 * hemi) + vColor * key * 0.34 + vColor * fill * 0.10;
|
|
2899
|
+
float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
|
|
2900
|
+
base += vec3(0.26, 0.31, 0.38) * fres * 0.35; // cool rim, restrained (view-dependent by design)
|
|
2901
|
+
// Unfocused floors fall back toward the background rather than vanishing.
|
|
2902
|
+
base = mix(base, base * 0.45, vDim);
|
|
887
2903
|
fragColor = vec4(base, 1.0);
|
|
888
2904
|
}`
|
|
889
2905
|
);
|
|
@@ -894,22 +2910,45 @@ precision highp float;
|
|
|
894
2910
|
in vec2 position; // quad corner in [-1,1]
|
|
895
2911
|
in vec3 iOffset; // per-instance world position
|
|
896
2912
|
in vec3 iColor; // per-instance state colour (resolved CPU-side)
|
|
2913
|
+
in float iMaxRadius; // per-instance world-radius ceiling (seat pitch derived)
|
|
2914
|
+
in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
|
|
2915
|
+
in float iFloor; // owning floor index
|
|
897
2916
|
uniform mat4 modelViewMatrix;
|
|
898
2917
|
uniform mat4 projectionMatrix;
|
|
899
2918
|
uniform float uSeatRadius;
|
|
900
2919
|
uniform float uSeatScale;
|
|
901
2920
|
uniform float uMinPixels;
|
|
902
2921
|
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
2922
|
+
uniform float uFocusFloor; // -1 = show every floor
|
|
903
2923
|
out vec2 vUv;
|
|
904
2924
|
out vec3 vColor;
|
|
2925
|
+
out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
|
|
2926
|
+
out vec3 vRing;
|
|
2927
|
+
out float vDim;
|
|
905
2928
|
void main() {
|
|
906
2929
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
907
2930
|
float depth = max(-mv.z, 0.001);
|
|
908
2931
|
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
909
|
-
|
|
2932
|
+
// Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
|
|
2933
|
+
// unbounded growth is what merges neighbouring rows into one mass at range.
|
|
2934
|
+
float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
|
|
2935
|
+
// How much of the requested pixel floor the dot could actually afford. Below 1
|
|
2936
|
+
// it is losing legibility to distance, and the fragment stage dissolves it
|
|
2937
|
+
// toward the tier top rather than letting a sub-pixel dot alias and shimmer.
|
|
2938
|
+
vBudget = minR > 0.0 ? clamp(r / minR, 0.0, 1.0) : 1.0;
|
|
910
2939
|
mv.xy += position * r; // camera-facing billboard
|
|
2940
|
+
// Seat the dot ON the deck instead of centring it in the deck. iOffset is the
|
|
2941
|
+
// exact surface point, so half the billboard would otherwise sit below the cap
|
|
2942
|
+
// and be clipped by it \u2014 and because uMinPixels grows r with distance, no
|
|
2943
|
+
// constant world-space lift can prevent that at every range. Offsetting by r
|
|
2944
|
+
// along the screen projection of WORLD up is self-correcting: it is full at a
|
|
2945
|
+
// grazing view (where slicing happens) and vanishes looking straight down
|
|
2946
|
+
// (where the dot must stay centred on its seat).
|
|
2947
|
+
mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
|
|
911
2948
|
vUv = position;
|
|
912
2949
|
vColor = iColor;
|
|
2950
|
+
vRing = iRing;
|
|
2951
|
+
vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
913
2952
|
gl_Position = projectionMatrix * mv;
|
|
914
2953
|
}`
|
|
915
2954
|
);
|
|
@@ -919,6 +2958,9 @@ var SEAT_FRAG = (
|
|
|
919
2958
|
precision highp float;
|
|
920
2959
|
in vec2 vUv;
|
|
921
2960
|
in vec3 vColor;
|
|
2961
|
+
in float vBudget;
|
|
2962
|
+
in vec3 vRing;
|
|
2963
|
+
in float vDim;
|
|
922
2964
|
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
923
2965
|
uniform vec3 uFadeColor;
|
|
924
2966
|
out vec4 fragColor;
|
|
@@ -929,6 +2971,20 @@ void main() {
|
|
|
929
2971
|
float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
|
|
930
2972
|
vec3 c = vColor * shade;
|
|
931
2973
|
c = mix(c, uFadeColor, uSeatFade);
|
|
2974
|
+
// Accommodation ring \u2014 the 3D echo of the coloured ring 2D draws around every
|
|
2975
|
+
// accessible seat. Painted INSIDE the dot's own radius rather than outside it,
|
|
2976
|
+
// so an accessible seat still respects the row-pitch ceiling and cannot grow
|
|
2977
|
+
// into its neighbour just for carrying a ring.
|
|
2978
|
+
float ringMask = step(0.001, dot(vRing, vRing));
|
|
2979
|
+
float ring = smoothstep(0.58, 0.70, d) * (1.0 - smoothstep(0.90, 1.0, d));
|
|
2980
|
+
c = mix(c, vRing, ring * ringMask * 0.95);
|
|
2981
|
+
// A dot that can no longer afford its pixel floor dissolves instead of
|
|
2982
|
+
// aliasing; the tier cap underneath already carries the section's category
|
|
2983
|
+
// tint, so the block reads as coloured seating rather than empty concrete.
|
|
2984
|
+
alpha *= smoothstep(0.35, 1.0, vBudget);
|
|
2985
|
+
// Seats on an unfocused floor recede with their structure.
|
|
2986
|
+
c = mix(c, uFadeColor, vDim * 0.75);
|
|
2987
|
+
alpha *= mix(1.0, 0.30, vDim);
|
|
932
2988
|
fragColor = vec4(c, alpha);
|
|
933
2989
|
}`
|
|
934
2990
|
);
|
|
@@ -964,6 +3020,7 @@ var SEAT_PICK_VERT = (
|
|
|
964
3020
|
precision highp float;
|
|
965
3021
|
in vec2 position;
|
|
966
3022
|
in vec3 iOffset;
|
|
3023
|
+
in float iMaxRadius;
|
|
967
3024
|
uniform mat4 modelViewMatrix;
|
|
968
3025
|
uniform mat4 projectionMatrix;
|
|
969
3026
|
uniform float uSeatRadius;
|
|
@@ -978,8 +3035,10 @@ void main() {
|
|
|
978
3035
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
979
3036
|
float depth = max(-mv.z, 0.001);
|
|
980
3037
|
float minR = uMinPixels * depth * uPixelToWorld;
|
|
981
|
-
float r = max(uSeatRadius * uSeatScale, minR);
|
|
3038
|
+
float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
|
|
982
3039
|
mv.xy += position * r;
|
|
3040
|
+
// Must match SEAT_VERT exactly, or the hit mask drifts off the drawn dot.
|
|
3041
|
+
mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
|
|
983
3042
|
vUv = position;
|
|
984
3043
|
gl_Position = projectionMatrix * mv;
|
|
985
3044
|
}`
|
|
@@ -1023,7 +3082,7 @@ function createSeatPickProgram(gl) {
|
|
|
1023
3082
|
depthWrite: true,
|
|
1024
3083
|
cullFace: false,
|
|
1025
3084
|
uniforms: {
|
|
1026
|
-
uSeatRadius: { value:
|
|
3085
|
+
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
1027
3086
|
uSeatScale: { value: 1 },
|
|
1028
3087
|
uMinPixels: { value: 2.5 },
|
|
1029
3088
|
uPixelToWorld: { value: 2e-3 }
|
|
@@ -1050,7 +3109,13 @@ function createSolidProgram(gl) {
|
|
|
1050
3109
|
fragment: SOLID_FRAG,
|
|
1051
3110
|
cullFace: false,
|
|
1052
3111
|
depthTest: true,
|
|
1053
|
-
depthWrite: true
|
|
3112
|
+
depthWrite: true,
|
|
3113
|
+
uniforms: {
|
|
3114
|
+
// High and off-axis, in world space: reads as a house rig rather than a
|
|
3115
|
+
// headlamp welded to the camera.
|
|
3116
|
+
uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
|
|
3117
|
+
uFocusFloor: { value: -1 }
|
|
3118
|
+
}
|
|
1054
3119
|
});
|
|
1055
3120
|
}
|
|
1056
3121
|
function createSeatProgram(gl) {
|
|
@@ -1062,11 +3127,12 @@ function createSeatProgram(gl) {
|
|
|
1062
3127
|
depthWrite: false,
|
|
1063
3128
|
cullFace: false,
|
|
1064
3129
|
uniforms: {
|
|
1065
|
-
uSeatRadius: { value:
|
|
3130
|
+
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
1066
3131
|
uSeatScale: { value: 1 },
|
|
1067
3132
|
uMinPixels: { value: 2.5 },
|
|
1068
3133
|
uPixelToWorld: { value: 2e-3 },
|
|
1069
3134
|
uSeatFade: { value: 0 },
|
|
3135
|
+
uFocusFloor: { value: -1 },
|
|
1070
3136
|
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
|
|
1071
3137
|
}
|
|
1072
3138
|
});
|
|
@@ -1086,9 +3152,9 @@ function createBackgroundProgram(gl, top, bottom) {
|
|
|
1086
3152
|
}
|
|
1087
3153
|
|
|
1088
3154
|
// src/view3d/scene/build.ts
|
|
1089
|
-
function writeSeatColors(iColor, iState, start, count) {
|
|
3155
|
+
function writeSeatColors(iColor, iState, start, count, states) {
|
|
1090
3156
|
for (let i = start; i < start + count; i++) {
|
|
1091
|
-
const c =
|
|
3157
|
+
const c = states[iState[i]] ?? states[0];
|
|
1092
3158
|
iColor[i * 3] = c[0];
|
|
1093
3159
|
iColor[i * 3 + 1] = c[1];
|
|
1094
3160
|
iColor[i * 3 + 2] = c[2];
|
|
@@ -1100,14 +3166,15 @@ function buildGpuScene(gl, model) {
|
|
|
1100
3166
|
const main = new import_ogl4.Transform();
|
|
1101
3167
|
const background = new import_ogl4.Transform();
|
|
1102
3168
|
const bgGeo = new import_ogl4.Geometry(gl, { position: { size: 2, data: BG_TRI } });
|
|
1103
|
-
const bgProg = createBackgroundProgram(gl,
|
|
3169
|
+
const bgProg = createBackgroundProgram(gl, model.theme.background.top, model.theme.background.bottom);
|
|
1104
3170
|
const bgMesh = new import_ogl4.Mesh(gl, { geometry: bgGeo, program: bgProg });
|
|
1105
3171
|
bgMesh.frustumCulled = false;
|
|
1106
3172
|
bgMesh.setParent(background);
|
|
1107
3173
|
const solidGeo = new import_ogl4.Geometry(gl, {
|
|
1108
3174
|
position: { size: 3, data: model.solids.position },
|
|
1109
3175
|
normal: { size: 3, data: model.solids.normal },
|
|
1110
|
-
color: { size: 3, data: model.solids.color }
|
|
3176
|
+
color: { size: 3, data: model.solids.color },
|
|
3177
|
+
floorIndex: { size: 1, data: model.solids.floor }
|
|
1111
3178
|
});
|
|
1112
3179
|
const solidProg = createSolidProgram(gl);
|
|
1113
3180
|
const solidMesh = new import_ogl4.Mesh(gl, { geometry: solidGeo, program: solidProg });
|
|
@@ -1115,11 +3182,18 @@ function buildGpuScene(gl, model) {
|
|
|
1115
3182
|
solidMesh.setParent(main);
|
|
1116
3183
|
const seatProg = createSeatProgram(gl);
|
|
1117
3184
|
const iColor = new Float32Array(model.seats.count * 3);
|
|
1118
|
-
|
|
3185
|
+
const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
|
|
3186
|
+
writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
|
|
1119
3187
|
const seatGeo = new import_ogl4.Geometry(gl, {
|
|
1120
3188
|
position: { size: 2, data: SEAT_QUAD },
|
|
1121
3189
|
iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
|
|
1122
|
-
iColor: { size: 3, data: iColor, instanced: 1 }
|
|
3190
|
+
iColor: { size: 3, data: iColor, instanced: 1 },
|
|
3191
|
+
// Per-seat world-radius ceiling: what stops distant rows merging into one
|
|
3192
|
+
// mass when the shader grows a dot to hold its minimum pixel size.
|
|
3193
|
+
iMaxRadius: { size: 1, data: model.seats.iMaxRadius, instanced: 1 },
|
|
3194
|
+
// Accommodation ring colour; (0,0,0) means the seat carries no access type.
|
|
3195
|
+
iRing: { size: 3, data: model.seats.iRing, instanced: 1 },
|
|
3196
|
+
iFloor: { size: 1, data: model.seats.iFloor, instanced: 1 }
|
|
1123
3197
|
});
|
|
1124
3198
|
const seatMesh = new import_ogl4.Mesh(gl, { geometry: seatGeo, program: seatProg });
|
|
1125
3199
|
seatMesh.frustumCulled = false;
|
|
@@ -1129,12 +3203,13 @@ function buildGpuScene(gl, model) {
|
|
|
1129
3203
|
main,
|
|
1130
3204
|
background,
|
|
1131
3205
|
seatProgram: seatProg,
|
|
3206
|
+
solidProgram: solidProg,
|
|
1132
3207
|
seatGeometry: seatGeo,
|
|
1133
3208
|
solidGeometry: solidGeo,
|
|
1134
3209
|
drawCalls: 3,
|
|
1135
3210
|
uploadSeatStateRuns(runs) {
|
|
1136
3211
|
if (!runs.length) return;
|
|
1137
|
-
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length);
|
|
3212
|
+
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
|
|
1138
3213
|
const buffer = colorAttr.buffer;
|
|
1139
3214
|
if (!buffer) {
|
|
1140
3215
|
colorAttr.needsUpdate = true;
|
|
@@ -1202,6 +3277,8 @@ var PickPipeline = class {
|
|
|
1202
3277
|
this.seatScene = new import_ogl5.Transform();
|
|
1203
3278
|
this.solidScene = new import_ogl5.Transform();
|
|
1204
3279
|
this.target = null;
|
|
3280
|
+
/** Display clear colour to restore after the pick pass (theme-dependent). */
|
|
3281
|
+
this.restoreClear = [0, 0, 0];
|
|
1205
3282
|
this.renderer = renderer;
|
|
1206
3283
|
this.gl = renderer.gl;
|
|
1207
3284
|
this.maxIndex = seatCount;
|
|
@@ -1214,6 +3291,10 @@ var PickPipeline = class {
|
|
|
1214
3291
|
solidMesh.frustumCulled = false;
|
|
1215
3292
|
solidMesh.setParent(this.solidScene);
|
|
1216
3293
|
}
|
|
3294
|
+
/** Follow the theme's background when the scene is (re)built. */
|
|
3295
|
+
setRestoreClear(rgb) {
|
|
3296
|
+
this.restoreClear = [rgb[0], rgb[1], rgb[2]];
|
|
3297
|
+
}
|
|
1217
3298
|
/** Match the display seat sizing so the pick mask lines up with the dots. */
|
|
1218
3299
|
syncFromSeatProgram(seatProgram) {
|
|
1219
3300
|
for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
|
|
@@ -1254,7 +3335,7 @@ var PickPipeline = class {
|
|
|
1254
3335
|
const boxH = Math.max(1, Math.min(bh, py + radius + 1) - y0);
|
|
1255
3336
|
gl.enable(gl.SCISSOR_TEST);
|
|
1256
3337
|
gl.scissor(x0, y0, boxW, boxH);
|
|
1257
|
-
const [br, bg, bb] =
|
|
3338
|
+
const [br, bg, bb] = this.restoreClear;
|
|
1258
3339
|
gl.clearColor(0, 0, 0, 1);
|
|
1259
3340
|
this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
|
|
1260
3341
|
this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
|
|
@@ -1682,7 +3763,7 @@ var Analytics3D = class {
|
|
|
1682
3763
|
|
|
1683
3764
|
// src/view3d/index.ts
|
|
1684
3765
|
var SEAT_EYE_ABOVE_DECK = 1.02;
|
|
1685
|
-
var
|
|
3766
|
+
var DEG3 = Math.PI / 180;
|
|
1686
3767
|
var TAP_SLOP = 6;
|
|
1687
3768
|
var TAP_MS = 500;
|
|
1688
3769
|
function mountVenue3D(container, input, opts = {}) {
|
|
@@ -1707,9 +3788,15 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1707
3788
|
const prefetch = /* @__PURE__ */ new Map();
|
|
1708
3789
|
let flightGen = 0;
|
|
1709
3790
|
let reducedForced = null;
|
|
3791
|
+
let focusedFloor = -1;
|
|
1710
3792
|
const rebuildGpu = () => {
|
|
1711
3793
|
gpu = buildGpuScene(glctx.gl, model);
|
|
1712
3794
|
pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
|
|
3795
|
+
glctx.setClearColor(model.theme.background.top);
|
|
3796
|
+
pick.setRestoreClear(model.theme.background.top);
|
|
3797
|
+
gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
|
|
3798
|
+
gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
3799
|
+
gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
1713
3800
|
};
|
|
1714
3801
|
const glctx = new GLContext(container, {
|
|
1715
3802
|
onContextLost: () => {
|
|
@@ -1732,8 +3819,21 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1732
3819
|
// first real drag/wheel/pinch (not the intro ease)
|
|
1733
3820
|
);
|
|
1734
3821
|
orbit.setAspect(glctx.aspect);
|
|
1735
|
-
|
|
3822
|
+
const stageAzimuth = (() => {
|
|
3823
|
+
const dx = model.focalWorld[0] - model.bounds.center[0];
|
|
3824
|
+
const dz = model.focalWorld[2] - model.bounds.center[2];
|
|
3825
|
+
return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
|
|
3826
|
+
})();
|
|
3827
|
+
orbit.frame(model.bounds, true, stageAzimuth);
|
|
1736
3828
|
const cinematic = new Cinematic(orbit.camera);
|
|
3829
|
+
if (!container.style.position || container.style.position === "static") {
|
|
3830
|
+
container.style.position = "relative";
|
|
3831
|
+
}
|
|
3832
|
+
const labelOverlay = new LabelOverlay(container, {
|
|
3833
|
+
fontFamily: input.doc.theme?.fontFamily,
|
|
3834
|
+
ink: input.doc.theme?.textColor
|
|
3835
|
+
});
|
|
3836
|
+
labelOverlay.setLabels(model.labels);
|
|
1737
3837
|
rebuildGpu();
|
|
1738
3838
|
const loop = new RenderLoop(() => {
|
|
1739
3839
|
if (contextLost || !gpu || frozen) return false;
|
|
@@ -1743,9 +3843,16 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1743
3843
|
const u = gpu.seatProgram.uniforms;
|
|
1744
3844
|
u.uSeatScale.value = lod.scale;
|
|
1745
3845
|
u.uSeatFade.value = lod.fade;
|
|
1746
|
-
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov *
|
|
3846
|
+
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
|
|
1747
3847
|
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
1748
3848
|
glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
|
|
3849
|
+
labelOverlay.update(
|
|
3850
|
+
orbit.camera.projectionViewMatrix,
|
|
3851
|
+
glctx.canvas.clientWidth || 1,
|
|
3852
|
+
glctx.canvas.clientHeight || 1,
|
|
3853
|
+
orbit.currentDistance,
|
|
3854
|
+
model.bounds.radius
|
|
3855
|
+
);
|
|
1749
3856
|
return moving;
|
|
1750
3857
|
});
|
|
1751
3858
|
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
|
|
@@ -1793,6 +3900,72 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1793
3900
|
orbit.camera.fov = FOV_END;
|
|
1794
3901
|
orbit.camera.updateProjectionMatrix();
|
|
1795
3902
|
};
|
|
3903
|
+
let arriveChip = null;
|
|
3904
|
+
const removeArriveChip = () => {
|
|
3905
|
+
arriveChip?.remove();
|
|
3906
|
+
arriveChip = null;
|
|
3907
|
+
};
|
|
3908
|
+
const showArriveChip = (seatId, gen) => {
|
|
3909
|
+
removeArriveChip();
|
|
3910
|
+
if (disposed || !opts.getSeatView) return;
|
|
3911
|
+
const chip = document.createElement("button");
|
|
3912
|
+
chip.type = "button";
|
|
3913
|
+
chip.textContent = "\u25C9 View in 360\xB0";
|
|
3914
|
+
chip.setAttribute("aria-label", `Open the 360\xB0 view from seat ${seatId}`);
|
|
3915
|
+
Object.assign(chip.style, {
|
|
3916
|
+
position: "absolute",
|
|
3917
|
+
left: "50%",
|
|
3918
|
+
bottom: "18px",
|
|
3919
|
+
transform: "translateX(-50%)",
|
|
3920
|
+
minHeight: "44px",
|
|
3921
|
+
padding: "10px 18px",
|
|
3922
|
+
borderRadius: "999px",
|
|
3923
|
+
background: "rgba(12,18,32,0.78)",
|
|
3924
|
+
color: "#eef1f8",
|
|
3925
|
+
border: "1px solid rgba(150,165,205,0.4)",
|
|
3926
|
+
backdropFilter: "blur(6px)",
|
|
3927
|
+
font: "600 13px/1 inherit",
|
|
3928
|
+
cursor: "pointer",
|
|
3929
|
+
zIndex: "4"
|
|
3930
|
+
});
|
|
3931
|
+
chip.addEventListener("click", () => {
|
|
3932
|
+
if (disposed || gen !== flightGen) {
|
|
3933
|
+
removeArriveChip();
|
|
3934
|
+
return;
|
|
3935
|
+
}
|
|
3936
|
+
removeArriveChip();
|
|
3937
|
+
void openPanorama(seatId, 400, gen);
|
|
3938
|
+
});
|
|
3939
|
+
container.appendChild(chip);
|
|
3940
|
+
arriveChip = chip;
|
|
3941
|
+
};
|
|
3942
|
+
const overviewChip = document.createElement("button");
|
|
3943
|
+
overviewChip.type = "button";
|
|
3944
|
+
overviewChip.textContent = "\u2302 Overview";
|
|
3945
|
+
overviewChip.setAttribute("aria-label", "Return to the venue overview");
|
|
3946
|
+
Object.assign(overviewChip.style, {
|
|
3947
|
+
position: "absolute",
|
|
3948
|
+
right: "14px",
|
|
3949
|
+
bottom: "18px",
|
|
3950
|
+
minHeight: "40px",
|
|
3951
|
+
padding: "8px 14px",
|
|
3952
|
+
borderRadius: "999px",
|
|
3953
|
+
background: "rgba(12,18,32,0.72)",
|
|
3954
|
+
color: "#c9d4ea",
|
|
3955
|
+
border: "1px solid rgba(150,165,205,0.35)",
|
|
3956
|
+
backdropFilter: "blur(6px)",
|
|
3957
|
+
font: "600 12.5px/1 inherit",
|
|
3958
|
+
cursor: "pointer",
|
|
3959
|
+
zIndex: "4"
|
|
3960
|
+
});
|
|
3961
|
+
overviewChip.addEventListener("click", () => {
|
|
3962
|
+
if (disposed || frozen) return;
|
|
3963
|
+
cancelFlight();
|
|
3964
|
+
removeArriveChip();
|
|
3965
|
+
orbit.frameSoft(model.bounds, stageAzimuth);
|
|
3966
|
+
loop.requestRender();
|
|
3967
|
+
});
|
|
3968
|
+
container.appendChild(overviewChip);
|
|
1796
3969
|
const openPanorama = async (seatId, fadeMs, gen) => {
|
|
1797
3970
|
const viewPromise = ensureSeatView(seatId);
|
|
1798
3971
|
if (!viewPromise) {
|
|
@@ -1813,6 +3986,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1813
3986
|
return;
|
|
1814
3987
|
}
|
|
1815
3988
|
if (disposed || gen !== flightGen) return;
|
|
3989
|
+
removeArriveChip();
|
|
1816
3990
|
panorama = mountPanorama(container, view, {
|
|
1817
3991
|
fadeMs,
|
|
1818
3992
|
seatLabel: seatId,
|
|
@@ -1822,6 +3996,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1822
3996
|
analytics.panoramaClosed();
|
|
1823
3997
|
orbit.resumeAfterFlight(model.focalWorld);
|
|
1824
3998
|
loop.requestRender();
|
|
3999
|
+
showArriveChip(seatId, flightGen);
|
|
1825
4000
|
}
|
|
1826
4001
|
});
|
|
1827
4002
|
analytics.panoramaOpened();
|
|
@@ -1843,6 +4018,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1843
4018
|
}
|
|
1844
4019
|
frozen = false;
|
|
1845
4020
|
const gen = ++flightGen;
|
|
4021
|
+
removeArriveChip();
|
|
1846
4022
|
const seatEye = seatEyeWorld(idx);
|
|
1847
4023
|
const focal = model.focalWorld;
|
|
1848
4024
|
const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
|
|
@@ -1851,9 +4027,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1851
4027
|
placeCameraFinal(finalPos, focal);
|
|
1852
4028
|
loop.requestRender();
|
|
1853
4029
|
analytics.cinematicSkipped();
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
4030
|
+
if (!disposed) orbit.syncFromCamera();
|
|
4031
|
+
showArriveChip(seatId, gen);
|
|
4032
|
+
return Promise.resolve();
|
|
1857
4033
|
}
|
|
1858
4034
|
const startQuat = new import_ogl7.Quat().copy(orbit.camera.quaternion);
|
|
1859
4035
|
const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
|
|
@@ -1861,7 +4037,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1861
4037
|
return cinematic.start(waypoints, startQuat, endQuat).then(() => {
|
|
1862
4038
|
if (disposed || gen !== flightGen) return;
|
|
1863
4039
|
analytics.cinematicPlayed(FLIGHT_DURATION_MS);
|
|
1864
|
-
|
|
4040
|
+
orbit.resumeAfterFlight(model.focalWorld);
|
|
4041
|
+
loop.requestRender();
|
|
4042
|
+
showArriveChip(seatId, gen);
|
|
1865
4043
|
});
|
|
1866
4044
|
};
|
|
1867
4045
|
let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
|
|
@@ -1937,13 +4115,51 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
1937
4115
|
loseContextForTest() {
|
|
1938
4116
|
glctx.simulateContextLossCycle();
|
|
1939
4117
|
},
|
|
4118
|
+
floors() {
|
|
4119
|
+
return model.floors;
|
|
4120
|
+
},
|
|
4121
|
+
focusFloor(index) {
|
|
4122
|
+
if (index !== null && !model.floors.some((f) => f.index === index)) return false;
|
|
4123
|
+
const value = index ?? -1;
|
|
4124
|
+
if (gpu) {
|
|
4125
|
+
gpu.seatProgram.uniforms.uFocusFloor.value = value;
|
|
4126
|
+
gpu.solidProgram.uniforms.uFocusFloor.value = value;
|
|
4127
|
+
}
|
|
4128
|
+
focusedFloor = value;
|
|
4129
|
+
if (index !== null) {
|
|
4130
|
+
const f = model.floors[index];
|
|
4131
|
+
if (f.seatCount > 0) {
|
|
4132
|
+
cinematic.cancel();
|
|
4133
|
+
orbit.frame({ center: f.center, radius: f.radius * 1.25 });
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
loop.requestRender();
|
|
4137
|
+
return true;
|
|
4138
|
+
},
|
|
4139
|
+
zones() {
|
|
4140
|
+
return model.zones;
|
|
4141
|
+
},
|
|
4142
|
+
focusZone(zoneId) {
|
|
4143
|
+
const zone = model.zones.find((z) => z.id === zoneId);
|
|
4144
|
+
if (!zone || zone.seatCount === 0) return false;
|
|
4145
|
+
cinematic.cancel();
|
|
4146
|
+
const dx = zone.focalWorld[0] - zone.center[0];
|
|
4147
|
+
const dz = zone.focalWorld[2] - zone.center[2];
|
|
4148
|
+
const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
|
|
4149
|
+
orbit.frame({ center: zone.center, radius: zone.radius * 1.25 }, false, azimuth);
|
|
4150
|
+
loop.requestRender();
|
|
4151
|
+
return true;
|
|
4152
|
+
},
|
|
1940
4153
|
setReducedMotionForTest(value) {
|
|
1941
4154
|
reducedForced = value;
|
|
1942
4155
|
},
|
|
1943
4156
|
dispose() {
|
|
1944
4157
|
disposed = true;
|
|
4158
|
+
removeArriveChip();
|
|
4159
|
+
overviewChip.remove();
|
|
1945
4160
|
cancelFlight();
|
|
1946
4161
|
loop.stop();
|
|
4162
|
+
labelOverlay.dispose();
|
|
1947
4163
|
ro?.disconnect();
|
|
1948
4164
|
if (panorama) {
|
|
1949
4165
|
panorama.dispose();
|