@seatlayer/core 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-FGS67GT3.js → chunk-MGC5BVAD.js} +156 -33
- package/dist/chunk-MGC5BVAD.js.map +1 -0
- package/dist/index.cjs +209 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -5
- package/dist/index.d.ts +28 -5
- package/dist/index.js +61 -13
- package/dist/index.js.map +1 -1
- package/dist/{types-CG2pEDoI.d.cts → types-DO_J5DtX.d.cts} +27 -1
- package/dist/{types-CG2pEDoI.d.ts → types-DO_J5DtX.d.ts} +27 -1
- package/dist/view3d/index.cjs +1556 -251
- package/dist/view3d/index.cjs.map +1 -1
- package/dist/view3d/index.d.cts +80 -1
- package/dist/view3d/index.d.ts +80 -1
- package/dist/view3d/index.js +1548 -249
- package/dist/view3d/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-FGS67GT3.js.map +0 -1
package/dist/view3d/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
pointInPolygonWithHoles,
|
|
8
8
|
resolveSection,
|
|
9
9
|
sectionGeometry
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-MGC5BVAD.js";
|
|
11
11
|
|
|
12
12
|
// src/view3d/index.ts
|
|
13
13
|
import { Quat as Quat2, Vec3 as Vec33 } from "ogl";
|
|
@@ -28,13 +28,36 @@ var SEAT_STATE_COLORS = {
|
|
|
28
28
|
selected: [0.24, 0.74, 1],
|
|
29
29
|
dimmed: [0.28, 0.32, 0.37]
|
|
30
30
|
};
|
|
31
|
+
function upholsteryTone(rgb) {
|
|
32
|
+
const luma = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
|
33
|
+
const SAT = 0.42;
|
|
34
|
+
const VALUE = 0.62;
|
|
35
|
+
return [
|
|
36
|
+
(luma + (rgb[0] - luma) * SAT) * VALUE,
|
|
37
|
+
(luma + (rgb[1] - luma) * SAT) * VALUE,
|
|
38
|
+
(luma + (rgb[2] - luma) * SAT) * VALUE
|
|
39
|
+
];
|
|
40
|
+
}
|
|
31
41
|
var STRUCTURE = {
|
|
32
42
|
ground: [0.07, 0.085, 0.11],
|
|
33
43
|
tierTop: [0.24, 0.28, 0.34],
|
|
34
44
|
tierWall: [0.17, 0.2, 0.25],
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The lit performance surface.
|
|
47
|
+
*
|
|
48
|
+
* This was [0.42, 0.36, 0.26] and commented "slightly emissive read", which it
|
|
49
|
+
* was not — under the scene's rig it resolved to a mid-brown slab, and from a
|
|
50
|
+
* seat the stage was the DIMMEST large surface in a dark hall. That is exactly
|
|
51
|
+
* backwards: the stage is the one place in a venue with light pointed at it,
|
|
52
|
+
* and it is what a buyer's eye should land on when they arrive at their seat.
|
|
53
|
+
*
|
|
54
|
+
* Bright and warm enough to hold that role against the surrounding structure,
|
|
55
|
+
* which sits around 0.24. An authored `fill` still tints it (see `buildShape`),
|
|
56
|
+
* so an organizer's own stage colour survives — it is just no longer lit like
|
|
57
|
+
* a basement.
|
|
58
|
+
*/
|
|
59
|
+
stageTop: [0.86, 0.64, 0.36],
|
|
60
|
+
stageWall: [0.34, 0.26, 0.18],
|
|
38
61
|
decorTop: [0.22, 0.25, 0.29],
|
|
39
62
|
decorWall: [0.15, 0.17, 0.2],
|
|
40
63
|
gaTop: [0.24, 0.28, 0.33],
|
|
@@ -183,10 +206,18 @@ var OrbitCamera = class {
|
|
|
183
206
|
this.maxDist = 100;
|
|
184
207
|
this.gestureFired = false;
|
|
185
208
|
this.dragging = false;
|
|
209
|
+
this.panning = false;
|
|
186
210
|
this.lastX = 0;
|
|
187
211
|
this.lastY = 0;
|
|
188
212
|
this.activePointers = /* @__PURE__ */ new Map();
|
|
189
213
|
this.pinchDist = 0;
|
|
214
|
+
this.pinchCx = 0;
|
|
215
|
+
this.pinchCy = 0;
|
|
216
|
+
/** Damped pan pivot. `target` chases this the way azimuth chases `azT`. */
|
|
217
|
+
this.targetT = new Vec3();
|
|
218
|
+
/** Venue centre + radius, so pan can be clamped to somewhere still useful. */
|
|
219
|
+
this.panAnchor = new Vec3();
|
|
220
|
+
this.panLimit = 0;
|
|
190
221
|
this.camera = new Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
|
|
191
222
|
this.canvas = canvas;
|
|
192
223
|
this.requestRender = requestRender;
|
|
@@ -198,12 +229,17 @@ var OrbitCamera = class {
|
|
|
198
229
|
}
|
|
199
230
|
this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
200
231
|
if (this.activePointers.size === 1) {
|
|
201
|
-
this.
|
|
232
|
+
this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
|
|
233
|
+
this.dragging = !this.panning;
|
|
202
234
|
this.lastX = e.clientX;
|
|
203
235
|
this.lastY = e.clientY;
|
|
204
236
|
} else if (this.activePointers.size === 2) {
|
|
205
237
|
this.dragging = false;
|
|
238
|
+
this.panning = false;
|
|
206
239
|
this.pinchDist = this.currentPinchDistance();
|
|
240
|
+
const c = this.pinchCentroid();
|
|
241
|
+
this.pinchCx = c.x;
|
|
242
|
+
this.pinchCy = c.y;
|
|
207
243
|
}
|
|
208
244
|
};
|
|
209
245
|
this.onPointerMove = (e) => {
|
|
@@ -216,11 +252,24 @@ var OrbitCamera = class {
|
|
|
216
252
|
this.fireGesture();
|
|
217
253
|
}
|
|
218
254
|
this.pinchDist = d;
|
|
255
|
+
const c = this.pinchCentroid();
|
|
256
|
+
this.panBy(c.x - this.pinchCx, c.y - this.pinchCy);
|
|
257
|
+
this.pinchCx = c.x;
|
|
258
|
+
this.pinchCy = c.y;
|
|
219
259
|
return;
|
|
220
260
|
}
|
|
221
|
-
if (!this.dragging) return;
|
|
222
261
|
const dx = e.clientX - this.lastX;
|
|
223
262
|
const dy = e.clientY - this.lastY;
|
|
263
|
+
if (this.panning) {
|
|
264
|
+
this.lastX = e.clientX;
|
|
265
|
+
this.lastY = e.clientY;
|
|
266
|
+
if (dx !== 0 || dy !== 0) {
|
|
267
|
+
this.panBy(dx, dy);
|
|
268
|
+
this.fireGesture();
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!this.dragging) return;
|
|
224
273
|
this.lastX = e.clientX;
|
|
225
274
|
this.lastY = e.clientY;
|
|
226
275
|
if (dx !== 0 || dy !== 0) this.fireGesture();
|
|
@@ -235,7 +284,13 @@ var OrbitCamera = class {
|
|
|
235
284
|
} catch {
|
|
236
285
|
}
|
|
237
286
|
if (this.activePointers.size < 2) this.pinchDist = 0;
|
|
238
|
-
if (this.activePointers.size === 0)
|
|
287
|
+
if (this.activePointers.size === 0) {
|
|
288
|
+
this.dragging = false;
|
|
289
|
+
this.panning = false;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
this.onContextMenu = (e) => {
|
|
293
|
+
e.preventDefault();
|
|
239
294
|
};
|
|
240
295
|
this.onWheel = (e) => {
|
|
241
296
|
e.preventDefault();
|
|
@@ -249,6 +304,55 @@ var OrbitCamera = class {
|
|
|
249
304
|
canvas.addEventListener("pointerup", this.onPointerUp);
|
|
250
305
|
canvas.addEventListener("pointercancel", this.onPointerUp);
|
|
251
306
|
canvas.addEventListener("wheel", this.onWheel, { passive: false });
|
|
307
|
+
canvas.addEventListener("contextmenu", this.onContextMenu);
|
|
308
|
+
}
|
|
309
|
+
pinchCentroid() {
|
|
310
|
+
const pts = [...this.activePointers.values()];
|
|
311
|
+
if (!pts.length) return { x: 0, y: 0 };
|
|
312
|
+
let x = 0;
|
|
313
|
+
let y = 0;
|
|
314
|
+
for (const p of pts) {
|
|
315
|
+
x += p.x;
|
|
316
|
+
y += p.y;
|
|
317
|
+
}
|
|
318
|
+
return { x: x / pts.length, y: y / pts.length };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Slide the orbit pivot across the camera's own screen plane.
|
|
322
|
+
*
|
|
323
|
+
* Scaled by distance and FOV so a pixel of drag moves the same amount of VENUE
|
|
324
|
+
* under the cursor whatever the zoom: at the overview a drag sweeps the whole
|
|
325
|
+
* bowl, and pushed in among the seats it nudges. A fixed world-units-per-pixel
|
|
326
|
+
* would be unusable at one end or the other.
|
|
327
|
+
*/
|
|
328
|
+
panBy(dxPx, dyPx) {
|
|
329
|
+
const h = this.canvas.clientHeight || 1;
|
|
330
|
+
const perPx = 2 * this.distance * Math.tan(this.fovY * DEG / 2) / h;
|
|
331
|
+
const sinA = Math.sin(this.azimuth);
|
|
332
|
+
const cosA = Math.cos(this.azimuth);
|
|
333
|
+
const rightX = cosA;
|
|
334
|
+
const rightZ = -sinA;
|
|
335
|
+
const cp = Math.cos(this.polar);
|
|
336
|
+
const sp = Math.sin(this.polar);
|
|
337
|
+
const fwdX = -sinA * cp;
|
|
338
|
+
const fwdZ = -cosA * cp;
|
|
339
|
+
this.targetT.x += (-dxPx * rightX + dyPx * fwdX) * perPx;
|
|
340
|
+
this.targetT.z += (-dxPx * rightZ + dyPx * fwdZ) * perPx;
|
|
341
|
+
this.targetT.y += dyPx * sp * perPx;
|
|
342
|
+
this.clampPan();
|
|
343
|
+
this.requestRender();
|
|
344
|
+
}
|
|
345
|
+
/** Keep the pivot within a bounds-derived box so a stray drag cannot lose the
|
|
346
|
+
* venue entirely — the Overview chip should never be the only way back. */
|
|
347
|
+
clampPan() {
|
|
348
|
+
if (this.panLimit <= 0) return;
|
|
349
|
+
const lim = this.panLimit;
|
|
350
|
+
const cx = this.panAnchor.x;
|
|
351
|
+
const cy = this.panAnchor.y;
|
|
352
|
+
const cz = this.panAnchor.z;
|
|
353
|
+
this.targetT.x = Math.max(cx - lim, Math.min(cx + lim, this.targetT.x));
|
|
354
|
+
this.targetT.z = Math.max(cz - lim, Math.min(cz + lim, this.targetT.z));
|
|
355
|
+
this.targetT.y = Math.max(cy - lim * 0.5, Math.min(cy + lim, this.targetT.y));
|
|
252
356
|
}
|
|
253
357
|
/** One-shot: notify the first real user gesture (drives 3d_orbit_engaged). */
|
|
254
358
|
fireGesture() {
|
|
@@ -275,6 +379,9 @@ var OrbitCamera = class {
|
|
|
275
379
|
*/
|
|
276
380
|
frame(bounds, intro = false, stageAzimuth) {
|
|
277
381
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
382
|
+
this.targetT.copy(this.target);
|
|
383
|
+
this.panAnchor.copy(this.target);
|
|
384
|
+
this.panLimit = Math.max(1, bounds.radius) * 1.5;
|
|
278
385
|
const r = Math.max(1, bounds.radius);
|
|
279
386
|
const halfV = this.fovY * DEG / 2;
|
|
280
387
|
const aspect = this.camera.aspect || 1;
|
|
@@ -306,6 +413,9 @@ var OrbitCamera = class {
|
|
|
306
413
|
*/
|
|
307
414
|
frameSoft(bounds, stageAzimuth) {
|
|
308
415
|
this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
|
|
416
|
+
this.targetT.copy(this.target);
|
|
417
|
+
this.panAnchor.copy(this.target);
|
|
418
|
+
this.panLimit = Math.max(1, bounds.radius) * 1.5;
|
|
309
419
|
this.syncFromCamera();
|
|
310
420
|
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
311
421
|
const r = Math.max(1, bounds.radius);
|
|
@@ -322,10 +432,17 @@ var OrbitCamera = class {
|
|
|
322
432
|
const da = this.azT - this.azimuth;
|
|
323
433
|
const dp = this.polT - this.polar;
|
|
324
434
|
const dd = this.distT - this.distance;
|
|
325
|
-
const
|
|
435
|
+
const tx = this.targetT.x - this.target.x;
|
|
436
|
+
const ty = this.targetT.y - this.target.y;
|
|
437
|
+
const tz = this.targetT.z - this.target.z;
|
|
438
|
+
const panEps = Math.max(1e-4, this.distance * 1e-4);
|
|
439
|
+
const moving = Math.abs(da) > 1e-4 || Math.abs(dp) > 1e-4 || Math.abs(dd) > 1e-4 || Math.abs(tx) > panEps || Math.abs(ty) > panEps || Math.abs(tz) > panEps;
|
|
326
440
|
this.azimuth += da * DAMP;
|
|
327
441
|
this.polar += dp * DAMP;
|
|
328
442
|
this.distance += dd * DAMP;
|
|
443
|
+
this.target.x += tx * DAMP;
|
|
444
|
+
this.target.y += ty * DAMP;
|
|
445
|
+
this.target.z += tz * DAMP;
|
|
329
446
|
if (moving) this.applyPosition();
|
|
330
447
|
return moving;
|
|
331
448
|
}
|
|
@@ -351,6 +468,7 @@ var OrbitCamera = class {
|
|
|
351
468
|
/** Point the orbit pivot at a new world target without moving the camera. */
|
|
352
469
|
setTarget(target) {
|
|
353
470
|
this.target.set(target[0], target[1], target[2]);
|
|
471
|
+
this.targetT.copy(this.target);
|
|
354
472
|
}
|
|
355
473
|
/** Restore the base FOV (a flight ends pushed-in) and re-sync orbit state. A
|
|
356
474
|
* flight ends looking at `target` (the venue focal), so re-pivot there first —
|
|
@@ -358,6 +476,7 @@ var OrbitCamera = class {
|
|
|
358
476
|
resumeAfterFlight(target) {
|
|
359
477
|
this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
|
|
360
478
|
if (target) this.target.set(target[0], target[1], target[2]);
|
|
479
|
+
this.targetT.copy(this.target);
|
|
361
480
|
this.syncFromCamera();
|
|
362
481
|
}
|
|
363
482
|
applyPosition() {
|
|
@@ -374,6 +493,7 @@ var OrbitCamera = class {
|
|
|
374
493
|
this.canvas.removeEventListener("pointerup", this.onPointerUp);
|
|
375
494
|
this.canvas.removeEventListener("pointercancel", this.onPointerUp);
|
|
376
495
|
this.canvas.removeEventListener("wheel", this.onWheel);
|
|
496
|
+
this.canvas.removeEventListener("contextmenu", this.onContextMenu);
|
|
377
497
|
this.activePointers.clear();
|
|
378
498
|
}
|
|
379
499
|
};
|
|
@@ -422,17 +542,129 @@ var RenderLoop = class {
|
|
|
422
542
|
};
|
|
423
543
|
|
|
424
544
|
// src/view3d/lod.ts
|
|
545
|
+
var SEAT_MIN_PIXELS_NEAR = 2.5;
|
|
546
|
+
var SEAT_MIN_PIXELS_FAR = 1.15;
|
|
547
|
+
var CHAIR_FULL_M = 12;
|
|
548
|
+
var CHAIR_NONE_M = 22;
|
|
549
|
+
var CHAIR_GATHER_M = 30;
|
|
550
|
+
var CHAIR_REBUILD_M = 4;
|
|
551
|
+
var CHAIR_MAX_INSTANCES = 8192;
|
|
552
|
+
var CHAIR_REQUIRED_COVER_M = CHAIR_NONE_M + CHAIR_REBUILD_M;
|
|
425
553
|
function computeSeatLod(distance, radius) {
|
|
426
554
|
const near = radius * 1.4;
|
|
427
555
|
const far = radius * 3.2;
|
|
428
|
-
if (distance <= near) return { scale: 1, fade: 0 };
|
|
556
|
+
if (distance <= near) return { scale: 1, fade: 0, minPixels: SEAT_MIN_PIXELS_NEAR };
|
|
429
557
|
const t = Math.min(1, (distance - near) / Math.max(1e-3, far - near));
|
|
430
558
|
return {
|
|
431
559
|
scale: 1 - t * 0.4,
|
|
432
|
-
fade: t * 0.55
|
|
560
|
+
fade: t * 0.55,
|
|
561
|
+
// Tapered on the same ramp as the fade: as the block starts reading by its
|
|
562
|
+
// tier tint, the dots stop fighting each other for pixels.
|
|
563
|
+
minPixels: SEAT_MIN_PIXELS_NEAR + t * (SEAT_MIN_PIXELS_FAR - SEAT_MIN_PIXELS_NEAR)
|
|
433
564
|
};
|
|
434
565
|
}
|
|
435
566
|
|
|
567
|
+
// src/view3d/scene/nearField.ts
|
|
568
|
+
var SEATS_PER_CELL = 4;
|
|
569
|
+
var NearFieldIndex = class {
|
|
570
|
+
constructor(iPosition, count) {
|
|
571
|
+
this.minX = 0;
|
|
572
|
+
this.minZ = 0;
|
|
573
|
+
this.cell = 1;
|
|
574
|
+
this.cols = 1;
|
|
575
|
+
this.rows = 1;
|
|
576
|
+
/** CSR-style buckets: `cellStart[c]…cellStart[c+1]` indexes into `cellItems`. */
|
|
577
|
+
this.cellStart = null;
|
|
578
|
+
this.cellItems = null;
|
|
579
|
+
this.iPosition = iPosition;
|
|
580
|
+
this.count = count;
|
|
581
|
+
}
|
|
582
|
+
/** Built on demand; safe to call repeatedly. */
|
|
583
|
+
ensureGrid() {
|
|
584
|
+
if (this.cellStart || this.count === 0) return;
|
|
585
|
+
const p = this.iPosition;
|
|
586
|
+
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
|
587
|
+
for (let i = 0; i < this.count; i++) {
|
|
588
|
+
const x = p[i * 3], z = p[i * 3 + 2];
|
|
589
|
+
if (x < minX) minX = x;
|
|
590
|
+
if (x > maxX) maxX = x;
|
|
591
|
+
if (z < minZ) minZ = z;
|
|
592
|
+
if (z > maxZ) maxZ = z;
|
|
593
|
+
}
|
|
594
|
+
const w = Math.max(maxX - minX, 1e-3);
|
|
595
|
+
const h = Math.max(maxZ - minZ, 1e-3);
|
|
596
|
+
this.cell = Math.max(Math.sqrt(w * h * SEATS_PER_CELL / this.count), 0.25);
|
|
597
|
+
this.cols = Math.max(1, Math.ceil(w / this.cell) + 1);
|
|
598
|
+
this.rows = Math.max(1, Math.ceil(h / this.cell) + 1);
|
|
599
|
+
this.minX = minX;
|
|
600
|
+
this.minZ = minZ;
|
|
601
|
+
const nCells = this.cols * this.rows;
|
|
602
|
+
const start = new Int32Array(nCells + 1);
|
|
603
|
+
const cellOf = (i) => {
|
|
604
|
+
const cx = Math.min(this.cols - 1, Math.max(0, Math.floor((p[i * 3] - minX) / this.cell)));
|
|
605
|
+
const cz = Math.min(this.rows - 1, Math.max(0, Math.floor((p[i * 3 + 2] - minZ) / this.cell)));
|
|
606
|
+
return cz * this.cols + cx;
|
|
607
|
+
};
|
|
608
|
+
for (let i = 0; i < this.count; i++) start[cellOf(i) + 1]++;
|
|
609
|
+
for (let c = 0; c < nCells; c++) start[c + 1] += start[c];
|
|
610
|
+
const items = new Int32Array(this.count);
|
|
611
|
+
const cursor = start.slice(0, nCells);
|
|
612
|
+
for (let i = 0; i < this.count; i++) items[cursor[cellOf(i)]++] = i;
|
|
613
|
+
this.cellStart = start;
|
|
614
|
+
this.cellItems = items;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Fill `out` with the indices of seats within `radius` metres of (camX, camZ),
|
|
618
|
+
* nearest cell-ring first, and return how many were written.
|
|
619
|
+
*
|
|
620
|
+
* Ring order is what makes the CHAIR_MAX_INSTANCES cap harmless: when the cap
|
|
621
|
+
* bites it drops the OUTERMOST seats, which are the ones already past the fade
|
|
622
|
+
* band and drawing nothing. A cap that truncated in index order would instead
|
|
623
|
+
* punch holes in the row you are sitting in.
|
|
624
|
+
*
|
|
625
|
+
* Note this is a horizontal (XZ) query and ignores height. A stacked venue's
|
|
626
|
+
* upper tier is therefore gathered along with the stalls beneath it — which is
|
|
627
|
+
* correct, because the fade weight is re-derived from true view depth in the
|
|
628
|
+
* shader anyway. The grid's only job is to bound the candidate set.
|
|
629
|
+
*/
|
|
630
|
+
gather(camX, camZ, radius, out) {
|
|
631
|
+
this.ensureGrid();
|
|
632
|
+
const start = this.cellStart;
|
|
633
|
+
const items = this.cellItems;
|
|
634
|
+
if (!start || !items) return 0;
|
|
635
|
+
const cap = out.length;
|
|
636
|
+
const r2 = radius * radius;
|
|
637
|
+
const cx = Math.floor((camX - this.minX) / this.cell);
|
|
638
|
+
const cz = Math.floor((camZ - this.minZ) / this.cell);
|
|
639
|
+
const maxRing = Math.ceil(radius / this.cell) + 1;
|
|
640
|
+
const p = this.iPosition;
|
|
641
|
+
let n = 0;
|
|
642
|
+
for (let ring = 0; ring <= maxRing && n < cap; ring++) {
|
|
643
|
+
const z0 = cz - ring, z1 = cz + ring;
|
|
644
|
+
const x0 = cx - ring, x1 = cx + ring;
|
|
645
|
+
for (let gz = z0; gz <= z1 && n < cap; gz++) {
|
|
646
|
+
if (gz < 0 || gz >= this.rows) continue;
|
|
647
|
+
const edge = gz === z0 || gz === z1;
|
|
648
|
+
for (let gx = x0; gx <= x1 && n < cap; gx++) {
|
|
649
|
+
if (!edge && gx !== x0 && gx !== x1) {
|
|
650
|
+
gx = x1 - 1;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (gx < 0 || gx >= this.cols) continue;
|
|
654
|
+
const c = gz * this.cols + gx;
|
|
655
|
+
for (let k = start[c], e = start[c + 1]; k < e && n < cap; k++) {
|
|
656
|
+
const i = items[k];
|
|
657
|
+
const dx = p[i * 3] - camX;
|
|
658
|
+
const dz = p[i * 3 + 2] - camZ;
|
|
659
|
+
if (dx * dx + dz * dz <= r2) out[n++] = i;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return n;
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
436
668
|
// src/view3d/scene/geometry.ts
|
|
437
669
|
import earcut from "earcut";
|
|
438
670
|
import polygonClipping from "polygon-clipping";
|
|
@@ -859,8 +1091,195 @@ function rectPolygon(x, y, w, h) {
|
|
|
859
1091
|
];
|
|
860
1092
|
}
|
|
861
1093
|
|
|
1094
|
+
// src/view3d/scene/seatChair.ts
|
|
1095
|
+
var CHAIR_PART = { pedestal: 0, pad: 1, back: 2, body: 3, head: 4 };
|
|
1096
|
+
var CHAIR_PART_OCCUPANT_MIN = CHAIR_PART.body;
|
|
1097
|
+
var CHAIR_PITCH_FRACTION = 0.44;
|
|
1098
|
+
var CHAIR_HALF_WIDTH_MIN_M = 0.15;
|
|
1099
|
+
var CHAIR_HALF_WIDTH_MAX_M = 0.3;
|
|
1100
|
+
var CHAIR_HALF_WIDTH_DEFAULT_M = 0.24;
|
|
1101
|
+
function chairHalfWidth(pitchM) {
|
|
1102
|
+
if (pitchM === void 0 || !Number.isFinite(pitchM) || pitchM <= 0) {
|
|
1103
|
+
return CHAIR_HALF_WIDTH_DEFAULT_M;
|
|
1104
|
+
}
|
|
1105
|
+
return Math.min(CHAIR_HALF_WIDTH_MAX_M, Math.max(CHAIR_HALF_WIDTH_MIN_M, pitchM * CHAIR_PITCH_FRACTION));
|
|
1106
|
+
}
|
|
1107
|
+
var BACK_RAKE_SLOPE = 0.21;
|
|
1108
|
+
var PAD_BACK_GAP_M = 0.05;
|
|
1109
|
+
var PAD_TOP_M = 0.45;
|
|
1110
|
+
var BACK_BASE_M = PAD_TOP_M + PAD_BACK_GAP_M;
|
|
1111
|
+
var BOXES = [
|
|
1112
|
+
// Pedestal — a plain column under the pad. Without it the pad floats 0.36 m
|
|
1113
|
+
// over the deck and the row reads as hovering trays.
|
|
1114
|
+
{ part: CHAIR_PART.pedestal, min: [-0.3, 0, -0.3], max: [0.3, 0.36, 0.3] },
|
|
1115
|
+
// Seat pad — a full seat width across and about as deep, which is what a real
|
|
1116
|
+
// one is. Its depth is bounded by the same pitch as its width, because the
|
|
1117
|
+
// pitch measure is the tighter of the in-row and row-to-row spacings, so a
|
|
1118
|
+
// tightly-raked tier cannot drive a pad into the back of the row in front.
|
|
1119
|
+
{ part: CHAIR_PART.pad, min: [-1, 0.36, -0.95], max: [1, PAD_TOP_M, 1] },
|
|
1120
|
+
// Back panel — thin, raked, and the tallest thing in the row, so it is what
|
|
1121
|
+
// carries the state colour when you look along a row from behind.
|
|
1122
|
+
{
|
|
1123
|
+
part: CHAIR_PART.back,
|
|
1124
|
+
min: [-1, BACK_BASE_M, -1],
|
|
1125
|
+
max: [1, 0.92, -0.72]
|
|
1126
|
+
},
|
|
1127
|
+
// --- the occupant ---------------------------------------------------------
|
|
1128
|
+
//
|
|
1129
|
+
// A hall with every seat empty reads as an architectural model, not a venue.
|
|
1130
|
+
// The 2048-px panorama this replaced drew a crowd; losing it was the price of
|
|
1131
|
+
// sharpness, and this is how it is bought back — in geometry, where there is
|
|
1132
|
+
// no resolution ceiling.
|
|
1133
|
+
//
|
|
1134
|
+
// Deliberately two blocks and no limbs. At the range these are visible a
|
|
1135
|
+
// person is a torso and a head, and every extra part multiplies by the number
|
|
1136
|
+
// of occupied seats in view. The silhouette is what carries it, exactly as it
|
|
1137
|
+
// does in the generated panorama's head-and-shoulder figures.
|
|
1138
|
+
//
|
|
1139
|
+
// Sized as a seated adult against the 0.92 m chair back: hips at the pad top,
|
|
1140
|
+
// shoulders just above the back panel, head clear of it. Torso is narrower
|
|
1141
|
+
// than the chair so neighbours never interpenetrate at any pitch, and it sits
|
|
1142
|
+
// forward of the back panel rather than inside it.
|
|
1143
|
+
{
|
|
1144
|
+
part: CHAIR_PART.body,
|
|
1145
|
+
min: [-0.66, PAD_TOP_M, -0.58],
|
|
1146
|
+
max: [0.66, 1, 0.26]
|
|
1147
|
+
},
|
|
1148
|
+
// The head is deliberately SMALL. Sized by eye against the chair it came out
|
|
1149
|
+
// near-cubic and read as Lego; a real head is about 0.16 m across and 0.22 m
|
|
1150
|
+
// tall, which against a 0.24 m chair half-width is roughly a third of the
|
|
1151
|
+
// chair's width and clearly taller than it is wide. Getting this ratio wrong
|
|
1152
|
+
// is what makes a crowd look like toys rather than people.
|
|
1153
|
+
{
|
|
1154
|
+
part: CHAIR_PART.head,
|
|
1155
|
+
min: [-0.34, 1.03, -0.4],
|
|
1156
|
+
max: [0.34, 1.27, 0.02]
|
|
1157
|
+
}
|
|
1158
|
+
];
|
|
1159
|
+
var FACES = [
|
|
1160
|
+
// +X
|
|
1161
|
+
{ n: [1, 0, 0], c: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
|
|
1162
|
+
// -X
|
|
1163
|
+
{ n: [-1, 0, 0], c: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
|
|
1164
|
+
// +Y
|
|
1165
|
+
{ n: [0, 1, 0], c: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
|
|
1166
|
+
// -Y
|
|
1167
|
+
{ n: [0, -1, 0], c: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
|
|
1168
|
+
// +Z
|
|
1169
|
+
{ n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
|
|
1170
|
+
// -Z
|
|
1171
|
+
{ n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
|
|
1172
|
+
];
|
|
1173
|
+
function buildChairMesh() {
|
|
1174
|
+
const vertexCount = BOXES.length * FACES.length * 4;
|
|
1175
|
+
const indexCount = BOXES.length * FACES.length * 6;
|
|
1176
|
+
const position = new Float32Array(vertexCount * 3);
|
|
1177
|
+
const normal = new Float32Array(vertexCount * 3);
|
|
1178
|
+
const part = new Float32Array(vertexCount);
|
|
1179
|
+
const index = new Uint16Array(indexCount);
|
|
1180
|
+
let v = 0;
|
|
1181
|
+
let t = 0;
|
|
1182
|
+
for (const box of BOXES) {
|
|
1183
|
+
for (const face of FACES) {
|
|
1184
|
+
const base = v;
|
|
1185
|
+
const corner3 = new Float32Array(12);
|
|
1186
|
+
for (let ci = 0; ci < 4; ci++) {
|
|
1187
|
+
const corner = face.c[ci];
|
|
1188
|
+
for (let a = 0; a < 3; a++) {
|
|
1189
|
+
corner3[ci * 3 + a] = corner[a] ? box.max[a] : box.min[a];
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
const ax = corner3[3] - corner3[0], ay = corner3[4] - corner3[1], az = corner3[5] - corner3[2];
|
|
1193
|
+
const bx = corner3[6] - corner3[0], by = corner3[7] - corner3[1], bz = corner3[8] - corner3[2];
|
|
1194
|
+
let nx = ay * bz - az * by;
|
|
1195
|
+
let ny = az * bx - ax * bz;
|
|
1196
|
+
let nz = ax * by - ay * bx;
|
|
1197
|
+
const nl = Math.hypot(nx, ny, nz);
|
|
1198
|
+
if (nl > 1e-9) {
|
|
1199
|
+
nx /= nl;
|
|
1200
|
+
ny /= nl;
|
|
1201
|
+
nz /= nl;
|
|
1202
|
+
} else {
|
|
1203
|
+
nx = face.n[0];
|
|
1204
|
+
ny = face.n[1];
|
|
1205
|
+
nz = face.n[2];
|
|
1206
|
+
}
|
|
1207
|
+
if (nx * face.n[0] + ny * face.n[1] + nz * face.n[2] < 0) {
|
|
1208
|
+
nx = -nx;
|
|
1209
|
+
ny = -ny;
|
|
1210
|
+
nz = -nz;
|
|
1211
|
+
}
|
|
1212
|
+
for (let ci = 0; ci < 4; ci++) {
|
|
1213
|
+
position[v * 3] = corner3[ci * 3];
|
|
1214
|
+
position[v * 3 + 1] = corner3[ci * 3 + 1];
|
|
1215
|
+
position[v * 3 + 2] = corner3[ci * 3 + 2];
|
|
1216
|
+
normal[v * 3] = nx;
|
|
1217
|
+
normal[v * 3 + 1] = ny;
|
|
1218
|
+
normal[v * 3 + 2] = nz;
|
|
1219
|
+
part[v] = box.part;
|
|
1220
|
+
v++;
|
|
1221
|
+
}
|
|
1222
|
+
index[t++] = base;
|
|
1223
|
+
index[t++] = base + 1;
|
|
1224
|
+
index[t++] = base + 2;
|
|
1225
|
+
index[t++] = base;
|
|
1226
|
+
index[t++] = base + 2;
|
|
1227
|
+
index[t++] = base + 3;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return { position, normal, part, index, vertexCount, indexCount };
|
|
1231
|
+
}
|
|
1232
|
+
function computeSeatYaw(iPosition, count, rowIdAt, focal) {
|
|
1233
|
+
const yaw = new Float32Array(count);
|
|
1234
|
+
const px = (i) => iPosition[i * 3];
|
|
1235
|
+
const pz = (i) => iPosition[i * 3 + 2];
|
|
1236
|
+
let runStart = 0;
|
|
1237
|
+
const flushRun = (start, end) => {
|
|
1238
|
+
const n = end - start;
|
|
1239
|
+
for (let i = start; i < end; i++) {
|
|
1240
|
+
const [fx, fz] = focal(i);
|
|
1241
|
+
let dx = fx - px(i);
|
|
1242
|
+
let dz = fz - pz(i);
|
|
1243
|
+
if (n >= 2) {
|
|
1244
|
+
const a = Math.max(start, i - 1);
|
|
1245
|
+
const b = Math.min(end - 1, i + 1);
|
|
1246
|
+
const tx = px(b) - px(a);
|
|
1247
|
+
const tz = pz(b) - pz(a);
|
|
1248
|
+
const tl = Math.hypot(tx, tz);
|
|
1249
|
+
if (tl > 1e-6) {
|
|
1250
|
+
let nx = -tz / tl;
|
|
1251
|
+
let nz = tx / tl;
|
|
1252
|
+
if (nx * dx + nz * dz < 0) {
|
|
1253
|
+
nx = -nx;
|
|
1254
|
+
nz = -nz;
|
|
1255
|
+
}
|
|
1256
|
+
dx = nx;
|
|
1257
|
+
dz = nz;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
yaw[i] = dx === 0 && dz === 0 ? 0 : Math.atan2(dx, dz);
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
for (let i = 1; i <= count; i++) {
|
|
1264
|
+
if (i === count || rowIdAt(i) !== rowIdAt(runStart)) {
|
|
1265
|
+
flushRun(runStart, i);
|
|
1266
|
+
runStart = i;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return yaw;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
862
1272
|
// src/view3d/scene/seatInstances.ts
|
|
863
1273
|
var SEAT_DOT_RADIUS_M = 0.22;
|
|
1274
|
+
function seatOccupantSeed(stateIndex, seatIndex) {
|
|
1275
|
+
const state = SEAT_STATES[stateIndex];
|
|
1276
|
+
if (state !== "sold" && state !== "held") return -1;
|
|
1277
|
+
let h = (seatIndex + 1) * 2654435761;
|
|
1278
|
+
h ^= h >>> 15;
|
|
1279
|
+
h = Math.imul(h, 2246822519);
|
|
1280
|
+
h ^= h >>> 13;
|
|
1281
|
+
return (h >>> 0) % 1e5 / 1e5;
|
|
1282
|
+
}
|
|
864
1283
|
var SEAT_PITCH_FRACTION = 0.42;
|
|
865
1284
|
function nearestNeighbourSpacing(seats) {
|
|
866
1285
|
const n = seats.length;
|
|
@@ -923,11 +1342,13 @@ function seatSurfaceY(seat) {
|
|
|
923
1342
|
if (Number.isFinite(eye)) return Math.max(0, eye - SEATED_EYE_HEIGHT_M);
|
|
924
1343
|
return 0;
|
|
925
1344
|
}
|
|
926
|
-
function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
1345
|
+
function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor) {
|
|
927
1346
|
const count = seats.length;
|
|
928
1347
|
const iPosition = new Float32Array(count * 3);
|
|
929
1348
|
const iState = new Float32Array(count);
|
|
1349
|
+
const iCategory = new Float32Array(count * 3);
|
|
930
1350
|
const iMaxRadius = new Float32Array(count);
|
|
1351
|
+
const iChairWidth = new Float32Array(count);
|
|
931
1352
|
const iRing = new Float32Array(count * 3);
|
|
932
1353
|
const idToIndex = /* @__PURE__ */ new Map();
|
|
933
1354
|
const spacing = nearestNeighbourSpacing(seats);
|
|
@@ -936,10 +1357,15 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
936
1357
|
const resolved = surfaces?.seatPitchU(i);
|
|
937
1358
|
const pitchM = (resolved ?? spacing[i]) * M;
|
|
938
1359
|
iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
|
|
1360
|
+
iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
|
|
939
1361
|
iPosition[i * 3] = seat.x * M;
|
|
940
1362
|
iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
|
|
941
1363
|
iPosition[i * 3 + 2] = seat.y * M;
|
|
942
1364
|
iState[i] = seatStateIndex(initial ? initial(seat) : "available");
|
|
1365
|
+
const cat = hexToRgb(categoryColor?.get(seat.categoryKey) ?? "#6e7bff") ?? [0.43, 0.48, 1];
|
|
1366
|
+
iCategory[i * 3] = cat[0];
|
|
1367
|
+
iCategory[i * 3 + 1] = cat[1];
|
|
1368
|
+
iCategory[i * 3 + 2] = cat[2];
|
|
943
1369
|
if (seat.accessibility?.length) {
|
|
944
1370
|
const rgb = hexToRgb(accessibilityRingColor(seat.accessibility));
|
|
945
1371
|
if (rgb) {
|
|
@@ -954,10 +1380,13 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor) {
|
|
|
954
1380
|
count,
|
|
955
1381
|
iPosition,
|
|
956
1382
|
iState,
|
|
1383
|
+
iCategory,
|
|
957
1384
|
iMaxRadius,
|
|
958
1385
|
iRing,
|
|
959
1386
|
idToIndex,
|
|
960
|
-
|
|
1387
|
+
iChairWidth,
|
|
1388
|
+
iFloor: seatFloor ?? new Float32Array(count),
|
|
1389
|
+
iYaw: new Float32Array(count)
|
|
961
1390
|
};
|
|
962
1391
|
}
|
|
963
1392
|
var RUN_MERGE_GAP = 64;
|
|
@@ -1092,6 +1521,28 @@ function cullOverlapping(items, separationX, separationY = separationX) {
|
|
|
1092
1521
|
}
|
|
1093
1522
|
return kept;
|
|
1094
1523
|
}
|
|
1524
|
+
var DENSE_LABEL_BUDGET = { row: 8, seat: 14 };
|
|
1525
|
+
function focusScore(screen, worldDistance, width, height) {
|
|
1526
|
+
const half = Math.max(1, Math.min(width, height) * 0.5);
|
|
1527
|
+
const off = Math.min(2, Math.hypot(screen.x - width / 2, screen.y - height / 2) / half);
|
|
1528
|
+
return worldDistance * (1 + 1.5 * off);
|
|
1529
|
+
}
|
|
1530
|
+
function pickDenseLabels(items, separationX, separationY, budget) {
|
|
1531
|
+
const ordered = [...items].sort((a, b) => a.focus - b.focus);
|
|
1532
|
+
const kept = [];
|
|
1533
|
+
for (const item of ordered) {
|
|
1534
|
+
if (kept.length >= budget) break;
|
|
1535
|
+
let clash = false;
|
|
1536
|
+
for (const k of kept) {
|
|
1537
|
+
if (Math.abs(item.screen.x - k.screen.x) < separationX && Math.abs(item.screen.y - k.screen.y) < separationY) {
|
|
1538
|
+
clash = true;
|
|
1539
|
+
break;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
if (!clash) kept.push(item);
|
|
1543
|
+
}
|
|
1544
|
+
return kept;
|
|
1545
|
+
}
|
|
1095
1546
|
function centroidOf(points) {
|
|
1096
1547
|
if (!points.length) return null;
|
|
1097
1548
|
let x = 0, y = 0;
|
|
@@ -1265,195 +1716,6 @@ function buildSectionRake(rows, focal) {
|
|
|
1265
1716
|
return rowsRake(fits);
|
|
1266
1717
|
}
|
|
1267
1718
|
|
|
1268
|
-
// src/view3d/scene/surface.ts
|
|
1269
|
-
var FLAT_SLAB_TOP_M = 0.05;
|
|
1270
|
-
var CAP_MAX_ERROR_M = 0.05;
|
|
1271
|
-
var SEAT_CLEARANCE_M = 0.15;
|
|
1272
|
-
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
1273
|
-
function bboxOf(pts) {
|
|
1274
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1275
|
-
for (const p of pts) {
|
|
1276
|
-
if (p.x < minX) minX = p.x;
|
|
1277
|
-
if (p.y < minY) minY = p.y;
|
|
1278
|
-
if (p.x > maxX) maxX = p.x;
|
|
1279
|
-
if (p.y > maxY) maxY = p.y;
|
|
1280
|
-
}
|
|
1281
|
-
return { minX, minY, maxX, maxY };
|
|
1282
|
-
}
|
|
1283
|
-
var MAX_TIER_RISE_M = 25;
|
|
1284
|
-
function buildVenueSurfaces(units, seats) {
|
|
1285
|
-
const bySection = /* @__PURE__ */ new Map();
|
|
1286
|
-
const seatOwner = new Array(seats.length).fill(null);
|
|
1287
|
-
const seatDeck = new Float64Array(seats.length);
|
|
1288
|
-
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
1289
|
-
const seatPitch = new Array(seats.length).fill(void 0);
|
|
1290
|
-
const acc = /* @__PURE__ */ new Map();
|
|
1291
|
-
const boxes = [];
|
|
1292
|
-
for (const unit of units) {
|
|
1293
|
-
for (const o of unit.objects) {
|
|
1294
|
-
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1295
|
-
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
1296
|
-
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
1297
|
-
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1301
|
-
const s = seats[i];
|
|
1302
|
-
for (const b of boxes) {
|
|
1303
|
-
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
1304
|
-
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
1305
|
-
seatOwner[i] = b.id;
|
|
1306
|
-
const a = acc.get(b.id);
|
|
1307
|
-
a.hasSeats = true;
|
|
1308
|
-
const f = s.focalPoint ?? b.unit.focal;
|
|
1309
|
-
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
1310
|
-
if (d < a.frontU) a.frontU = d;
|
|
1311
|
-
a.seatIndices.push(i);
|
|
1312
|
-
const rowKey = s.rowId || `__seat-${i}`;
|
|
1313
|
-
const arr = a.rows.get(rowKey);
|
|
1314
|
-
if (arr) arr.push({ x: s.x, y: s.y });
|
|
1315
|
-
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
1316
|
-
break;
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
for (const [id, a] of acc) {
|
|
1320
|
-
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
1321
|
-
const bottomY = a.unit.baseHeightM;
|
|
1322
|
-
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
1323
|
-
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
1324
|
-
const kind = a.section.surfaceKind;
|
|
1325
|
-
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
1326
|
-
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
1327
|
-
const needsRake = structure.rows.length < 2;
|
|
1328
|
-
const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
|
|
1329
|
-
let frontU = Infinity;
|
|
1330
|
-
if (rake) {
|
|
1331
|
-
if (a.hasSeats) {
|
|
1332
|
-
for (const pts of a.rows.values()) {
|
|
1333
|
-
for (const p of pts) {
|
|
1334
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1335
|
-
if (d < frontU) frontU = d;
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
1340
|
-
frontU = Infinity;
|
|
1341
|
-
for (const p of a.section.outline) {
|
|
1342
|
-
const d = rake.depthAt(p.x, p.y);
|
|
1343
|
-
if (d < frontU) frontU = d;
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
const flatTop = bottomY + FLAT_SLAB_TOP_M;
|
|
1348
|
-
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
1349
|
-
const levelFor = (depthU) => {
|
|
1350
|
-
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
1351
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1352
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1353
|
-
};
|
|
1354
|
-
const levelForBlockDepth = (blockDepthU) => {
|
|
1355
|
-
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
1356
|
-
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
1357
|
-
return Math.max(baseFloor, geo.height + rise);
|
|
1358
|
-
};
|
|
1359
|
-
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
1360
|
-
pts: r.pts,
|
|
1361
|
-
y: levelForBlockDepth(r.blockDepth),
|
|
1362
|
-
depth: r.blockDepth,
|
|
1363
|
-
blockId: r.blockId
|
|
1364
|
-
}));
|
|
1365
|
-
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
1366
|
-
const rowBounds = rowLevels.map((r) => {
|
|
1367
|
-
let cx = 0, cy = 0;
|
|
1368
|
-
for (const p of r.pts) {
|
|
1369
|
-
cx += p.x;
|
|
1370
|
-
cy += p.y;
|
|
1371
|
-
}
|
|
1372
|
-
const n = r.pts.length || 1;
|
|
1373
|
-
cx /= n;
|
|
1374
|
-
cy /= n;
|
|
1375
|
-
let rad = 0;
|
|
1376
|
-
for (const p of r.pts) {
|
|
1377
|
-
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1378
|
-
if (d > rad) rad = d;
|
|
1379
|
-
}
|
|
1380
|
-
return { cx, cy, rad };
|
|
1381
|
-
});
|
|
1382
|
-
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
1383
|
-
let best = Infinity, bestY = landingY;
|
|
1384
|
-
for (let i = 0; i < rowLevels.length; i++) {
|
|
1385
|
-
const b = rowBounds[i];
|
|
1386
|
-
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
1387
|
-
const d = distanceToPolyline(rowLevels[i].pts, x, y);
|
|
1388
|
-
if (d < best) {
|
|
1389
|
-
best = d;
|
|
1390
|
-
bestY = rowLevels[i].y;
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
return bestY;
|
|
1394
|
-
} : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
|
|
1395
|
-
const UP = [0, 1, 0];
|
|
1396
|
-
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
1397
|
-
if (!rake) return UP;
|
|
1398
|
-
const d = rake.depthAt(x, y);
|
|
1399
|
-
if (d <= frontU) return UP;
|
|
1400
|
-
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
1401
|
-
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
1402
|
-
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
1403
|
-
const [gx, gy] = rake.gradientAt(x, y);
|
|
1404
|
-
if (gx === 0 && gy === 0) return UP;
|
|
1405
|
-
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
1406
|
-
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
1407
|
-
};
|
|
1408
|
-
if (!flat) {
|
|
1409
|
-
for (const r of structure.rows) {
|
|
1410
|
-
const y = levelForBlockDepth(r.blockDepth);
|
|
1411
|
-
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
for (const r of structure.rows) {
|
|
1415
|
-
const gaps = [];
|
|
1416
|
-
for (let k = 1; k < r.pts.length; k++) {
|
|
1417
|
-
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
1418
|
-
if (d > 1e-6) gaps.push(d);
|
|
1419
|
-
}
|
|
1420
|
-
gaps.sort((x, y) => x - y);
|
|
1421
|
-
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
1422
|
-
let across = Infinity;
|
|
1423
|
-
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
1424
|
-
if (probe) {
|
|
1425
|
-
for (const other of structure.rows) {
|
|
1426
|
-
if (other === r || other.blockId !== r.blockId) continue;
|
|
1427
|
-
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
1428
|
-
if (d > 1e-6 && d < across) across = d;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
1432
|
-
if (Number.isFinite(pitch) && pitch > 0) {
|
|
1433
|
-
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
1437
|
-
}
|
|
1438
|
-
for (let i = 0; i < seats.length; i++) {
|
|
1439
|
-
const ownerId = seatOwner[i];
|
|
1440
|
-
const s = seats[i];
|
|
1441
|
-
if (ownerId) {
|
|
1442
|
-
const own = seatRowLevel[i];
|
|
1443
|
-
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
1444
|
-
continue;
|
|
1445
|
-
}
|
|
1446
|
-
const eye = s.eyeHeightM;
|
|
1447
|
-
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
1448
|
-
}
|
|
1449
|
-
return {
|
|
1450
|
-
bySection,
|
|
1451
|
-
seatOwner,
|
|
1452
|
-
seatDeckY: (i) => seatDeck[i],
|
|
1453
|
-
seatPitchU: (i) => seatPitch[i]
|
|
1454
|
-
};
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
1719
|
// src/view3d/scene/deckBands.ts
|
|
1458
1720
|
import polygonClipping2 from "polygon-clipping";
|
|
1459
1721
|
import earcut2 from "earcut";
|
|
@@ -1502,6 +1764,68 @@ function extendEnds(pts, by) {
|
|
|
1502
1764
|
});
|
|
1503
1765
|
return out;
|
|
1504
1766
|
}
|
|
1767
|
+
var CORNER_STEP_RAD = Math.PI / 12;
|
|
1768
|
+
function convexHull(pts) {
|
|
1769
|
+
if (pts.length < 3) return [...pts];
|
|
1770
|
+
const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
|
|
1771
|
+
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
1772
|
+
const half = (src) => {
|
|
1773
|
+
const out = [];
|
|
1774
|
+
for (const p of src) {
|
|
1775
|
+
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
|
|
1776
|
+
out.push(p);
|
|
1777
|
+
}
|
|
1778
|
+
out.pop();
|
|
1779
|
+
return out;
|
|
1780
|
+
};
|
|
1781
|
+
const hull = [...half(s), ...half([...s].reverse())];
|
|
1782
|
+
return hull.length >= 3 ? hull : [...pts];
|
|
1783
|
+
}
|
|
1784
|
+
function seatClusterPatch(pts, pad) {
|
|
1785
|
+
if (!pts.length || !(pad > 0)) return [];
|
|
1786
|
+
const hull = convexHull(pts);
|
|
1787
|
+
const arc = (v, from, to, out2) => {
|
|
1788
|
+
let sweep = to - from;
|
|
1789
|
+
while (sweep < 0) sweep += Math.PI * 2;
|
|
1790
|
+
while (sweep > Math.PI * 2) sweep -= Math.PI * 2;
|
|
1791
|
+
const steps = Math.max(1, Math.ceil(sweep / CORNER_STEP_RAD));
|
|
1792
|
+
const r = pad / Math.cos(sweep / steps / 2);
|
|
1793
|
+
for (let k = 0; k <= steps; k++) {
|
|
1794
|
+
const a = from + sweep * k / steps;
|
|
1795
|
+
out2.push({ x: v.x + Math.cos(a) * r, y: v.y + Math.sin(a) * r });
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
if (hull.length < 3) {
|
|
1799
|
+
let cx = 0, cy = 0;
|
|
1800
|
+
for (const p of hull) {
|
|
1801
|
+
cx += p.x;
|
|
1802
|
+
cy += p.y;
|
|
1803
|
+
}
|
|
1804
|
+
cx /= hull.length || 1;
|
|
1805
|
+
cy /= hull.length || 1;
|
|
1806
|
+
let far = 0;
|
|
1807
|
+
for (const p of hull) far = Math.max(far, Math.hypot(p.x - cx, p.y - cy));
|
|
1808
|
+
const out2 = [];
|
|
1809
|
+
const steps = Math.max(3, Math.ceil(Math.PI * 2 / CORNER_STEP_RAD));
|
|
1810
|
+
const r = (far + pad) / Math.cos(Math.PI / steps);
|
|
1811
|
+
for (let k = 0; k < steps; k++) {
|
|
1812
|
+
const a = k / steps * Math.PI * 2;
|
|
1813
|
+
out2.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
|
|
1814
|
+
}
|
|
1815
|
+
return out2;
|
|
1816
|
+
}
|
|
1817
|
+
const n = hull.length;
|
|
1818
|
+
const edgeAngle = [];
|
|
1819
|
+
for (let i = 0; i < n; i++) {
|
|
1820
|
+
const a = hull[i], b = hull[(i + 1) % n];
|
|
1821
|
+
edgeAngle.push(Math.atan2(-(b.x - a.x), b.y - a.y));
|
|
1822
|
+
}
|
|
1823
|
+
const out = [];
|
|
1824
|
+
for (let i = 0; i < n; i++) {
|
|
1825
|
+
arc(hull[i], edgeAngle[(i + n - 1) % n], edgeAngle[i], out);
|
|
1826
|
+
}
|
|
1827
|
+
return dedupeAdjacent(out);
|
|
1828
|
+
}
|
|
1505
1829
|
function distToPolyline(pts, x, y) {
|
|
1506
1830
|
if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
|
|
1507
1831
|
let best = Infinity;
|
|
@@ -1632,7 +1956,14 @@ function deckFootprints(rows, focal, shared) {
|
|
|
1632
1956
|
else byBlock.set(rows[i].blockId, [i]);
|
|
1633
1957
|
}
|
|
1634
1958
|
const out = [];
|
|
1635
|
-
for (const [,
|
|
1959
|
+
for (const [, allIndices] of byBlock) {
|
|
1960
|
+
const indices = [];
|
|
1961
|
+
for (const i of allIndices) {
|
|
1962
|
+
const patch = rows[i].patch;
|
|
1963
|
+
if (patch && patch.length >= 3) out.push({ outline: [...patch], holes: [], topY: rows[i].y });
|
|
1964
|
+
else indices.push(i);
|
|
1965
|
+
}
|
|
1966
|
+
if (!indices.length) continue;
|
|
1636
1967
|
indices.sort((a, b) => rows[a].depth - rows[b].depth);
|
|
1637
1968
|
const ribbons = indices.map((i) => ribbonOf(rows, i, nbrs, focal));
|
|
1638
1969
|
const usable = ribbons.filter((r) => r !== null);
|
|
@@ -1760,15 +2091,18 @@ var ClipTest = class {
|
|
|
1760
2091
|
return false;
|
|
1761
2092
|
}
|
|
1762
2093
|
};
|
|
1763
|
-
function
|
|
1764
|
-
if (
|
|
2094
|
+
function emitClippedPoly(builder, clipRing, clipTest, poly, y, color) {
|
|
2095
|
+
if (poly.length < 3) return;
|
|
2096
|
+
if (clipTest.containsAll(poly)) {
|
|
1765
2097
|
const UPF = [0, 1, 0];
|
|
1766
|
-
const a =
|
|
1767
|
-
|
|
1768
|
-
|
|
2098
|
+
const a = poly[0];
|
|
2099
|
+
for (let i = 1; i + 1 < poly.length; i++) {
|
|
2100
|
+
const b = poly[i], c = poly[i + 1];
|
|
2101
|
+
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);
|
|
2102
|
+
}
|
|
1769
2103
|
return;
|
|
1770
2104
|
}
|
|
1771
|
-
const ring =
|
|
2105
|
+
const ring = poly.map((p) => [p.x, p.y]);
|
|
1772
2106
|
ring.push(ring[0]);
|
|
1773
2107
|
let pieces;
|
|
1774
2108
|
try {
|
|
@@ -1777,9 +2111,9 @@ function emitClippedQuad(builder, clipRing, clipTest, quad, y, color) {
|
|
|
1777
2111
|
return;
|
|
1778
2112
|
}
|
|
1779
2113
|
const UP = [0, 1, 0];
|
|
1780
|
-
for (const
|
|
1781
|
-
if (!
|
|
1782
|
-
const outer =
|
|
2114
|
+
for (const poly2 of pieces) {
|
|
2115
|
+
if (!poly2.length || poly2[0].length < 4) continue;
|
|
2116
|
+
const outer = poly2[0];
|
|
1783
2117
|
const flat = [];
|
|
1784
2118
|
const pts = [];
|
|
1785
2119
|
for (let i = 0; i < outer.length - 1; i++) {
|
|
@@ -1808,6 +2142,46 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
|
|
|
1808
2142
|
const clipTest = clip && clip.length >= 3 ? new ClipTest([[...clip]]) : null;
|
|
1809
2143
|
for (let i = 0; i < rows.length; i++) {
|
|
1810
2144
|
const row = rows[i];
|
|
2145
|
+
if (row.patch && row.patch.length >= 3) {
|
|
2146
|
+
const patch = [...row.patch];
|
|
2147
|
+
const belowY2 = nbrs[i].belowY ?? landingY;
|
|
2148
|
+
if (clipRing && clipTest) emitClippedPoly(builder, clipRing, clipTest, patch, row.y, colors.tread);
|
|
2149
|
+
else {
|
|
2150
|
+
const a = patch[0];
|
|
2151
|
+
for (let k = 1; k + 1 < patch.length; k++) {
|
|
2152
|
+
const b = patch[k], c = patch[k + 1];
|
|
2153
|
+
builder.tri([a.x * M, row.y, a.y * M], [b.x * M, row.y, b.y * M], [c.x * M, row.y, c.y * M], UP, colors.tread);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
if (row.y > belowY2 + MIN_RISER_M) {
|
|
2157
|
+
let cx = 0, cy = 0;
|
|
2158
|
+
for (const p of patch) {
|
|
2159
|
+
cx += p.x;
|
|
2160
|
+
cy += p.y;
|
|
2161
|
+
}
|
|
2162
|
+
cx /= patch.length;
|
|
2163
|
+
cy /= patch.length;
|
|
2164
|
+
for (let k = 0; k < patch.length; k++) {
|
|
2165
|
+
const p = patch[k], q = patch[(k + 1) % patch.length];
|
|
2166
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
2167
|
+
const len = Math.hypot(dx, dy);
|
|
2168
|
+
if (len < 1e-6) continue;
|
|
2169
|
+
let nx = dy / len, ny = -dx / len;
|
|
2170
|
+
if ((p.x - cx) * nx + (p.y - cy) * ny < 0) {
|
|
2171
|
+
nx = -nx;
|
|
2172
|
+
ny = -ny;
|
|
2173
|
+
}
|
|
2174
|
+
const rn = [nx, 0, ny];
|
|
2175
|
+
const pt = [p.x * M, row.y, p.y * M];
|
|
2176
|
+
const qt = [q.x * M, row.y, q.y * M];
|
|
2177
|
+
const pb = [p.x * M, belowY2, p.y * M];
|
|
2178
|
+
const qb = [q.x * M, belowY2, q.y * M];
|
|
2179
|
+
builder.tri(pt, qt, qb, rn, colors.riser);
|
|
2180
|
+
builder.tri(pt, qb, pb, rn, colors.riser);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
continue;
|
|
2184
|
+
}
|
|
1811
2185
|
const rib = ribbonOf(rows, i, nbrs, focal);
|
|
1812
2186
|
if (!rib) continue;
|
|
1813
2187
|
const { pts, nrm, front, back } = rib;
|
|
@@ -1822,7 +2196,7 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
|
|
|
1822
2196
|
const pB = [(p.x + np[0] * back) * M, row.y, (p.y + np[1] * back) * M];
|
|
1823
2197
|
const qB = [(q.x + nq[0] * back) * M, row.y, (q.y + nq[1] * back) * M];
|
|
1824
2198
|
if (clipRing && clipTest) {
|
|
1825
|
-
|
|
2199
|
+
emitClippedPoly(builder, clipRing, clipTest, [
|
|
1826
2200
|
{ x: p.x - np[0] * front, y: p.y - np[1] * front },
|
|
1827
2201
|
{ x: q.x - nq[0] * front, y: q.y - nq[1] * front },
|
|
1828
2202
|
{ x: q.x + nq[0] * back, y: q.y + nq[1] * back },
|
|
@@ -1843,6 +2217,208 @@ function emitDeckBands(builder, rows, focal, landingY, colors, clip, shared) {
|
|
|
1843
2217
|
}
|
|
1844
2218
|
}
|
|
1845
2219
|
|
|
2220
|
+
// src/view3d/scene/surface.ts
|
|
2221
|
+
var FLAT_SLAB_TOP_M = 0.05;
|
|
2222
|
+
var CAP_MAX_ERROR_M = 0.05;
|
|
2223
|
+
var SEAT_CLEARANCE_M = 0.15;
|
|
2224
|
+
var SEAT_OWNERSHIP_PAD_U = SEAT_DOT_RADIUS_M * 1.5 * CHART_UNITS_PER_METRE;
|
|
2225
|
+
function bboxOf(pts) {
|
|
2226
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2227
|
+
for (const p of pts) {
|
|
2228
|
+
if (p.x < minX) minX = p.x;
|
|
2229
|
+
if (p.y < minY) minY = p.y;
|
|
2230
|
+
if (p.x > maxX) maxX = p.x;
|
|
2231
|
+
if (p.y > maxY) maxY = p.y;
|
|
2232
|
+
}
|
|
2233
|
+
return { minX, minY, maxX, maxY };
|
|
2234
|
+
}
|
|
2235
|
+
function pointInRing2(ring, x, y) {
|
|
2236
|
+
let inside = false;
|
|
2237
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
2238
|
+
const a = ring[i], b = ring[j];
|
|
2239
|
+
if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
2240
|
+
}
|
|
2241
|
+
return inside;
|
|
2242
|
+
}
|
|
2243
|
+
var MAX_TIER_RISE_M = 25;
|
|
2244
|
+
function buildVenueSurfaces(units, seats) {
|
|
2245
|
+
const bySection = /* @__PURE__ */ new Map();
|
|
2246
|
+
const seatOwner = new Array(seats.length).fill(null);
|
|
2247
|
+
const seatDeck = new Float64Array(seats.length);
|
|
2248
|
+
const seatRowLevel = new Array(seats.length).fill(void 0);
|
|
2249
|
+
const seatPitch = new Array(seats.length).fill(void 0);
|
|
2250
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2251
|
+
const boxes = [];
|
|
2252
|
+
const tableRowIds = /* @__PURE__ */ new Set();
|
|
2253
|
+
for (const unit of units) {
|
|
2254
|
+
for (const o of unit.objects) {
|
|
2255
|
+
if (o.type === "table") tableRowIds.add(o.id);
|
|
2256
|
+
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
2257
|
+
const owned = outsetRing(o.outline, SEAT_OWNERSHIP_PAD_U);
|
|
2258
|
+
acc.set(o.id, { section: o, unit, frontU: Infinity, hasSeats: false, rows: /* @__PURE__ */ new Map(), seatIndices: [] });
|
|
2259
|
+
boxes.push({ id: o.id, box: bboxOf(owned), section: o, unit, outline: owned });
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2263
|
+
const s = seats[i];
|
|
2264
|
+
for (const b of boxes) {
|
|
2265
|
+
if (s.x < b.box.minX || s.x > b.box.maxX || s.y < b.box.minY || s.y > b.box.maxY) continue;
|
|
2266
|
+
if (!pointInPolygonWithHoles({ x: s.x, y: s.y }, b.outline, b.section.holes)) continue;
|
|
2267
|
+
seatOwner[i] = b.id;
|
|
2268
|
+
const a = acc.get(b.id);
|
|
2269
|
+
a.hasSeats = true;
|
|
2270
|
+
const f = s.focalPoint ?? b.unit.focal;
|
|
2271
|
+
const d = Math.hypot(s.x - f.x, s.y - f.y);
|
|
2272
|
+
if (d < a.frontU) a.frontU = d;
|
|
2273
|
+
a.seatIndices.push(i);
|
|
2274
|
+
const rowKey = s.rowId || `__seat-${i}`;
|
|
2275
|
+
const arr = a.rows.get(rowKey);
|
|
2276
|
+
if (arr) arr.push({ x: s.x, y: s.y });
|
|
2277
|
+
else a.rows.set(rowKey, [{ x: s.x, y: s.y }]);
|
|
2278
|
+
break;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
for (const [id, a] of acc) {
|
|
2282
|
+
const geo = sectionGeometry(a.section, { floorBaseHeightM: a.unit.baseHeightM });
|
|
2283
|
+
const bottomY = a.unit.baseHeightM;
|
|
2284
|
+
const rakeTan = geo.rake > 0 ? Math.tan(geo.rake * Math.PI / 180) : 0;
|
|
2285
|
+
const inferredFlat = geo.rake <= 0.01 && geo.height <= bottomY + 1e-3;
|
|
2286
|
+
const kind = a.section.surfaceKind;
|
|
2287
|
+
const flat = kind === "flat" ? true : kind === "rakedRows" ? geo.rake > 0.01 ? false : inferredFlat : inferredFlat;
|
|
2288
|
+
const structure = a.hasSeats ? resolveSection(id, seats, a.seatIndices, a.unit.focal) : { sectionId: id, rows: [], blockCount: 0 };
|
|
2289
|
+
const needsRake = structure.rows.length < 2;
|
|
2290
|
+
const rake = needsRake ? buildSectionRake([...a.rows.values()].map((points) => ({ points })), a.unit.focal) : null;
|
|
2291
|
+
let frontU = Infinity;
|
|
2292
|
+
if (rake) {
|
|
2293
|
+
if (a.hasSeats) {
|
|
2294
|
+
for (const pts of a.rows.values()) {
|
|
2295
|
+
for (const p of pts) {
|
|
2296
|
+
const d = rake.depthAt(p.x, p.y);
|
|
2297
|
+
if (d < frontU) frontU = d;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
if (!a.hasSeats || !Number.isFinite(frontU)) {
|
|
2302
|
+
frontU = Infinity;
|
|
2303
|
+
for (const p of a.section.outline) {
|
|
2304
|
+
const d = rake.depthAt(p.x, p.y);
|
|
2305
|
+
if (d < frontU) frontU = d;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
const baseFloor = bottomY + FLAT_SLAB_TOP_M;
|
|
2310
|
+
const flatTop = Math.max(baseFloor, geo.height);
|
|
2311
|
+
const levelFor = (depthU) => {
|
|
2312
|
+
const depthM = Math.max(0, depthU - frontU) * METRES_PER_CHART_UNIT;
|
|
2313
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
2314
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
2315
|
+
};
|
|
2316
|
+
const levelForBlockDepth = (blockDepthU) => {
|
|
2317
|
+
const depthM = Math.max(0, blockDepthU) * METRES_PER_CHART_UNIT;
|
|
2318
|
+
const rise = Math.min(depthM * rakeTan, MAX_TIER_RISE_M);
|
|
2319
|
+
return Math.max(baseFloor, geo.height + rise);
|
|
2320
|
+
};
|
|
2321
|
+
const rowLevels = flat ? [] : structure.rows.map((r) => ({
|
|
2322
|
+
pts: r.pts,
|
|
2323
|
+
y: levelForBlockDepth(r.blockDepth),
|
|
2324
|
+
depth: r.blockDepth,
|
|
2325
|
+
blockId: r.blockId,
|
|
2326
|
+
...tableRowIds.has(r.id) ? { patch: seatClusterPatch(r.pts, MIN_REACH_U) } : {}
|
|
2327
|
+
}));
|
|
2328
|
+
const landingY = rowLevels.length ? rowLevels[0].y : flatTop;
|
|
2329
|
+
const rowBounds = rowLevels.map((r) => {
|
|
2330
|
+
const ext = r.patch && r.patch.length >= 3 ? r.patch : r.pts;
|
|
2331
|
+
let cx = 0, cy = 0;
|
|
2332
|
+
for (const p of ext) {
|
|
2333
|
+
cx += p.x;
|
|
2334
|
+
cy += p.y;
|
|
2335
|
+
}
|
|
2336
|
+
const n = ext.length || 1;
|
|
2337
|
+
cx /= n;
|
|
2338
|
+
cy /= n;
|
|
2339
|
+
let rad = 0;
|
|
2340
|
+
for (const p of ext) {
|
|
2341
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
2342
|
+
if (d > rad) rad = d;
|
|
2343
|
+
}
|
|
2344
|
+
return { cx, cy, rad };
|
|
2345
|
+
});
|
|
2346
|
+
const deckAt = flat ? () => flatTop : rowLevels.length >= 2 ? (x, y) => {
|
|
2347
|
+
let best = Infinity, bestY = landingY;
|
|
2348
|
+
for (let i = 0; i < rowLevels.length; i++) {
|
|
2349
|
+
const b = rowBounds[i];
|
|
2350
|
+
if (Math.hypot(x - b.cx, y - b.cy) - b.rad >= best) continue;
|
|
2351
|
+
const patch = rowLevels[i].patch;
|
|
2352
|
+
const d = patch && patch.length >= 3 ? pointInRing2(patch, x, y) ? 0 : distanceToPolyline(patch, x, y) : distanceToPolyline(rowLevels[i].pts, x, y);
|
|
2353
|
+
if (d < best) {
|
|
2354
|
+
best = d;
|
|
2355
|
+
bestY = rowLevels[i].y;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
return bestY;
|
|
2359
|
+
} : (x, y) => rake ? levelFor(rake.depthAt(x, y)) : flatTop;
|
|
2360
|
+
const UP = [0, 1, 0];
|
|
2361
|
+
const normalAt = rowLevels.length >= 2 || flat ? () => UP : (x, y) => {
|
|
2362
|
+
if (!rake) return UP;
|
|
2363
|
+
const d = rake.depthAt(x, y);
|
|
2364
|
+
if (d <= frontU) return UP;
|
|
2365
|
+
const depthM = (d - frontU) * METRES_PER_CHART_UNIT;
|
|
2366
|
+
if (depthM * rakeTan >= MAX_TIER_RISE_M) return UP;
|
|
2367
|
+
if (geo.height + depthM * rakeTan <= baseFloor) return UP;
|
|
2368
|
+
const [gx, gy] = rake.gradientAt(x, y);
|
|
2369
|
+
if (gx === 0 && gy === 0) return UP;
|
|
2370
|
+
const inv = 1 / Math.hypot(rakeTan, 1);
|
|
2371
|
+
return [-gx * rakeTan * inv, inv, -gy * rakeTan * inv];
|
|
2372
|
+
};
|
|
2373
|
+
if (!flat) {
|
|
2374
|
+
for (const r of structure.rows) {
|
|
2375
|
+
const y = levelForBlockDepth(r.blockDepth);
|
|
2376
|
+
for (const si of r.seatIndices) seatRowLevel[si] = y;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
for (const r of structure.rows) {
|
|
2380
|
+
const gaps = [];
|
|
2381
|
+
for (let k = 1; k < r.pts.length; k++) {
|
|
2382
|
+
const d = Math.hypot(r.pts[k].x - r.pts[k - 1].x, r.pts[k].y - r.pts[k - 1].y);
|
|
2383
|
+
if (d > 1e-6) gaps.push(d);
|
|
2384
|
+
}
|
|
2385
|
+
gaps.sort((x, y) => x - y);
|
|
2386
|
+
const along = gaps.length ? gaps[Math.floor(gaps.length / 2)] : Infinity;
|
|
2387
|
+
let across = Infinity;
|
|
2388
|
+
const probe = r.pts[Math.floor(r.pts.length / 2)];
|
|
2389
|
+
if (probe) {
|
|
2390
|
+
for (const other of structure.rows) {
|
|
2391
|
+
if (other === r || other.blockId !== r.blockId) continue;
|
|
2392
|
+
const d = distanceToPolyline(other.pts, probe.x, probe.y);
|
|
2393
|
+
if (d > 1e-6 && d < across) across = d;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
const pitch = Math.min(along, Math.max(across, along * 0.5));
|
|
2397
|
+
if (Number.isFinite(pitch) && pitch > 0) {
|
|
2398
|
+
for (const si of r.seatIndices) seatPitch[si] = pitch;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
bySection.set(id, { sectionId: id, deckAt, normalAt, flat, bottomY, rowLevels, landingY });
|
|
2402
|
+
}
|
|
2403
|
+
for (let i = 0; i < seats.length; i++) {
|
|
2404
|
+
const ownerId = seatOwner[i];
|
|
2405
|
+
const s = seats[i];
|
|
2406
|
+
if (ownerId) {
|
|
2407
|
+
const own = seatRowLevel[i];
|
|
2408
|
+
seatDeck[i] = (own ?? bySection.get(ownerId).deckAt(s.x, s.y)) + SEAT_CLEARANCE_M;
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
const eye = s.eyeHeightM;
|
|
2412
|
+
seatDeck[i] = Number.isFinite(eye) ? Math.max(0, eye - SEATED_EYE_HEIGHT_M) : 0;
|
|
2413
|
+
}
|
|
2414
|
+
return {
|
|
2415
|
+
bySection,
|
|
2416
|
+
seatOwner,
|
|
2417
|
+
seatDeckY: (i) => seatDeck[i],
|
|
2418
|
+
seatPitchU: (i) => seatPitch[i]
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
|
|
1846
2422
|
// src/view3d/scene/sceneModel.ts
|
|
1847
2423
|
function floorUnits(doc) {
|
|
1848
2424
|
if (doc.floors?.length) {
|
|
@@ -2008,22 +2584,39 @@ function buildTier(builder, section, unit, fill, surface, claimed, siblings, S)
|
|
|
2008
2584
|
}
|
|
2009
2585
|
const maxErr = surface.flat ? Infinity : CAP_MAX_ERROR_M;
|
|
2010
2586
|
const topN = (p) => surface.normalAt(p.x, p.y);
|
|
2011
|
-
|
|
2587
|
+
const level = surface.flat ? surface.landingY : void 0;
|
|
2588
|
+
for (const ring of claimed.subtract(outline, level)) {
|
|
2012
2589
|
extrudePrism(builder, ring, section.holes, topY, bottomY, colTop, S.tierWall, AO, maxErr, topN);
|
|
2013
2590
|
}
|
|
2014
2591
|
}
|
|
2592
|
+
function coplanarLevels(a, b) {
|
|
2593
|
+
if (a === void 0 || b === void 0) return true;
|
|
2594
|
+
return Math.abs(a - b) < 1e-3;
|
|
2595
|
+
}
|
|
2015
2596
|
var ClaimedArea = class {
|
|
2016
2597
|
constructor() {
|
|
2017
2598
|
this.rings = [];
|
|
2018
2599
|
this.boxes = [];
|
|
2600
|
+
/** Constant deck height of each claim, or undefined when it is not level. */
|
|
2601
|
+
this.levels = [];
|
|
2019
2602
|
}
|
|
2020
|
-
/**
|
|
2021
|
-
|
|
2603
|
+
/**
|
|
2604
|
+
* `ring` minus everything claimed so far AT THE SAME HEIGHT; then claim it.
|
|
2605
|
+
*
|
|
2606
|
+
* `level` is the claim's constant deck height when it has one. Two decks only
|
|
2607
|
+
* z-fight when they are coplanar, so only a coplanar claim may take ground
|
|
2608
|
+
* away — an elevated box hanging over a ground-level section overlaps it in
|
|
2609
|
+
* plan and must still draw its whole floor, or the box loses the part of its
|
|
2610
|
+
* deck that shares a footprint with whatever is underneath it. Passing
|
|
2611
|
+
* undefined (a surface with no single height) keeps the original
|
|
2612
|
+
* clip-against-everything behaviour.
|
|
2613
|
+
*/
|
|
2614
|
+
subtract(ring, level) {
|
|
2022
2615
|
const closed = ring.map((p) => [p.x, p.y]);
|
|
2023
2616
|
if (closed.length < 3) return [];
|
|
2024
2617
|
closed.push(closed[0]);
|
|
2025
2618
|
const box = bboxOfRing(ring);
|
|
2026
|
-
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]));
|
|
2619
|
+
const overlapping = this.rings.filter((_, i) => boxesOverlap(box, this.boxes[i]) && coplanarLevels(level, this.levels[i]));
|
|
2027
2620
|
let pieces = [[closed]];
|
|
2028
2621
|
if (overlapping.length) {
|
|
2029
2622
|
try {
|
|
@@ -2035,6 +2628,7 @@ var ClaimedArea = class {
|
|
|
2035
2628
|
}
|
|
2036
2629
|
this.rings.push([closed]);
|
|
2037
2630
|
this.boxes.push(box);
|
|
2631
|
+
this.levels.push(level);
|
|
2038
2632
|
const out = [];
|
|
2039
2633
|
for (const poly of pieces) {
|
|
2040
2634
|
if (!poly.length) continue;
|
|
@@ -2127,11 +2721,27 @@ function shapePolygon(shape) {
|
|
|
2127
2721
|
}
|
|
2128
2722
|
return null;
|
|
2129
2723
|
}
|
|
2130
|
-
function buildShape(builder, shape, base, S) {
|
|
2724
|
+
function buildShape(builder, shape, base, S, stages) {
|
|
2131
2725
|
const poly = shapePolygon(shape);
|
|
2132
2726
|
if (!poly) return;
|
|
2133
2727
|
const isStage = shape.role === "stage";
|
|
2134
2728
|
const height = isStage ? base + 1 : base + 0.25;
|
|
2729
|
+
if (isStage && stages) {
|
|
2730
|
+
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
|
2731
|
+
for (const pt of poly) {
|
|
2732
|
+
if (pt.x < minX) minX = pt.x;
|
|
2733
|
+
if (pt.x > maxX) maxX = pt.x;
|
|
2734
|
+
if (pt.y < minZ) minZ = pt.y;
|
|
2735
|
+
if (pt.y > maxZ) maxZ = pt.y;
|
|
2736
|
+
}
|
|
2737
|
+
stages.push({
|
|
2738
|
+
cx: (minX + maxX) / 2 * M,
|
|
2739
|
+
cz: (minZ + maxZ) / 2 * M,
|
|
2740
|
+
y: height,
|
|
2741
|
+
halfX: Math.max((maxX - minX) / 2 * M, 0.5),
|
|
2742
|
+
halfZ: Math.max((maxZ - minZ) / 2 * M, 0.5)
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2135
2745
|
const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
|
|
2136
2746
|
const colWall = isStage ? S.stageWall : S.decorWall;
|
|
2137
2747
|
extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
|
|
@@ -2241,6 +2851,7 @@ function buildSceneModel(input) {
|
|
|
2241
2851
|
const sectionFills = resolveSectionFills(doc, seats);
|
|
2242
2852
|
const catColor = /* @__PURE__ */ new Map();
|
|
2243
2853
|
for (const c of doc.categories ?? []) catColor.set(c.key, c.color);
|
|
2854
|
+
const stageBounds = [];
|
|
2244
2855
|
const surfaces = buildVenueSurfaces(units, seats);
|
|
2245
2856
|
const seatFloor = new Float32Array(seats.length);
|
|
2246
2857
|
for (let unitIndex = 0; unitIndex < units.length; unitIndex++) {
|
|
@@ -2250,7 +2861,7 @@ function buildSceneModel(input) {
|
|
|
2250
2861
|
const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
|
|
2251
2862
|
for (const o of unit.objects) {
|
|
2252
2863
|
if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
|
|
2253
|
-
else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S);
|
|
2864
|
+
else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S, stageBounds);
|
|
2254
2865
|
else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2255
2866
|
else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
2256
2867
|
else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
|
|
@@ -2259,7 +2870,7 @@ function buildSceneModel(input) {
|
|
|
2259
2870
|
}
|
|
2260
2871
|
const focal = doc.focalPoint ?? { x: (fp.minX + fp.maxX) / 2, y: (fp.minY + fp.maxY) / 2 };
|
|
2261
2872
|
const solids = mergeMeshData([builder.build()]);
|
|
2262
|
-
const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor);
|
|
2873
|
+
const seatData = buildSeatInstances(seats, input.initialState, surfaces, seatFloor, catColor);
|
|
2263
2874
|
const zoneDefs = doc.zones ?? [];
|
|
2264
2875
|
const zones = [];
|
|
2265
2876
|
if (zoneDefs.length) {
|
|
@@ -2490,6 +3101,15 @@ function buildSceneModel(input) {
|
|
|
2490
3101
|
});
|
|
2491
3102
|
}
|
|
2492
3103
|
}
|
|
3104
|
+
seatData.iYaw = computeSeatYaw(
|
|
3105
|
+
seatData.iPosition,
|
|
3106
|
+
seats.length,
|
|
3107
|
+
(i) => seats[i].rowId,
|
|
3108
|
+
(i) => {
|
|
3109
|
+
const f = units[seatFloor[i]]?.focal ?? focal;
|
|
3110
|
+
return [f.x * M, f.y * M];
|
|
3111
|
+
}
|
|
3112
|
+
);
|
|
2493
3113
|
const cx = (fp.minX + fp.maxX) / 2 * M;
|
|
2494
3114
|
const cz = (fp.minY + fp.maxY) / 2 * M;
|
|
2495
3115
|
const radius = 0.5 * Math.hypot((fp.maxX - fp.minX) * M, (fp.maxY - fp.minY) * M) || 10;
|
|
@@ -2503,6 +3123,7 @@ function buildSceneModel(input) {
|
|
|
2503
3123
|
// Look-at target ~1.5 m up so a seated camera aims slightly down at the stage.
|
|
2504
3124
|
focalWorld: [focal.x * M, 1.5, focal.y * M],
|
|
2505
3125
|
zones,
|
|
3126
|
+
stages: stageBounds,
|
|
2506
3127
|
sections,
|
|
2507
3128
|
labels,
|
|
2508
3129
|
floors
|
|
@@ -2527,7 +3148,11 @@ var KIND_STYLE = {
|
|
|
2527
3148
|
var DENSE_KINDS = /* @__PURE__ */ new Set(["row", "seat"]);
|
|
2528
3149
|
var DENSE_SEPARATION = {
|
|
2529
3150
|
row: { x: 62, y: 16 },
|
|
2530
|
-
|
|
3151
|
+
// Widened from 24: at close range seat labels stopped overlapping at all, so
|
|
3152
|
+
// the separation had no work left to do and the budget was carrying the whole
|
|
3153
|
+
// load. A wider box means the few labels that ARE kept are spread across the
|
|
3154
|
+
// seating instead of clustering into one stack.
|
|
3155
|
+
seat: { x: 46, y: 18 }
|
|
2531
3156
|
};
|
|
2532
3157
|
var LabelOverlay = class {
|
|
2533
3158
|
constructor(container, opts = {}) {
|
|
@@ -2544,6 +3169,20 @@ var LabelOverlay = class {
|
|
|
2544
3169
|
if (opts.fontFamily) s.fontFamily = opts.fontFamily;
|
|
2545
3170
|
container.appendChild(this.root);
|
|
2546
3171
|
}
|
|
3172
|
+
/**
|
|
3173
|
+
* Hide or show the whole overlay without disturbing which labels exist.
|
|
3174
|
+
*
|
|
3175
|
+
* The in-scene panorama sphere is GL, so it draws BEHIND every DOM label —
|
|
3176
|
+
* row and seat labels floated on top of a 360 photo until this existed. The
|
|
3177
|
+
* old DOM panorama never needed it because its opaque div covered them.
|
|
3178
|
+
*
|
|
3179
|
+
* Deliberately not `setLabels([])`: that destroys the nodes and the declutter
|
|
3180
|
+
* state, so closing the panorama would rebuild and re-rank the whole overlay
|
|
3181
|
+
* and flash. Visibility is a view concern, not a data one.
|
|
3182
|
+
*/
|
|
3183
|
+
setVisible(visible) {
|
|
3184
|
+
this.root.style.visibility = visible ? "" : "hidden";
|
|
3185
|
+
}
|
|
2547
3186
|
setLabels(labels) {
|
|
2548
3187
|
this.labels = labels;
|
|
2549
3188
|
for (const [id, node] of this.nodes) {
|
|
@@ -2558,7 +3197,7 @@ var LabelOverlay = class {
|
|
|
2558
3197
|
*
|
|
2559
3198
|
* `viewProjection` is column-major, as OGL supplies it.
|
|
2560
3199
|
*/
|
|
2561
|
-
update(viewProjection, width, height, cameraDistance, venueRadius) {
|
|
3200
|
+
update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
|
|
2562
3201
|
if (!this.labels.length) return;
|
|
2563
3202
|
const kinds = visibleLabelKinds(cameraDistance, venueRadius);
|
|
2564
3203
|
const candidates = [];
|
|
@@ -2566,15 +3205,24 @@ var LabelOverlay = class {
|
|
|
2566
3205
|
if (!kinds.has(label.kind)) continue;
|
|
2567
3206
|
const screen = projectToScreen(viewProjection, label.anchor, width, height);
|
|
2568
3207
|
if (!screen.visible) continue;
|
|
2569
|
-
|
|
3208
|
+
const world = cameraWorld ? Math.hypot(
|
|
3209
|
+
label.anchor[0] - cameraWorld[0],
|
|
3210
|
+
label.anchor[1] - cameraWorld[1],
|
|
3211
|
+
label.anchor[2] - cameraWorld[2]
|
|
3212
|
+
) : 1;
|
|
3213
|
+
candidates.push({ label, screen, focus: focusScore(screen, world, width, height) });
|
|
2570
3214
|
}
|
|
2571
3215
|
const structure = candidates.filter((c) => !DENSE_KINDS.has(c.label.kind));
|
|
2572
3216
|
const kept = [
|
|
2573
3217
|
...cullOverlapping(structure, SEPARATION_X_PX, SEPARATION_Y_PX),
|
|
2574
|
-
|
|
3218
|
+
// The dense rungs are BUDGETED, not merely deduplicated — see
|
|
3219
|
+
// DENSE_LABEL_BUDGET for why the overlap test alone gets worse the closer
|
|
3220
|
+
// the camera gets.
|
|
3221
|
+
...["row", "seat"].flatMap((kind) => pickDenseLabels(
|
|
2575
3222
|
candidates.filter((c) => c.label.kind === kind),
|
|
2576
3223
|
DENSE_SEPARATION[kind].x,
|
|
2577
|
-
DENSE_SEPARATION[kind].y
|
|
3224
|
+
DENSE_SEPARATION[kind].y,
|
|
3225
|
+
DENSE_LABEL_BUDGET[kind]
|
|
2578
3226
|
))
|
|
2579
3227
|
];
|
|
2580
3228
|
const keptIds = new Set(kept.map((k) => k.label.id));
|
|
@@ -2623,6 +3271,13 @@ import { Geometry, Mesh, Transform } from "ogl";
|
|
|
2623
3271
|
|
|
2624
3272
|
// src/view3d/scene/materials.ts
|
|
2625
3273
|
import { Program } from "ogl";
|
|
3274
|
+
var CHAIR_WEIGHT_GLSL = (
|
|
3275
|
+
/* glsl */
|
|
3276
|
+
`
|
|
3277
|
+
float chairWeight(float depth) {
|
|
3278
|
+
return 1.0 - smoothstep(uChairFull, uChairNone, depth);
|
|
3279
|
+
}`
|
|
3280
|
+
);
|
|
2626
3281
|
var SOLID_VERT = (
|
|
2627
3282
|
/* glsl */
|
|
2628
3283
|
`#version 300 es
|
|
@@ -2702,14 +3357,23 @@ uniform float uSeatScale;
|
|
|
2702
3357
|
uniform float uMinPixels;
|
|
2703
3358
|
uniform float uPixelToWorld; // (2*tan(fovY/2)) / viewportHeightPx
|
|
2704
3359
|
uniform float uFocusFloor; // -1 = show every floor
|
|
3360
|
+
uniform float uChairFull; // view depth at which the chair mesh is full size
|
|
3361
|
+
uniform float uChairNone; // ...and at which it has scaled away entirely
|
|
2705
3362
|
out vec2 vUv;
|
|
2706
3363
|
out vec3 vColor;
|
|
2707
3364
|
out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
|
|
2708
3365
|
out vec3 vRing;
|
|
2709
3366
|
out float vDim;
|
|
3367
|
+
out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
|
|
3368
|
+
${CHAIR_WEIGHT_GLSL}
|
|
2710
3369
|
void main() {
|
|
2711
3370
|
vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
|
|
2712
3371
|
float depth = max(-mv.z, 0.001);
|
|
3372
|
+
// Hand the seat over to the chair mesh as it comes into range. Derived from
|
|
3373
|
+
// this instance's OWN depth rather than from a global uniform, so a row two
|
|
3374
|
+
// metres away and the far side of the bowl resolve differently in the same
|
|
3375
|
+
// frame \u2014 which is the entire point of a ladder over a switch.
|
|
3376
|
+
vDotWeight = 1.0 - chairWeight(depth);
|
|
2713
3377
|
float minR = uMinPixels * depth * uPixelToWorld; // screen-space floor
|
|
2714
3378
|
// Grow to hold the pixel floor, but never past this seat's own pitch ceiling:
|
|
2715
3379
|
// unbounded growth is what merges neighbouring rows into one mass at range.
|
|
@@ -2743,6 +3407,7 @@ in vec3 vColor;
|
|
|
2743
3407
|
in float vBudget;
|
|
2744
3408
|
in vec3 vRing;
|
|
2745
3409
|
in float vDim;
|
|
3410
|
+
in float vDotWeight;
|
|
2746
3411
|
uniform float uSeatFade; // fade toward tier colour with distance (LOD)
|
|
2747
3412
|
uniform vec3 uFadeColor;
|
|
2748
3413
|
out vec4 fragColor;
|
|
@@ -2767,9 +3432,173 @@ void main() {
|
|
|
2767
3432
|
// Seats on an unfocused floor recede with their structure.
|
|
2768
3433
|
c = mix(c, uFadeColor, vDim * 0.75);
|
|
2769
3434
|
alpha *= mix(1.0, 0.30, vDim);
|
|
3435
|
+
// Yield to the chair. The chair grows out of this exact point, so through the
|
|
3436
|
+
// band the dot is always at least as big as the chair inside it and the seat
|
|
3437
|
+
// never thins out to nothing in between.
|
|
3438
|
+
alpha *= vDotWeight;
|
|
3439
|
+
if (alpha <= 0.0) discard;
|
|
2770
3440
|
fragColor = vec4(c, alpha);
|
|
2771
3441
|
}`
|
|
2772
3442
|
);
|
|
3443
|
+
var CHAIR_VERT = (
|
|
3444
|
+
/* glsl */
|
|
3445
|
+
`#version 300 es
|
|
3446
|
+
precision highp float;
|
|
3447
|
+
in vec3 position; // local: x/z in units of the seat radius, y in METRES
|
|
3448
|
+
in vec3 normal;
|
|
3449
|
+
in float part; // 0 = pedestal, 1 = pad, 2 = back, 3 = body, 4 = head
|
|
3450
|
+
in vec3 iOffset; // per-instance world deck point (identical to the dot's)
|
|
3451
|
+
in vec3 iColor; // per-instance state colour
|
|
3452
|
+
in float iRadius; // per-instance horizontal half-width, world metres
|
|
3453
|
+
in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
|
|
3454
|
+
in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
|
|
3455
|
+
in float iFloor;
|
|
3456
|
+
in float iSeed; // <0 = seat is empty; else per-person hash in [0,1)
|
|
3457
|
+
uniform mat4 modelViewMatrix;
|
|
3458
|
+
uniform mat4 projectionMatrix;
|
|
3459
|
+
uniform float uChairFull;
|
|
3460
|
+
uniform float uChairNone;
|
|
3461
|
+
uniform float uFocusFloor;
|
|
3462
|
+
uniform float uBackRake; // metres of z per metre of rise, above uBackBase
|
|
3463
|
+
uniform float uBackBase; // local height at which the back starts
|
|
3464
|
+
out vec3 vColor;
|
|
3465
|
+
out vec3 vNormalWorld;
|
|
3466
|
+
out vec3 vNormalView;
|
|
3467
|
+
out vec3 vPosView;
|
|
3468
|
+
out float vPart;
|
|
3469
|
+
out float vHeight; // local height in metres, for the vertical occlusion ramp
|
|
3470
|
+
out vec3 vRing;
|
|
3471
|
+
out float vDim;
|
|
3472
|
+
out float vOccupant; // 1 = this vertex belongs to a person, not to the chair
|
|
3473
|
+
out vec3 vOccupantTint;
|
|
3474
|
+
${CHAIR_WEIGHT_GLSL}
|
|
3475
|
+
void main() {
|
|
3476
|
+
vec4 anchor = modelViewMatrix * vec4(iOffset, 1.0);
|
|
3477
|
+
float w = chairWeight(max(-anchor.z, 0.001));
|
|
3478
|
+
// 1. Local units -> world metres. Only x/z scale: narrow rows get narrow
|
|
3479
|
+
// chairs, but nobody gets a short one (people are the same height at every
|
|
3480
|
+
// seat pitch).
|
|
3481
|
+
vec3 p = position;
|
|
3482
|
+
// The occupant. Parts 3 and 4 are a person; everything below is furniture.
|
|
3483
|
+
//
|
|
3484
|
+
// One instanced mesh draws both an empty seat and a taken one, because the
|
|
3485
|
+
// alternative \u2014 a second geometry and a second draw call gathered per frame \u2014
|
|
3486
|
+
// would double the near-field cost to show what is already known per instance.
|
|
3487
|
+
// An unoccupied seat collapses its person to a point at the seat, which the
|
|
3488
|
+
// rasteriser discards, exactly as the chair itself collapses at w=0.
|
|
3489
|
+
float occupant = step(2.5, part);
|
|
3490
|
+
float taken = step(0.0, iSeed);
|
|
3491
|
+
vOccupant = occupant;
|
|
3492
|
+
if (occupant > 0.5) {
|
|
3493
|
+
if (taken < 0.5) {
|
|
3494
|
+
p = vec3(0.0); // empty seat: no person
|
|
3495
|
+
} else {
|
|
3496
|
+
// Vary the build so a sold-out row is people rather than a rank of
|
|
3497
|
+
// identical mannequins: +/-6% height and +/-8% width off the hash.
|
|
3498
|
+
p.y *= 0.94 + 0.12 * iSeed;
|
|
3499
|
+
p.xz *= 0.92 + 0.16 * fract(iSeed * 7.13);
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
p.xz *= iRadius;
|
|
3503
|
+
// 2. Lean the back. Done here rather than in the base mesh so the lean is a
|
|
3504
|
+
// real angle in METRES \u2014 baked into the mesh it would scale with the seat's
|
|
3505
|
+
// width and the same chair would lean 20 degrees on a wide stadium row and
|
|
3506
|
+
// 6 on a tight theatre one.
|
|
3507
|
+
// Only the BACK panel rakes. The old test (part > 1.5) meant "the back", and
|
|
3508
|
+
// now also catches the occupant \u2014 leaning a person by the panel's rule would
|
|
3509
|
+
// translate their head backwards by a rake measured from the panel's base.
|
|
3510
|
+
float rake = (part > 1.5 && part < 2.5) ? max(p.y - uBackBase, 0.0) * uBackRake : 0.0;
|
|
3511
|
+
p.z -= rake;
|
|
3512
|
+
// 3. Scale-in. At w=0 the chair is a point at the seat, under a dot at full
|
|
3513
|
+
// opacity \u2014 which is what makes the handover invisible. sqrt front-loads
|
|
3514
|
+
// the growth so the chair is already near full size while the dot is still
|
|
3515
|
+
// half there; see chairScale() in lod.ts.
|
|
3516
|
+
p *= sqrt(w);
|
|
3517
|
+
float c = cos(iYaw), s = sin(iYaw);
|
|
3518
|
+
vec3 rp = vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
|
|
3519
|
+
// Normals under the same two transforms, in reverse and inverted-transposed.
|
|
3520
|
+
// The xz scale is non-uniform, so an axis-aligned normal does NOT survive it
|
|
3521
|
+
// unchanged; and the rake is a shear, whose normal transform adds a y term.
|
|
3522
|
+
// Skipping either lights the raked back as though it were still vertical.
|
|
3523
|
+
vec3 n = vec3(normal.x / iRadius, normal.y, normal.z / iRadius);
|
|
3524
|
+
if (part > 1.5 && part < 2.5) n.y += uBackRake * n.z;
|
|
3525
|
+
n = normalize(n);
|
|
3526
|
+
vec3 rn = vec3(n.x * c + n.z * s, n.y, -n.x * s + n.z * c);
|
|
3527
|
+
vec4 mv = modelViewMatrix * vec4(iOffset + rp, 1.0);
|
|
3528
|
+
vPosView = mv.xyz;
|
|
3529
|
+
vNormalWorld = rn;
|
|
3530
|
+
vNormalView = normalize(mat3(modelViewMatrix) * rn);
|
|
3531
|
+
vColor = iColor;
|
|
3532
|
+
// Occupant colour, resolved here so the fragment stage needs no extra
|
|
3533
|
+
// varyings beyond this one. A person is NOT painted in the seat's state
|
|
3534
|
+
// colour: a sold seat is red, and a hall of red people reads as a warning,
|
|
3535
|
+
// not an audience. Hair/clothing for the body, a warm tone for the head,
|
|
3536
|
+
// both varied by the same per-person hash.
|
|
3537
|
+
float t = fract(iSeed * 3.71);
|
|
3538
|
+
vec3 clothes = mix(vec3(0.13, 0.15, 0.20), vec3(0.34, 0.30, 0.36), t);
|
|
3539
|
+
vec3 skin = mix(vec3(0.52, 0.38, 0.29), vec3(0.86, 0.70, 0.58), fract(iSeed * 11.3));
|
|
3540
|
+
vOccupantTint = (part > 3.5) ? skin : clothes;
|
|
3541
|
+
vPart = part;
|
|
3542
|
+
vHeight = position.y;
|
|
3543
|
+
vRing = iRing;
|
|
3544
|
+
vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
|
|
3545
|
+
gl_Position = projectionMatrix * mv;
|
|
3546
|
+
}`
|
|
3547
|
+
);
|
|
3548
|
+
var CHAIR_FRAG = (
|
|
3549
|
+
/* glsl */
|
|
3550
|
+
`#version 300 es
|
|
3551
|
+
precision highp float;
|
|
3552
|
+
in vec3 vColor;
|
|
3553
|
+
in vec3 vNormalWorld;
|
|
3554
|
+
in vec3 vNormalView;
|
|
3555
|
+
in vec3 vPosView;
|
|
3556
|
+
in float vPart;
|
|
3557
|
+
in float vHeight;
|
|
3558
|
+
in vec3 vRing;
|
|
3559
|
+
in float vDim;
|
|
3560
|
+
in float vOccupant;
|
|
3561
|
+
in vec3 vOccupantTint;
|
|
3562
|
+
uniform vec3 uKeyDir;
|
|
3563
|
+
uniform vec3 uFadeColor;
|
|
3564
|
+
out vec4 fragColor;
|
|
3565
|
+
void main() {
|
|
3566
|
+
vec3 N = normalize(vNormalWorld);
|
|
3567
|
+
vec3 V = normalize(-vPosView);
|
|
3568
|
+
// The solids' rig, unchanged, so the chairs sit in the venue's light.
|
|
3569
|
+
float hemi = 0.5 + 0.5 * N.y;
|
|
3570
|
+
float key = max(dot(N, uKeyDir), 0.0);
|
|
3571
|
+
vec3 fillDir = normalize(vec3(-uKeyDir.x, 0.25, -uKeyDir.z));
|
|
3572
|
+
float fill = max(dot(N, fillDir), 0.0);
|
|
3573
|
+
vec3 tint = vColor;
|
|
3574
|
+
if (vOccupant > 0.5) tint = vOccupantTint;
|
|
3575
|
+
// The accommodation ring, kept legible once the dot (which drew it) is gone:
|
|
3576
|
+
// an accessible seat's PEDESTAL is painted in the ring colour, so the marker
|
|
3577
|
+
// survives to close range instead of vanishing exactly when the buyer arrives.
|
|
3578
|
+
float ringMask = step(0.001, dot(vRing, vRing));
|
|
3579
|
+
if (vPart < 0.5 && ringMask > 0.5) tint = vRing;
|
|
3580
|
+
// Pad brightest, back a step below it, pedestal darkest. Three untextured
|
|
3581
|
+
// boxes only read as one object if they are separated tonally \u2014 with a single
|
|
3582
|
+
// flat colour the chair silhouettes as a crate.
|
|
3583
|
+
// A person is lit as a person, not as upholstery: no part shading, and less
|
|
3584
|
+
// of the pad's sheen, so a head does not read as a polished box.
|
|
3585
|
+
float partShade = vOccupant > 0.5 ? 0.95 : (vPart < 0.5 ? 0.50 : (vPart < 1.5 ? 1.10 : 0.80));
|
|
3586
|
+
// Cheap vertical occlusion: a chair is in a dense row, so the closer a surface
|
|
3587
|
+
// sits to the deck the less sky it can actually see. This is the depth cue \u2014
|
|
3588
|
+
// without it the pad top, the back and the deck all resolve to the same flat
|
|
3589
|
+
// value and the row loses its form entirely.
|
|
3590
|
+
// Occupants rise above the 0.92 m chair back, so their ramp uses their own
|
|
3591
|
+
// height or every head would clamp to full brightness and float.
|
|
3592
|
+
float ao = mix(0.58, 1.0, clamp(vHeight / (vOccupant > 0.5 ? 1.30 : 0.92), 0.0, 1.0));
|
|
3593
|
+
vec3 base = tint * partShade * ao * (0.52 + 0.40 * hemi) + tint * key * 0.38 + tint * fill * 0.10;
|
|
3594
|
+
float fres = pow(1.0 - max(dot(normalize(vNormalView), V), 0.0), 3.0);
|
|
3595
|
+
// A brighter rim than the solids get: it picks out every chair's own edge,
|
|
3596
|
+
// which is what stops a block of them merging into one mass up close.
|
|
3597
|
+
base += vec3(0.26, 0.31, 0.38) * fres * 0.55;
|
|
3598
|
+
base = mix(base, uFadeColor, vDim * 0.75);
|
|
3599
|
+
fragColor = vec4(base, 1.0);
|
|
3600
|
+
}`
|
|
3601
|
+
);
|
|
2773
3602
|
var BG_VERT = (
|
|
2774
3603
|
/* glsl */
|
|
2775
3604
|
`#version 300 es
|
|
@@ -2866,7 +3695,7 @@ function createSeatPickProgram(gl) {
|
|
|
2866
3695
|
uniforms: {
|
|
2867
3696
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
2868
3697
|
uSeatScale: { value: 1 },
|
|
2869
|
-
uMinPixels: { value:
|
|
3698
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
2870
3699
|
uPixelToWorld: { value: 2e-3 }
|
|
2871
3700
|
}
|
|
2872
3701
|
});
|
|
@@ -2911,11 +3740,32 @@ function createSeatProgram(gl) {
|
|
|
2911
3740
|
uniforms: {
|
|
2912
3741
|
uSeatRadius: { value: SEAT_DOT_RADIUS_M },
|
|
2913
3742
|
uSeatScale: { value: 1 },
|
|
2914
|
-
uMinPixels: { value:
|
|
3743
|
+
uMinPixels: { value: SEAT_MIN_PIXELS_NEAR },
|
|
2915
3744
|
uPixelToWorld: { value: 2e-3 },
|
|
2916
3745
|
uSeatFade: { value: 0 },
|
|
2917
3746
|
uFocusFloor: { value: -1 },
|
|
2918
|
-
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) }
|
|
3747
|
+
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
|
|
3748
|
+
uChairFull: { value: CHAIR_FULL_M },
|
|
3749
|
+
uChairNone: { value: CHAIR_NONE_M }
|
|
3750
|
+
}
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
function createChairProgram(gl) {
|
|
3754
|
+
return new Program(gl, {
|
|
3755
|
+
vertex: CHAIR_VERT,
|
|
3756
|
+
fragment: CHAIR_FRAG,
|
|
3757
|
+
transparent: false,
|
|
3758
|
+
depthTest: true,
|
|
3759
|
+
depthWrite: true,
|
|
3760
|
+
cullFace: false,
|
|
3761
|
+
uniforms: {
|
|
3762
|
+
uKeyDir: { value: new Float32Array([0.38, 0.86, 0.34]) },
|
|
3763
|
+
uFadeColor: { value: new Float32Array([0.32, 0.37, 0.43]) },
|
|
3764
|
+
uChairFull: { value: CHAIR_FULL_M },
|
|
3765
|
+
uChairNone: { value: CHAIR_NONE_M },
|
|
3766
|
+
uBackRake: { value: BACK_RAKE_SLOPE },
|
|
3767
|
+
uBackBase: { value: BACK_BASE_M },
|
|
3768
|
+
uFocusFloor: { value: -1 }
|
|
2919
3769
|
}
|
|
2920
3770
|
});
|
|
2921
3771
|
}
|
|
@@ -2934,12 +3784,13 @@ function createBackgroundProgram(gl, top, bottom) {
|
|
|
2934
3784
|
}
|
|
2935
3785
|
|
|
2936
3786
|
// src/view3d/scene/build.ts
|
|
2937
|
-
function writeSeatColors(iColor, iState, start, count, states) {
|
|
3787
|
+
function writeSeatColors(iColor, iState, start, count, states, iCategory) {
|
|
2938
3788
|
for (let i = start; i < start + count; i++) {
|
|
2939
|
-
const
|
|
2940
|
-
|
|
2941
|
-
iColor[i * 3
|
|
2942
|
-
iColor[i * 3 +
|
|
3789
|
+
const useCategory = iCategory && iState[i] === 0;
|
|
3790
|
+
const c = useCategory ? null : states[iState[i]] ?? states[0];
|
|
3791
|
+
iColor[i * 3] = c ? c[0] : iCategory[i * 3];
|
|
3792
|
+
iColor[i * 3 + 1] = c ? c[1] : iCategory[i * 3 + 1];
|
|
3793
|
+
iColor[i * 3 + 2] = c ? c[2] : iCategory[i * 3 + 2];
|
|
2943
3794
|
}
|
|
2944
3795
|
}
|
|
2945
3796
|
var SEAT_QUAD = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
|
|
@@ -2965,7 +3816,7 @@ function buildGpuScene(gl, model) {
|
|
|
2965
3816
|
const seatProg = createSeatProgram(gl);
|
|
2966
3817
|
const iColor = new Float32Array(model.seats.count * 3);
|
|
2967
3818
|
const stateColors = SEAT_STATES.map((st) => model.theme.seatStates[st]);
|
|
2968
|
-
writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors);
|
|
3819
|
+
writeSeatColors(iColor, model.seats.iState, 0, model.seats.count, stateColors, model.seats.iCategory);
|
|
2969
3820
|
const seatGeo = new Geometry(gl, {
|
|
2970
3821
|
position: { size: 2, data: SEAT_QUAD },
|
|
2971
3822
|
iOffset: { size: 3, data: model.seats.iPosition, instanced: 1 },
|
|
@@ -2981,17 +3832,105 @@ function buildGpuScene(gl, model) {
|
|
|
2981
3832
|
seatMesh.frustumCulled = false;
|
|
2982
3833
|
if (model.seats.count > 0) seatMesh.setParent(main);
|
|
2983
3834
|
const colorAttr = seatGeo.attributes.iColor;
|
|
2984
|
-
|
|
3835
|
+
const chairBase = buildChairMesh();
|
|
3836
|
+
const chairProg = createChairProgram(gl);
|
|
3837
|
+
const CAP = CHAIR_MAX_INSTANCES;
|
|
3838
|
+
const cOffset = new Float32Array(CAP * 3);
|
|
3839
|
+
const cColor = new Float32Array(CAP * 3);
|
|
3840
|
+
const cRadius = new Float32Array(CAP);
|
|
3841
|
+
const cYaw = new Float32Array(CAP);
|
|
3842
|
+
const cRing = new Float32Array(CAP * 3);
|
|
3843
|
+
const cFloor = new Float32Array(CAP);
|
|
3844
|
+
const cSeed = new Float32Array(CAP);
|
|
3845
|
+
const chairGeo = new Geometry(gl, {
|
|
3846
|
+
position: { size: 3, data: chairBase.position },
|
|
3847
|
+
normal: { size: 3, data: chairBase.normal },
|
|
3848
|
+
part: { size: 1, data: chairBase.part },
|
|
3849
|
+
index: { data: chairBase.index },
|
|
3850
|
+
iOffset: { size: 3, data: cOffset, instanced: 1 },
|
|
3851
|
+
iColor: { size: 3, data: cColor, instanced: 1 },
|
|
3852
|
+
iRadius: { size: 1, data: cRadius, instanced: 1 },
|
|
3853
|
+
iYaw: { size: 1, data: cYaw, instanced: 1 },
|
|
3854
|
+
iRing: { size: 3, data: cRing, instanced: 1 },
|
|
3855
|
+
iFloor: { size: 1, data: cFloor, instanced: 1 },
|
|
3856
|
+
iSeed: { size: 1, data: cSeed, instanced: 1 }
|
|
3857
|
+
});
|
|
3858
|
+
const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
|
|
3859
|
+
chairMesh.frustumCulled = false;
|
|
3860
|
+
let nearCount = 0;
|
|
3861
|
+
let nearIndices = new Int32Array(0);
|
|
3862
|
+
const writeChairColors = () => {
|
|
3863
|
+
const src = model.seats;
|
|
3864
|
+
for (let k = 0; k < nearCount; k++) {
|
|
3865
|
+
const i = nearIndices[k];
|
|
3866
|
+
const useCategory = src.iCategory && src.iState[i] === 0;
|
|
3867
|
+
const c = useCategory ? null : stateColors[src.iState[i]] ?? stateColors[0];
|
|
3868
|
+
if (c) {
|
|
3869
|
+
cColor[k * 3] = c[0];
|
|
3870
|
+
cColor[k * 3 + 1] = c[1];
|
|
3871
|
+
cColor[k * 3 + 2] = c[2];
|
|
3872
|
+
} else {
|
|
3873
|
+
const u = upholsteryTone([src.iCategory[i * 3], src.iCategory[i * 3 + 1], src.iCategory[i * 3 + 2]]);
|
|
3874
|
+
cColor[k * 3] = u[0];
|
|
3875
|
+
cColor[k * 3 + 1] = u[1];
|
|
3876
|
+
cColor[k * 3 + 2] = u[2];
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
chairGeo.attributes.iColor.needsUpdate = true;
|
|
3880
|
+
};
|
|
3881
|
+
const scene = {
|
|
2985
3882
|
main,
|
|
2986
3883
|
background,
|
|
2987
3884
|
seatProgram: seatProg,
|
|
2988
3885
|
solidProgram: solidProg,
|
|
3886
|
+
chairProgram: chairProg,
|
|
2989
3887
|
seatGeometry: seatGeo,
|
|
2990
3888
|
solidGeometry: solidGeo,
|
|
2991
3889
|
drawCalls: 3,
|
|
3890
|
+
setNearSeats(indices, count) {
|
|
3891
|
+
const n = Math.min(count, CAP);
|
|
3892
|
+
nearIndices = indices;
|
|
3893
|
+
nearCount = n;
|
|
3894
|
+
if (n === 0) {
|
|
3895
|
+
if (chairMesh.parent) chairMesh.setParent(null);
|
|
3896
|
+
chairGeo.instancedCount = 0;
|
|
3897
|
+
scene.drawCalls = 3;
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
const src = model.seats;
|
|
3901
|
+
for (let k = 0; k < n; k++) {
|
|
3902
|
+
const i = indices[k];
|
|
3903
|
+
cOffset[k * 3] = src.iPosition[i * 3];
|
|
3904
|
+
cOffset[k * 3 + 1] = src.iPosition[i * 3 + 1];
|
|
3905
|
+
cOffset[k * 3 + 2] = src.iPosition[i * 3 + 2];
|
|
3906
|
+
cRadius[k] = src.iChairWidth[i];
|
|
3907
|
+
cYaw[k] = src.iYaw[i];
|
|
3908
|
+
cRing[k * 3] = src.iRing[i * 3];
|
|
3909
|
+
cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
|
|
3910
|
+
cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
|
|
3911
|
+
cFloor[k] = src.iFloor[i];
|
|
3912
|
+
cSeed[k] = seatOccupantSeed(src.iState[i], i);
|
|
3913
|
+
}
|
|
3914
|
+
writeChairColors();
|
|
3915
|
+
chairGeo.attributes.iOffset.needsUpdate = true;
|
|
3916
|
+
chairGeo.attributes.iRadius.needsUpdate = true;
|
|
3917
|
+
chairGeo.attributes.iYaw.needsUpdate = true;
|
|
3918
|
+
chairGeo.attributes.iRing.needsUpdate = true;
|
|
3919
|
+
chairGeo.attributes.iFloor.needsUpdate = true;
|
|
3920
|
+
chairGeo.attributes.iSeed.needsUpdate = true;
|
|
3921
|
+
chairGeo.instancedCount = n;
|
|
3922
|
+
if (!chairMesh.parent) chairMesh.setParent(main);
|
|
3923
|
+
scene.drawCalls = 4;
|
|
3924
|
+
},
|
|
3925
|
+
nearSeatCount() {
|
|
3926
|
+
return nearCount;
|
|
3927
|
+
},
|
|
2992
3928
|
uploadSeatStateRuns(runs) {
|
|
2993
3929
|
if (!runs.length) return;
|
|
2994
|
-
|
|
3930
|
+
if (nearCount) writeChairColors();
|
|
3931
|
+
for (const run of runs) {
|
|
3932
|
+
writeSeatColors(iColor, model.seats.iState, run.start, run.length, stateColors, model.seats.iCategory);
|
|
3933
|
+
}
|
|
2995
3934
|
const buffer = colorAttr.buffer;
|
|
2996
3935
|
if (!buffer) {
|
|
2997
3936
|
colorAttr.needsUpdate = true;
|
|
@@ -3010,8 +3949,11 @@ function buildGpuScene(gl, model) {
|
|
|
3010
3949
|
solidProg.remove();
|
|
3011
3950
|
seatGeo.remove();
|
|
3012
3951
|
seatProg.remove();
|
|
3952
|
+
chairGeo.remove();
|
|
3953
|
+
chairProg.remove();
|
|
3013
3954
|
}
|
|
3014
3955
|
};
|
|
3956
|
+
return scene;
|
|
3015
3957
|
}
|
|
3016
3958
|
|
|
3017
3959
|
// src/view3d/pick/pickPipeline.ts
|
|
@@ -3494,6 +4436,317 @@ function mountPanorama(container, view, opts = {}) {
|
|
|
3494
4436
|
};
|
|
3495
4437
|
}
|
|
3496
4438
|
|
|
4439
|
+
// src/view3d/scene/panoSphere.ts
|
|
4440
|
+
import { Geometry as Geometry3, Mesh as Mesh3, Program as Program4, Texture } from "ogl";
|
|
4441
|
+
var PANO_SEGMENTS_LON = 64;
|
|
4442
|
+
var PANO_SEGMENTS_LAT = 32;
|
|
4443
|
+
var PANO_RADIUS_M = 500;
|
|
4444
|
+
function buildPanoSphere(segmentsLon = PANO_SEGMENTS_LON, segmentsLat = PANO_SEGMENTS_LAT, radius = PANO_RADIUS_M) {
|
|
4445
|
+
const lonCount = segmentsLon + 1;
|
|
4446
|
+
const latCount = segmentsLat + 1;
|
|
4447
|
+
const vertexCount = lonCount * latCount;
|
|
4448
|
+
const position = new Float32Array(vertexCount * 3);
|
|
4449
|
+
const uv = new Float32Array(vertexCount * 2);
|
|
4450
|
+
for (let iLat = 0; iLat < latCount; iLat++) {
|
|
4451
|
+
const v = iLat / segmentsLat;
|
|
4452
|
+
const polar = v * Math.PI;
|
|
4453
|
+
const sinPolar = Math.sin(polar);
|
|
4454
|
+
const cosPolar = Math.cos(polar);
|
|
4455
|
+
for (let iLon = 0; iLon < lonCount; iLon++) {
|
|
4456
|
+
const u = iLon / segmentsLon;
|
|
4457
|
+
const azimuth = (u - 0.5) * Math.PI * 2;
|
|
4458
|
+
const i = iLat * lonCount + iLon;
|
|
4459
|
+
position[i * 3] = radius * sinPolar * Math.sin(azimuth);
|
|
4460
|
+
position[i * 3 + 1] = radius * cosPolar;
|
|
4461
|
+
position[i * 3 + 2] = -radius * sinPolar * Math.cos(azimuth);
|
|
4462
|
+
uv[i * 2] = u;
|
|
4463
|
+
uv[i * 2 + 1] = v;
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
const index = new Uint16Array(segmentsLon * segmentsLat * 6);
|
|
4467
|
+
let w = 0;
|
|
4468
|
+
for (let iLat = 0; iLat < segmentsLat; iLat++) {
|
|
4469
|
+
for (let iLon = 0; iLon < segmentsLon; iLon++) {
|
|
4470
|
+
const a = iLat * lonCount + iLon;
|
|
4471
|
+
const b = a + lonCount;
|
|
4472
|
+
index[w++] = a;
|
|
4473
|
+
index[w++] = b;
|
|
4474
|
+
index[w++] = a + 1;
|
|
4475
|
+
index[w++] = a + 1;
|
|
4476
|
+
index[w++] = b;
|
|
4477
|
+
index[w++] = b + 1;
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
return { position, uv, index };
|
|
4481
|
+
}
|
|
4482
|
+
function bearingPitchToDirection(bearingDeg, pitchDeg) {
|
|
4483
|
+
const yaw = bearingDeg * Math.PI / 180;
|
|
4484
|
+
const pitch = pitchDeg * Math.PI / 180;
|
|
4485
|
+
const cosPitch = Math.cos(pitch);
|
|
4486
|
+
return [cosPitch * Math.sin(yaw), Math.sin(pitch), -cosPitch * Math.cos(yaw)];
|
|
4487
|
+
}
|
|
4488
|
+
var PANO_VERT = (
|
|
4489
|
+
/* glsl */
|
|
4490
|
+
`#version 300 es
|
|
4491
|
+
precision highp float;
|
|
4492
|
+
in vec3 position;
|
|
4493
|
+
in vec2 uv;
|
|
4494
|
+
uniform mat4 modelViewMatrix;
|
|
4495
|
+
uniform mat4 projectionMatrix;
|
|
4496
|
+
out vec2 vUv;
|
|
4497
|
+
void main() {
|
|
4498
|
+
vUv = uv;
|
|
4499
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
4500
|
+
}`
|
|
4501
|
+
);
|
|
4502
|
+
var PANO_FRAG = (
|
|
4503
|
+
/* glsl */
|
|
4504
|
+
`#version 300 es
|
|
4505
|
+
precision highp float;
|
|
4506
|
+
in vec2 vUv;
|
|
4507
|
+
uniform sampler2D uPano;
|
|
4508
|
+
uniform float uOpacity;
|
|
4509
|
+
out vec4 fragColor;
|
|
4510
|
+
void main() {
|
|
4511
|
+
vec3 rgb = texture(uPano, vUv).rgb;
|
|
4512
|
+
fragColor = vec4(rgb, uOpacity);
|
|
4513
|
+
}`
|
|
4514
|
+
);
|
|
4515
|
+
function createPanoSphere(gl, image) {
|
|
4516
|
+
const { position, uv, index } = buildPanoSphere();
|
|
4517
|
+
const geometry = new Geometry3(gl, {
|
|
4518
|
+
position: { size: 3, data: position },
|
|
4519
|
+
uv: { size: 2, data: uv },
|
|
4520
|
+
index: { data: index }
|
|
4521
|
+
});
|
|
4522
|
+
const texture = new Texture(gl, {
|
|
4523
|
+
image,
|
|
4524
|
+
// The seam column is duplicated in geometry, so CLAMP is correct and avoids
|
|
4525
|
+
// a wrapped bilinear tap bleeding the far edge of the image into it.
|
|
4526
|
+
wrapS: gl.CLAMP_TO_EDGE,
|
|
4527
|
+
wrapT: gl.CLAMP_TO_EDGE,
|
|
4528
|
+
generateMipmaps: true,
|
|
4529
|
+
// MUST be false, and this is the one line that decides whether the whole
|
|
4530
|
+
// panorama is upside down.
|
|
4531
|
+
//
|
|
4532
|
+
// OGL defaults `flipY` to true for 2D textures, which is right for the usual
|
|
4533
|
+
// case: a UV of 0 means the BOTTOM of a quad, an image's first row is its
|
|
4534
|
+
// TOP, and flipping on upload reconciles the two. This sphere is the other
|
|
4535
|
+
// case. Its `v` is authored in IMAGE space — v=0 is the top of the equirect
|
|
4536
|
+
// and maps to the top of the sphere (`buildPanoSphere`, and the mapping
|
|
4537
|
+
// `generatePanorama` and the 2D viewer both use). Flipping on upload as well
|
|
4538
|
+
// applies the correction twice: the stadium pitch renders on the ceiling and
|
|
4539
|
+
// the audience sits overhead.
|
|
4540
|
+
//
|
|
4541
|
+
// The geometry tests state the v mapping and pass either way — they never
|
|
4542
|
+
// touch GL — so this is not something a unit test can defend. It was caught
|
|
4543
|
+
// by looking at a stadium.
|
|
4544
|
+
flipY: false
|
|
4545
|
+
});
|
|
4546
|
+
const program = new Program4(gl, {
|
|
4547
|
+
vertex: PANO_VERT,
|
|
4548
|
+
fragment: PANO_FRAG,
|
|
4549
|
+
uniforms: { uPano: { value: texture }, uOpacity: { value: 0 } },
|
|
4550
|
+
transparent: true,
|
|
4551
|
+
depthTest: false,
|
|
4552
|
+
depthWrite: false
|
|
4553
|
+
});
|
|
4554
|
+
const mesh = new Mesh3(gl, { geometry, program });
|
|
4555
|
+
return {
|
|
4556
|
+
mesh,
|
|
4557
|
+
setOpacity(value) {
|
|
4558
|
+
program.uniforms.uOpacity.value = Math.max(0, Math.min(1, value));
|
|
4559
|
+
},
|
|
4560
|
+
dispose() {
|
|
4561
|
+
mesh.setParent(null);
|
|
4562
|
+
geometry.remove();
|
|
4563
|
+
if (texture.texture) gl.deleteTexture(texture.texture);
|
|
4564
|
+
program.remove();
|
|
4565
|
+
}
|
|
4566
|
+
};
|
|
4567
|
+
}
|
|
4568
|
+
|
|
4569
|
+
// src/view3d/crossfade/panoramaSphere.ts
|
|
4570
|
+
var DEG_PER_PX = 0.15;
|
|
4571
|
+
function loadImage(url) {
|
|
4572
|
+
return new Promise((resolve, reject) => {
|
|
4573
|
+
const img = new Image();
|
|
4574
|
+
img.crossOrigin = "anonymous";
|
|
4575
|
+
img.onload = () => resolve(img);
|
|
4576
|
+
img.onerror = () => reject(new Error("panorama_image_failed"));
|
|
4577
|
+
img.src = url;
|
|
4578
|
+
});
|
|
4579
|
+
}
|
|
4580
|
+
async function mountPanoramaSphere(container, view, deps, opts = {}) {
|
|
4581
|
+
const fadeMs = opts.fadeMs ?? 400;
|
|
4582
|
+
let bearing = view?.initialBearingDeg ?? 0;
|
|
4583
|
+
let pitch = 0;
|
|
4584
|
+
let disposed = false;
|
|
4585
|
+
if (!view && deps.focalWorld) {
|
|
4586
|
+
const p = deps.camera.position;
|
|
4587
|
+
const dx = deps.focalWorld[0] - p.x;
|
|
4588
|
+
const dy = deps.focalWorld[1] - p.y;
|
|
4589
|
+
const dz = deps.focalWorld[2] - p.z;
|
|
4590
|
+
const flat = Math.hypot(dx, dz);
|
|
4591
|
+
if (flat > 1e-3) {
|
|
4592
|
+
bearing = Math.atan2(dx, -dz) * 180 / Math.PI;
|
|
4593
|
+
pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, Math.atan2(dy, flat) * 180 / Math.PI));
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
let sphere = null;
|
|
4597
|
+
if (view) {
|
|
4598
|
+
let image;
|
|
4599
|
+
try {
|
|
4600
|
+
image = await loadImage(view.url);
|
|
4601
|
+
} catch {
|
|
4602
|
+
return null;
|
|
4603
|
+
}
|
|
4604
|
+
try {
|
|
4605
|
+
sphere = createPanoSphere(deps.gl, image);
|
|
4606
|
+
} catch {
|
|
4607
|
+
return null;
|
|
4608
|
+
}
|
|
4609
|
+
sphere.mesh.renderOrder = 999;
|
|
4610
|
+
sphere.mesh.setParent(deps.scene);
|
|
4611
|
+
}
|
|
4612
|
+
const priorFov = deps.camera.fov;
|
|
4613
|
+
deps.camera.perspective({ fov: VFOV_DEG, aspect: deps.camera.aspect });
|
|
4614
|
+
const priorQuat = deps.camera.quaternion.slice();
|
|
4615
|
+
const aim = () => {
|
|
4616
|
+
const p = deps.camera.position;
|
|
4617
|
+
sphere?.mesh.position.set(p.x, p.y, p.z);
|
|
4618
|
+
const [dx, dy, dz] = bearingPitchToDirection(bearing, pitch);
|
|
4619
|
+
deps.camera.lookAt([p.x + dx, p.y + dy, p.z + dz]);
|
|
4620
|
+
deps.requestRender();
|
|
4621
|
+
};
|
|
4622
|
+
aim();
|
|
4623
|
+
const root = document.createElement("div");
|
|
4624
|
+
root.setAttribute("role", "dialog");
|
|
4625
|
+
root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
|
|
4626
|
+
Object.assign(root.style, {
|
|
4627
|
+
position: "absolute",
|
|
4628
|
+
inset: "0",
|
|
4629
|
+
zIndex: "10",
|
|
4630
|
+
cursor: "grab",
|
|
4631
|
+
touchAction: "none"
|
|
4632
|
+
});
|
|
4633
|
+
const closeBtn = document.createElement("button");
|
|
4634
|
+
closeBtn.type = "button";
|
|
4635
|
+
closeBtn.setAttribute("aria-label", "Close");
|
|
4636
|
+
closeBtn.textContent = "\u2715";
|
|
4637
|
+
Object.assign(closeBtn.style, {
|
|
4638
|
+
position: "absolute",
|
|
4639
|
+
top: "12px",
|
|
4640
|
+
right: "12px",
|
|
4641
|
+
zIndex: "2",
|
|
4642
|
+
width: "34px",
|
|
4643
|
+
height: "34px",
|
|
4644
|
+
borderRadius: "999px",
|
|
4645
|
+
cursor: "pointer",
|
|
4646
|
+
border: "1px solid rgba(255,255,255,0.25)",
|
|
4647
|
+
background: "rgba(8,12,18,0.6)",
|
|
4648
|
+
color: "#e6edf3",
|
|
4649
|
+
fontSize: "15px",
|
|
4650
|
+
lineHeight: "1"
|
|
4651
|
+
});
|
|
4652
|
+
root.appendChild(closeBtn);
|
|
4653
|
+
const hint = document.createElement("div");
|
|
4654
|
+
hint.textContent = "Drag to look around \xB7 Esc to close";
|
|
4655
|
+
Object.assign(hint.style, {
|
|
4656
|
+
position: "absolute",
|
|
4657
|
+
bottom: "12px",
|
|
4658
|
+
left: "0",
|
|
4659
|
+
right: "0",
|
|
4660
|
+
textAlign: "center",
|
|
4661
|
+
color: "rgba(230,237,243,0.7)",
|
|
4662
|
+
font: "12px ui-sans-serif, system-ui, sans-serif",
|
|
4663
|
+
pointerEvents: "none"
|
|
4664
|
+
});
|
|
4665
|
+
root.appendChild(hint);
|
|
4666
|
+
container.appendChild(root);
|
|
4667
|
+
let dragging = false;
|
|
4668
|
+
let lastX = 0;
|
|
4669
|
+
let lastY = 0;
|
|
4670
|
+
const onDown = (e) => {
|
|
4671
|
+
if (e.target === closeBtn) return;
|
|
4672
|
+
dragging = true;
|
|
4673
|
+
lastX = e.clientX;
|
|
4674
|
+
lastY = e.clientY;
|
|
4675
|
+
root.setPointerCapture?.(e.pointerId);
|
|
4676
|
+
root.style.cursor = "grabbing";
|
|
4677
|
+
};
|
|
4678
|
+
const onMove = (e) => {
|
|
4679
|
+
if (!dragging) return;
|
|
4680
|
+
bearing += (e.clientX - lastX) * DEG_PER_PX;
|
|
4681
|
+
pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.clientY - lastY) * DEG_PER_PX));
|
|
4682
|
+
lastX = e.clientX;
|
|
4683
|
+
lastY = e.clientY;
|
|
4684
|
+
aim();
|
|
4685
|
+
};
|
|
4686
|
+
const onUp = () => {
|
|
4687
|
+
dragging = false;
|
|
4688
|
+
root.style.cursor = "grab";
|
|
4689
|
+
};
|
|
4690
|
+
root.addEventListener("pointerdown", onDown);
|
|
4691
|
+
root.addEventListener("pointermove", onMove);
|
|
4692
|
+
root.addEventListener("pointerup", onUp);
|
|
4693
|
+
root.addEventListener("pointercancel", onUp);
|
|
4694
|
+
const onKey = (e) => {
|
|
4695
|
+
if (e.key !== "Escape" || disposed) return;
|
|
4696
|
+
e.stopPropagation();
|
|
4697
|
+
handle.close();
|
|
4698
|
+
};
|
|
4699
|
+
window.addEventListener("keydown", onKey);
|
|
4700
|
+
let raf = 0;
|
|
4701
|
+
const fadeTo = (target, done) => {
|
|
4702
|
+
if (!sphere) {
|
|
4703
|
+
done?.();
|
|
4704
|
+
return;
|
|
4705
|
+
}
|
|
4706
|
+
const from = target === 1 ? 0 : 1;
|
|
4707
|
+
const start = performance.now();
|
|
4708
|
+
const step = () => {
|
|
4709
|
+
if (disposed) return;
|
|
4710
|
+
const t = fadeMs <= 0 ? 1 : Math.min(1, (performance.now() - start) / fadeMs);
|
|
4711
|
+
sphere?.setOpacity(from + (target - from) * t);
|
|
4712
|
+
deps.requestRender();
|
|
4713
|
+
if (t < 1) raf = requestAnimationFrame(step);
|
|
4714
|
+
else done?.();
|
|
4715
|
+
};
|
|
4716
|
+
raf = requestAnimationFrame(step);
|
|
4717
|
+
};
|
|
4718
|
+
fadeTo(1);
|
|
4719
|
+
const teardown = () => {
|
|
4720
|
+
if (disposed) return;
|
|
4721
|
+
disposed = true;
|
|
4722
|
+
cancelAnimationFrame(raf);
|
|
4723
|
+
root.removeEventListener("pointerdown", onDown);
|
|
4724
|
+
root.removeEventListener("pointermove", onMove);
|
|
4725
|
+
root.removeEventListener("pointerup", onUp);
|
|
4726
|
+
root.removeEventListener("pointercancel", onUp);
|
|
4727
|
+
window.removeEventListener("keydown", onKey);
|
|
4728
|
+
root.remove();
|
|
4729
|
+
sphere?.dispose();
|
|
4730
|
+
deps.camera.perspective({ fov: priorFov, aspect: deps.camera.aspect });
|
|
4731
|
+
deps.camera.quaternion.set(priorQuat[0], priorQuat[1], priorQuat[2], priorQuat[3]);
|
|
4732
|
+
deps.requestRender();
|
|
4733
|
+
};
|
|
4734
|
+
const handle = {
|
|
4735
|
+
close() {
|
|
4736
|
+
if (disposed) return;
|
|
4737
|
+
const finish = () => {
|
|
4738
|
+
teardown();
|
|
4739
|
+
opts.onClose?.();
|
|
4740
|
+
};
|
|
4741
|
+
cancelAnimationFrame(raf);
|
|
4742
|
+
fadeTo(0, finish);
|
|
4743
|
+
},
|
|
4744
|
+
dispose: teardown
|
|
4745
|
+
};
|
|
4746
|
+
closeBtn.addEventListener("click", () => handle.close());
|
|
4747
|
+
return handle;
|
|
4748
|
+
}
|
|
4749
|
+
|
|
3497
4750
|
// src/view3d/analytics.ts
|
|
3498
4751
|
var now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
3499
4752
|
var Analytics3D = class {
|
|
@@ -3579,6 +4832,28 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3579
4832
|
gpu.seatProgram.uniforms.uSeatRadius.value = SEAT_DOT_RADIUS_M * model.theme.seatScale;
|
|
3580
4833
|
gpu.seatProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
3581
4834
|
gpu.solidProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
4835
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = focusedFloor;
|
|
4836
|
+
lastGatherX = Infinity;
|
|
4837
|
+
};
|
|
4838
|
+
const nearIndex = new NearFieldIndex(model.seats.iPosition, model.seats.count);
|
|
4839
|
+
const nearBuf = new Int32Array(CHAIR_MAX_INSTANCES);
|
|
4840
|
+
let lastGatherX = Infinity;
|
|
4841
|
+
let lastGatherZ = Infinity;
|
|
4842
|
+
const updateNearField = () => {
|
|
4843
|
+
if (!gpu) return;
|
|
4844
|
+
const cam = orbit.camera.position;
|
|
4845
|
+
const moved2 = Math.hypot(cam.x - lastGatherX, cam.z - lastGatherZ);
|
|
4846
|
+
if (moved2 < CHAIR_REBUILD_M) return;
|
|
4847
|
+
lastGatherX = cam.x;
|
|
4848
|
+
lastGatherZ = cam.z;
|
|
4849
|
+
const outside = Math.hypot(cam.x - model.bounds.center[0], cam.z - model.bounds.center[2]) - model.bounds.radius;
|
|
4850
|
+
if (outside > CHAIR_GATHER_M) {
|
|
4851
|
+
if (gpu.nearSeatCount()) gpu.setNearSeats(nearBuf, 0);
|
|
4852
|
+
return;
|
|
4853
|
+
}
|
|
4854
|
+
const n = nearIndex.gather(cam.x, cam.z, CHAIR_GATHER_M, nearBuf);
|
|
4855
|
+
if (n === 0 && gpu.nearSeatCount() === 0) return;
|
|
4856
|
+
gpu.setNearSeats(nearBuf, n);
|
|
3582
4857
|
};
|
|
3583
4858
|
const glctx = new GLContext(container, {
|
|
3584
4859
|
onContextLost: () => {
|
|
@@ -3625,7 +4900,9 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3625
4900
|
const u = gpu.seatProgram.uniforms;
|
|
3626
4901
|
u.uSeatScale.value = lod.scale;
|
|
3627
4902
|
u.uSeatFade.value = lod.fade;
|
|
4903
|
+
u.uMinPixels.value = lod.minPixels;
|
|
3628
4904
|
u.uPixelToWorld.value = 2 * Math.tan(orbit.camera.fov * DEG2 / 2) / Math.max(1, glctx.pixelHeight);
|
|
4905
|
+
updateNearField();
|
|
3629
4906
|
glctx.renderer.render({ scene: gpu.background, clear: true });
|
|
3630
4907
|
glctx.renderer.render({ scene: gpu.main, camera: orbit.camera, clear: false });
|
|
3631
4908
|
labelOverlay.update(
|
|
@@ -3633,7 +4910,10 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3633
4910
|
glctx.canvas.clientWidth || 1,
|
|
3634
4911
|
glctx.canvas.clientHeight || 1,
|
|
3635
4912
|
orbit.currentDistance,
|
|
3636
|
-
model.bounds.radius
|
|
4913
|
+
model.bounds.radius,
|
|
4914
|
+
// The dense label rungs rank by real distance from the eye, not by the
|
|
4915
|
+
// orbit radius — at the arrival pose those are wildly different numbers.
|
|
4916
|
+
[orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z]
|
|
3637
4917
|
);
|
|
3638
4918
|
return moving;
|
|
3639
4919
|
});
|
|
@@ -3741,7 +5021,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3741
5021
|
zIndex: "4"
|
|
3742
5022
|
});
|
|
3743
5023
|
overviewChip.addEventListener("click", () => {
|
|
3744
|
-
if (disposed || frozen) return;
|
|
5024
|
+
if (disposed || frozen || panorama) return;
|
|
3745
5025
|
cancelFlight();
|
|
3746
5026
|
removeArriveChip();
|
|
3747
5027
|
orbit.frameSoft(model.bounds, stageAzimuth);
|
|
@@ -3769,18 +5049,36 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3769
5049
|
}
|
|
3770
5050
|
if (disposed || gen !== flightGen) return;
|
|
3771
5051
|
removeArriveChip();
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
5052
|
+
const onClose = () => {
|
|
5053
|
+
panorama = null;
|
|
5054
|
+
labelOverlay.setVisible(true);
|
|
5055
|
+
frozen = false;
|
|
5056
|
+
analytics.panoramaClosed();
|
|
5057
|
+
orbit.resumeAfterFlight(model.focalWorld);
|
|
5058
|
+
loop.requestRender();
|
|
5059
|
+
showArriveChip(seatId, flightGen);
|
|
5060
|
+
};
|
|
5061
|
+
const sceneMode = view.generated === true;
|
|
5062
|
+
const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
|
|
5063
|
+
gl: glctx.gl,
|
|
5064
|
+
scene: gpu.main,
|
|
5065
|
+
camera: orbit.camera,
|
|
5066
|
+
requestRender: () => loop.requestRender(),
|
|
5067
|
+
focalWorld: model.focalWorld
|
|
5068
|
+
}, { fadeMs, seatLabel: seatId, onClose }) : null;
|
|
5069
|
+
if (disposed || gen !== flightGen) {
|
|
5070
|
+
spherical?.dispose();
|
|
5071
|
+
return;
|
|
5072
|
+
}
|
|
5073
|
+
if (spherical) {
|
|
5074
|
+
orbit.syncFromCamera();
|
|
5075
|
+
labelOverlay.setVisible(false);
|
|
5076
|
+
frozen = false;
|
|
5077
|
+
loop.requestRender();
|
|
5078
|
+
panorama = spherical;
|
|
5079
|
+
} else {
|
|
5080
|
+
panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
|
|
5081
|
+
}
|
|
3784
5082
|
analytics.panoramaOpened();
|
|
3785
5083
|
};
|
|
3786
5084
|
const cancelFlight = () => {
|
|
@@ -3906,6 +5204,7 @@ function mountVenue3D(container, input, opts = {}) {
|
|
|
3906
5204
|
if (gpu) {
|
|
3907
5205
|
gpu.seatProgram.uniforms.uFocusFloor.value = value;
|
|
3908
5206
|
gpu.solidProgram.uniforms.uFocusFloor.value = value;
|
|
5207
|
+
gpu.chairProgram.uniforms.uFocusFloor.value = value;
|
|
3909
5208
|
}
|
|
3910
5209
|
focusedFloor = value;
|
|
3911
5210
|
if (index !== null) {
|