@seatlayer/core 0.28.4 → 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 +1415 -124
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -1459
- package/dist/index.d.ts +36 -1459
- package/dist/index.js +1091 -972
- package/dist/index.js.map +1 -1
- package/dist/types-CG2pEDoI.d.cts +1608 -0
- package/dist/types-CG2pEDoI.d.ts +1608 -0
- package/dist/view3d/index.cjs +4188 -0
- package/dist/view3d/index.cjs.map +1 -0
- package/dist/view3d/index.d.cts +385 -0
- package/dist/view3d/index.d.ts +385 -0
- package/dist/view3d/index.js +3720 -0
- package/dist/view3d/index.js.map +1 -0
- package/package.json +25 -3
|
@@ -0,0 +1,4188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/view3d/index.ts
|
|
31
|
+
var view3d_exports = {};
|
|
32
|
+
__export(view3d_exports, {
|
|
33
|
+
buildSceneModel: () => buildSceneModel,
|
|
34
|
+
mountVenue3D: () => mountVenue3D
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(view3d_exports);
|
|
37
|
+
var import_ogl7 = require("ogl");
|
|
38
|
+
|
|
39
|
+
// src/view3d/gl/context.ts
|
|
40
|
+
var import_ogl = require("ogl");
|
|
41
|
+
|
|
42
|
+
// src/view3d/palette.ts
|
|
43
|
+
var SEAT_STATES = ["available", "held", "sold", "selected", "dimmed"];
|
|
44
|
+
function seatStateIndex(state) {
|
|
45
|
+
const i = SEAT_STATES.indexOf(state);
|
|
46
|
+
return i < 0 ? 0 : i;
|
|
47
|
+
}
|
|
48
|
+
var SEAT_STATE_COLORS = {
|
|
49
|
+
available: [0.24, 0.82, 0.52],
|
|
50
|
+
held: [0.95, 0.66, 0.22],
|
|
51
|
+
sold: [0.34, 0.39, 0.45],
|
|
52
|
+
selected: [0.24, 0.74, 1],
|
|
53
|
+
dimmed: [0.28, 0.32, 0.37]
|
|
54
|
+
};
|
|
55
|
+
var STRUCTURE = {
|
|
56
|
+
ground: [0.07, 0.085, 0.11],
|
|
57
|
+
tierTop: [0.24, 0.28, 0.34],
|
|
58
|
+
tierWall: [0.17, 0.2, 0.25],
|
|
59
|
+
stageTop: [0.42, 0.36, 0.26],
|
|
60
|
+
// warm, slightly emissive read
|
|
61
|
+
stageWall: [0.26, 0.22, 0.16],
|
|
62
|
+
decorTop: [0.22, 0.25, 0.29],
|
|
63
|
+
decorWall: [0.15, 0.17, 0.2],
|
|
64
|
+
gaTop: [0.24, 0.28, 0.33],
|
|
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]
|
|
72
|
+
};
|
|
73
|
+
var BACKGROUND = {
|
|
74
|
+
top: [0.05, 0.06, 0.08],
|
|
75
|
+
bottom: [0.1, 0.12, 0.15]
|
|
76
|
+
};
|
|
77
|
+
function hexToRgb(hex) {
|
|
78
|
+
if (!hex) return null;
|
|
79
|
+
let h = hex.trim();
|
|
80
|
+
if (h[0] === "#") h = h.slice(1);
|
|
81
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
82
|
+
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
|
|
83
|
+
const n = parseInt(h, 16);
|
|
84
|
+
return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
|
|
85
|
+
}
|
|
86
|
+
function mix(a, b, t) {
|
|
87
|
+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
88
|
+
}
|
|
89
|
+
function desaturate(c, amount) {
|
|
90
|
+
const l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
91
|
+
return mix(c, [l, l, l], amount);
|
|
92
|
+
}
|
|
93
|
+
function scaleRgb(c, k) {
|
|
94
|
+
return [Math.min(1, c[0] * k), Math.min(1, c[1] * k), Math.min(1, c[2] * k)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/view3d/gl/context.ts
|
|
98
|
+
function computeDpr() {
|
|
99
|
+
const raw = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
100
|
+
const mem = navigator.deviceMemory;
|
|
101
|
+
const cap = typeof mem === "number" && mem <= 4 ? 1.5 : 2;
|
|
102
|
+
return Math.min(raw, cap);
|
|
103
|
+
}
|
|
104
|
+
var GLContext = class {
|
|
105
|
+
constructor(container, opts) {
|
|
106
|
+
/** Current clear colour, so a context restore repaints the themed background. */
|
|
107
|
+
this.clearRgb = [0, 0, 0];
|
|
108
|
+
this.container = container;
|
|
109
|
+
this.canvas = document.createElement("canvas");
|
|
110
|
+
this.canvas.style.display = "block";
|
|
111
|
+
this.canvas.style.width = "100%";
|
|
112
|
+
this.canvas.style.height = "100%";
|
|
113
|
+
this.canvas.style.touchAction = "none";
|
|
114
|
+
this.renderer = new import_ogl.Renderer({
|
|
115
|
+
canvas: this.canvas,
|
|
116
|
+
dpr: computeDpr(),
|
|
117
|
+
alpha: false,
|
|
118
|
+
antialias: false,
|
|
119
|
+
depth: true,
|
|
120
|
+
stencil: false,
|
|
121
|
+
powerPreference: "high-performance",
|
|
122
|
+
webgl: 2
|
|
123
|
+
});
|
|
124
|
+
this.gl = this.renderer.gl;
|
|
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
|
+
container.appendChild(this.canvas);
|
|
128
|
+
this.lostHandler = (e) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
opts.onContextLost();
|
|
131
|
+
};
|
|
132
|
+
this.restoredHandler = () => opts.onContextRestored();
|
|
133
|
+
this.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
|
|
134
|
+
this.canvas.addEventListener("webglcontextrestored", this.restoredHandler, false);
|
|
135
|
+
this.resize();
|
|
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
|
+
}
|
|
152
|
+
/** Match the drawing buffer to the container's CSS box. */
|
|
153
|
+
resize() {
|
|
154
|
+
const w = Math.max(1, this.container.clientWidth || this.canvas.clientWidth || 1);
|
|
155
|
+
const h = Math.max(1, this.container.clientHeight || this.canvas.clientHeight || 1);
|
|
156
|
+
this.renderer.setSize(w, h);
|
|
157
|
+
return { width: w, height: h };
|
|
158
|
+
}
|
|
159
|
+
get pixelHeight() {
|
|
160
|
+
return this.renderer.height * this.renderer.dpr;
|
|
161
|
+
}
|
|
162
|
+
get aspect() {
|
|
163
|
+
return this.renderer.width / Math.max(1, this.renderer.height);
|
|
164
|
+
}
|
|
165
|
+
dispose() {
|
|
166
|
+
this.canvas.removeEventListener("webglcontextlost", this.lostHandler, false);
|
|
167
|
+
this.canvas.removeEventListener("webglcontextrestored", this.restoredHandler, false);
|
|
168
|
+
const ext = this.gl.getExtension("WEBGL_lose_context");
|
|
169
|
+
if (ext) ext.loseContext();
|
|
170
|
+
if (this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Test hook: force a full loss→restore cycle. `restoreContext()` must be called
|
|
174
|
+
* only AFTER the browser has dispatched `webglcontextlost` (calling it too soon
|
|
175
|
+
* makes the browser drop the restore request), so we sequence it off a one-shot
|
|
176
|
+
* listener rather than a fixed timeout.
|
|
177
|
+
*/
|
|
178
|
+
simulateContextLossCycle() {
|
|
179
|
+
const ext = this.gl.getExtension("WEBGL_lose_context");
|
|
180
|
+
if (!ext) return;
|
|
181
|
+
ext.loseContext();
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
if (ext.restoreContext) ext.restoreContext();
|
|
184
|
+
}, 300);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/view3d/camera/orbit.ts
|
|
189
|
+
var import_ogl2 = require("ogl");
|
|
190
|
+
var DEG = Math.PI / 180;
|
|
191
|
+
var POLAR_MIN = 15 * DEG;
|
|
192
|
+
var POLAR_MAX = 80 * DEG;
|
|
193
|
+
var DAMP = 0.12;
|
|
194
|
+
var FOV = 35;
|
|
195
|
+
var FRAME_MARGIN = 1.25;
|
|
196
|
+
var OrbitCamera = class {
|
|
197
|
+
constructor(gl, canvas, requestRender, onGesture) {
|
|
198
|
+
this.fovY = FOV;
|
|
199
|
+
this.target = new import_ogl2.Vec3();
|
|
200
|
+
this.azimuth = -30 * DEG;
|
|
201
|
+
this.polar = 55 * DEG;
|
|
202
|
+
this.distance = 10;
|
|
203
|
+
this.azT = -30 * DEG;
|
|
204
|
+
this.polT = 55 * DEG;
|
|
205
|
+
this.distT = 10;
|
|
206
|
+
this.minDist = 1;
|
|
207
|
+
this.maxDist = 100;
|
|
208
|
+
this.gestureFired = false;
|
|
209
|
+
this.dragging = false;
|
|
210
|
+
this.lastX = 0;
|
|
211
|
+
this.lastY = 0;
|
|
212
|
+
this.activePointers = /* @__PURE__ */ new Map();
|
|
213
|
+
this.pinchDist = 0;
|
|
214
|
+
this.camera = new import_ogl2.Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
|
|
215
|
+
this.canvas = canvas;
|
|
216
|
+
this.requestRender = requestRender;
|
|
217
|
+
this.onGesture = onGesture;
|
|
218
|
+
this.onPointerDown = (e) => {
|
|
219
|
+
try {
|
|
220
|
+
this.canvas.setPointerCapture?.(e.pointerId);
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
224
|
+
if (this.activePointers.size === 1) {
|
|
225
|
+
this.dragging = true;
|
|
226
|
+
this.lastX = e.clientX;
|
|
227
|
+
this.lastY = e.clientY;
|
|
228
|
+
} else if (this.activePointers.size === 2) {
|
|
229
|
+
this.dragging = false;
|
|
230
|
+
this.pinchDist = this.currentPinchDistance();
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
this.onPointerMove = (e) => {
|
|
234
|
+
if (!this.activePointers.has(e.pointerId)) return;
|
|
235
|
+
this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
236
|
+
if (this.activePointers.size >= 2) {
|
|
237
|
+
const d = this.currentPinchDistance();
|
|
238
|
+
if (this.pinchDist > 0) {
|
|
239
|
+
this.dollyBy(Math.exp((this.pinchDist - d) * 5e-3));
|
|
240
|
+
this.fireGesture();
|
|
241
|
+
}
|
|
242
|
+
this.pinchDist = d;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!this.dragging) return;
|
|
246
|
+
const dx = e.clientX - this.lastX;
|
|
247
|
+
const dy = e.clientY - this.lastY;
|
|
248
|
+
this.lastX = e.clientX;
|
|
249
|
+
this.lastY = e.clientY;
|
|
250
|
+
if (dx !== 0 || dy !== 0) this.fireGesture();
|
|
251
|
+
this.azT -= dx * 6e-3;
|
|
252
|
+
this.polT = Math.max(POLAR_MIN, Math.min(POLAR_MAX, this.polT - dy * 6e-3));
|
|
253
|
+
this.requestRender();
|
|
254
|
+
};
|
|
255
|
+
this.onPointerUp = (e) => {
|
|
256
|
+
this.activePointers.delete(e.pointerId);
|
|
257
|
+
try {
|
|
258
|
+
this.canvas.releasePointerCapture?.(e.pointerId);
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
if (this.activePointers.size < 2) this.pinchDist = 0;
|
|
262
|
+
if (this.activePointers.size === 0) this.dragging = false;
|
|
263
|
+
};
|
|
264
|
+
this.onWheel = (e) => {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
|
|
267
|
+
const norm2 = e.deltaY * unit / 100;
|
|
268
|
+
this.dollyBy(Math.exp(norm2 * 0.4));
|
|
269
|
+
this.fireGesture();
|
|
270
|
+
};
|
|
271
|
+
canvas.addEventListener("pointerdown", this.onPointerDown);
|
|
272
|
+
canvas.addEventListener("pointermove", this.onPointerMove);
|
|
273
|
+
canvas.addEventListener("pointerup", this.onPointerUp);
|
|
274
|
+
canvas.addEventListener("pointercancel", this.onPointerUp);
|
|
275
|
+
canvas.addEventListener("wheel", this.onWheel, { passive: false });
|
|
276
|
+
}
|
|
277
|
+
/** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
|
|
278
|
+
fireGesture() {
|
|
279
|
+
if (this.gestureFired) return;
|
|
280
|
+
this.gestureFired = true;
|
|
281
|
+
this.onGesture?.();
|
|
282
|
+
}
|
|
283
|
+
currentPinchDistance() {
|
|
284
|
+
const pts = [...this.activePointers.values()];
|
|
285
|
+
if (pts.length < 2) return 0;
|
|
286
|
+
return Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
|
287
|
+
}
|
|
288
|
+
/** Multiply target distance by `factor` (proportional zoom — same feel near
|
|
289
|
+
* and far), clamped so you can swoop right down among the seats. */
|
|
290
|
+
dollyBy(factor) {
|
|
291
|
+
this.distT = Math.max(this.minDist, Math.min(this.maxDist, this.distT * factor));
|
|
292
|
+
this.requestRender();
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Fit a flattering 3/4 view to the bounds sphere. With `intro`, the camera
|
|
296
|
+
* STARTS nearly top-down (matching the 2D map's orientation) and further out,
|
|
297
|
+
* then the damped `update()` eases it up into the 3/4 architectural angle and
|
|
298
|
+
* dollies in — the venue "stands up" instead of teleporting (~600ms).
|
|
299
|
+
*/
|
|
300
|
+
frame(bounds, intro = false, stageAzimuth) {
|
|
301
|
+
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
302
|
+
const r = Math.max(1, bounds.radius);
|
|
303
|
+
const halfV = this.fovY * DEG / 2;
|
|
304
|
+
const aspect = this.camera.aspect || 1;
|
|
305
|
+
const halfH = Math.atan(Math.tan(halfV) * aspect);
|
|
306
|
+
const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
|
|
307
|
+
this.azT = stageAzimuth ?? -30 * DEG;
|
|
308
|
+
this.polT = 55 * DEG;
|
|
309
|
+
this.distT = fit * FRAME_MARGIN;
|
|
310
|
+
this.minDist = Math.max(2, r * 0.12);
|
|
311
|
+
this.maxDist = fit * 4;
|
|
312
|
+
if (intro) {
|
|
313
|
+
this.azimuth = this.azT;
|
|
314
|
+
this.polar = 12 * DEG;
|
|
315
|
+
this.distance = this.distT * 1.7;
|
|
316
|
+
} else {
|
|
317
|
+
this.azimuth = this.azT;
|
|
318
|
+
this.polar = this.polT;
|
|
319
|
+
this.distance = this.distT;
|
|
320
|
+
}
|
|
321
|
+
this.applyPosition();
|
|
322
|
+
}
|
|
323
|
+
setAspect(aspect) {
|
|
324
|
+
this.camera.perspective({ aspect });
|
|
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
|
+
}
|
|
344
|
+
/** Damp toward targets; returns true while still moving. */
|
|
345
|
+
update() {
|
|
346
|
+
const da = this.azT - this.azimuth;
|
|
347
|
+
const dp = this.polT - this.polar;
|
|
348
|
+
const dd = this.distT - this.distance;
|
|
349
|
+
const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4;
|
|
350
|
+
this.azimuth += da * DAMP;
|
|
351
|
+
this.polar += dp * DAMP;
|
|
352
|
+
this.distance += dd * DAMP;
|
|
353
|
+
if (moving) this.applyPosition();
|
|
354
|
+
return moving;
|
|
355
|
+
}
|
|
356
|
+
/** Distance from camera to target (for LOD). */
|
|
357
|
+
get currentDistance() {
|
|
358
|
+
return this.distance;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Re-derive the orbit's spherical state from the camera's CURRENT pose (after a
|
|
362
|
+
* cinematic flight leaves it somewhere arbitrary), so a subsequent drag damps
|
|
363
|
+
* from where it actually is with no snap. Does not move the camera.
|
|
364
|
+
*/
|
|
365
|
+
syncFromCamera() {
|
|
366
|
+
const dx = this.camera.position.x - this.target.x;
|
|
367
|
+
const dy = this.camera.position.y - this.target.y;
|
|
368
|
+
const dz = this.camera.position.z - this.target.z;
|
|
369
|
+
const dist = Math.hypot(dx, dy, dz) || 1;
|
|
370
|
+
const polar = Math.max(POLAR_MIN, Math.min(POLAR_MAX, Math.acos(Math.max(-1, Math.min(1, dy / dist)))));
|
|
371
|
+
this.distance = this.distT = dist;
|
|
372
|
+
this.polar = this.polT = polar;
|
|
373
|
+
this.azimuth = this.azT = Math.atan2(dx, dz);
|
|
374
|
+
}
|
|
375
|
+
/** Point the orbit pivot at a new world target without moving the camera. */
|
|
376
|
+
setTarget(target) {
|
|
377
|
+
this.target.set(target[0], target[1], target[2]);
|
|
378
|
+
}
|
|
379
|
+
/** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
|
|
380
|
+
* flight ends looking at `target` (the venue focal), so re-pivot there first —
|
|
381
|
+
* otherwise the first drag would `lookAt(bounds.center)` and pop the aim. */
|
|
382
|
+
resumeAfterFlight(target) {
|
|
383
|
+
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
384
|
+
if (target) this.target.set(target[0], target[1], target[2]);
|
|
385
|
+
this.syncFromCamera();
|
|
386
|
+
}
|
|
387
|
+
applyPosition() {
|
|
388
|
+
const sp = Math.sin(this.polar);
|
|
389
|
+
const x = this.target.x + this.distance * sp * Math.sin(this.azimuth);
|
|
390
|
+
const y = this.target.y + this.distance * Math.cos(this.polar);
|
|
391
|
+
const z = this.target.z + this.distance * sp * Math.cos(this.azimuth);
|
|
392
|
+
this.camera.position.set(x, y, z);
|
|
393
|
+
this.camera.lookAt(this.target);
|
|
394
|
+
}
|
|
395
|
+
dispose() {
|
|
396
|
+
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
|
|
397
|
+
this.canvas.removeEventListener("pointermove", this.onPointerMove);
|
|
398
|
+
this.canvas.removeEventListener("pointerup", this.onPointerUp);
|
|
399
|
+
this.canvas.removeEventListener("pointercancel", this.onPointerUp);
|
|
400
|
+
this.canvas.removeEventListener("wheel", this.onWheel);
|
|
401
|
+
this.activePointers.clear();
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// src/view3d/loop.ts
|
|
406
|
+
var RenderLoop = class {
|
|
407
|
+
/** `frame(dt)` renders one frame and returns true if another is needed. */
|
|
408
|
+
constructor(frame) {
|
|
409
|
+
this.rafId = 0;
|
|
410
|
+
this.running = false;
|
|
411
|
+
this.lastTime = 0;
|
|
412
|
+
this.fpsEma = 0;
|
|
413
|
+
this.rendered = 0;
|
|
414
|
+
this.tick = (now2) => {
|
|
415
|
+
const dt = this.lastTime ? (now2 - this.lastTime) / 1e3 : 1 / 60;
|
|
416
|
+
this.lastTime = now2;
|
|
417
|
+
if (dt > 0) {
|
|
418
|
+
const instFps = 1 / dt;
|
|
419
|
+
this.fpsEma = this.fpsEma ? this.fpsEma * 0.9 + instFps * 0.1 : instFps;
|
|
420
|
+
}
|
|
421
|
+
this.rendered++;
|
|
422
|
+
const again = this.frame(dt);
|
|
423
|
+
if (again) {
|
|
424
|
+
this.rafId = requestAnimationFrame(this.tick);
|
|
425
|
+
} else {
|
|
426
|
+
this.running = false;
|
|
427
|
+
this.rafId = 0;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
this.frame = frame;
|
|
431
|
+
}
|
|
432
|
+
requestRender() {
|
|
433
|
+
if (this.running) return;
|
|
434
|
+
this.running = true;
|
|
435
|
+
this.lastTime = 0;
|
|
436
|
+
this.rafId = requestAnimationFrame(this.tick);
|
|
437
|
+
}
|
|
438
|
+
stats() {
|
|
439
|
+
return { fps: this.running ? Math.round(this.fpsEma) : 0, rendered: this.rendered, idle: !this.running };
|
|
440
|
+
}
|
|
441
|
+
stop() {
|
|
442
|
+
if (this.rafId) cancelAnimationFrame(this.rafId);
|
|
443
|
+
this.rafId = 0;
|
|
444
|
+
this.running = false;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// src/view3d/lod.ts
|
|
449
|
+
function computeSeatLod(distance, radius) {
|
|
450
|
+
const near = radius * 1.4;
|
|
451
|
+
const far = radius * 3.2;
|
|
452
|
+
if (distance <= near) return { scale: 1, fade: 0 };
|
|
453
|
+
const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
|
|
454
|
+
return {
|
|
455
|
+
scale: 1 - t * 0.4,
|
|
456
|
+
fade: t * 0.55
|
|
457
|
+
};
|
|
458
|
+
}
|
|
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
|
+
|
|
500
|
+
// src/core/units.ts
|
|
501
|
+
var METRES_PER_CHART_UNIT = 0.55 / 24;
|
|
502
|
+
var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
|
|
503
|
+
var LIFT_PER_STEP_WORLD = 58;
|
|
504
|
+
var TIER_HEIGHT_M = LIFT_PER_STEP_WORLD * METRES_PER_CHART_UNIT;
|
|
505
|
+
var SECTION_ELEVATION_TIER_MAX = 3;
|
|
506
|
+
var SECTION_HEIGHT_MIN_M = 0;
|
|
507
|
+
var SECTION_HEIGHT_MAX_M = 120;
|
|
508
|
+
var SECTION_RAKE_MIN_DEG = 0;
|
|
509
|
+
var SECTION_RAKE_MAX_DEG = 45;
|
|
510
|
+
var LEGACY_SECTION_ELEVATION_TIER_MAX = 7;
|
|
511
|
+
var SEATED_EYE_HEIGHT_M = 1.2;
|
|
512
|
+
function finiteClamped(value, min, max, fallback) {
|
|
513
|
+
return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
|
|
514
|
+
}
|
|
515
|
+
function compatibleAutomaticTier(value) {
|
|
516
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return 0;
|
|
517
|
+
if (value <= LEGACY_SECTION_ELEVATION_TIER_MAX) return value;
|
|
518
|
+
return SECTION_ELEVATION_TIER_MAX;
|
|
519
|
+
}
|
|
520
|
+
function sectionGeometry(section, context = {}) {
|
|
521
|
+
const floorBaseHeightM = finiteClamped(
|
|
522
|
+
context.floorBaseHeightM,
|
|
523
|
+
SECTION_HEIGHT_MIN_M,
|
|
524
|
+
SECTION_HEIGHT_MAX_M,
|
|
525
|
+
0
|
|
526
|
+
);
|
|
527
|
+
const automaticHeight = Math.min(
|
|
528
|
+
SECTION_HEIGHT_MAX_M,
|
|
529
|
+
floorBaseHeightM + compatibleAutomaticTier(section.elevation) * TIER_HEIGHT_M
|
|
530
|
+
);
|
|
531
|
+
const height = section.height === void 0 ? automaticHeight : finiteClamped(section.height, SECTION_HEIGHT_MIN_M, SECTION_HEIGHT_MAX_M, automaticHeight);
|
|
532
|
+
const rake = finiteClamped(section.rake, SECTION_RAKE_MIN_DEG, SECTION_RAKE_MAX_DEG, 0);
|
|
533
|
+
return { height, rake };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/view3d/scene/geometry.ts
|
|
537
|
+
var import_earcut = __toESM(require("earcut"), 1);
|
|
538
|
+
var import_polygon_clipping = __toESM(require("polygon-clipping"), 1);
|
|
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
|
+
}
|
|
551
|
+
var MeshBuilder = class {
|
|
552
|
+
constructor() {
|
|
553
|
+
this.pos = [];
|
|
554
|
+
this.nor = [];
|
|
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;
|
|
563
|
+
}
|
|
564
|
+
/** One triangle with a shared (flat) normal and per-vertex colours. */
|
|
565
|
+
tri(p0, p1, p2, n, c0, c1 = c0, c2 = c0) {
|
|
566
|
+
if (isDegenerate(p0, p1, p2)) return;
|
|
567
|
+
this.pos.push(p0[0], p0[1], p0[2], p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
|
|
568
|
+
this.nor.push(n[0], n[1], n[2], n[0], n[1], n[2], n[0], n[1], n[2]);
|
|
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);
|
|
579
|
+
}
|
|
580
|
+
get vertexCount() {
|
|
581
|
+
return this.pos.length / 3;
|
|
582
|
+
}
|
|
583
|
+
build() {
|
|
584
|
+
return {
|
|
585
|
+
position: new Float32Array(this.pos),
|
|
586
|
+
normal: new Float32Array(this.nor),
|
|
587
|
+
color: new Float32Array(this.col),
|
|
588
|
+
floor: new Float32Array(this.flr),
|
|
589
|
+
count: this.pos.length / 3
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
function faceNormal(a, b, c) {
|
|
594
|
+
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
|
|
595
|
+
const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
|
|
596
|
+
let nx = uy * vz - uz * vy;
|
|
597
|
+
let ny = uz * vx - ux * vz;
|
|
598
|
+
let nz = ux * vy - uy * vx;
|
|
599
|
+
const len = Math.hypot(nx, ny, nz) || 1;
|
|
600
|
+
nx /= len;
|
|
601
|
+
ny /= len;
|
|
602
|
+
nz /= len;
|
|
603
|
+
return [nx, ny, nz];
|
|
604
|
+
}
|
|
605
|
+
function triangulate(outline, holes) {
|
|
606
|
+
const pts = [...outline];
|
|
607
|
+
const flat = [];
|
|
608
|
+
for (const p of outline) flat.push(p.x, p.y);
|
|
609
|
+
const holeIndices = [];
|
|
610
|
+
if (holes) {
|
|
611
|
+
for (const hole of holes) {
|
|
612
|
+
if (hole.length < 3) continue;
|
|
613
|
+
holeIndices.push(pts.length);
|
|
614
|
+
for (const p of hole) {
|
|
615
|
+
pts.push(p);
|
|
616
|
+
flat.push(p.x, p.y);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const tris = (0, import_earcut.default)(flat, holeIndices.length ? holeIndices : void 0, 2);
|
|
621
|
+
return { pts, tris };
|
|
622
|
+
}
|
|
623
|
+
function centroid(pts) {
|
|
624
|
+
let x = 0, y = 0;
|
|
625
|
+
for (const p of pts) {
|
|
626
|
+
x += p.x;
|
|
627
|
+
y += p.y;
|
|
628
|
+
}
|
|
629
|
+
const n = pts.length || 1;
|
|
630
|
+
return { x: x / n, y: y / n };
|
|
631
|
+
}
|
|
632
|
+
function signedArea(pts) {
|
|
633
|
+
let a = 0;
|
|
634
|
+
for (let i = 0, n = pts.length; i < n; i++) {
|
|
635
|
+
const p = pts[i], q = pts[(i + 1) % n];
|
|
636
|
+
a += p.x * q.y - q.x * p.y;
|
|
637
|
+
}
|
|
638
|
+
return a / 2;
|
|
639
|
+
}
|
|
640
|
+
function toCCW(pts) {
|
|
641
|
+
return signedArea(pts) < 0 ? [...pts].reverse() : pts;
|
|
642
|
+
}
|
|
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) {
|
|
749
|
+
if (!outlineIn || outlineIn.length < 3) return;
|
|
750
|
+
if (maxCapError === void 0) maxCapError = Infinity;
|
|
751
|
+
if (Math.abs(signedArea(outlineIn)) < 1e-4) return;
|
|
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);
|
|
754
|
+
const { pts, tris } = triangulate(outline, holes);
|
|
755
|
+
const cTop = [colTop[0] * ao.top, colTop[1] * ao.top, colTop[2] * ao.top];
|
|
756
|
+
const cBot = [colTop[0] * ao.bottomCap, colTop[1] * ao.bottomCap, colTop[2] * ao.bottomCap];
|
|
757
|
+
const cWallTop = [colWall[0] * ao.top, colWall[1] * ao.top, colWall[2] * ao.top];
|
|
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
|
+
}
|
|
770
|
+
for (let i = 0; i < tris.length; i += 3) {
|
|
771
|
+
const a = pts[tris[i]], b = pts[tris[i + 1]], c = pts[tris[i + 2]];
|
|
772
|
+
emitCapUniform(builder, a, b, c, topY, cTop, capDepth, topNormal);
|
|
773
|
+
const ab = [a.x * M, bottomY, a.y * M];
|
|
774
|
+
const bb = [b.x * M, bottomY, b.y * M];
|
|
775
|
+
const cb = [c.x * M, bottomY, c.y * M];
|
|
776
|
+
builder.tri(ab, cb, bb, [0, -1, 0], cBot);
|
|
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
|
+
};
|
|
791
|
+
const oc = centroid(outline);
|
|
792
|
+
const rings = [{ ring: splitRing(outline), flip: false }];
|
|
793
|
+
if (holes) {
|
|
794
|
+
for (const h of holes) if (h.length >= 3) rings.push({ ring: splitRing(h), flip: true });
|
|
795
|
+
}
|
|
796
|
+
for (const { ring, flip } of rings) {
|
|
797
|
+
for (let i = 0; i < ring.length; i++) {
|
|
798
|
+
const a = ring[i];
|
|
799
|
+
const b = ring[(i + 1) % ring.length];
|
|
800
|
+
const dx = (b.x - a.x) * M;
|
|
801
|
+
const dz = (b.y - a.y) * M;
|
|
802
|
+
let nx = dz, nz = -dx;
|
|
803
|
+
const nl = Math.hypot(nx, nz) || 1;
|
|
804
|
+
nx /= nl;
|
|
805
|
+
nz /= nl;
|
|
806
|
+
const mx = (a.x + b.x) / 2 - oc.x;
|
|
807
|
+
const mz = (a.y + b.y) / 2 - oc.y;
|
|
808
|
+
let dot = nx * mx + nz * mz;
|
|
809
|
+
if (flip) dot = -dot;
|
|
810
|
+
if (dot < 0) {
|
|
811
|
+
nx = -nx;
|
|
812
|
+
nz = -nz;
|
|
813
|
+
}
|
|
814
|
+
const n = [nx, 0, nz];
|
|
815
|
+
const aTop = [a.x * M, topY(a), a.y * M];
|
|
816
|
+
const bTop = [b.x * M, topY(b), b.y * M];
|
|
817
|
+
const aBot = [a.x * M, bottomY, a.y * M];
|
|
818
|
+
const bBot = [b.x * M, bottomY, b.y * M];
|
|
819
|
+
builder.tri(aTop, bTop, bBot, n, cWallTop, cWallTop, cWallBot);
|
|
820
|
+
builder.tri(aTop, bBot, aBot, n, cWallTop, cWallBot, cWallBot);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function mergeMeshData(parts) {
|
|
825
|
+
let total = 0;
|
|
826
|
+
for (const p of parts) total += p.count;
|
|
827
|
+
const position = new Float32Array(total * 3);
|
|
828
|
+
const normal = new Float32Array(total * 3);
|
|
829
|
+
const color = new Float32Array(total * 3);
|
|
830
|
+
const floor = new Float32Array(total);
|
|
831
|
+
let off = 0;
|
|
832
|
+
for (const p of parts) {
|
|
833
|
+
position.set(p.position, off * 3);
|
|
834
|
+
normal.set(p.normal, off * 3);
|
|
835
|
+
color.set(p.color, off * 3);
|
|
836
|
+
floor.set(p.floor, off);
|
|
837
|
+
off += p.count;
|
|
838
|
+
}
|
|
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
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function ellipsePolygon(cx, cy, rx, ry, seg = 28) {
|
|
900
|
+
const out = [];
|
|
901
|
+
for (let i = 0; i < seg; i++) {
|
|
902
|
+
const a = i / seg * Math.PI * 2;
|
|
903
|
+
out.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) });
|
|
904
|
+
}
|
|
905
|
+
return out;
|
|
906
|
+
}
|
|
907
|
+
function rectPolygon(x, y, w, h) {
|
|
908
|
+
return [
|
|
909
|
+
{ x, y },
|
|
910
|
+
{ x: x + w, y },
|
|
911
|
+
{ x: x + w, y: y + h },
|
|
912
|
+
{ x, y: y + h }
|
|
913
|
+
];
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/view3d/scene/seatInstances.ts
|
|
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
|
+
}
|
|
975
|
+
function seatSurfaceY(seat) {
|
|
976
|
+
const eye = seat.eyeHeightM;
|
|
977
|
+
if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
|
|
978
|
+
return 0;
|
|
979
|
+
}
|
|
980
|
+
function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
981
|
+
const count = seats.length;
|
|
982
|
+
const iPosition = new Float32Array(count * 3);
|
|
983
|
+
const iState = new Float32Array(count);
|
|
984
|
+
const iMaxRadius = new Float32Array(count);
|
|
985
|
+
const iRing = new Float32Array(count * 3);
|
|
986
|
+
const idToIndex = /* @__PURE__ */ new Map();
|
|
987
|
+
const spacing = nearestNeighbourSpacing(seats);
|
|
988
|
+
for (let i = 0; i < count; i++) {
|
|
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;
|
|
993
|
+
iPosition[i * 3] = seat.x * M;
|
|
994
|
+
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
995
|
+
iPosition[i * 3 + 2] = seat.y * M;
|
|
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
|
+
}
|
|
1005
|
+
idToIndex.set(seat.id, i);
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
count,
|
|
1009
|
+
iPosition,
|
|
1010
|
+
iState,
|
|
1011
|
+
iMaxRadius,
|
|
1012
|
+
iRing,
|
|
1013
|
+
idToIndex,
|
|
1014
|
+
iFloor: seatFloor ?? new Float32Array(count)
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
var RUN_MERGE_GAP = 64;
|
|
1018
|
+
function applySeatStates(data, updates) {
|
|
1019
|
+
const changed = [];
|
|
1020
|
+
for (const u of updates) {
|
|
1021
|
+
const idx = data.idToIndex.get(u.seatId);
|
|
1022
|
+
if (idx === void 0) continue;
|
|
1023
|
+
const v = seatStateIndex(u.state);
|
|
1024
|
+
if (data.iState[idx] !== v) {
|
|
1025
|
+
data.iState[idx] = v;
|
|
1026
|
+
changed.push(idx);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (!changed.length) return [];
|
|
1030
|
+
changed.sort((a, b) => a - b);
|
|
1031
|
+
const runs = [];
|
|
1032
|
+
let start = changed[0];
|
|
1033
|
+
let prev = changed[0];
|
|
1034
|
+
for (let i = 1; i < changed.length; i++) {
|
|
1035
|
+
const idx = changed[i];
|
|
1036
|
+
if (idx === prev) continue;
|
|
1037
|
+
if (idx <= prev + RUN_MERGE_GAP) {
|
|
1038
|
+
prev = idx;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
runs.push({ start, length: prev - start + 1 });
|
|
1042
|
+
start = idx;
|
|
1043
|
+
prev = idx;
|
|
1044
|
+
}
|
|
1045
|
+
runs.push({ start, length: prev - start + 1 });
|
|
1046
|
+
return runs;
|
|
1047
|
+
}
|
|
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
|
+
|
|
2198
|
+
// src/view3d/scene/sceneModel.ts
|
|
2199
|
+
function floorUnits(doc) {
|
|
2200
|
+
if (doc.floors?.length) {
|
|
2201
|
+
return doc.floors.map((f) => ({
|
|
2202
|
+
objects: f.objects,
|
|
2203
|
+
focal: f.focalPoint ?? doc.focalPoint,
|
|
2204
|
+
baseHeightM: f.baseHeightM ?? 0
|
|
2205
|
+
}));
|
|
2206
|
+
}
|
|
2207
|
+
return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
|
|
2208
|
+
}
|
|
2209
|
+
var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
|
|
2210
|
+
function tintTop(fill, neutral) {
|
|
2211
|
+
if (!fill) return neutral;
|
|
2212
|
+
const muted = scaleRgb(desaturate(fill, 0.4), 0.62);
|
|
2213
|
+
return mix(neutral, muted, 0.72);
|
|
2214
|
+
}
|
|
2215
|
+
function resolveSectionFills(doc, seats) {
|
|
2216
|
+
const catColor = /* @__PURE__ */ new Map();
|
|
2217
|
+
for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
|
|
2218
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2219
|
+
for (const s of seats) {
|
|
2220
|
+
if (!s.sectionId) continue;
|
|
2221
|
+
let m = counts.get(s.sectionId);
|
|
2222
|
+
if (!m) {
|
|
2223
|
+
m = /* @__PURE__ */ new Map();
|
|
2224
|
+
counts.set(s.sectionId, m);
|
|
2225
|
+
}
|
|
2226
|
+
m.set(s.categoryKey, (m.get(s.categoryKey) ?? 0) + 1);
|
|
2227
|
+
}
|
|
2228
|
+
const out = /* @__PURE__ */ new Map();
|
|
2229
|
+
for (const [sid, byCat] of counts) {
|
|
2230
|
+
let r = 0, g = 0, b = 0, w = 0;
|
|
2231
|
+
for (const [key, n] of byCat) {
|
|
2232
|
+
const rgb = hexToRgb(catColor.get(key));
|
|
2233
|
+
if (!rgb) continue;
|
|
2234
|
+
r += rgb[0] * n;
|
|
2235
|
+
g += rgb[1] * n;
|
|
2236
|
+
b += rgb[2] * n;
|
|
2237
|
+
w += n;
|
|
2238
|
+
}
|
|
2239
|
+
if (w > 0) out.set(sid, [r / w, g / w, b / w]);
|
|
2240
|
+
}
|
|
2241
|
+
return out;
|
|
2242
|
+
}
|
|
2243
|
+
function sectionFill(section, byLogical) {
|
|
2244
|
+
return hexToRgb(section.color) ?? byLogical.get(section.logicalSectionId ?? section.id) ?? null;
|
|
2245
|
+
}
|
|
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) {
|
|
2318
|
+
if (!section.outline || section.outline.length < 3) return;
|
|
2319
|
+
const bottomY = unit.baseHeightM;
|
|
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);
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
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
|
+
);
|
|
2469
|
+
}
|
|
2470
|
+
function shapePolygon(shape) {
|
|
2471
|
+
if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
|
|
2472
|
+
if (shape.kind === "rect" && shape.width && shape.height) {
|
|
2473
|
+
return rectPolygon(shape.x ?? 0, shape.y ?? 0, shape.width, shape.height);
|
|
2474
|
+
}
|
|
2475
|
+
if (shape.kind === "ellipse" && shape.width && shape.height) {
|
|
2476
|
+
const cx = (shape.x ?? 0) + shape.width / 2;
|
|
2477
|
+
const cy = (shape.y ?? 0) + shape.height / 2;
|
|
2478
|
+
return ellipsePolygon(cx, cy, shape.width / 2, shape.height / 2);
|
|
2479
|
+
}
|
|
2480
|
+
return null;
|
|
2481
|
+
}
|
|
2482
|
+
function buildShape(builder, shape, base, S) {
|
|
2483
|
+
const poly = shapePolygon(shape);
|
|
2484
|
+
if (!poly) return;
|
|
2485
|
+
const isStage = shape.role === "stage";
|
|
2486
|
+
const height = isStage ? base + 1 : base + 0.25;
|
|
2487
|
+
const colTop = isStage ? S.stageTop : S.decorTop;
|
|
2488
|
+
const colWall = isStage ? S.stageWall : S.decorWall;
|
|
2489
|
+
extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
|
|
2490
|
+
}
|
|
2491
|
+
function buildGa(builder, ga, base, fill, S) {
|
|
2492
|
+
if (!ga.points || ga.points.length < 3) return;
|
|
2493
|
+
const colTop = tintTop(fill, S.gaTop);
|
|
2494
|
+
extrudePrism(builder, ga.points, ga.holes, () => base + 0.15, base, colTop, S.gaWall, AO);
|
|
2495
|
+
}
|
|
2496
|
+
function chartFootprint(units, seats) {
|
|
2497
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2498
|
+
const acc = (x, y) => {
|
|
2499
|
+
if (x < minX) minX = x;
|
|
2500
|
+
if (y < minY) minY = y;
|
|
2501
|
+
if (x > maxX) maxX = x;
|
|
2502
|
+
if (y > maxY) maxY = y;
|
|
2503
|
+
};
|
|
2504
|
+
for (const s of seats) acc(s.x, s.y);
|
|
2505
|
+
for (const u of units) {
|
|
2506
|
+
for (const o of u.objects) {
|
|
2507
|
+
if (o.type === "section") for (const p of o.outline) acc(p.x, p.y);
|
|
2508
|
+
else if (o.type === "shape" && o.points) for (const p of o.points) acc(p.x, p.y);
|
|
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
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
if (!Number.isFinite(minX)) {
|
|
2520
|
+
minX = -100;
|
|
2521
|
+
minY = -100;
|
|
2522
|
+
maxX = 100;
|
|
2523
|
+
maxY = 100;
|
|
2524
|
+
}
|
|
2525
|
+
return { minX, minY, maxX, maxY };
|
|
2526
|
+
}
|
|
2527
|
+
var __PROF = {};
|
|
2528
|
+
var __t = (k, t0) => {
|
|
2529
|
+
__PROF[k] = (__PROF[k] ?? 0) + (performance.now() - t0);
|
|
2530
|
+
};
|
|
2531
|
+
function buildSceneModel(input) {
|
|
2532
|
+
for (const k of Object.keys(__PROF)) delete __PROF[k];
|
|
2533
|
+
const { doc, seats } = input;
|
|
2534
|
+
const theme = resolveTheme3D(doc.theme);
|
|
2535
|
+
const S = theme.structure;
|
|
2536
|
+
const units = floorUnits(doc);
|
|
2537
|
+
const builder = new MeshBuilder();
|
|
2538
|
+
const fp = chartFootprint(units, seats);
|
|
2539
|
+
const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
|
|
2540
|
+
const groundPoly = rectPolygon(fp.minX - padU, fp.minY - padU, fp.maxX - fp.minX + padU * 2, fp.maxY - fp.minY + padU * 2);
|
|
2541
|
+
extrudePrism(builder, groundPoly, void 0, () => 0, -0.4, S.ground, S.ground, AO);
|
|
2542
|
+
const sectionFills = resolveSectionFills(doc, seats);
|
|
2543
|
+
const catColor = /* @__PURE__ */ new Map();
|
|
2544
|
+
for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
|
|
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);
|
|
2554
|
+
for (const o of unit.objects) {
|
|
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);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
2566
|
+
const __tm = performance.now();
|
|
2567
|
+
const solids = mergeMeshData([builder.build()]);
|
|
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
|
+
}
|
|
2731
|
+
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
2732
|
+
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
2733
|
+
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
2734
|
+
return {
|
|
2735
|
+
solids,
|
|
2736
|
+
seats: seatData,
|
|
2737
|
+
bounds: { center: [cx, radius * 0.08, cz], radius, groundY: 0 },
|
|
2738
|
+
stateColorLUT: themeSeatColorLUT(theme, SEAT_STATES),
|
|
2739
|
+
theme,
|
|
2740
|
+
seatCount: seats.length,
|
|
2741
|
+
// Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
|
|
2742
|
+
focalWorld: [focal.x * M, 1.5, focal.y * M],
|
|
2743
|
+
zones,
|
|
2744
|
+
labels,
|
|
2745
|
+
floors
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
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
|
+
|
|
2839
|
+
// src/view3d/scene/build.ts
|
|
2840
|
+
var import_ogl4 = require("ogl");
|
|
2841
|
+
|
|
2842
|
+
// src/view3d/scene/materials.ts
|
|
2843
|
+
var import_ogl3 = require("ogl");
|
|
2844
|
+
var SOLID_VERT = (
|
|
2845
|
+
/* glsl */
|
|
2846
|
+
`#version 300 es
|
|
2847
|
+
precision highp float;
|
|
2848
|
+
in vec3 position;
|
|
2849
|
+
in vec3 normal;
|
|
2850
|
+
in vec3 color;
|
|
2851
|
+
in float floorIndex;
|
|
2852
|
+
uniform mat4 modelMatrix;
|
|
2853
|
+
uniform float uFocusFloor; // -1 = show every floor
|
|
2854
|
+
uniform mat4 modelViewMatrix;
|
|
2855
|
+
uniform mat4 projectionMatrix;
|
|
2856
|
+
uniform mat3 normalMatrix;
|
|
2857
|
+
out vec3 vColor;
|
|
2858
|
+
out vec3 vNormalWorld;
|
|
2859
|
+
out vec3 vNormalView;
|
|
2860
|
+
out vec3 vPosView;
|
|
2861
|
+
out float vDim;
|
|
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;
|
|
2867
|
+
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
|
2868
|
+
vPosView = mv.xyz;
|
|
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);
|
|
2874
|
+
vColor = color;
|
|
2875
|
+
gl_Position = projectionMatrix * mv;
|
|
2876
|
+
}`
|
|
2877
|
+
);
|
|
2878
|
+
var SOLID_FRAG = (
|
|
2879
|
+
/* glsl */
|
|
2880
|
+
`#version 300 es
|
|
2881
|
+
precision highp float;
|
|
2882
|
+
in vec3 vColor;
|
|
2883
|
+
in vec3 vNormalWorld;
|
|
2884
|
+
in vec3 vNormalView;
|
|
2885
|
+
in vec3 vPosView;
|
|
2886
|
+
in float vDim;
|
|
2887
|
+
uniform vec3 uKeyDir; // WORLD space, unit, points at the light
|
|
2888
|
+
out vec4 fragColor;
|
|
2889
|
+
void main() {
|
|
2890
|
+
vec3 N = normalize(vNormalWorld);
|
|
2891
|
+
vec3 V = normalize(-vPosView);
|
|
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);
|
|
2903
|
+
fragColor = vec4(base, 1.0);
|
|
2904
|
+
}`
|
|
2905
|
+
);
|
|
2906
|
+
var SEAT_VERT = (
|
|
2907
|
+
/* glsl */
|
|
2908
|
+
`#version 300 es
|
|
2909
|
+
precision highp float;
|
|
2910
|
+
in vec2 position; // quad corner in [-1,1]
|
|
2911
|
+
in vec3 iOffset; // per-instance world position
|
|
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
|
|
2916
|
+
uniform mat4 modelViewMatrix;
|
|
2917
|
+
uniform mat4 projectionMatrix;
|
|
2918
|
+
uniform float uSeatRadius;
|
|
2919
|
+
uniform float uSeatScale;
|
|
2920
|
+
uniform float uMinPixels;
|
|
2921
|
+
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
2922
|
+
uniform float uFocusFloor; // -1 = show every floor
|
|
2923
|
+
out vec2 vUv;
|
|
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;
|
|
2928
|
+
void main() {
|
|
2929
|
+
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
2930
|
+
float depth = max(-mv.z, 0.001);
|
|
2931
|
+
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
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;
|
|
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;
|
|
2948
|
+
vUv = position;
|
|
2949
|
+
vColor = iColor;
|
|
2950
|
+
vRing = iRing;
|
|
2951
|
+
vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
2952
|
+
gl_Position = projectionMatrix * mv;
|
|
2953
|
+
}`
|
|
2954
|
+
);
|
|
2955
|
+
var SEAT_FRAG = (
|
|
2956
|
+
/* glsl */
|
|
2957
|
+
`#version 300 es
|
|
2958
|
+
precision highp float;
|
|
2959
|
+
in vec2 vUv;
|
|
2960
|
+
in vec3 vColor;
|
|
2961
|
+
in float vBudget;
|
|
2962
|
+
in vec3 vRing;
|
|
2963
|
+
in float vDim;
|
|
2964
|
+
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
2965
|
+
uniform vec3 uFadeColor;
|
|
2966
|
+
out vec4 fragColor;
|
|
2967
|
+
void main() {
|
|
2968
|
+
float d = length(vUv);
|
|
2969
|
+
if (d > 1.0) discard;
|
|
2970
|
+
float alpha = smoothstep(1.0, 0.72, d);
|
|
2971
|
+
float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
|
|
2972
|
+
vec3 c = vColor * shade;
|
|
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);
|
|
2988
|
+
fragColor = vec4(c, alpha);
|
|
2989
|
+
}`
|
|
2990
|
+
);
|
|
2991
|
+
var BG_VERT = (
|
|
2992
|
+
/* glsl */
|
|
2993
|
+
`#version 300 es
|
|
2994
|
+
precision highp float;
|
|
2995
|
+
in vec2 position;
|
|
2996
|
+
out vec2 vUv;
|
|
2997
|
+
void main() {
|
|
2998
|
+
vUv = position * 0.5 + 0.5;
|
|
2999
|
+
gl_Position = vec4(position, 0.999, 1.0);
|
|
3000
|
+
}`
|
|
3001
|
+
);
|
|
3002
|
+
var BG_FRAG = (
|
|
3003
|
+
/* glsl */
|
|
3004
|
+
`#version 300 es
|
|
3005
|
+
precision highp float;
|
|
3006
|
+
in vec2 vUv;
|
|
3007
|
+
uniform vec3 uTop;
|
|
3008
|
+
uniform vec3 uBottom;
|
|
3009
|
+
out vec4 fragColor;
|
|
3010
|
+
void main() {
|
|
3011
|
+
vec3 col = mix(uBottom, uTop, vUv.y);
|
|
3012
|
+
vec2 c = vUv - 0.5;
|
|
3013
|
+
float vig = 1.0 - dot(c, c) * 0.85; // soft vignette
|
|
3014
|
+
fragColor = vec4(col * vig, 1.0);
|
|
3015
|
+
}`
|
|
3016
|
+
);
|
|
3017
|
+
var SEAT_PICK_VERT = (
|
|
3018
|
+
/* glsl */
|
|
3019
|
+
`#version 300 es
|
|
3020
|
+
precision highp float;
|
|
3021
|
+
in vec2 position;
|
|
3022
|
+
in vec3 iOffset;
|
|
3023
|
+
in float iMaxRadius;
|
|
3024
|
+
uniform mat4 modelViewMatrix;
|
|
3025
|
+
uniform mat4 projectionMatrix;
|
|
3026
|
+
uniform float uSeatRadius;
|
|
3027
|
+
uniform float uSeatScale;
|
|
3028
|
+
uniform float uMinPixels;
|
|
3029
|
+
uniform float uPixelToWorld;
|
|
3030
|
+
out vec2 vUv;
|
|
3031
|
+
flat out vec3 vPick;
|
|
3032
|
+
void main() {
|
|
3033
|
+
int id = gl_InstanceID + 1; // 0 reserved for no-hit
|
|
3034
|
+
vPick = vec3(float(id & 255), float((id >> 8) & 255), float((id >> 16) & 255)) / 255.0;
|
|
3035
|
+
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
3036
|
+
float depth = max(-mv.z, 0.001);
|
|
3037
|
+
float minR = uMinPixels * depth * uPixelToWorld;
|
|
3038
|
+
float r = min(max(uSeatRadius * uSeatScale, minR), iMaxRadius);
|
|
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;
|
|
3042
|
+
vUv = position;
|
|
3043
|
+
gl_Position = projectionMatrix * mv;
|
|
3044
|
+
}`
|
|
3045
|
+
);
|
|
3046
|
+
var SEAT_PICK_FRAG = (
|
|
3047
|
+
/* glsl */
|
|
3048
|
+
`#version 300 es
|
|
3049
|
+
precision highp float;
|
|
3050
|
+
in vec2 vUv;
|
|
3051
|
+
flat in vec3 vPick;
|
|
3052
|
+
out vec4 fragColor;
|
|
3053
|
+
void main() {
|
|
3054
|
+
if (length(vUv) > 1.0) discard; // round hit-mask matches the dot
|
|
3055
|
+
fragColor = vec4(vPick, 1.0);
|
|
3056
|
+
}`
|
|
3057
|
+
);
|
|
3058
|
+
var PICK_DEPTH_VERT = (
|
|
3059
|
+
/* glsl */
|
|
3060
|
+
`#version 300 es
|
|
3061
|
+
precision highp float;
|
|
3062
|
+
in vec3 position;
|
|
3063
|
+
uniform mat4 modelViewMatrix;
|
|
3064
|
+
uniform mat4 projectionMatrix;
|
|
3065
|
+
void main() {
|
|
3066
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
3067
|
+
}`
|
|
3068
|
+
);
|
|
3069
|
+
var PICK_DEPTH_FRAG = (
|
|
3070
|
+
/* glsl */
|
|
3071
|
+
`#version 300 es
|
|
3072
|
+
precision highp float;
|
|
3073
|
+
out vec4 fragColor;
|
|
3074
|
+
void main() { fragColor = vec4(0.0, 0.0, 0.0, 1.0); }`
|
|
3075
|
+
);
|
|
3076
|
+
function createSeatPickProgram(gl) {
|
|
3077
|
+
return new import_ogl3.Program(gl, {
|
|
3078
|
+
vertex: SEAT_PICK_VERT,
|
|
3079
|
+
fragment: SEAT_PICK_FRAG,
|
|
3080
|
+
transparent: false,
|
|
3081
|
+
depthTest: true,
|
|
3082
|
+
depthWrite: true,
|
|
3083
|
+
cullFace: false,
|
|
3084
|
+
uniforms: {
|
|
3085
|
+
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
3086
|
+
uSeatScale: { value: 1 },
|
|
3087
|
+
uMinPixels: { value: 2.5 },
|
|
3088
|
+
uPixelToWorld: { value: 2e-3 }
|
|
3089
|
+
}
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
function createPickDepthProgram(gl) {
|
|
3093
|
+
return new import_ogl3.Program(gl, {
|
|
3094
|
+
vertex: PICK_DEPTH_VERT,
|
|
3095
|
+
fragment: PICK_DEPTH_FRAG,
|
|
3096
|
+
transparent: false,
|
|
3097
|
+
depthTest: true,
|
|
3098
|
+
depthWrite: true,
|
|
3099
|
+
cullFace: false
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
function createSolidProgram(gl) {
|
|
3103
|
+
return new import_ogl3.Program(gl, {
|
|
3104
|
+
// No backface culling: free-hand section polygons are stored in raw click
|
|
3105
|
+
// order (either winding), so a culled solid would render see-through. The
|
|
3106
|
+
// shader lights both faces and closed opaque prisms + depth test keep
|
|
3107
|
+
// overdraw negligible; extrudePrism also normalises winding as a belt.
|
|
3108
|
+
vertex: SOLID_VERT,
|
|
3109
|
+
fragment: SOLID_FRAG,
|
|
3110
|
+
cullFace: false,
|
|
3111
|
+
depthTest: 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
|
+
}
|
|
3119
|
+
});
|
|
3120
|
+
}
|
|
3121
|
+
function createSeatProgram(gl) {
|
|
3122
|
+
return new import_ogl3.Program(gl, {
|
|
3123
|
+
vertex: SEAT_VERT,
|
|
3124
|
+
fragment: SEAT_FRAG,
|
|
3125
|
+
transparent: true,
|
|
3126
|
+
depthTest: true,
|
|
3127
|
+
depthWrite: false,
|
|
3128
|
+
cullFace: false,
|
|
3129
|
+
uniforms: {
|
|
3130
|
+
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
3131
|
+
uSeatScale: { value: 1 },
|
|
3132
|
+
uMinPixels: { value: 2.5 },
|
|
3133
|
+
uPixelToWorld: { value: 2e-3 },
|
|
3134
|
+
uSeatFade: { value: 0 },
|
|
3135
|
+
uFocusFloor: { value: -1 },
|
|
3136
|
+
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
}
|
|
3140
|
+
function createBackgroundProgram(gl, top, bottom) {
|
|
3141
|
+
return new import_ogl3.Program(gl, {
|
|
3142
|
+
vertex: BG_VERT,
|
|
3143
|
+
fragment: BG_FRAG,
|
|
3144
|
+
depthTest: false,
|
|
3145
|
+
depthWrite: false,
|
|
3146
|
+
cullFace: false,
|
|
3147
|
+
uniforms: {
|
|
3148
|
+
uTop: { value: new Float32Array(top) },
|
|
3149
|
+
uBottom: { value: new Float32Array(bottom) }
|
|
3150
|
+
}
|
|
3151
|
+
});
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// src/view3d/scene/build.ts
|
|
3155
|
+
function writeSeatColors(iColor, iState, start, count, states) {
|
|
3156
|
+
for (let i = start; i < start + count; i++) {
|
|
3157
|
+
const c = states[iState[i]] ?? states[0];
|
|
3158
|
+
iColor[i * 3] = c[0];
|
|
3159
|
+
iColor[i * 3 + 1] = c[1];
|
|
3160
|
+
iColor[i * 3 + 2] = c[2];
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
var SEAT_QUAD = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
|
|
3164
|
+
var BG_TRI = new Float32Array([-1, -1, 3, -1, -1, 3]);
|
|
3165
|
+
function buildGpuScene(gl, model) {
|
|
3166
|
+
const main = new import_ogl4.Transform();
|
|
3167
|
+
const background = new import_ogl4.Transform();
|
|
3168
|
+
const bgGeo = new import_ogl4.Geometry(gl, { position: { size: 2, data: BG_TRI } });
|
|
3169
|
+
const bgProg = createBackgroundProgram(gl, model.theme.background.top, model.theme.background.bottom);
|
|
3170
|
+
const bgMesh = new import_ogl4.Mesh(gl, { geometry: bgGeo, program: bgProg });
|
|
3171
|
+
bgMesh.frustumCulled = false;
|
|
3172
|
+
bgMesh.setParent(background);
|
|
3173
|
+
const solidGeo = new import_ogl4.Geometry(gl, {
|
|
3174
|
+
position: { size: 3, data: model.solids.position },
|
|
3175
|
+
normal: { size: 3, data: model.solids.normal },
|
|
3176
|
+
color: { size: 3, data: model.solids.color },
|
|
3177
|
+
floorIndex: { size: 1, data: model.solids.floor }
|
|
3178
|
+
});
|
|
3179
|
+
const solidProg = createSolidProgram(gl);
|
|
3180
|
+
const solidMesh = new import_ogl4.Mesh(gl, { geometry: solidGeo, program: solidProg });
|
|
3181
|
+
solidMesh.frustumCulled = false;
|
|
3182
|
+
solidMesh.setParent(main);
|
|
3183
|
+
const seatProg = createSeatProgram(gl);
|
|
3184
|
+
const iColor = new Float32Array(model.seats.count * 3);
|
|
3185
|
+
const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
|
|
3186
|
+
writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
|
|
3187
|
+
const seatGeo = new import_ogl4.Geometry(gl, {
|
|
3188
|
+
position: { size: 2, data: SEAT_QUAD },
|
|
3189
|
+
iOffset: { size: 3, data: model.seats.iPosition, 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 }
|
|
3197
|
+
});
|
|
3198
|
+
const seatMesh = new import_ogl4.Mesh(gl, { geometry: seatGeo, program: seatProg });
|
|
3199
|
+
seatMesh.frustumCulled = false;
|
|
3200
|
+
if (model.seats.count > 0) seatMesh.setParent(main);
|
|
3201
|
+
const colorAttr = seatGeo.attributes.iColor;
|
|
3202
|
+
return {
|
|
3203
|
+
main,
|
|
3204
|
+
background,
|
|
3205
|
+
seatProgram: seatProg,
|
|
3206
|
+
solidProgram: solidProg,
|
|
3207
|
+
seatGeometry: seatGeo,
|
|
3208
|
+
solidGeometry: solidGeo,
|
|
3209
|
+
drawCalls: 3,
|
|
3210
|
+
uploadSeatStateRuns(runs) {
|
|
3211
|
+
if (!runs.length) return;
|
|
3212
|
+
for (const run of runs) writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors);
|
|
3213
|
+
const buffer = colorAttr.buffer;
|
|
3214
|
+
if (!buffer) {
|
|
3215
|
+
colorAttr.needsUpdate = true;
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
3219
|
+
for (const run of runs) {
|
|
3220
|
+
const sub2 = iColor.subarray(run.start * 3, (run.start + run.length) * 3);
|
|
3221
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, run.start * 3 * Float32Array.BYTES_PER_ELEMENT, sub2);
|
|
3222
|
+
}
|
|
3223
|
+
},
|
|
3224
|
+
dispose() {
|
|
3225
|
+
bgGeo.remove();
|
|
3226
|
+
bgProg.remove();
|
|
3227
|
+
solidGeo.remove();
|
|
3228
|
+
solidProg.remove();
|
|
3229
|
+
seatGeo.remove();
|
|
3230
|
+
seatProg.remove();
|
|
3231
|
+
}
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
// src/view3d/pick/pickPipeline.ts
|
|
3236
|
+
var import_ogl5 = require("ogl");
|
|
3237
|
+
|
|
3238
|
+
// src/view3d/pick/encode.ts
|
|
3239
|
+
function decodePickRGB(r, g, b) {
|
|
3240
|
+
const id = r + (g << 8) + (b << 16);
|
|
3241
|
+
return id === 0 ? -1 : id - 1;
|
|
3242
|
+
}
|
|
3243
|
+
function pickNearestFromBuffer(pixels, boxW, boxH, centerI, centerJ, maxIndex) {
|
|
3244
|
+
let best = -1;
|
|
3245
|
+
let bestDist = Infinity;
|
|
3246
|
+
for (let j = 0; j < boxH; j++) {
|
|
3247
|
+
for (let i = 0; i < boxW; i++) {
|
|
3248
|
+
const o = (j * boxW + i) * 4;
|
|
3249
|
+
const idx = decodePickRGB(pixels[o], pixels[o + 1], pixels[o + 2]);
|
|
3250
|
+
if (idx < 0 || idx >= maxIndex) continue;
|
|
3251
|
+
const di = i - centerI;
|
|
3252
|
+
const dj = j - centerJ;
|
|
3253
|
+
const d = di * di + dj * dj;
|
|
3254
|
+
if (d < bestDist) {
|
|
3255
|
+
bestDist = d;
|
|
3256
|
+
best = idx;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
return best;
|
|
3261
|
+
}
|
|
3262
|
+
function pickPixelCoords(clientX, clientY, rect, dpr, bufferWidth, bufferHeight) {
|
|
3263
|
+
const cssX = clientX - rect.left;
|
|
3264
|
+
const cssY = clientY - rect.top;
|
|
3265
|
+
const x = Math.round(cssX * dpr);
|
|
3266
|
+
const y = Math.round((rect.height - cssY) * dpr);
|
|
3267
|
+
return {
|
|
3268
|
+
x: Math.max(0, Math.min(bufferWidth - 1, x)),
|
|
3269
|
+
y: Math.max(0, Math.min(bufferHeight - 1, y))
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
// src/view3d/pick/pickPipeline.ts
|
|
3274
|
+
var SYNC_KEYS = ["uSeatRadius", "uSeatScale", "uMinPixels", "uPixelToWorld"];
|
|
3275
|
+
var PickPipeline = class {
|
|
3276
|
+
constructor(renderer, seatGeo, solidGeo, seatCount) {
|
|
3277
|
+
this.seatScene = new import_ogl5.Transform();
|
|
3278
|
+
this.solidScene = new import_ogl5.Transform();
|
|
3279
|
+
this.target = null;
|
|
3280
|
+
/** Display clear colour to restore after the pick pass (theme-dependent). */
|
|
3281
|
+
this.restoreClear = [0, 0, 0];
|
|
3282
|
+
this.renderer = renderer;
|
|
3283
|
+
this.gl = renderer.gl;
|
|
3284
|
+
this.maxIndex = seatCount;
|
|
3285
|
+
this.seatProg = createSeatPickProgram(this.gl);
|
|
3286
|
+
this.depthProg = createPickDepthProgram(this.gl);
|
|
3287
|
+
const seatMesh = new import_ogl5.Mesh(this.gl, { geometry: seatGeo, program: this.seatProg });
|
|
3288
|
+
seatMesh.frustumCulled = false;
|
|
3289
|
+
seatMesh.setParent(this.seatScene);
|
|
3290
|
+
const solidMesh = new import_ogl5.Mesh(this.gl, { geometry: solidGeo, program: this.depthProg });
|
|
3291
|
+
solidMesh.frustumCulled = false;
|
|
3292
|
+
solidMesh.setParent(this.solidScene);
|
|
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
|
+
}
|
|
3298
|
+
/** Match the display seat sizing so the pick mask lines up with the dots. */
|
|
3299
|
+
syncFromSeatProgram(seatProgram) {
|
|
3300
|
+
for (const k of SYNC_KEYS) this.seatProg.uniforms[k].value = seatProgram.uniforms[k].value;
|
|
3301
|
+
}
|
|
3302
|
+
ensureTarget() {
|
|
3303
|
+
const w = this.gl.drawingBufferWidth;
|
|
3304
|
+
const h = this.gl.drawingBufferHeight;
|
|
3305
|
+
if (this.target && (this.target.width !== w || this.target.height !== h)) {
|
|
3306
|
+
this.destroyTarget();
|
|
3307
|
+
}
|
|
3308
|
+
if (!this.target) {
|
|
3309
|
+
this.target = new import_ogl5.RenderTarget(this.gl, { width: w, height: h, depth: true });
|
|
3310
|
+
}
|
|
3311
|
+
return this.target;
|
|
3312
|
+
}
|
|
3313
|
+
destroyTarget() {
|
|
3314
|
+
if (!this.target) return;
|
|
3315
|
+
const gl = this.gl;
|
|
3316
|
+
if (this.target.buffer) gl.deleteFramebuffer(this.target.buffer);
|
|
3317
|
+
for (const t of this.target.textures ?? []) if (t.texture) gl.deleteTexture(t.texture);
|
|
3318
|
+
if (this.target.depthBuffer) gl.deleteRenderbuffer(this.target.depthBuffer);
|
|
3319
|
+
this.target = null;
|
|
3320
|
+
}
|
|
3321
|
+
/**
|
|
3322
|
+
* Read back the seat instance index NEAREST framebuffer pixel (px, py), or -1.
|
|
3323
|
+
* `radius` is the tap tolerance in buffer px: a box of side (2·radius+1) is
|
|
3324
|
+
* rendered + read so a tap that lands between the ~2px overview dots still
|
|
3325
|
+
* finds the closest seat. px/py/radius are bottom-left-origin buffer pixels.
|
|
3326
|
+
*/
|
|
3327
|
+
pick(camera, px, py, radius) {
|
|
3328
|
+
const gl = this.gl;
|
|
3329
|
+
const target = this.ensureTarget();
|
|
3330
|
+
const bw = gl.drawingBufferWidth;
|
|
3331
|
+
const bh = gl.drawingBufferHeight;
|
|
3332
|
+
const x0 = Math.max(0, px - radius);
|
|
3333
|
+
const y0 = Math.max(0, py - radius);
|
|
3334
|
+
const boxW = Math.max(1, Math.min(bw, px + radius + 1) - x0);
|
|
3335
|
+
const boxH = Math.max(1, Math.min(bh, py + radius + 1) - y0);
|
|
3336
|
+
gl.enable(gl.SCISSOR_TEST);
|
|
3337
|
+
gl.scissor(x0, y0, boxW, boxH);
|
|
3338
|
+
const [br, bg, bb] = this.restoreClear;
|
|
3339
|
+
gl.clearColor(0, 0, 0, 1);
|
|
3340
|
+
this.renderer.render({ scene: this.solidScene, camera, target, clear: true });
|
|
3341
|
+
this.renderer.render({ scene: this.seatScene, camera, target, clear: false });
|
|
3342
|
+
gl.clearColor(br, bg, bb, 1);
|
|
3343
|
+
gl.disable(gl.SCISSOR_TEST);
|
|
3344
|
+
const buf = new Uint8Array(boxW * boxH * 4);
|
|
3345
|
+
this.renderer.bindFramebuffer(target);
|
|
3346
|
+
gl.readPixels(x0, y0, boxW, boxH, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
|
3347
|
+
this.renderer.bindFramebuffer();
|
|
3348
|
+
return pickNearestFromBuffer(buf, boxW, boxH, px - x0, py - y0, this.maxIndex);
|
|
3349
|
+
}
|
|
3350
|
+
dispose() {
|
|
3351
|
+
this.destroyTarget();
|
|
3352
|
+
this.seatProg.remove();
|
|
3353
|
+
this.depthProg.remove();
|
|
3354
|
+
}
|
|
3355
|
+
};
|
|
3356
|
+
|
|
3357
|
+
// src/view3d/pick/selection.ts
|
|
3358
|
+
function mergeAvailabilityIntoSelection(selection, updates) {
|
|
3359
|
+
const passthrough = [];
|
|
3360
|
+
for (const u of updates) {
|
|
3361
|
+
if (selection.has(u.seatId)) selection.set(u.seatId, seatStateIndex(u.state));
|
|
3362
|
+
else passthrough.push(u);
|
|
3363
|
+
}
|
|
3364
|
+
return passthrough;
|
|
3365
|
+
}
|
|
3366
|
+
function diffSelection(prev, desiredIds, baseStateIndex) {
|
|
3367
|
+
const desired = /* @__PURE__ */ new Set();
|
|
3368
|
+
for (const id of desiredIds) {
|
|
3369
|
+
if (prev.has(id) || baseStateIndex(id) !== void 0) desired.add(id);
|
|
3370
|
+
}
|
|
3371
|
+
const next = new Map(prev);
|
|
3372
|
+
const updates = [];
|
|
3373
|
+
for (const [id, base] of prev) {
|
|
3374
|
+
if (!desired.has(id)) {
|
|
3375
|
+
updates.push({ seatId: id, state: SEAT_STATES[base] ?? "available" });
|
|
3376
|
+
next.delete(id);
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
for (const id of desired) {
|
|
3380
|
+
if (next.has(id)) continue;
|
|
3381
|
+
const base = baseStateIndex(id);
|
|
3382
|
+
if (base === void 0) continue;
|
|
3383
|
+
next.set(id, base);
|
|
3384
|
+
updates.push({ seatId: id, state: "selected" });
|
|
3385
|
+
}
|
|
3386
|
+
return { updates, next };
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// src/view3d/camera/cinematic.ts
|
|
3390
|
+
var import_ogl6 = require("ogl");
|
|
3391
|
+
|
|
3392
|
+
// src/view3d/camera/cinematicMath.ts
|
|
3393
|
+
var FLIGHT_DURATION_MS = 2500;
|
|
3394
|
+
var FOV_START = 35;
|
|
3395
|
+
var FOV_END = 28;
|
|
3396
|
+
var ORIENTATION_LEAD = 0.15;
|
|
3397
|
+
var BACK_M = 2.5;
|
|
3398
|
+
var ABOVE_EYE_M = 1.5;
|
|
3399
|
+
function sub(a, b) {
|
|
3400
|
+
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
3401
|
+
}
|
|
3402
|
+
function add(a, b) {
|
|
3403
|
+
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
3404
|
+
}
|
|
3405
|
+
function scale(a, k) {
|
|
3406
|
+
return [a[0] * k, a[1] * k, a[2] * k];
|
|
3407
|
+
}
|
|
3408
|
+
function norm(a) {
|
|
3409
|
+
const l = Math.hypot(a[0], a[1], a[2]);
|
|
3410
|
+
return l > 1e-6 ? [a[0] / l, a[1] / l, a[2] / l] : [0, 0, 0];
|
|
3411
|
+
}
|
|
3412
|
+
function smootherstep(t) {
|
|
3413
|
+
const x = Math.max(0, Math.min(1, t));
|
|
3414
|
+
return x * x * x * (x * (x * 6 - 15) + 10);
|
|
3415
|
+
}
|
|
3416
|
+
function orientationLeadT(posT, lead) {
|
|
3417
|
+
return Math.max(0, Math.min(1, posT + lead));
|
|
3418
|
+
}
|
|
3419
|
+
function catmullRom(points, u) {
|
|
3420
|
+
const n = points.length;
|
|
3421
|
+
if (n === 0) return [0, 0, 0];
|
|
3422
|
+
if (n === 1) return [...points[0]];
|
|
3423
|
+
const cu = Math.max(0, Math.min(1, u));
|
|
3424
|
+
const segCount = n - 1;
|
|
3425
|
+
let seg = Math.floor(cu * segCount);
|
|
3426
|
+
if (seg >= segCount) seg = segCount - 1;
|
|
3427
|
+
const t = cu * segCount - seg;
|
|
3428
|
+
const p0 = points[Math.max(0, seg - 1)];
|
|
3429
|
+
const p1 = points[seg];
|
|
3430
|
+
const p2 = points[seg + 1];
|
|
3431
|
+
const p3 = points[Math.min(n - 1, seg + 2)];
|
|
3432
|
+
const t2 = t * t;
|
|
3433
|
+
const t3 = t2 * t;
|
|
3434
|
+
const out = [0, 0, 0];
|
|
3435
|
+
for (let i = 0; i < 3; i++) {
|
|
3436
|
+
out[i] = 0.5 * (2 * p1[i] + (-p0[i] + p2[i]) * t + (2 * p0[i] - 5 * p1[i] + 4 * p2[i] - p3[i]) * t2 + (-p0[i] + 3 * p1[i] - 3 * p2[i] + p3[i]) * t3);
|
|
3437
|
+
}
|
|
3438
|
+
return out;
|
|
3439
|
+
}
|
|
3440
|
+
function buildWaypoints(start, seatEye, focal, center, radius) {
|
|
3441
|
+
let away = norm(sub(seatEye, focal));
|
|
3442
|
+
if (away[0] === 0 && away[1] === 0 && away[2] === 0) away = [0, 0, 1];
|
|
3443
|
+
const finalPos = add(add(seatEye, scale(away, BACK_M)), [0, ABOVE_EYE_M, 0]);
|
|
3444
|
+
const horiz = [seatEye[0] - center[0], 0, seatEye[2] - center[2]];
|
|
3445
|
+
let hn = norm(horiz);
|
|
3446
|
+
if (hn[0] === 0 && hn[2] === 0) hn = [away[0], 0, away[2]];
|
|
3447
|
+
const r = Math.max(1, radius);
|
|
3448
|
+
const arc = [
|
|
3449
|
+
center[0] + hn[0] * r * 1.3,
|
|
3450
|
+
center[1] + r * 0.75,
|
|
3451
|
+
center[2] + hn[2] * r * 1.3
|
|
3452
|
+
];
|
|
3453
|
+
return { waypoints: [start, arc, finalPos], finalPos };
|
|
3454
|
+
}
|
|
3455
|
+
function sampleFlight(waypoints, u, fovStart = FOV_START, fovEnd = FOV_END) {
|
|
3456
|
+
const eased = smootherstep(u);
|
|
3457
|
+
const pos = catmullRom(waypoints, eased);
|
|
3458
|
+
const fovT = smootherstep(Math.max(0, Math.min(1, (u - 0.66) / 0.34)));
|
|
3459
|
+
return { pos, fov: fovStart + (fovEnd - fovStart) * fovT, eased };
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
// src/view3d/camera/cinematic.ts
|
|
3463
|
+
function lookAtQuat(camera, from, to) {
|
|
3464
|
+
const savedPos = camera.position.clone();
|
|
3465
|
+
const savedQuat = new import_ogl6.Quat().copy(camera.quaternion);
|
|
3466
|
+
camera.position.set(from[0], from[1], from[2]);
|
|
3467
|
+
camera.lookAt(new import_ogl6.Vec3(to[0], to[1], to[2]));
|
|
3468
|
+
const q = new import_ogl6.Quat().copy(camera.quaternion);
|
|
3469
|
+
camera.position.copy(savedPos);
|
|
3470
|
+
camera.quaternion.copy(savedQuat);
|
|
3471
|
+
return q;
|
|
3472
|
+
}
|
|
3473
|
+
var Cinematic = class {
|
|
3474
|
+
constructor(camera) {
|
|
3475
|
+
this.active = false;
|
|
3476
|
+
this.waypoints = [];
|
|
3477
|
+
this.startQuat = new import_ogl6.Quat();
|
|
3478
|
+
this.endQuat = new import_ogl6.Quat();
|
|
3479
|
+
this.outQuat = new import_ogl6.Quat();
|
|
3480
|
+
this.startTime = 0;
|
|
3481
|
+
this.duration = FLIGHT_DURATION_MS;
|
|
3482
|
+
this.resolveFn = null;
|
|
3483
|
+
this.camera = camera;
|
|
3484
|
+
}
|
|
3485
|
+
/** Begin (or retarget) a flight. Resolves when it lands or is cancelled. */
|
|
3486
|
+
start(waypoints, startQuat, endQuat, duration = FLIGHT_DURATION_MS) {
|
|
3487
|
+
this.settle();
|
|
3488
|
+
this.waypoints = waypoints;
|
|
3489
|
+
this.startQuat.copy(startQuat);
|
|
3490
|
+
this.endQuat.copy(endQuat);
|
|
3491
|
+
this.duration = duration;
|
|
3492
|
+
this.startTime = performance.now();
|
|
3493
|
+
this.active = true;
|
|
3494
|
+
return new Promise((res) => {
|
|
3495
|
+
this.resolveFn = res;
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
3498
|
+
/** Advance the flight, mutating the camera. Returns true while still flying. */
|
|
3499
|
+
update(now2) {
|
|
3500
|
+
if (!this.active) return false;
|
|
3501
|
+
const u = Math.min(1, (now2 - this.startTime) / this.duration);
|
|
3502
|
+
const { pos, fov, eased } = sampleFlight(this.waypoints, u);
|
|
3503
|
+
this.camera.position.set(pos[0], pos[1], pos[2]);
|
|
3504
|
+
this.outQuat.copy(this.startQuat).slerp(this.endQuat, orientationLeadT(eased, ORIENTATION_LEAD));
|
|
3505
|
+
this.camera.quaternion.copy(this.outQuat);
|
|
3506
|
+
this.camera.fov = fov;
|
|
3507
|
+
this.camera.updateProjectionMatrix();
|
|
3508
|
+
if (u >= 1) {
|
|
3509
|
+
this.settle();
|
|
3510
|
+
return false;
|
|
3511
|
+
}
|
|
3512
|
+
return true;
|
|
3513
|
+
}
|
|
3514
|
+
/** Stop where we are (no snap) — the camera keeps its current pose. */
|
|
3515
|
+
cancel() {
|
|
3516
|
+
this.settle();
|
|
3517
|
+
}
|
|
3518
|
+
settle() {
|
|
3519
|
+
this.active = false;
|
|
3520
|
+
const r = this.resolveFn;
|
|
3521
|
+
this.resolveFn = null;
|
|
3522
|
+
if (r) r();
|
|
3523
|
+
}
|
|
3524
|
+
};
|
|
3525
|
+
|
|
3526
|
+
// src/view3d/crossfade/panorama.ts
|
|
3527
|
+
var VFOV_DEG = 70;
|
|
3528
|
+
var MAX_PITCH_DEG = 35;
|
|
3529
|
+
function bearingToOffsetPx(bearingDeg, viewportW, bgW) {
|
|
3530
|
+
const col = (0.5 + bearingDeg / 360) * bgW;
|
|
3531
|
+
return viewportW / 2 - col;
|
|
3532
|
+
}
|
|
3533
|
+
function windowedBgHeight(viewportH, vfovDeg = VFOV_DEG) {
|
|
3534
|
+
return viewportH * (180 / vfovDeg);
|
|
3535
|
+
}
|
|
3536
|
+
function horizonOffsetPy(viewportH, bgH, pitchPx) {
|
|
3537
|
+
return (viewportH - bgH) / 2 + clampPitchPx(pitchPx, bgH);
|
|
3538
|
+
}
|
|
3539
|
+
function clampPitchPx(pitchPx, bgH) {
|
|
3540
|
+
const limit = MAX_PITCH_DEG / 180 * bgH;
|
|
3541
|
+
return Math.max(-limit, Math.min(limit, pitchPx));
|
|
3542
|
+
}
|
|
3543
|
+
function mountPanorama(container, view, opts = {}) {
|
|
3544
|
+
const fadeMs = opts.fadeMs ?? 400;
|
|
3545
|
+
const bearing = view.initialBearingDeg ?? 0;
|
|
3546
|
+
const root = document.createElement("div");
|
|
3547
|
+
root.setAttribute("role", "dialog");
|
|
3548
|
+
root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
|
|
3549
|
+
Object.assign(root.style, {
|
|
3550
|
+
position: "absolute",
|
|
3551
|
+
inset: "0",
|
|
3552
|
+
zIndex: "10",
|
|
3553
|
+
opacity: "0",
|
|
3554
|
+
transition: `opacity ${fadeMs}ms ease`,
|
|
3555
|
+
background: "#05070c",
|
|
3556
|
+
overflow: "hidden",
|
|
3557
|
+
touchAction: "none"
|
|
3558
|
+
});
|
|
3559
|
+
const pano = document.createElement("div");
|
|
3560
|
+
Object.assign(pano.style, {
|
|
3561
|
+
position: "absolute",
|
|
3562
|
+
inset: "0",
|
|
3563
|
+
backgroundImage: `url("${view.url}")`,
|
|
3564
|
+
backgroundRepeat: "repeat-x",
|
|
3565
|
+
cursor: "grab"
|
|
3566
|
+
});
|
|
3567
|
+
root.appendChild(pano);
|
|
3568
|
+
const closeBtn = document.createElement("button");
|
|
3569
|
+
closeBtn.type = "button";
|
|
3570
|
+
closeBtn.setAttribute("aria-label", "Close");
|
|
3571
|
+
closeBtn.textContent = "\u2715";
|
|
3572
|
+
Object.assign(closeBtn.style, {
|
|
3573
|
+
position: "absolute",
|
|
3574
|
+
top: "12px",
|
|
3575
|
+
right: "12px",
|
|
3576
|
+
zIndex: "2",
|
|
3577
|
+
width: "34px",
|
|
3578
|
+
height: "34px",
|
|
3579
|
+
borderRadius: "999px",
|
|
3580
|
+
cursor: "pointer",
|
|
3581
|
+
border: "1px solid rgba(255,255,255,0.25)",
|
|
3582
|
+
background: "rgba(8,12,18,0.6)",
|
|
3583
|
+
color: "#e6edf3",
|
|
3584
|
+
fontSize: "15px",
|
|
3585
|
+
lineHeight: "1"
|
|
3586
|
+
});
|
|
3587
|
+
root.appendChild(closeBtn);
|
|
3588
|
+
const hint = document.createElement("div");
|
|
3589
|
+
hint.textContent = "Drag to look around \xB7 Esc to close";
|
|
3590
|
+
Object.assign(hint.style, {
|
|
3591
|
+
position: "absolute",
|
|
3592
|
+
bottom: "12px",
|
|
3593
|
+
left: "0",
|
|
3594
|
+
right: "0",
|
|
3595
|
+
textAlign: "center",
|
|
3596
|
+
color: "rgba(230,237,243,0.7)",
|
|
3597
|
+
font: "12px ui-sans-serif, system-ui, sans-serif",
|
|
3598
|
+
pointerEvents: "none"
|
|
3599
|
+
});
|
|
3600
|
+
root.appendChild(hint);
|
|
3601
|
+
container.appendChild(root);
|
|
3602
|
+
let bgW = 0;
|
|
3603
|
+
let bgH = 0;
|
|
3604
|
+
let posX = 0;
|
|
3605
|
+
let pitchPx = 0;
|
|
3606
|
+
const layout = () => {
|
|
3607
|
+
const vh = root.clientHeight || 1;
|
|
3608
|
+
const vw = root.clientWidth || 1;
|
|
3609
|
+
const natW = img.naturalWidth || vw * 2;
|
|
3610
|
+
const natH = img.naturalHeight || vh;
|
|
3611
|
+
bgH = windowedBgHeight(vh);
|
|
3612
|
+
bgW = bgH * (natW / natH);
|
|
3613
|
+
pano.style.backgroundSize = `${bgW}px ${bgH}px`;
|
|
3614
|
+
if (!posInitialised) {
|
|
3615
|
+
posX = bearingToOffsetPx(bearing, vw, bgW);
|
|
3616
|
+
posInitialised = true;
|
|
3617
|
+
}
|
|
3618
|
+
pitchPx = clampPitchPx(pitchPx, bgH);
|
|
3619
|
+
pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
|
|
3620
|
+
};
|
|
3621
|
+
let posInitialised = false;
|
|
3622
|
+
const applyPos = () => {
|
|
3623
|
+
const vh = root.clientHeight || 1;
|
|
3624
|
+
pitchPx = clampPitchPx(pitchPx, bgH);
|
|
3625
|
+
pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
|
|
3626
|
+
};
|
|
3627
|
+
const img = new Image();
|
|
3628
|
+
img.onload = layout;
|
|
3629
|
+
img.src = view.url;
|
|
3630
|
+
requestAnimationFrame(layout);
|
|
3631
|
+
let dragging = false;
|
|
3632
|
+
let lastX = 0;
|
|
3633
|
+
let lastY = 0;
|
|
3634
|
+
const onDown = (e) => {
|
|
3635
|
+
dragging = true;
|
|
3636
|
+
lastX = e.clientX;
|
|
3637
|
+
lastY = e.clientY;
|
|
3638
|
+
pano.style.cursor = "grabbing";
|
|
3639
|
+
try {
|
|
3640
|
+
pano.setPointerCapture?.(e.pointerId);
|
|
3641
|
+
} catch {
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
const onMove = (e) => {
|
|
3645
|
+
if (!dragging) return;
|
|
3646
|
+
posX += e.clientX - lastX;
|
|
3647
|
+
pitchPx += e.clientY - lastY;
|
|
3648
|
+
lastX = e.clientX;
|
|
3649
|
+
lastY = e.clientY;
|
|
3650
|
+
applyPos();
|
|
3651
|
+
};
|
|
3652
|
+
const onUp = (e) => {
|
|
3653
|
+
dragging = false;
|
|
3654
|
+
pano.style.cursor = "grab";
|
|
3655
|
+
try {
|
|
3656
|
+
pano.releasePointerCapture?.(e.pointerId);
|
|
3657
|
+
} catch {
|
|
3658
|
+
}
|
|
3659
|
+
};
|
|
3660
|
+
pano.addEventListener("pointerdown", onDown);
|
|
3661
|
+
pano.addEventListener("pointermove", onMove);
|
|
3662
|
+
pano.addEventListener("pointerup", onUp);
|
|
3663
|
+
pano.addEventListener("pointercancel", onUp);
|
|
3664
|
+
let closed = false;
|
|
3665
|
+
let disposed = false;
|
|
3666
|
+
let fadeTimer = 0;
|
|
3667
|
+
const removeListeners = () => {
|
|
3668
|
+
pano.removeEventListener("pointerdown", onDown);
|
|
3669
|
+
pano.removeEventListener("pointermove", onMove);
|
|
3670
|
+
pano.removeEventListener("pointerup", onUp);
|
|
3671
|
+
pano.removeEventListener("pointercancel", onUp);
|
|
3672
|
+
window.removeEventListener("keydown", onKey);
|
|
3673
|
+
};
|
|
3674
|
+
const teardown = () => {
|
|
3675
|
+
if (fadeTimer) {
|
|
3676
|
+
window.clearTimeout(fadeTimer);
|
|
3677
|
+
fadeTimer = 0;
|
|
3678
|
+
}
|
|
3679
|
+
removeListeners();
|
|
3680
|
+
if (root.parentNode) root.parentNode.removeChild(root);
|
|
3681
|
+
};
|
|
3682
|
+
const close = () => {
|
|
3683
|
+
if (closed) return;
|
|
3684
|
+
closed = true;
|
|
3685
|
+
root.style.opacity = "0";
|
|
3686
|
+
const done = () => {
|
|
3687
|
+
fadeTimer = 0;
|
|
3688
|
+
if (disposed) return;
|
|
3689
|
+
teardown();
|
|
3690
|
+
opts.onClose?.();
|
|
3691
|
+
};
|
|
3692
|
+
fadeTimer = window.setTimeout(done, fadeMs);
|
|
3693
|
+
};
|
|
3694
|
+
const onKey = (e) => {
|
|
3695
|
+
if (e.key === "Escape") {
|
|
3696
|
+
e.stopPropagation();
|
|
3697
|
+
close();
|
|
3698
|
+
}
|
|
3699
|
+
};
|
|
3700
|
+
window.addEventListener("keydown", onKey);
|
|
3701
|
+
closeBtn.addEventListener("click", close);
|
|
3702
|
+
requestAnimationFrame(() => {
|
|
3703
|
+
root.style.opacity = "1";
|
|
3704
|
+
});
|
|
3705
|
+
return {
|
|
3706
|
+
close,
|
|
3707
|
+
dispose() {
|
|
3708
|
+
closed = true;
|
|
3709
|
+
disposed = true;
|
|
3710
|
+
teardown();
|
|
3711
|
+
}
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// src/view3d/analytics.ts
|
|
3716
|
+
var now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
3717
|
+
var Analytics3D = class {
|
|
3718
|
+
constructor(cb) {
|
|
3719
|
+
this.orbitLatched = false;
|
|
3720
|
+
this.panoramaOpenedAt = 0;
|
|
3721
|
+
this.cb = cb;
|
|
3722
|
+
}
|
|
3723
|
+
/** The single guarded emit point — analytics must never throw into the loop. */
|
|
3724
|
+
emit(event, props) {
|
|
3725
|
+
if (!this.cb) return;
|
|
3726
|
+
try {
|
|
3727
|
+
this.cb(event, props);
|
|
3728
|
+
} catch {
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
opened(seats, hasHeights) {
|
|
3732
|
+
this.emit("3d_opened", { seats, hasHeights });
|
|
3733
|
+
}
|
|
3734
|
+
/** First user-driven orbit/dolly per mount only (the intro ease is not user
|
|
3735
|
+
* input, so callers must gate this on real pointer/wheel gestures). */
|
|
3736
|
+
orbitEngaged() {
|
|
3737
|
+
if (this.orbitLatched) return;
|
|
3738
|
+
this.orbitLatched = true;
|
|
3739
|
+
this.emit("3d_orbit_engaged");
|
|
3740
|
+
}
|
|
3741
|
+
seatPicked(seatId, sectionId) {
|
|
3742
|
+
this.emit("3d_seat_picked", { seatId, sectionId });
|
|
3743
|
+
}
|
|
3744
|
+
cinematicPlayed(durationMs) {
|
|
3745
|
+
this.emit("3d_cinematic_played", { durationMs, reducedMotion: false });
|
|
3746
|
+
}
|
|
3747
|
+
cinematicSkipped() {
|
|
3748
|
+
this.emit("3d_cinematic_skipped", { reducedMotion: true });
|
|
3749
|
+
}
|
|
3750
|
+
cinematicCancelled() {
|
|
3751
|
+
this.emit("3d_cinematic_cancelled");
|
|
3752
|
+
}
|
|
3753
|
+
panoramaOpened() {
|
|
3754
|
+
this.panoramaOpenedAt = now();
|
|
3755
|
+
this.emit("3d_panorama_opened");
|
|
3756
|
+
}
|
|
3757
|
+
panoramaClosed() {
|
|
3758
|
+
const viewMs = this.panoramaOpenedAt ? Math.round(now() - this.panoramaOpenedAt) : 0;
|
|
3759
|
+
this.panoramaOpenedAt = 0;
|
|
3760
|
+
this.emit("3d_panorama_closed", { viewMs });
|
|
3761
|
+
}
|
|
3762
|
+
};
|
|
3763
|
+
|
|
3764
|
+
// src/view3d/index.ts
|
|
3765
|
+
var SEAT_EYE_ABOVE_DECK = 1.02;
|
|
3766
|
+
var DEG3 = Math.PI / 180;
|
|
3767
|
+
var TAP_SLOP = 6;
|
|
3768
|
+
var TAP_MS = 500;
|
|
3769
|
+
function mountVenue3D(container, input, opts = {}) {
|
|
3770
|
+
const model = buildSceneModel(input);
|
|
3771
|
+
const analytics = new Analytics3D(opts.onAnalytics);
|
|
3772
|
+
const seatIdByIndex = new Array(model.seats.count);
|
|
3773
|
+
for (const [id, idx] of model.seats.idToIndex) seatIdByIndex[idx] = id;
|
|
3774
|
+
const sectionIdBySeatId = /* @__PURE__ */ new Map();
|
|
3775
|
+
for (const s of input.seats) sectionIdBySeatId.set(s.id, s.sectionId);
|
|
3776
|
+
const hasHeights = (() => {
|
|
3777
|
+
if (input.doc.floors?.some((f) => (f.baseHeightM ?? 0) > 0)) return true;
|
|
3778
|
+
const objs = input.doc.floors?.flatMap((f) => f.objects) ?? input.doc.objects;
|
|
3779
|
+
return objs.some((o) => o.type === "section" && ((o.height ?? 0) > 0 || (o.rake ?? 0) > 0));
|
|
3780
|
+
})();
|
|
3781
|
+
let gpu = null;
|
|
3782
|
+
let pick = null;
|
|
3783
|
+
let contextLost = false;
|
|
3784
|
+
let frozen = false;
|
|
3785
|
+
let disposed = false;
|
|
3786
|
+
let selection = /* @__PURE__ */ new Map();
|
|
3787
|
+
let panorama = null;
|
|
3788
|
+
const prefetch = /* @__PURE__ */ new Map();
|
|
3789
|
+
let flightGen = 0;
|
|
3790
|
+
let reducedForced = null;
|
|
3791
|
+
let focusedFloor = -1;
|
|
3792
|
+
const rebuildGpu = () => {
|
|
3793
|
+
gpu = buildGpuScene(glctx.gl, model);
|
|
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;
|
|
3800
|
+
};
|
|
3801
|
+
const glctx = new GLContext(container, {
|
|
3802
|
+
onContextLost: () => {
|
|
3803
|
+
contextLost = true;
|
|
3804
|
+
loop.stop();
|
|
3805
|
+
gpu = null;
|
|
3806
|
+
pick = null;
|
|
3807
|
+
},
|
|
3808
|
+
onContextRestored: () => {
|
|
3809
|
+
rebuildGpu();
|
|
3810
|
+
contextLost = false;
|
|
3811
|
+
loop.requestRender();
|
|
3812
|
+
}
|
|
3813
|
+
});
|
|
3814
|
+
const orbit = new OrbitCamera(
|
|
3815
|
+
glctx.gl,
|
|
3816
|
+
glctx.canvas,
|
|
3817
|
+
() => loop.requestRender(),
|
|
3818
|
+
() => analytics.orbitEngaged()
|
|
3819
|
+
// first real drag/wheel/pinch (not the intro ease)
|
|
3820
|
+
);
|
|
3821
|
+
orbit.setAspect(glctx.aspect);
|
|
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);
|
|
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);
|
|
3837
|
+
rebuildGpu();
|
|
3838
|
+
const loop = new RenderLoop(() => {
|
|
3839
|
+
if (contextLost || !gpu || frozen) return false;
|
|
3840
|
+
const flying = cinematic.active;
|
|
3841
|
+
const moving = flying ? cinematic.update(performance.now()) : orbit.update();
|
|
3842
|
+
const lod = computeSeatLod(orbit.currentDistance, model.bounds.radius);
|
|
3843
|
+
const u = gpu.seatProgram.uniforms;
|
|
3844
|
+
u.uSeatScale.value = lod.scale;
|
|
3845
|
+
u.uSeatFade.value = lod.fade;
|
|
3846
|
+
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG3 / 2) / Math.max(1, glctx.pixelHeight);
|
|
3847
|
+
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
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
|
+
);
|
|
3856
|
+
return moving;
|
|
3857
|
+
});
|
|
3858
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => handle.resize()) : null;
|
|
3859
|
+
ro?.observe(container);
|
|
3860
|
+
const setSelection = (ids) => {
|
|
3861
|
+
const baseStateIndex = (id) => {
|
|
3862
|
+
const idx = model.seats.idToIndex.get(id);
|
|
3863
|
+
return idx === void 0 ? void 0 : model.seats.iState[idx];
|
|
3864
|
+
};
|
|
3865
|
+
const { updates, next } = diffSelection(selection, ids, baseStateIndex);
|
|
3866
|
+
selection = next;
|
|
3867
|
+
if (updates.length) {
|
|
3868
|
+
const runs = applySeatStates(model.seats, updates);
|
|
3869
|
+
if (gpu) gpu.uploadSeatStateRuns(runs);
|
|
3870
|
+
loop.requestRender();
|
|
3871
|
+
}
|
|
3872
|
+
};
|
|
3873
|
+
const reducedMotion = () => {
|
|
3874
|
+
if (reducedForced !== null) return reducedForced;
|
|
3875
|
+
return typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
3876
|
+
};
|
|
3877
|
+
const PREFETCH_CAP = 8;
|
|
3878
|
+
const ensureSeatView = (seatId) => {
|
|
3879
|
+
if (!opts.getSeatView) return null;
|
|
3880
|
+
let p = prefetch.get(seatId);
|
|
3881
|
+
if (!p) {
|
|
3882
|
+
p = Promise.resolve(opts.getSeatView(seatId));
|
|
3883
|
+
prefetch.set(seatId, p);
|
|
3884
|
+
while (prefetch.size > PREFETCH_CAP) {
|
|
3885
|
+
const oldest = prefetch.keys().next().value;
|
|
3886
|
+
if (oldest === void 0) break;
|
|
3887
|
+
prefetch.delete(oldest);
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
return p;
|
|
3891
|
+
};
|
|
3892
|
+
const seatEyeWorld = (idx) => [
|
|
3893
|
+
model.seats.iPosition[idx * 3],
|
|
3894
|
+
model.seats.iPosition[idx * 3 + 1] + SEAT_EYE_ABOVE_DECK,
|
|
3895
|
+
model.seats.iPosition[idx * 3 + 2]
|
|
3896
|
+
];
|
|
3897
|
+
const placeCameraFinal = (finalPos, focal) => {
|
|
3898
|
+
orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
|
|
3899
|
+
orbit.camera.lookAt(new import_ogl7.Vec3(focal[0], focal[1], focal[2]));
|
|
3900
|
+
orbit.camera.fov = FOV_END;
|
|
3901
|
+
orbit.camera.updateProjectionMatrix();
|
|
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);
|
|
3969
|
+
const openPanorama = async (seatId, fadeMs, gen) => {
|
|
3970
|
+
const viewPromise = ensureSeatView(seatId);
|
|
3971
|
+
if (!viewPromise) {
|
|
3972
|
+
orbit.syncFromCamera();
|
|
3973
|
+
return;
|
|
3974
|
+
}
|
|
3975
|
+
frozen = true;
|
|
3976
|
+
loop.stop();
|
|
3977
|
+
let view;
|
|
3978
|
+
try {
|
|
3979
|
+
view = await viewPromise;
|
|
3980
|
+
} catch {
|
|
3981
|
+
if (!disposed && gen === flightGen) {
|
|
3982
|
+
frozen = false;
|
|
3983
|
+
orbit.resumeAfterFlight(model.focalWorld);
|
|
3984
|
+
loop.requestRender();
|
|
3985
|
+
}
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
if (disposed || gen !== flightGen) return;
|
|
3989
|
+
removeArriveChip();
|
|
3990
|
+
panorama = mountPanorama(container, view, {
|
|
3991
|
+
fadeMs,
|
|
3992
|
+
seatLabel: seatId,
|
|
3993
|
+
onClose: () => {
|
|
3994
|
+
panorama = null;
|
|
3995
|
+
frozen = false;
|
|
3996
|
+
analytics.panoramaClosed();
|
|
3997
|
+
orbit.resumeAfterFlight(model.focalWorld);
|
|
3998
|
+
loop.requestRender();
|
|
3999
|
+
showArriveChip(seatId, flightGen);
|
|
4000
|
+
}
|
|
4001
|
+
});
|
|
4002
|
+
analytics.panoramaOpened();
|
|
4003
|
+
};
|
|
4004
|
+
const cancelFlight = () => {
|
|
4005
|
+
flightGen++;
|
|
4006
|
+
if (cinematic.active) {
|
|
4007
|
+
cinematic.cancel();
|
|
4008
|
+
orbit.resumeAfterFlight(model.focalWorld);
|
|
4009
|
+
}
|
|
4010
|
+
};
|
|
4011
|
+
const flyToSeat = (seatId) => {
|
|
4012
|
+
if (disposed || !gpu) return Promise.resolve();
|
|
4013
|
+
const idx = model.seats.idToIndex.get(seatId);
|
|
4014
|
+
if (idx === void 0) return Promise.resolve();
|
|
4015
|
+
if (panorama) {
|
|
4016
|
+
panorama.dispose();
|
|
4017
|
+
panorama = null;
|
|
4018
|
+
}
|
|
4019
|
+
frozen = false;
|
|
4020
|
+
const gen = ++flightGen;
|
|
4021
|
+
removeArriveChip();
|
|
4022
|
+
const seatEye = seatEyeWorld(idx);
|
|
4023
|
+
const focal = model.focalWorld;
|
|
4024
|
+
const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
|
|
4025
|
+
const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
|
|
4026
|
+
if (reducedMotion()) {
|
|
4027
|
+
placeCameraFinal(finalPos, focal);
|
|
4028
|
+
loop.requestRender();
|
|
4029
|
+
analytics.cinematicSkipped();
|
|
4030
|
+
if (!disposed) orbit.syncFromCamera();
|
|
4031
|
+
showArriveChip(seatId, gen);
|
|
4032
|
+
return Promise.resolve();
|
|
4033
|
+
}
|
|
4034
|
+
const startQuat = new import_ogl7.Quat().copy(orbit.camera.quaternion);
|
|
4035
|
+
const endQuat = lookAtQuat(orbit.camera, finalPos, focal);
|
|
4036
|
+
loop.requestRender();
|
|
4037
|
+
return cinematic.start(waypoints, startQuat, endQuat).then(() => {
|
|
4038
|
+
if (disposed || gen !== flightGen) return;
|
|
4039
|
+
analytics.cinematicPlayed(FLIGHT_DURATION_MS);
|
|
4040
|
+
orbit.resumeAfterFlight(model.focalWorld);
|
|
4041
|
+
loop.requestRender();
|
|
4042
|
+
showArriveChip(seatId, gen);
|
|
4043
|
+
});
|
|
4044
|
+
};
|
|
4045
|
+
let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
|
|
4046
|
+
const onDown = (e) => {
|
|
4047
|
+
if (downId !== -1) return;
|
|
4048
|
+
downId = e.pointerId;
|
|
4049
|
+
downX = e.clientX;
|
|
4050
|
+
downY = e.clientY;
|
|
4051
|
+
downT = performance.now();
|
|
4052
|
+
moved = false;
|
|
4053
|
+
suppressTap = cinematic.active;
|
|
4054
|
+
if (cinematic.active) {
|
|
4055
|
+
analytics.cinematicCancelled();
|
|
4056
|
+
cancelFlight();
|
|
4057
|
+
}
|
|
4058
|
+
};
|
|
4059
|
+
const onMove = (e) => {
|
|
4060
|
+
if (e.pointerId !== downId) return;
|
|
4061
|
+
if (Math.hypot(e.clientX - downX, e.clientY - downY) > TAP_SLOP) moved = true;
|
|
4062
|
+
};
|
|
4063
|
+
const onUp = (e) => {
|
|
4064
|
+
if (e.pointerId !== downId) return;
|
|
4065
|
+
const isTap = !moved && performance.now() - downT < TAP_MS;
|
|
4066
|
+
downId = -1;
|
|
4067
|
+
if (suppressTap) {
|
|
4068
|
+
suppressTap = false;
|
|
4069
|
+
return;
|
|
4070
|
+
}
|
|
4071
|
+
if (!isTap || !gpu || !pick) return;
|
|
4072
|
+
pick.syncFromSeatProgram(gpu.seatProgram);
|
|
4073
|
+
const rect = glctx.canvas.getBoundingClientRect();
|
|
4074
|
+
const dpr = glctx.renderer.dpr;
|
|
4075
|
+
const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
|
|
4076
|
+
const radius = Math.max(2, Math.round(8 * dpr));
|
|
4077
|
+
const idx = pick.pick(orbit.camera, x, y, radius);
|
|
4078
|
+
if (idx < 0 || idx >= seatIdByIndex.length) {
|
|
4079
|
+
if (selection.size) setSelection([]);
|
|
4080
|
+
return;
|
|
4081
|
+
}
|
|
4082
|
+
const seatId = seatIdByIndex[idx];
|
|
4083
|
+
if (selection.has(seatId) && selection.size === 1) setSelection([]);
|
|
4084
|
+
else setSelection([seatId]);
|
|
4085
|
+
ensureSeatView(seatId);
|
|
4086
|
+
analytics.seatPicked(seatId, sectionIdBySeatId.get(seatId));
|
|
4087
|
+
opts.onSeatPick?.(seatId);
|
|
4088
|
+
};
|
|
4089
|
+
glctx.canvas.addEventListener("pointerdown", onDown);
|
|
4090
|
+
glctx.canvas.addEventListener("pointermove", onMove);
|
|
4091
|
+
glctx.canvas.addEventListener("pointerup", onUp);
|
|
4092
|
+
glctx.canvas.addEventListener("pointercancel", onUp);
|
|
4093
|
+
loop.requestRender();
|
|
4094
|
+
const handle = {
|
|
4095
|
+
setAvailability(updates) {
|
|
4096
|
+
const passthrough = mergeAvailabilityIntoSelection(selection, updates);
|
|
4097
|
+
const runs = applySeatStates(model.seats, passthrough);
|
|
4098
|
+
if (runs.length && gpu) gpu.uploadSeatStateRuns(runs);
|
|
4099
|
+
loop.requestRender();
|
|
4100
|
+
},
|
|
4101
|
+
setSelection,
|
|
4102
|
+
flyToSeat,
|
|
4103
|
+
resize() {
|
|
4104
|
+
const { width, height } = glctx.resize();
|
|
4105
|
+
orbit.setAspect(width / Math.max(1, height));
|
|
4106
|
+
loop.requestRender();
|
|
4107
|
+
},
|
|
4108
|
+
stats() {
|
|
4109
|
+
return {
|
|
4110
|
+
...loop.stats(),
|
|
4111
|
+
drawCalls: gpu ? gpu.drawCalls : 0,
|
|
4112
|
+
seatCount: model.seatCount
|
|
4113
|
+
};
|
|
4114
|
+
},
|
|
4115
|
+
loseContextForTest() {
|
|
4116
|
+
glctx.simulateContextLossCycle();
|
|
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
|
+
},
|
|
4153
|
+
setReducedMotionForTest(value) {
|
|
4154
|
+
reducedForced = value;
|
|
4155
|
+
},
|
|
4156
|
+
dispose() {
|
|
4157
|
+
disposed = true;
|
|
4158
|
+
removeArriveChip();
|
|
4159
|
+
overviewChip.remove();
|
|
4160
|
+
cancelFlight();
|
|
4161
|
+
loop.stop();
|
|
4162
|
+
labelOverlay.dispose();
|
|
4163
|
+
ro?.disconnect();
|
|
4164
|
+
if (panorama) {
|
|
4165
|
+
panorama.dispose();
|
|
4166
|
+
panorama = null;
|
|
4167
|
+
}
|
|
4168
|
+
glctx.canvas.removeEventListener("pointerdown", onDown);
|
|
4169
|
+
glctx.canvas.removeEventListener("pointermove", onMove);
|
|
4170
|
+
glctx.canvas.removeEventListener("pointerup", onUp);
|
|
4171
|
+
glctx.canvas.removeEventListener("pointercancel", onUp);
|
|
4172
|
+
orbit.dispose();
|
|
4173
|
+
if (pick) pick.dispose();
|
|
4174
|
+
if (gpu) gpu.dispose();
|
|
4175
|
+
gpu = null;
|
|
4176
|
+
pick = null;
|
|
4177
|
+
glctx.dispose();
|
|
4178
|
+
}
|
|
4179
|
+
};
|
|
4180
|
+
analytics.opened(model.seatCount, hasHeights);
|
|
4181
|
+
return handle;
|
|
4182
|
+
}
|
|
4183
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
4184
|
+
0 && (module.exports = {
|
|
4185
|
+
buildSceneModel,
|
|
4186
|
+
mountVenue3D
|
|
4187
|
+
});
|
|
4188
|
+
//# sourceMappingURL=index.cjs.map
|