@pascal-app/viewer 1.0.7 → 1.1.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.
Files changed (67) hide show
  1. package/README.md +54 -21
  2. package/dist/components/viewer/glb-replace-instances.d.ts +6 -3
  3. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -1
  4. package/dist/components/viewer/glb-replace-instances.js +5 -4
  5. package/dist/components/viewer/glb-scene.d.ts +3 -1
  6. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  7. package/dist/components/viewer/glb-scene.js +2 -1
  8. package/dist/components/viewer/index.d.ts.map +1 -1
  9. package/dist/components/viewer/index.js +8 -4
  10. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  11. package/dist/components/viewer/registered-systems.js +2 -2
  12. package/dist/components/viewer/unsupported-gpu-fallback.d.ts.map +1 -1
  13. package/dist/components/viewer/unsupported-gpu-fallback.js +21 -1
  14. package/dist/components/viewer/use-daylight-rooms.d.ts.map +1 -1
  15. package/dist/components/viewer/use-daylight-rooms.js +75 -33
  16. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  17. package/dist/hooks/use-gltf-ktx2.js +2 -1
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -1
  21. package/dist/lib/asset-url.d.ts +0 -1
  22. package/dist/lib/asset-url.d.ts.map +1 -1
  23. package/dist/lib/asset-url.js +3 -3
  24. package/dist/lib/daylight/room-context.d.ts.map +1 -1
  25. package/dist/lib/daylight/room-context.js +3 -2
  26. package/dist/lib/daylight/room-map.d.ts +124 -0
  27. package/dist/lib/daylight/room-map.d.ts.map +1 -0
  28. package/dist/lib/daylight/room-map.js +630 -0
  29. package/dist/lib/daylight/room-scene.d.ts +4 -2
  30. package/dist/lib/daylight/room-scene.d.ts.map +1 -1
  31. package/dist/lib/daylight/room-scene.js +72 -37
  32. package/dist/lib/daylight/rooms.d.ts +60 -30
  33. package/dist/lib/daylight/rooms.d.ts.map +1 -1
  34. package/dist/lib/daylight/rooms.js +331 -286
  35. package/dist/lib/ktx2-loader.d.ts.map +1 -1
  36. package/dist/lib/ktx2-loader.js +2 -1
  37. package/dist/lib/measurement-label.d.ts +4 -0
  38. package/dist/lib/measurement-label.d.ts.map +1 -0
  39. package/dist/lib/measurement-label.js +54 -0
  40. package/dist/lib/measurement-presentation.d.ts +5 -0
  41. package/dist/lib/measurement-presentation.d.ts.map +1 -0
  42. package/dist/lib/measurement-presentation.js +8 -0
  43. package/dist/lib/pointer-events.d.ts.map +1 -1
  44. package/dist/lib/pointer-events.js +3 -3
  45. package/dist/lib/texture-reference.d.ts.map +1 -1
  46. package/dist/lib/texture-reference.js +4 -3
  47. package/dist/lib/viewer-config.d.ts +21 -0
  48. package/dist/lib/viewer-config.d.ts.map +1 -0
  49. package/dist/lib/viewer-config.js +21 -0
  50. package/dist/lib/viewer-preference-storage.d.ts +7 -0
  51. package/dist/lib/viewer-preference-storage.d.ts.map +1 -0
  52. package/dist/lib/viewer-preference-storage.js +23 -0
  53. package/dist/store/use-viewer.d.ts +2 -56
  54. package/dist/store/use-viewer.d.ts.map +1 -1
  55. package/dist/store/use-viewer.js +3 -1
  56. package/dist/systems/elevator/elevator-interaction-system.d.ts +16 -2
  57. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
  58. package/dist/systems/elevator/elevator-interaction-system.js +41 -8
  59. package/dist/systems/geometry/geometry-status.d.ts +8 -0
  60. package/dist/systems/geometry/geometry-status.d.ts.map +1 -0
  61. package/dist/systems/geometry/geometry-status.js +24 -0
  62. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  63. package/dist/systems/roof/roof-system.js +107 -65
  64. package/dist/systems/wall/wall-system.d.ts +11 -0
  65. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  66. package/dist/systems/wall/wall-system.js +56 -10
  67. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
- import { Box3, Color, DataTexture, FloatType, Frustum, MathUtils, Matrix4, Mesh, NearestFilter, Raycaster, RedFormat, RGBAFormat, SRGBColorSpace, Vector2, Vector3, } from 'three';
2
- import { abs, clamp, dot, Fn, float, If, int, ivec2, Loop, max, normalize, normalWorld, positionWorld, select, sharedUniformGroup, smoothstep, texture, uniform, uniformArray, vec3, vec4, } from 'three/tsl';
3
- const CELL = 0.1;
1
+ import { Box3, Color, DataTexture, FloatType, Frustum, MathUtils, Matrix4, Mesh, NearestFilter, Raycaster, RGBAFormat, SRGBColorSpace, Vector2, Vector3, } from 'three';
2
+ import { abs, clamp, dot, Fn, float, If, int, ivec2, Loop, max, normalize, normalWorld, positionWorld, select, smoothstep, texture, uniform, vec3, vec4, } from 'three/tsl';
3
+ import { CELL, RoomMap, TILE } from './room-map';
4
4
  /** Texels per portal: [center, halfW] [normal, halfH] [radiance, neighbour] [floorY, mainLamp, gain, kind]. */
5
5
  const PSTRIDE = 4;
6
6
  /** Target average lamp irradiance of a room at night (same units as the ~1.5 sky irradiance of day). */
@@ -8,7 +8,6 @@ const LAMP_E_TARGET = 1.1;
8
8
  /** Lamps have a size (shade/diffuser): never evaluate the inverse square closer than ~0.8 m. */
9
9
  const LAMP_MIN_D2 = 0.64;
10
10
  const DATA_W = 256;
11
- const MAX_LEVELS = 8;
12
11
  /** Texels per exterior lamp: [pos, range] [rgb, cosInner] [spot dir, cosOuter]. */
13
12
  const EXT_STRIDE = 3;
14
13
  const _viewProj = new Matrix4();
@@ -21,13 +20,10 @@ export class RoomLighting {
21
20
  exteriorLamps = [];
22
21
  portals = [];
23
22
  openings = [];
24
- levelY = [0];
23
+ levels = [];
24
+ /** Which room a point is in (tiled per level; see `room-map.ts`). */
25
+ map = new RoomMap();
25
26
  data = new Float32Array(4);
26
- map = new Float32Array(1);
27
- origin = new Vector2();
28
- gridW = 1;
29
- gridH = 1;
30
- levels = 1;
31
27
  lampTexel = new Map();
32
28
  lastSky = null;
33
29
  // ---- runtime controls (the enhanced viewer's daylight preset) -------------------------------
@@ -49,22 +45,10 @@ export class RoomLighting {
49
45
  skyPatch = uniform(3);
50
46
  /** Light through interior openings + threshold crossfade (off on the Low tier). */
51
47
  softRooms = uniform(1);
48
+ /** Camera world Y (live). */
52
49
  cameraY = uniform(0);
53
- focusedLevel = uniform(-1, 'int');
54
50
  // ---- shader inputs that change with the scene ----------------------------------------------
55
- /**
56
- * The level arrays live in one shared group, uploaded when a level moves: an array uniform in
57
- * the default (per-object) group is re-sent for every object in every pass, whether it changed
58
- * or not — some 760 uploads a frame in a furnished flat.
59
- */
60
- levelGroup = sharedUniformGroup('daylightLevels');
61
51
  u = {
62
- levelYs: uniformArray(new Array(MAX_LEVELS).fill(1e9), 'float').setGroup(this.levelGroup),
63
- levelOffsets: uniformArray(new Array(MAX_LEVELS).fill(0), 'float').setGroup(this.levelGroup),
64
- levelCount: uniform(1, 'int'),
65
- origin: uniform(new Vector2()),
66
- gridW: uniform(1, 'int'),
67
- gridH: uniform(1, 'int'),
68
52
  portalCount: uniform(0, 'int'),
69
53
  portalBase: uniform(0, 'int'),
70
54
  extLampStart: uniform(0, 'int'),
@@ -73,8 +57,7 @@ export class RoomLighting {
73
57
  activeBase: uniform(0, 'int'),
74
58
  spillCount: uniform(0, 'int'),
75
59
  extActiveCount: uniform(0, 'int'),
76
- map: texture(emptyTexture(RedFormat)),
77
- data: texture(emptyTexture(RGBAFormat)),
60
+ data: texture(emptyTexture()),
78
61
  };
79
62
  /** Rebuild from a scene: rooms, lamps, portals, openings, room-id map. */
80
63
  build(input) {
@@ -83,24 +66,11 @@ export class RoomLighting {
83
66
  this.portals = [];
84
67
  this.openings = [];
85
68
  this.lampTexel.clear();
86
- const b = input.bounds;
87
- if (b.isEmpty()) {
88
- this.levelY = [0];
89
- this.gridW = this.gridH = this.levels = 1;
90
- this.map = new Float32Array(1);
91
- this.uploadMap();
92
- this.allocateData();
93
- return;
94
- }
95
- this.origin.set(b.min.x - 1, b.min.z - 1);
96
- this.gridW = Math.max(1, Math.ceil((b.max.x - b.min.x + 2) / CELL));
97
- this.gridH = Math.max(1, Math.ceil((b.max.z - b.min.z + 2) / CELL));
98
- this.levelY = input.levelY.length ? input.levelY.slice(0, MAX_LEVELS) : [0];
99
- this.levels = this.levelY.length;
69
+ this.levels = input.levels.length ? input.levels : [{ y: 0, building: 0 }];
100
70
  // ---- rooms ---------------------------------------------------------------------------
101
71
  const raycaster = new Raycaster();
102
72
  input.configureRaycaster?.(raycaster);
103
- const coverObjects = input.covers.map((c) => c.object);
73
+ const coverGrid = new MeshGrid(input.covers.map((c) => c.object));
104
74
  const coverKind = new Map();
105
75
  for (const c of input.covers)
106
76
  c.object.traverse((o) => coverKind.set(o, c.kind));
@@ -112,16 +82,29 @@ export class RoomLighting {
112
82
  }
113
83
  return '';
114
84
  };
85
+ // Level order inside each building (for the ceiling fallback) before the map is built.
86
+ const above = this.levels.map((level, i) => {
87
+ let next = -1;
88
+ this.levels.forEach((other, j) => {
89
+ if (j === i || other.building !== level.building || other.y <= level.y)
90
+ return;
91
+ if (next < 0 || other.y < this.levels[next].y)
92
+ next = j;
93
+ });
94
+ return next;
95
+ });
115
96
  for (const zone of input.zones) {
116
- const levelIndex = MathUtils.clamp(zone.levelIndex, 0, this.levels - 1);
117
- const floorY = this.levelY[levelIndex] ?? 0;
118
- const nextY = this.levelY[levelIndex + 1];
119
- const area = Math.abs(polygonArea(zone.polygon));
120
- const c = polygonCentroid(zone.polygon);
97
+ const levelIndex = MathUtils.clamp(zone.levelIndex, 0, this.levels.length - 1);
98
+ const floorY = this.levels[levelIndex].y;
99
+ const nextY = above[levelIndex] >= 0 ? this.levels[above[levelIndex]].y : undefined;
100
+ const holes = zone.holes ?? [];
101
+ const area = Math.max(0, Math.abs(polygonArea(zone.polygon)) -
102
+ holes.reduce((sum, hole) => sum + Math.abs(polygonArea(hole)), 0));
103
+ const c = interiorPoint(zone.polygon, holes);
121
104
  // Enclosed = something overhead (ceiling / roof / slab above). Open terraces get sky light.
122
105
  raycaster.set(new Vector3(c.x, floorY + 1.2, c.y), new Vector3(0, 1, 0));
123
106
  raycaster.far = 30;
124
- const hits = intersectGeometry(raycaster, coverObjects, true);
107
+ const hits = intersectGeometry(raycaster, coverGrid.near(c.x, c.y, c.x, c.y), false);
125
108
  const hit = hits[0];
126
109
  const top = hits[hits.length - 1];
127
110
  const topKind = top ? kindOf(top.object) : '';
@@ -142,52 +125,14 @@ export class RoomLighting {
142
125
  portals: [],
143
126
  });
144
127
  }
145
- // ---- room-id map (levels stacked along Y of the texture) -----------------------------
146
- const map = new Float32Array(this.gridW * this.gridH * this.levels);
147
- this.rooms.forEach((room, ri) => {
148
- const poly = room.zone.polygon;
149
- let minX = Number.POSITIVE_INFINITY;
150
- let minZ = Number.POSITIVE_INFINITY;
151
- let maxX = Number.NEGATIVE_INFINITY;
152
- let maxZ = Number.NEGATIVE_INFINITY;
153
- for (const [x, z] of poly) {
154
- minX = Math.min(minX, x);
155
- maxX = Math.max(maxX, x);
156
- minZ = Math.min(minZ, z);
157
- maxZ = Math.max(maxZ, z);
158
- }
159
- const i0 = Math.max(0, Math.floor((minX - this.origin.x) / CELL));
160
- const i1 = Math.min(this.gridW - 1, Math.ceil((maxX - this.origin.x) / CELL));
161
- const j0 = Math.max(0, Math.floor((minZ - this.origin.y) / CELL));
162
- const j1 = Math.min(this.gridH - 1, Math.ceil((maxZ - this.origin.y) / CELL));
163
- const rowBase = room.levelIndex * this.gridH;
164
- for (let j = j0; j <= j1; j++) {
165
- const z = this.origin.y + (j + 0.5) * CELL;
166
- for (let i = i0; i <= i1; i++) {
167
- const x = this.origin.x + (i + 0.5) * CELL;
168
- if (pointInPolygon(x, z, poly))
169
- map[(rowBase + j) * this.gridW + i] = ri + 1;
170
- }
171
- }
172
- });
173
- // Dilate by 2 cells into empty cells so wall-thickness gaps and polygon slop still resolve.
174
- for (let pass = 0; pass < 2; pass++) {
175
- const src = map.slice();
176
- for (let l = 0; l < this.levels; l++)
177
- for (let j = 1; j < this.gridH - 1; j++)
178
- for (let i = 1; i < this.gridW - 1; i++) {
179
- const k = (l * this.gridH + j) * this.gridW + i;
180
- if (src[k])
181
- continue;
182
- const n = src[k - 1] || src[k + 1] || src[k - this.gridW] || src[k + this.gridW];
183
- if (n)
184
- map[k] = n;
185
- }
186
- }
187
- this.map = map;
188
- this.uploadMap();
189
- // ---- openings between rooms (soft room map) -------------------------------------------
190
- this.openings = this.detectOpenings(input, raycaster);
128
+ // ---- room map + openings between rooms (walked on each level's grid) ---------------------
129
+ const openings = this.openingSearch(input, raycaster);
130
+ this.map.build(this.levels, this.rooms.map((room) => ({
131
+ level: room.levelIndex,
132
+ polygon: room.zone.polygon,
133
+ holes: room.zone.holes,
134
+ })), openings.walk);
135
+ this.openings = openings.finish();
191
136
  // ---- lamps ---------------------------------------------------------------------------
192
137
  for (const lamp of input.lamps) {
193
138
  const ri = this.roomAt(lamp.position.clone().add(new Vector3(0, -0.3, 0)));
@@ -318,18 +263,9 @@ export class RoomLighting {
318
263
  if (this.lastSky)
319
264
  this.updateSky(...this.lastSky);
320
265
  }
321
- /** CPU-side room lookup (same rule as the shader, without the normal offset). */
266
+ /** CPU-side room lookup in assembled space (same rule as the shader, without the normal offset). */
322
267
  roomAt(p) {
323
- let level = 0;
324
- for (let i = 0; i < this.levelY.length; i++)
325
- if (this.levelY[i] <= p.y - 0.12)
326
- level = i;
327
- const i = Math.floor((p.x - this.origin.x) / CELL);
328
- const j = Math.floor((p.z - this.origin.y) / CELL);
329
- if (i < 0 || j < 0 || i >= this.gridW || j >= this.gridH)
330
- return -1;
331
- const v = this.map[(level * this.gridH + j) * this.gridW + i];
332
- return v ? v - 1 : -1;
268
+ return this.map.roomAt(p.x, p.y, p.z);
333
269
  }
334
270
  /**
335
271
  * Whether each level is on screen. A camera over a level whose upper floors are hidden stands
@@ -337,6 +273,10 @@ export class RoomLighting {
337
273
  * such, not as a lamplit room it cannot see.
338
274
  */
339
275
  levelShown = [];
276
+ /** Per level: focused (its rooms open from above when the camera is over their cover). */
277
+ levelFocused = [];
278
+ /** Per level: the highest one shown in its building (its rooms seen from above). */
279
+ levelTopShown = [];
340
280
  /**
341
281
  * What the lit rooms in view add to an outside camera's meter: a blend of the exterior reading
342
282
  * and the rooms' own irradiance (their indoor meter), weighted by the share of the frame each
@@ -360,10 +300,6 @@ export class RoomLighting {
360
300
  const t = Math.tan(MathUtils.degToRad(c.fov) / 2);
361
301
  return 4 * t * t * c.aspect;
362
302
  })();
363
- let topShown = -1;
364
- for (let i = 0; i < this.levelY.length; i++)
365
- if (this.levelShown[i] !== false)
366
- topShown = i;
367
303
  let weight = 0;
368
304
  let weighted = 0;
369
305
  _roomsLight.setRGB(0, 0, 0);
@@ -375,7 +311,7 @@ export class RoomLighting {
375
311
  _toCamera.subVectors(camera.position, r.centroid);
376
312
  const facing = Math.max(0.2, _toCamera.y / Math.max(_toCamera.length(), 1e-4));
377
313
  const reach = ortho ? 1 : Math.max(_toCamera.lengthSq(), 1);
378
- const open = r.levelIndex === topShown ? 1 : 0.15;
314
+ const open = this.levelTopShown[r.levelIndex] !== false ? 1 : 0.15;
379
315
  const w = (r.area * facing * open) / reach / view;
380
316
  const E = this.meter(index, skyE);
381
317
  weight += w;
@@ -407,33 +343,43 @@ export class RoomLighting {
407
343
  return { sceneE, roomsE, illuminant };
408
344
  }
409
345
  isEnclosedAt(p) {
410
- let level = 0;
411
- for (let i = 0; i < this.levelY.length; i++)
412
- if (this.levelY[i] <= p.y - 0.12)
413
- level = i;
414
- if (this.levelShown[level] === false)
415
- return { room: -1, inside: false };
416
346
  const room = this.roomAt(p);
417
347
  const r = room >= 0 ? this.rooms[room] : undefined;
418
- return { room, inside: !!r?.enclosed && p.y < r.ceilingY };
348
+ if (!r || this.levelShown[r.levelIndex] === false)
349
+ return { room: -1, inside: false };
350
+ return { room, inside: r.enclosed && p.y < r.ceilingY };
419
351
  }
420
- /** Explode / level motion: live world Y of each level (assembled + offset). */
421
- setLevelOffsets(offsets) {
422
- const ys = this.u.levelYs.array;
423
- const offs = this.u.levelOffsets.array;
424
- let changed = false;
425
- for (let i = 0; i < MAX_LEVELS; i++) {
426
- const base = this.levelY[i];
427
- const off = offsets[i] ?? 0;
428
- const y = base === undefined ? 1e9 : base + off;
429
- if (ys[i] === y && offs[i] === off)
430
- continue;
431
- ys[i] = y;
432
- offs[i] = off;
433
- changed = true;
434
- }
435
- if (changed)
436
- this.levelGroup.needsUpdate = true;
352
+ /**
353
+ * Live level presentation, per level: explode offset (live world Y − assembled), whether it is
354
+ * shown, and whether it is the selected level of a cutaway (solo / levels above hidden). A shown
355
+ * level is focused — its rooms open from above — when selected so, or when every level over it
356
+ * in its building is hidden.
357
+ */
358
+ setLevelPresentation(offsets, shown, selected) {
359
+ this.levelShown = shown;
360
+ const focused = this.levels.map((_, i) => {
361
+ if (shown[i] === false)
362
+ return false;
363
+ if (selected[i])
364
+ return true;
365
+ let up = this.map.levelAbove(i);
366
+ if (up < 0)
367
+ return false;
368
+ for (; up >= 0; up = this.map.levelAbove(up))
369
+ if (shown[up] !== false)
370
+ return false;
371
+ return true;
372
+ });
373
+ this.levelFocused = focused;
374
+ this.levelTopShown = this.levels.map((_, i) => {
375
+ if (shown[i] === false)
376
+ return false;
377
+ for (let up = this.map.levelAbove(i); up >= 0; up = this.map.levelAbove(up))
378
+ if (shown[up] !== false)
379
+ return false;
380
+ return true;
381
+ });
382
+ this.map.setPresentation(offsets, shown, focused);
437
383
  }
438
384
  /** Re-run the light transfer (after a quality-tier change of the soft room map). */
439
385
  refresh() {
@@ -608,7 +554,7 @@ export class RoomLighting {
608
554
  this.rooms.forEach((r, i) => {
609
555
  this.set(i * 3, r.enclosed ? 1 : 0, lampBase + lamp * 2, r.lamps.length, 0);
610
556
  this.set(i * 3 + 1, r.portals.length, 0, 0, 0);
611
- this.set(i * 3 + 2, r.coverY, r.coverIsRoof ? 1 : 0, r.levelIndex, 0);
557
+ this.set(i * 3 + 2, r.coverY, r.coverIsRoof ? 1 : 0, this.map.gpuLevel(r.levelIndex), 0);
612
558
  for (const l of r.lamps) {
613
559
  this.lampTexel.set(l, lampBase + lamp * 2);
614
560
  this.set(lampBase + lamp * 2, l.position.x, l.position.y, l.position.z, l.range);
@@ -644,7 +590,7 @@ export class RoomLighting {
644
590
  const room = this.rooms[p.room];
645
591
  const main = room.lamps.slice().sort((a, b) => b.intensity - a.intensity)[0];
646
592
  this.set(t + 2, 0, 0, 0, p.neighbor);
647
- this.set(t + 3, this.levelY[room.levelIndex] ?? 0, main ? this.lampTexel.get(main) : -1, p.gain, p.kind);
593
+ this.set(t + 3, this.levels[room.levelIndex]?.y ?? 0, main ? this.lampTexel.get(main) : -1, p.gain, p.kind);
648
594
  });
649
595
  const previous = this.u.data.value;
650
596
  const tex = new DataTexture(this.data, DATA_W, rows, RGBAFormat, FloatType);
@@ -686,19 +632,6 @@ export class RoomLighting {
686
632
  this.u.spillCount.value = spill;
687
633
  this.u.extActiveCount.value = lit;
688
634
  }
689
- uploadMap() {
690
- const previous = this.u.map.value;
691
- const tex = new DataTexture(this.map, this.gridW, this.gridH * this.levels, RedFormat, FloatType);
692
- tex.magFilter = tex.minFilter = NearestFilter;
693
- tex.needsUpdate = true;
694
- this.u.map.value = tex;
695
- previous.dispose();
696
- this.u.origin.value.copy(this.origin);
697
- this.u.gridW.value = this.gridW;
698
- this.u.gridH.value = this.gridH;
699
- this.u.levelCount.value = this.levels;
700
- this.setLevelOffsets([]);
701
- }
702
635
  markData() {
703
636
  ;
704
637
  this.u.data.value.needsUpdate = true;
@@ -732,148 +665,183 @@ export class RoomLighting {
732
665
  this.data.set([a, b, c, d], texel * 4);
733
666
  }
734
667
  /**
735
- * Walk every boundary between two room cells (or a room and outside) on the room map and cast
736
- * short horizontal rays against the walls at 0.35 m and 1.6 m: open at both heights = a walkable
737
- * opening (doorway, arch, open-plan edge). Windows don't count (sills are above 0.35 m).
668
+ * Walk every boundary between two room cells (or a room and outside) on each level's grid and
669
+ * cast short horizontal rays against the walls at 0.35 m and 1.6 m: open at both heights = a
670
+ * walkable opening (doorway, arch, open-plan edge). Windows don't count (sills are above 0.35 m).
671
+ * `walk` runs per level while its grid exists; `finish` clusters the samples into openings.
738
672
  */
739
- detectOpenings(input, ray) {
673
+ openingSearch(input, ray) {
740
674
  const doors = input.openings
741
675
  .filter((o) => o.kind === 'door')
742
676
  .map((o) => ({ id: o.id, box: new Box3().setFromObject(o.object) }));
743
677
  const doorHoles = doors.map((door) => door.box.clone().expandByScalar(0.05));
744
678
  ray.far = 0.9;
679
+ const wallGrid = new MeshGrid(input.walls);
745
680
  const samples = new Map();
746
- const W = this.gridW;
747
- const H = this.gridH;
748
- const map = this.map;
749
681
  const v = new Vector3();
750
682
  const dirV = new Vector3();
751
- for (let l = 0; l < this.levels; l++) {
752
- const floorY = this.levelY[l] ?? 0;
753
- for (let j = 0; j < H - 1; j++) {
754
- for (let i = 0; i < W - 1; i++) {
755
- const k = (l * H + j) * W + i;
756
- const A = map[k];
757
- for (const [di, dj, axis] of [
758
- [1, 0, 'x'],
759
- [0, 1, 'z'],
760
- ]) {
761
- if ((axis === 'x' ? j : i) % 2)
762
- continue; // sample every 2nd cell along the edge (0.2 m)
763
- const B = map[k + di + dj * W];
764
- if (A === B || (!A && !B))
765
- continue;
766
- const a = A ? A - 1 : -1;
767
- const b = B ? B - 1 : -1;
768
- // Outside openings only from enclosed rooms.
769
- if (a < 0 && !this.rooms[b].enclosed)
770
- continue;
771
- if (b < 0 && !this.rooms[a].enclosed)
772
- continue;
773
- const x = this.origin.x + (i + 0.5 + di * 0.5) * CELL;
774
- const z = this.origin.y + (j + 0.5 + dj * 0.5) * CELL;
775
- dirV.set(di, 0, dj);
776
- let open = true;
777
- for (const h of [0.35, 1.6]) {
778
- v.set(x, floorY + h, z).addScaledVector(dirV, -0.45);
779
- ray.set(v, dirV);
780
- // A doorway is open whatever wall-attached geometry crosses it (finishes, trims).
781
- const blocked = intersectGeometry(ray, input.walls, false).some((hit) => !doorHoles.some((hole) => hole.containsPoint(hit.point)));
782
- if (blocked) {
783
- open = false;
784
- break;
683
+ const walk = (tile) => {
684
+ const { cells, stride, i0, j0, x0, z0, ci0, cj0, level: l } = tile;
685
+ const floorY = this.levels[l]?.y ?? 0;
686
+ const visit = (i, j, axis) => {
687
+ const di = axis === 'x' ? 1 : 0;
688
+ const dj = axis === 'z' ? 1 : 0;
689
+ if ((axis === 'x' ? cj0 + j : ci0 + i) % 2)
690
+ return; // every 2nd cell along the edge (0.2 m)
691
+ const A = cells[j * stride + i];
692
+ const B = cells[(j + dj) * stride + i + di];
693
+ if (A === B)
694
+ return;
695
+ const a = A ? A - 1 : -1;
696
+ const b = B ? B - 1 : -1;
697
+ // Outside openings only from enclosed rooms.
698
+ if (a < 0 && !this.rooms[b].enclosed)
699
+ return;
700
+ if (b < 0 && !this.rooms[a].enclosed)
701
+ return;
702
+ const x = x0 + (i + 0.5 + di * 0.5) * CELL;
703
+ const z = z0 + (j + 0.5 + dj * 0.5) * CELL;
704
+ dirV.set(di, 0, dj);
705
+ for (const h of [0.35, 1.6]) {
706
+ v.set(x, floorY + h, z).addScaledVector(dirV, -0.45);
707
+ ray.set(v, dirV);
708
+ // A doorway is open whatever wall-attached geometry crosses it (finishes, trims).
709
+ const near = wallGrid.near(Math.min(v.x, v.x + dirV.x * 0.9), Math.min(v.z, v.z + dirV.z * 0.9), Math.max(v.x, v.x + dirV.x * 0.9), Math.max(v.z, v.z + dirV.z * 0.9));
710
+ const blocked = intersectGeometry(ray, near, false).some((hit) => !doorHoles.some((hole) => hole.containsPoint(hit.point)));
711
+ if (blocked)
712
+ return;
713
+ }
714
+ // Normalise so key/direction are from the lower room id (outside = last).
715
+ const lo = a < 0 ? b : b < 0 ? a : Math.min(a, b);
716
+ const hi = lo === a ? b : a;
717
+ const dir = lo === a ? 1 : -1;
718
+ const key = `${l}|${lo}|${hi}`;
719
+ let s = samples.get(key);
720
+ if (!s) {
721
+ s = { a: lo, b: hi, pts: [], facing: [], level: l };
722
+ samples.set(key, s);
723
+ }
724
+ s.pts.push(new Vector3(x, floorY, z));
725
+ s.facing.push(new Vector3(di * dir, 0, dj * dir));
726
+ };
727
+ // Each edge belongs to the tile of the cell before it. A uniform tile has none inside: only
728
+ // its borders with the next tiles can hold one.
729
+ const iEnd = i0 + TILE;
730
+ const jEnd = j0 + TILE;
731
+ if (tile.uniform) {
732
+ for (let j = j0; j < jEnd; j++)
733
+ visit(iEnd - 1, j, 'x');
734
+ for (let i = i0; i < iEnd; i++)
735
+ visit(i, jEnd - 1, 'z');
736
+ return;
737
+ }
738
+ for (let j = j0; j < jEnd; j++)
739
+ for (let i = i0; i < iEnd; i++) {
740
+ visit(i, j, 'x');
741
+ visit(i, j, 'z');
742
+ }
743
+ };
744
+ const finish = () => {
745
+ const openings = [];
746
+ // Boundary samples a step apart along an edge belong to one opening. Off the grid axes (a
747
+ // rotated building) the edge is a staircase of cells, so adjacency is by distance.
748
+ const gap = Math.max(0.45, CELL * 3);
749
+ for (const s of samples.values()) {
750
+ const order = s.pts.map((_, k) => k).sort((p, q) => s.pts[p].x - s.pts[q].x);
751
+ const seen = new Uint8Array(order.length);
752
+ for (let seed = 0; seed < order.length; seed++) {
753
+ if (seen[seed])
754
+ continue;
755
+ seen[seed] = 1;
756
+ const members = [];
757
+ const queue = [seed];
758
+ while (queue.length) {
759
+ const k = queue.pop();
760
+ const p = s.pts[order[k]];
761
+ members.push(order[k]);
762
+ for (const step of [-1, 1]) {
763
+ for (let m = k + step; m >= 0 && m < order.length; m += step) {
764
+ const q = s.pts[order[m]];
765
+ if (Math.abs(q.x - p.x) > gap)
766
+ break;
767
+ if (seen[m] || Math.hypot(q.x - p.x, q.z - p.z) > gap)
768
+ continue;
769
+ seen[m] = 1;
770
+ queue.push(m);
785
771
  }
786
772
  }
787
- if (!open)
788
- continue;
789
- // Normalise so key/direction are from the lower room id (outside = last).
790
- const lo = a < 0 ? b : b < 0 ? a : Math.min(a, b);
791
- const hi = lo === a ? b : a;
792
- const dir = lo === a ? 1 : -1;
793
- const key = `${l}|${lo}|${hi}|${axis}`;
794
- let s = samples.get(key);
795
- if (!s) {
796
- s = { a: lo, b: hi, axis, dir, pts: [], level: l };
797
- samples.set(key, s);
798
- }
799
- s.pts.push(new Vector3(x, floorY, z));
800
773
  }
774
+ if (members.length < 3)
775
+ continue; // < 0.5 m
776
+ openings.push(this.openingFromRun(members, s, doors));
801
777
  }
802
778
  }
779
+ return openings;
780
+ };
781
+ return { walk, finish };
782
+ }
783
+ /** One opening from its boundary samples: measured along their own line, facing room b. */
784
+ openingFromRun(members, s, doors) {
785
+ const run = members.map((k) => s.pts[k]);
786
+ const c = run.reduce((acc, p) => acc.add(p), new Vector3()).multiplyScalar(1 / run.length);
787
+ // Principal direction of the samples (2×2 covariance in XZ).
788
+ let xx = 0;
789
+ let xz = 0;
790
+ let zz = 0;
791
+ for (const p of run) {
792
+ const dx = p.x - c.x;
793
+ const dz = p.z - c.z;
794
+ xx += dx * dx;
795
+ xz += dx * dz;
796
+ zz += dz * dz;
803
797
  }
804
- const openings = [];
805
- for (const s of samples.values()) {
806
- // Cluster along the edge: sort by the coordinate running along the boundary.
807
- const along = (p) => (s.axis === 'x' ? p.z : p.x);
808
- const across = (p) => (s.axis === 'x' ? p.x : p.z);
809
- s.pts.sort((p, q) => across(p) - across(q) || along(p) - along(q));
810
- let run = [];
811
- const flush = () => {
812
- if (run.length < 3) {
813
- run = []; // < 0.5 m
814
- return;
815
- }
816
- const c = run.reduce((acc, p) => acc.add(p), new Vector3()).multiplyScalar(1 / run.length);
817
- const width = along(run[run.length - 1]) - along(run[0]) + 0.2;
818
- const normal = new Vector3(s.axis === 'x' ? s.dir : 0, 0, s.axis === 'z' ? s.dir : 0);
819
- const roomA = this.rooms[s.a];
820
- const roomB = s.b >= 0 ? this.rooms[s.b] : undefined;
821
- const floorY = this.levelY[s.level] ?? 0;
822
- // Door in this opening? → its height + state. Otherwise floor to the lower ceiling.
823
- const door = doors.find((d) => d.box
824
- .clone()
825
- .expandByScalar(0.25)
826
- .containsPoint(c.clone().setY(floorY + 1)));
827
- const ceil = Math.min(roomA.ceilingY, roomB?.ceilingY ?? roomA.ceilingY);
828
- const height = door
829
- ? Math.min(door.box.max.y - floorY, ceil - floorY)
830
- : Math.max(1.9, ceil - floorY);
831
- openings.push({
832
- a: s.a,
833
- b: s.b,
834
- center: c.setY(floorY + height / 2),
835
- normal,
836
- width: door ? Math.min(width, door.box.getSize(new Vector3()).length()) : width,
837
- height,
838
- doorId: door?.id,
839
- });
840
- run = [];
841
- };
842
- for (const p of s.pts) {
843
- const last = run[run.length - 1];
844
- if (last && (Math.abs(across(p) - across(last)) > 0.11 || along(p) - along(last) > 0.45))
845
- flush();
846
- run.push(p);
847
- }
848
- flush();
798
+ const angle = 0.5 * Math.atan2(2 * xz, xx - zz);
799
+ const tangent = new Vector3(Math.cos(angle), 0, Math.sin(angle));
800
+ let lo = Number.POSITIVE_INFINITY;
801
+ let hi = Number.NEGATIVE_INFINITY;
802
+ for (const p of run) {
803
+ const t = (p.x - c.x) * tangent.x + (p.z - c.z) * tangent.z;
804
+ lo = Math.min(lo, t);
805
+ hi = Math.max(hi, t);
849
806
  }
850
- return openings;
807
+ c.addScaledVector(tangent, (lo + hi) / 2);
808
+ const width = hi - lo + 2 * CELL;
809
+ const facing = members.reduce((acc, k) => acc.add(s.facing[k]), new Vector3());
810
+ const normal = new Vector3(-tangent.z, 0, tangent.x);
811
+ if (normal.dot(facing) < 0)
812
+ normal.negate();
813
+ const roomA = this.rooms[s.a];
814
+ const roomB = s.b >= 0 ? this.rooms[s.b] : undefined;
815
+ const floorY = this.levels[s.level]?.y ?? 0;
816
+ // Door in this opening? → its height + state. Otherwise floor to the lower ceiling.
817
+ const door = doors.find((d) => d.box
818
+ .clone()
819
+ .expandByScalar(0.25)
820
+ .containsPoint(c.clone().setY(floorY + 1)));
821
+ const ceil = Math.min(roomA.ceilingY, roomB?.ceilingY ?? roomA.ceilingY);
822
+ const height = door
823
+ ? Math.min(door.box.max.y - floorY, ceil - floorY)
824
+ : Math.max(1.9, ceil - floorY);
825
+ return {
826
+ a: s.a,
827
+ b: s.b,
828
+ center: c.setY(floorY + height / 2),
829
+ normal,
830
+ width: door ? Math.min(width, door.box.getSize(new Vector3()).length()) : width,
831
+ height,
832
+ doorId: door?.id,
833
+ };
851
834
  }
852
835
  // ---- TSL ---------------------------------------------------------------------------------
853
- /** Level index for a world-space height (live, explode-offset level heights). */
854
- levelOfNode(y) {
855
- const level = int(0).toVar();
856
- Loop({ start: int(1), end: this.u.levelCount, type: 'int', condition: '<' }, ({ i }) => {
857
- If(this.u.levelYs.element(i).lessThanEqual(y.sub(0.12)), () => {
858
- level.assign(i);
859
- });
860
- });
861
- return level;
836
+ /** Room index (−1 outside) at a world-space position (live heights). */
837
+ roomAtNode = Fn(([p]) => this.map.roomAtNode(p));
838
+ /**
839
+ * The room a surface faces (probed 18 cm off it along its normal). One node per material: its
840
+ * lighting and its exterior mask share the lookup.
841
+ */
842
+ probeRoomNode() {
843
+ return this.roomAtNode(positionWorld.add(normalWorld.mul(0.18)));
862
844
  }
863
- /** Room index (−1 outside) at a world-space position. */
864
- roomAtNode = Fn(([p]) => {
865
- const u = this.u;
866
- const level = this.levelOfNode(p.y);
867
- const cell = ivec2(p.xz.sub(u.origin).div(CELL).floor());
868
- const inside = cell.x
869
- .greaterThanEqual(0)
870
- .and(cell.y.greaterThanEqual(0))
871
- .and(cell.x.lessThan(u.gridW))
872
- .and(cell.y.lessThan(u.gridH));
873
- const safe = ivec2(clamp(cell.x, int(0), u.gridW.sub(1)), clamp(cell.y, int(0), u.gridH.sub(1)));
874
- const id = u.map.load(ivec2(safe.x, safe.y.add(level.mul(u.gridH)))).r;
875
- return select(inside, int(id).sub(1), int(-1));
876
- });
877
845
  /** E' = Emax·(1 − e^(−E/Emax)), on luminance, hue kept — near-field hot spots compress, never blow out. */
878
846
  softClampLamps(e) {
879
847
  const L = dot(e, vec3(0.2126, 0.7152, 0.0722)).max(1e-5);
@@ -885,11 +853,12 @@ export class RoomLighting {
885
853
  * topmost cover and that cover is hidden from there: a ceiling (hidden from above), or anything
886
854
  * once its level is focused (the levels over it hidden). From inside, under the cover, the room
887
855
  * stays enclosed whatever is focused: it must not take the full sky on top of its windows.
888
- * `h2` is the room's third header texel: [coverY, roof, level, 0].
856
+ * `h2` is the room's third header texel: [coverY, roof, level, 0]; `level` its level's
857
+ * [th, entryOffset, offset, flags] (flag 1: focused).
889
858
  */
890
- openToSky(h2) {
891
- const focused = this.focusedLevel.greaterThanEqual(0).and(int(h2.z).equal(this.focusedLevel));
892
- return this.cameraY.greaterThan(h2.x).and(h2.y.lessThan(0.5).or(focused));
859
+ openToSky(h2, level) {
860
+ const focused = level.w.mod(2).greaterThan(0.5);
861
+ return this.cameraY.sub(level.z).greaterThan(h2.x).and(h2.y.lessThan(0.5).or(focused));
893
862
  }
894
863
  texel(i) {
895
864
  const idx = int(i);
@@ -900,21 +869,20 @@ export class RoomLighting {
900
869
  * irradiance — lamp + portal + bounce diffuse irradiance (linear)
901
870
  * enclosure — multiplier for the sky IBL (1 outdoors, `indoorEnv` in enclosed rooms)
902
871
  */
903
- lightingNode = Fn(() => {
904
- const probe = positionWorld.add(normalWorld.mul(0.18));
905
- const room = this.roomAtNode(probe).toVar();
906
- // Shade in assembled space so exploded floors stay lit exactly as when stacked.
907
- const assembled = positionWorld.sub(vec3(0, this.u.levelOffsets.element(this.levelOfNode(probe.y)), 0));
872
+ lightingNode = Fn(([probeRoom]) => {
873
+ const room = int(probeRoom).toVar();
908
874
  const irr = vec3(0).toVar();
909
875
  const enclosure = float(1).toVar();
910
876
  If(room.greaterThanEqual(0), () => {
911
877
  const h0 = this.texel(room.mul(3));
912
878
  const h1 = this.texel(room.mul(3).add(1));
913
879
  const h2 = this.texel(room.mul(3).add(2));
914
- If(h0.x.greaterThan(0.5).and(this.openToSky(h2).not()), () => {
880
+ const level = this.map.levelTexel1(h2.z);
881
+ If(h0.x.greaterThan(0.5).and(this.openToSky(h2, level).not()), () => {
915
882
  enclosure.assign(this.indoorEnv);
916
883
  });
917
- const P = assembled;
884
+ // Shade in assembled space so exploded floors stay lit exactly as when stacked.
885
+ const P = positionWorld.sub(vec3(0, level.z, 0));
918
886
  const Nw = normalWorld;
919
887
  // Lamps: smooth windowed inverse-square, wrapped diffuse so the room reads as filled
920
888
  // rather than spotlit — stands in for the bounce we don't compute.
@@ -1114,13 +1082,13 @@ export class RoomLighting {
1114
1082
  return this.softClampLamps(out).mul(this.lampGain).mul(this.lampView);
1115
1083
  });
1116
1084
  /** 1 where exterior lights may shine (outside, or rooms open to the sky), 0 inside. */
1117
- exteriorMaskNode = Fn(() => {
1118
- const room = this.roomAtNode(positionWorld.add(normalWorld.mul(0.18))).toVar();
1085
+ exteriorMaskNode = Fn(([probeRoom]) => {
1086
+ const room = int(probeRoom).toVar();
1119
1087
  const mask = float(1).toVar();
1120
1088
  If(room.greaterThanEqual(0), () => {
1121
1089
  const h0 = this.texel(room.mul(3));
1122
1090
  const h2 = this.texel(room.mul(3).add(2));
1123
- If(h0.x.greaterThan(0.5).and(this.openToSky(h2).not()), () => {
1091
+ If(h0.x.greaterThan(0.5).and(this.openToSky(h2, this.map.levelTexel1(h2.z)).not()), () => {
1124
1092
  mask.assign(0);
1125
1093
  });
1126
1094
  });
@@ -1157,8 +1125,59 @@ function intersectGeometry(ray, objects, recursive) {
1157
1125
  visit(object);
1158
1126
  return hits.sort((a, b) => a.distance - b.distance);
1159
1127
  }
1160
- function emptyTexture(format) {
1161
- const tex = new DataTexture(new Float32Array(format === RedFormat ? 1 : 4), 1, 1, format, FloatType);
1128
+ /**
1129
+ * Meshes (and the meshes under them) bucketed by their world XZ bounds, so a short doorway ray or
1130
+ * a vertical cover ray tests its neighbours, not every wall and slab of the scene.
1131
+ */
1132
+ class MeshGrid {
1133
+ static SIZE = 2;
1134
+ cells = new Map();
1135
+ seen = new Set();
1136
+ constructor(roots) {
1137
+ const box = new Box3();
1138
+ for (const root of roots)
1139
+ root.traverse((object) => {
1140
+ const mesh = object;
1141
+ if (!mesh.isMesh)
1142
+ return;
1143
+ // From the vertices as they are now: a cached bounding box can predate geometry that was
1144
+ // rebuilt in place (walls finishing while a large scene loads).
1145
+ const position = mesh.geometry?.getAttribute('position');
1146
+ if (!position || position.count === 0)
1147
+ return;
1148
+ box.setFromBufferAttribute(position).applyMatrix4(mesh.matrixWorld);
1149
+ if (box.isEmpty())
1150
+ return;
1151
+ const s = MeshGrid.SIZE;
1152
+ for (let iz = Math.floor(box.min.z / s); iz <= Math.floor(box.max.z / s); iz++)
1153
+ for (let ix = Math.floor(box.min.x / s); ix <= Math.floor(box.max.x / s); ix++) {
1154
+ const key = cellKey(ix, iz);
1155
+ const list = this.cells.get(key);
1156
+ if (list)
1157
+ list.push(mesh);
1158
+ else
1159
+ this.cells.set(key, [mesh]);
1160
+ }
1161
+ });
1162
+ }
1163
+ /** Meshes whose bounds may reach the XZ rect. */
1164
+ near(minX, minZ, maxX, maxZ) {
1165
+ const s = MeshGrid.SIZE;
1166
+ const out = [];
1167
+ this.seen.clear();
1168
+ for (let iz = Math.floor(minZ / s); iz <= Math.floor(maxZ / s); iz++)
1169
+ for (let ix = Math.floor(minX / s); ix <= Math.floor(maxX / s); ix++)
1170
+ for (const mesh of this.cells.get(cellKey(ix, iz)) ?? [])
1171
+ if (!this.seen.has(mesh)) {
1172
+ this.seen.add(mesh);
1173
+ out.push(mesh);
1174
+ }
1175
+ return out;
1176
+ }
1177
+ }
1178
+ const cellKey = (ix, iz) => (ix + 1_000_000) * 2_000_003 + (iz + 1_000_000);
1179
+ function emptyTexture() {
1180
+ const tex = new DataTexture(new Float32Array(4), 1, 1, RGBAFormat, FloatType);
1162
1181
  tex.magFilter = tex.minFilter = NearestFilter;
1163
1182
  tex.needsUpdate = true;
1164
1183
  return tex;
@@ -1201,6 +1220,32 @@ export function polygonCentroid(poly) {
1201
1220
  return new Vector2(cx / (3 * a), cz / (3 * a));
1202
1221
  }
1203
1222
  /** Tanner Helland blackbody fit → linear RGB. */
1223
+ /** A point inside the polygon and outside its holes: the centroid when it is, else a scan. */
1224
+ export function interiorPoint(poly, holes = []) {
1225
+ const inside = (x, z) => pointInPolygon(x, z, poly) && !holes.some((hole) => pointInPolygon(x, z, hole));
1226
+ const c = polygonCentroid(poly);
1227
+ if (inside(c.x, c.y))
1228
+ return c;
1229
+ let minX = Number.POSITIVE_INFINITY;
1230
+ let minZ = Number.POSITIVE_INFINITY;
1231
+ let maxX = Number.NEGATIVE_INFINITY;
1232
+ let maxZ = Number.NEGATIVE_INFINITY;
1233
+ for (const [x, z] of poly) {
1234
+ minX = Math.min(minX, x);
1235
+ maxX = Math.max(maxX, x);
1236
+ minZ = Math.min(minZ, z);
1237
+ maxZ = Math.max(maxZ, z);
1238
+ }
1239
+ for (let n = 4; n <= 32; n *= 2)
1240
+ for (let j = 1; j < n; j++)
1241
+ for (let i = 1; i < n; i++) {
1242
+ const x = minX + ((maxX - minX) * i) / n;
1243
+ const z = minZ + ((maxZ - minZ) * j) / n;
1244
+ if (inside(x, z))
1245
+ return new Vector2(x, z);
1246
+ }
1247
+ return c;
1248
+ }
1204
1249
  export function kelvin(k) {
1205
1250
  const t = k / 100;
1206
1251
  let r;