@pascal-app/viewer 0.9.1 → 0.9.2

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 (176) hide show
  1. package/dist/components/error-boundary.d.ts +6 -0
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +6 -0
  4. package/dist/components/renderers/node-renderer.d.ts +5 -1
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +5 -2
  7. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  8. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  9. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  10. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  11. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  12. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  13. package/dist/components/viewer/frame-limiter.js +39 -4
  14. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  15. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-interactive.js +394 -0
  17. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  19. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  20. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-replace-instances.js +61 -0
  23. package/dist/components/viewer/glb-scene.d.ts +54 -0
  24. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  25. package/dist/components/viewer/glb-scene.js +920 -0
  26. package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
  29. package/dist/components/viewer/index.d.ts +19 -2
  30. package/dist/components/viewer/index.d.ts.map +1 -1
  31. package/dist/components/viewer/index.js +104 -12
  32. package/dist/components/viewer/lights.d.ts.map +1 -1
  33. package/dist/components/viewer/lights.js +36 -5
  34. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  35. package/dist/components/viewer/perf-monitor.js +17 -2
  36. package/dist/components/viewer/post-processing.d.ts +7 -1
  37. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  38. package/dist/components/viewer/post-processing.js +179 -24
  39. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  40. package/dist/components/viewer/registered-systems.js +6 -4
  41. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  42. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  43. package/dist/components/viewer/scene-bvh.js +3 -0
  44. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  46. package/dist/components/viewer/scene-bvh.test.js +13 -0
  47. package/dist/components/viewer/scene-environment.d.ts +2 -0
  48. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  49. package/dist/components/viewer/scene-environment.js +79 -0
  50. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  51. package/dist/components/viewer/selection-manager.js +18 -10
  52. package/dist/components/viewer/walkthrough-controls.d.ts +1 -0
  53. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  54. package/dist/components/viewer/walkthrough-controls.js +20 -0
  55. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  56. package/dist/hooks/use-gltf-ktx2.js +3 -22
  57. package/dist/hooks/use-node-events.d.ts +1 -1
  58. package/dist/hooks/use-node-events.d.ts.map +1 -1
  59. package/dist/hooks/use-node-events.js +22 -14
  60. package/dist/index.d.ts +19 -8
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +22 -8
  63. package/dist/lib/backdrop.d.ts +33 -0
  64. package/dist/lib/backdrop.d.ts.map +1 -0
  65. package/dist/lib/backdrop.js +92 -0
  66. package/dist/lib/box-uv.d.ts +13 -0
  67. package/dist/lib/box-uv.d.ts.map +1 -0
  68. package/dist/lib/box-uv.js +32 -0
  69. package/dist/lib/csg-utils.d.ts +3 -9
  70. package/dist/lib/csg-utils.d.ts.map +1 -1
  71. package/dist/lib/csg-utils.js +65 -5
  72. package/dist/lib/csg-utils.test.d.ts +2 -0
  73. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  74. package/dist/lib/csg-utils.test.js +45 -0
  75. package/dist/lib/edge-style.d.ts +1 -0
  76. package/dist/lib/edge-style.d.ts.map +1 -1
  77. package/dist/lib/edge-style.js +26 -6
  78. package/dist/lib/ink-edges.d.ts +1 -1
  79. package/dist/lib/ink-edges.d.ts.map +1 -1
  80. package/dist/lib/ink-edges.js +13 -7
  81. package/dist/lib/ktx2-loader.d.ts +43 -0
  82. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  83. package/dist/lib/ktx2-loader.js +136 -0
  84. package/dist/lib/layers.d.ts +10 -0
  85. package/dist/lib/layers.d.ts.map +1 -1
  86. package/dist/lib/layers.js +10 -0
  87. package/dist/lib/materials.d.ts +14 -1
  88. package/dist/lib/materials.d.ts.map +1 -1
  89. package/dist/lib/materials.js +103 -26
  90. package/dist/lib/scene-themes.d.ts +1 -0
  91. package/dist/lib/scene-themes.d.ts.map +1 -1
  92. package/dist/lib/scene-themes.js +25 -16
  93. package/dist/lib/shadow-only.d.ts +4 -0
  94. package/dist/lib/shadow-only.d.ts.map +1 -0
  95. package/dist/lib/shadow-only.js +36 -0
  96. package/dist/lib/tsl-compat.d.ts +12 -0
  97. package/dist/lib/tsl-compat.d.ts.map +1 -0
  98. package/dist/lib/tsl-compat.js +13 -0
  99. package/dist/store/use-viewer.d.ts +60 -18
  100. package/dist/store/use-viewer.d.ts.map +1 -1
  101. package/dist/store/use-viewer.js +175 -3
  102. package/dist/store/use-viewer.test.d.ts +2 -0
  103. package/dist/store/use-viewer.test.d.ts.map +1 -0
  104. package/dist/store/use-viewer.test.js +42 -0
  105. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  106. package/dist/systems/ceiling/ceiling-system.js +4 -1
  107. package/dist/systems/door/door-animation.test.d.ts +2 -0
  108. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  109. package/dist/systems/door/door-animation.test.js +51 -0
  110. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  111. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  112. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  113. package/dist/systems/door/door-system.d.ts +24 -0
  114. package/dist/systems/door/door-system.d.ts.map +1 -1
  115. package/dist/systems/door/door-system.js +494 -161
  116. package/dist/systems/fence/fence-system.d.ts +17 -0
  117. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  118. package/dist/systems/fence/fence-system.js +221 -53
  119. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  120. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  121. package/dist/systems/geometry/geometry-system.js +95 -26
  122. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  123. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  124. package/dist/systems/geometry/geometry-system.test.js +30 -0
  125. package/dist/systems/interactive/control-widget.d.ts +10 -0
  126. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  127. package/dist/systems/interactive/control-widget.js +35 -0
  128. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  129. package/dist/systems/interactive/interactive-system.js +55 -56
  130. package/dist/systems/item/item-system.d.ts.map +1 -1
  131. package/dist/systems/item/item-system.js +20 -6
  132. package/dist/systems/level/level-system.d.ts.map +1 -1
  133. package/dist/systems/level/level-system.js +28 -4
  134. package/dist/systems/level/level-utils.d.ts +0 -3
  135. package/dist/systems/level/level-utils.d.ts.map +1 -1
  136. package/dist/systems/level/level-utils.js +2 -41
  137. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  138. package/dist/systems/roof/roof-materials.js +29 -8
  139. package/dist/systems/roof/roof-system.d.ts +21 -8
  140. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  141. package/dist/systems/roof/roof-system.js +1079 -221
  142. package/dist/systems/slab/slab-system.d.ts +5 -3
  143. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  144. package/dist/systems/slab/slab-system.js +41 -23
  145. package/dist/systems/slab/slab-system.test.js +3 -2
  146. package/dist/systems/stair/stair-system.js +6 -3
  147. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  148. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  149. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  150. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  151. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  152. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  153. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  154. package/dist/systems/wall/wall-cutout.js +46 -17
  155. package/dist/systems/wall/wall-materials.d.ts +11 -7
  156. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  157. package/dist/systems/wall/wall-materials.js +244 -35
  158. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  159. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  160. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  161. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  162. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  163. package/dist/systems/wall/wall-support-extension.test.js +48 -0
  164. package/dist/systems/wall/wall-system.d.ts +2 -2
  165. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  166. package/dist/systems/wall/wall-system.js +329 -174
  167. package/dist/systems/window/window-animation-system.d.ts +12 -0
  168. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  169. package/dist/systems/window/window-animation-system.js +16 -5
  170. package/dist/systems/window/window-system.d.ts +7 -0
  171. package/dist/systems/window/window-system.d.ts.map +1 -1
  172. package/dist/systems/window/window-system.js +240 -14
  173. package/package.json +4 -4
  174. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  175. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  176. package/dist/components/viewer/ground-occluder.js +0 -67
@@ -0,0 +1,920 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { bakePolicyOf } from '@pascal-app/core';
4
+ import { Html, useAnimations } from '@react-three/drei';
5
+ import { useFrame, useThree } from '@react-three/fiber';
6
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
7
+ import * as THREE from 'three';
8
+ import { lerp } from 'three/src/math/MathUtils.js';
9
+ import { color, float, uniform, uv } from 'three/tsl';
10
+ import { MeshBasicNodeMaterial } from 'three/webgpu';
11
+ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';
12
+ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2';
13
+ import { ZONE_LAYER } from '../../lib/layers';
14
+ import { createSurfaceRoleMaterial } from '../../lib/materials';
15
+ import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only';
16
+ import useViewer from '../../store/use-viewer';
17
+ import { GlbInteractive } from './glb-interactive';
18
+ import { GlbReferenceNodes } from './glb-reference-nodes';
19
+ import { GlbReplaceInstances } from './glb-replace-instances';
20
+ /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
21
+ const EXPLODED_GAP = 5;
22
+ /** Baked `kind` → surface role, so monochrome can recolor by role like the
23
+ * parametric viewer (textures-off collapses each face to its themed clay). */
24
+ const ROLE_BY_KIND = {
25
+ wall: 'wall',
26
+ slab: 'floor',
27
+ floor: 'floor',
28
+ ceiling: 'ceiling',
29
+ roof: 'roof',
30
+ 'roof-segment': 'roof',
31
+ window: 'glazing',
32
+ door: 'joinery',
33
+ item: 'furnishing',
34
+ };
35
+ function findIdentityAncestor(object) {
36
+ let current = object;
37
+ while (current) {
38
+ if (current.userData.pascalId)
39
+ return current;
40
+ current = current.parent;
41
+ }
42
+ return null;
43
+ }
44
+ function findAncestorLevelId(object) {
45
+ let current = object.parent;
46
+ while (current) {
47
+ const extras = current.userData;
48
+ if (extras.kind === 'level' && extras.pascalId)
49
+ return extras.pascalId;
50
+ current = current.parent;
51
+ }
52
+ return null;
53
+ }
54
+ const _local = new THREE.Vector3();
55
+ const _floorHit = new THREE.Vector3();
56
+ const _floorPlanePoint = new THREE.Vector3();
57
+ const _floorPlane = new THREE.Plane();
58
+ const _up = new THREE.Vector3(0, 1, 0);
59
+ const _bounds = new THREE.Box3();
60
+ const _boundsCenter = new THREE.Vector3();
61
+ const _sample = new THREE.Vector3();
62
+ const _camBox = new THREE.Box3();
63
+ const _camCenter = new THREE.Vector3();
64
+ const _camSize = new THREE.Vector3();
65
+ const _camPoint = new THREE.Vector3();
66
+ const _walkPos = new THREE.Vector3();
67
+ const _reticleNdc = new THREE.Vector2(0, 0);
68
+ const _reticleRaycaster = new THREE.Raycaster();
69
+ /** How far ahead (metres) a door/window counts as "in view" for activation. */
70
+ const WALK_REACH = 3;
71
+ const ZONE_FOOTPRINT_EPSILON = 0.05;
72
+ const NO_RAYCAST = () => { };
73
+ /** Ray-cast point-in-polygon (polygon is a list of [x, z] in the test frame). */
74
+ function pointInPolygon(x, z, polygon) {
75
+ let inside = false;
76
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
77
+ const [xi, zi] = polygon[i];
78
+ const [xj, zj] = polygon[j];
79
+ if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi)
80
+ inside = !inside;
81
+ }
82
+ return inside;
83
+ }
84
+ function pointOnSegment(x, z, ax, az, bx, bz) {
85
+ const dx = bx - ax;
86
+ const dz = bz - az;
87
+ const lengthSq = dx * dx + dz * dz;
88
+ if (lengthSq === 0)
89
+ return Math.hypot(x - ax, z - az) <= ZONE_FOOTPRINT_EPSILON;
90
+ const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / lengthSq));
91
+ const px = ax + t * dx;
92
+ const pz = az + t * dz;
93
+ return Math.hypot(x - px, z - pz) <= ZONE_FOOTPRINT_EPSILON;
94
+ }
95
+ function pointInPolygonInclusive(x, z, polygon) {
96
+ if (pointInPolygon(x, z, polygon))
97
+ return true;
98
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
99
+ const [xi, zi] = polygon[i];
100
+ const [xj, zj] = polygon[j];
101
+ if (pointOnSegment(x, z, xi, zi, xj, zj))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ function worldPointInZoneFootprint(worldPoint, zone) {
107
+ _local.copy(worldPoint);
108
+ zone.node.worldToLocal(_local);
109
+ return pointInPolygonInclusive(_local.x, _local.z, zone.polygon);
110
+ }
111
+ function objectFootprintTouchesZone(object, zone) {
112
+ _bounds.setFromObject(object);
113
+ if (_bounds.isEmpty()) {
114
+ object.getWorldPosition(_sample);
115
+ return worldPointInZoneFootprint(_sample, zone);
116
+ }
117
+ _bounds.getCenter(_boundsCenter);
118
+ const y = _bounds.min.y;
119
+ const samples = [
120
+ [_boundsCenter.x, _boundsCenter.z],
121
+ [_bounds.min.x, _bounds.min.z],
122
+ [_bounds.min.x, _bounds.max.z],
123
+ [_bounds.max.x, _bounds.min.z],
124
+ [_bounds.max.x, _bounds.max.z],
125
+ ];
126
+ for (const [x, z] of samples) {
127
+ _sample.set(x, y, z);
128
+ if (worldPointInZoneFootprint(_sample, zone))
129
+ return true;
130
+ }
131
+ return false;
132
+ }
133
+ const Y_OFFSET = 0.01;
134
+ const ZONE_WALL_HEIGHT = 2.3;
135
+ /** Floor fill — flat 0.25 tint scaled by the fade uniform (matches the editor). */
136
+ function createZoneFloorMaterial(zoneColor) {
137
+ const o = uniform(0);
138
+ const material = new MeshBasicNodeMaterial({
139
+ colorNode: color(new THREE.Color(zoneColor)),
140
+ depthTest: false,
141
+ depthWrite: false,
142
+ opacityNode: float(0.25).mul(o),
143
+ side: THREE.DoubleSide,
144
+ transparent: true,
145
+ });
146
+ material.userData.uOpacity = o;
147
+ return material;
148
+ }
149
+ /** Vertical border — color at the base fading to transparent at the top. */
150
+ function createZoneWallMaterial(zoneColor) {
151
+ const o = uniform(0);
152
+ const material = new MeshBasicNodeMaterial({
153
+ colorNode: color(new THREE.Color(zoneColor)),
154
+ depthTest: false,
155
+ depthWrite: false,
156
+ opacityNode: float(0.6).mul(float(1).sub(uv().y)).mul(o),
157
+ side: THREE.DoubleSide,
158
+ transparent: true,
159
+ });
160
+ material.userData.uOpacity = o;
161
+ return material;
162
+ }
163
+ /** Vertical quads along each polygon edge (UV.y 0 at the floor, 1 at the top). */
164
+ function createZoneWallGeometry(polygon) {
165
+ const positions = [];
166
+ const uvs = [];
167
+ const indices = [];
168
+ for (let i = 0; i < polygon.length; i++) {
169
+ const [cx, cz] = polygon[i];
170
+ const [nx, nz] = polygon[(i + 1) % polygon.length];
171
+ const base = i * 4;
172
+ positions.push(cx, Y_OFFSET, cz, nx, Y_OFFSET, nz, nx, Y_OFFSET + ZONE_WALL_HEIGHT, nz, cx, Y_OFFSET + ZONE_WALL_HEIGHT, cz);
173
+ uvs.push(0, 0, 1, 0, 1, 1, 0, 1);
174
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
175
+ }
176
+ const geometry = new THREE.BufferGeometry();
177
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
178
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
179
+ geometry.setIndex(indices);
180
+ geometry.computeVertexNormals();
181
+ return geometry;
182
+ }
183
+ /**
184
+ * GLB-consuming viewer scene (plan phase 2). Loads a baked artifact and drives
185
+ * the editor's presentation/interaction with no parametric scene graph. Hover
186
+ * and click resolve through the drill hierarchy (building → level → zone →
187
+ * object): the cursor targets the floor in the building view, the room or
188
+ * structure in a level, and items/structure inside a room. Selection feeds the
189
+ * existing outline post-FX, openables play their baked clips, and the shared
190
+ * `useViewer.selection` (with its hierarchy guard) holds the drill state. The
191
+ * host disables the parametric `SelectionManager` (`selectionManager="custom"`).
192
+ */
193
+ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes, onLevelsChange, onIdentityChange, onHoverChange, onWalkthroughChange, }) {
194
+ const gltf = useGLTFKTX2(url);
195
+ const rootRef = useRef(null);
196
+ const { actions } = useAnimations(gltf.animations, rootRef);
197
+ const camera = useThree((state) => state.camera);
198
+ const raycaster = useThree((state) => state.raycaster);
199
+ const controls = useThree((state) => state.controls);
200
+ const walkthroughMode = useViewer((s) => s.walkthroughMode);
201
+ const textures = useViewer((s) => s.textures);
202
+ const sceneTheme = useViewer((s) => s.sceneTheme);
203
+ // Monochrome: strip the baked textures and recolor every building mesh with a
204
+ // flat themed-clay material by surface role — mirrors the parametric viewer's
205
+ // textures-off path. The original baked material is stashed on the mesh
206
+ // (`userData.__bakedMaterial`) so it survives the cached GLTF across remounts.
207
+ useEffect(() => {
208
+ gltf.scene.traverse((object) => {
209
+ const role = ROLE_BY_KIND[object.userData.kind ?? ''];
210
+ if (!role)
211
+ return;
212
+ object.traverse((child) => {
213
+ const mesh = child;
214
+ if (!mesh.isMesh || mesh.layers.isEnabled(ZONE_LAYER))
215
+ return;
216
+ const ud = mesh.userData;
217
+ if (!ud.__bakedMaterial)
218
+ ud.__bakedMaterial = mesh.material;
219
+ mesh.material = textures
220
+ ? ud.__bakedMaterial
221
+ : createSurfaceRoleMaterial(role, 'clay', THREE.DoubleSide, sceneTheme);
222
+ });
223
+ });
224
+ }, [gltf.scene, textures, sceneTheme]);
225
+ // The baked scene isn't wrapped in <SceneBvh> (the community viewer runs with
226
+ // useBvh={false}), so hover/pick raycasts against the baked building were
227
+ // brute-force triangle tests — dozens of ms per pointer move on a dense scene,
228
+ // and far worse once a `replace` forest is portaled in. Give each baked mesh a
229
+ // BVH so those raycasts are accelerated. `replace` instances are NO_RAYCAST, so
230
+ // the `raycast === Mesh.prototype.raycast` guard skips them (mirrors SceneBvh).
231
+ useEffect(() => {
232
+ const accelerated = new Set();
233
+ const computed = new Set();
234
+ gltf.scene.traverse((child) => {
235
+ const mesh = child;
236
+ if (!mesh.isMesh || mesh.raycast !== THREE.Mesh.prototype.raycast)
237
+ return;
238
+ mesh.raycast = acceleratedRaycast;
239
+ accelerated.add(mesh);
240
+ const geometry = mesh.geometry;
241
+ if (geometry.boundsTree || !geometry.getAttribute('position'))
242
+ return;
243
+ try {
244
+ // three-mesh-bvh + @types/three disagree on the helper signatures; cast
245
+ // through unknown like SceneBvh does — the runtime call is correct.
246
+ ;
247
+ geometry.computeBoundsTree =
248
+ computeBoundsTree;
249
+ geometry.disposeBoundsTree =
250
+ disposeBoundsTree;
251
+ geometry.computeBoundsTree();
252
+ computed.add(geometry);
253
+ }
254
+ catch (error) {
255
+ console.warn('[viewer] skipping BVH for baked mesh geometry', error);
256
+ }
257
+ });
258
+ return () => {
259
+ for (const geometry of computed) {
260
+ if (geometry.boundsTree)
261
+ geometry.disposeBoundsTree();
262
+ }
263
+ for (const mesh of accelerated) {
264
+ if (mesh.raycast === acceleratedRaycast)
265
+ mesh.raycast = THREE.Mesh.prototype.raycast;
266
+ }
267
+ };
268
+ }, [gltf.scene]);
269
+ // One pass over the artifact: identity objects (id → Object3D), ordered floors,
270
+ // and zone polygons. Levels stay out of `sceneRegistry` so the parametric
271
+ // LevelSystem never re-stacks them.
272
+ const { levels, identity, zoneEntries, occluders, rootNode, levelsWithZones } = useMemo(() => {
273
+ const objects = new Map();
274
+ const floors = [];
275
+ const zoneList = [];
276
+ // Ceilings + roof are hidden when a floor is focused (dollhouse view) so the
277
+ // camera sees the rooms and the pointer ray reaches their contents.
278
+ const occluderNodes = [];
279
+ // The building (or site) node anchors the building-view camera bookmark/fit.
280
+ let buildingNode = null;
281
+ let siteNode = null;
282
+ gltf.scene.traverse((object) => {
283
+ const extras = object.userData;
284
+ // The spawn marker is an authoring-only node (walkthrough start pose); it
285
+ // should never render in the viewer. Its transform still feeds the
286
+ // walkthrough controller — visibility doesn't affect that.
287
+ if (extras.kind === 'spawn') {
288
+ object.visible = false;
289
+ return;
290
+ }
291
+ if (!extras.pascalId)
292
+ return;
293
+ objects.set(extras.pascalId, object);
294
+ // `bake: 'replace'` kinds are baked as static geometry (portable), but a
295
+ // loaded plugin re-renders them live from the scene graph — hide the frozen
296
+ // baked mesh so only the live one shows. Gated on the registry, so with no
297
+ // plugin loaded the policy is `'static'` and the baked mesh stays.
298
+ if (bakePolicyOf(extras.kind ?? '') === 'replace')
299
+ object.visible = false;
300
+ if (extras.kind === 'building')
301
+ buildingNode = object;
302
+ else if (extras.kind === 'site')
303
+ siteNode = object;
304
+ if (extras.kind === 'ceiling' || extras.kind === 'roof')
305
+ occluderNodes.push(object);
306
+ if (extras.kind === 'level') {
307
+ floors.push({
308
+ id: extras.pascalId,
309
+ node: object,
310
+ baseY: object.position.y,
311
+ });
312
+ }
313
+ if (extras.kind === 'zone' && extras.polygon && extras.polygon.length >= 3) {
314
+ const polygon = extras.polygon;
315
+ const centroid = [
316
+ polygon.reduce((sum, [x]) => sum + x, 0) / polygon.length,
317
+ polygon.reduce((sum, [, z]) => sum + z, 0) / polygon.length,
318
+ ];
319
+ zoneList.push({
320
+ id: extras.pascalId,
321
+ node: object,
322
+ levelId: findAncestorLevelId(object),
323
+ polygon,
324
+ label: extras.label ?? extras.pascalId,
325
+ color: extras.color ?? '#3b82f6',
326
+ centroid,
327
+ });
328
+ }
329
+ });
330
+ floors.sort((a, b) => a.baseY - b.baseY);
331
+ return {
332
+ levels: floors,
333
+ identity: objects,
334
+ zoneEntries: zoneList,
335
+ occluders: occluderNodes,
336
+ rootNode: (buildingNode ?? siteNode),
337
+ // Levels that have rooms — only these trigger the dollhouse occluder strip.
338
+ levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)),
339
+ };
340
+ }, [gltf.scene]);
341
+ const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]);
342
+ // Level pascalIds bottom-to-top, for the interactive light pool's level factor.
343
+ const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels]);
344
+ // The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted
345
+ // on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding
346
+ // the whole occluder node would hide them too, so collect just the occluder's
347
+ // own meshes (stop descending at any nested identity node) and toggle those.
348
+ const occluderOwnMeshes = useMemo(() => {
349
+ const meshes = [];
350
+ const walk = (node) => {
351
+ for (const child of node.children) {
352
+ if (child.userData.pascalId)
353
+ continue; // hosted item — keep visible
354
+ if (child.isMesh)
355
+ meshes.push(child);
356
+ walk(child);
357
+ }
358
+ };
359
+ for (const occluder of occluders) {
360
+ if (occluder.isMesh)
361
+ meshes.push(occluder);
362
+ walk(occluder);
363
+ }
364
+ return meshes;
365
+ }, [occluders]);
366
+ // Move the camera to match the drill depth: a saved bookmark (extras.camera)
367
+ // wins; otherwise fit to the target's bounds (the object, the room's polygon
368
+ // footprint for empty zone nodes, the level, or the whole building). Mirrors
369
+ // the parametric viewer's selection framing so the GLB path feels identical.
370
+ const focusLevelId = useViewer((s) => s.selection.levelId);
371
+ const focusZoneId = useViewer((s) => s.selection.zoneId);
372
+ const focusSelectedId = useViewer((s) => s.selection.selectedIds[0] ?? null);
373
+ useEffect(() => {
374
+ if (!controls)
375
+ return;
376
+ const flyToBookmark = (bookmark) => {
377
+ const { position: p, target: t } = bookmark;
378
+ controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true);
379
+ controls.normalizeRotations?.();
380
+ };
381
+ // Item selection happens inside a room, where we're already at a good angle:
382
+ // fly to the item's own bookmark if it has one, otherwise just pan to it
383
+ // (keep the current orbit angle + distance) rather than reframing the camera.
384
+ if (focusSelectedId) {
385
+ const object = identity.get(focusSelectedId);
386
+ if (!object)
387
+ return;
388
+ const itemBookmark = object.userData.camera;
389
+ if (itemBookmark) {
390
+ flyToBookmark(itemBookmark);
391
+ return;
392
+ }
393
+ _camBox.makeEmpty();
394
+ _camBox.setFromObject(object);
395
+ if (_camBox.isEmpty())
396
+ return;
397
+ _camBox.getCenter(_camCenter);
398
+ controls.moveTo?.(_camCenter.x, _camCenter.y, _camCenter.z, true);
399
+ return;
400
+ }
401
+ let bookmarkNode = null;
402
+ _camBox.makeEmpty();
403
+ if (focusZoneId) {
404
+ const zone = zoneById.get(focusZoneId);
405
+ if (!zone)
406
+ return;
407
+ bookmarkNode = zone.node;
408
+ // Zone identity nodes carry no mesh — bound the room from its polygon.
409
+ zone.node.updateWorldMatrix(true, false);
410
+ for (const [x, z] of zone.polygon) {
411
+ _camBox.expandByPoint(_camPoint.set(x, 0, z).applyMatrix4(zone.node.matrixWorld));
412
+ _camBox.expandByPoint(_camPoint.set(x, ZONE_WALL_HEIGHT, z).applyMatrix4(zone.node.matrixWorld));
413
+ }
414
+ }
415
+ else if (focusLevelId) {
416
+ const object = identity.get(focusLevelId);
417
+ if (!object)
418
+ return;
419
+ bookmarkNode = object;
420
+ _camBox.setFromObject(object);
421
+ }
422
+ else {
423
+ bookmarkNode = rootNode;
424
+ _camBox.setFromObject(gltf.scene);
425
+ }
426
+ const bookmark = bookmarkNode?.userData?.camera;
427
+ if (bookmark) {
428
+ flyToBookmark(bookmark);
429
+ return;
430
+ }
431
+ if (_camBox.isEmpty())
432
+ return;
433
+ _camBox.getCenter(_camCenter);
434
+ _camBox.getSize(_camSize);
435
+ const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15);
436
+ controls.setLookAt(_camCenter.x + distance * 0.7, _camCenter.y + distance * 0.5, _camCenter.z + distance * 0.7, _camCenter.x, _camCenter.y, _camCenter.z, true);
437
+ controls.normalizeRotations?.();
438
+ }, [
439
+ controls,
440
+ focusSelectedId,
441
+ focusZoneId,
442
+ focusLevelId,
443
+ identity,
444
+ zoneById,
445
+ rootNode,
446
+ gltf.scene,
447
+ ]);
448
+ useEffect(() => {
449
+ const cameraMask = camera.layers.mask;
450
+ const raycasterMask = raycaster.layers.mask;
451
+ camera.layers.enable(ZONE_LAYER);
452
+ raycaster.layers.disable(ZONE_LAYER);
453
+ return () => {
454
+ camera.layers.mask = cameraMask;
455
+ raycaster.layers.mask = raycasterMask;
456
+ };
457
+ }, [camera, raycaster]);
458
+ useEffect(() => {
459
+ onLevelsChange?.(levels.map(({ id, node }) => ({ id, label: node.userData.label ?? id })));
460
+ const labels = {};
461
+ identity.forEach((object, id) => {
462
+ const extras = object.userData;
463
+ labels[id] = { kind: extras.kind ?? 'node', label: extras.label ?? id };
464
+ });
465
+ onIdentityChange?.(labels);
466
+ return () => {
467
+ onLevelsChange?.([]);
468
+ onIdentityChange?.({});
469
+ };
470
+ }, [levels, identity, onLevelsChange, onIdentityChange]);
471
+ // Apply the editor's level modes to the baked floors each frame. Walkthrough
472
+ // always shows the full stacked building (you're standing inside it) — and the
473
+ // first-person collider is built from the visible meshes, so a hidden solo
474
+ // floor would otherwise drop the player through the world.
475
+ useFrame((_, delta) => {
476
+ if (levels.length === 0)
477
+ return;
478
+ const { levelMode, selection, walkthroughMode } = useViewer.getState();
479
+ const selectedLevel = selection.levelId;
480
+ const selectedIdx = selectedLevel ? levels.findIndex((l) => l.id === selectedLevel) : -1;
481
+ levels.forEach(({ id, node, baseY }, index) => {
482
+ const exploded = !walkthroughMode && levelMode === 'exploded';
483
+ const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0);
484
+ // Snap (not lerp) in walkthrough so the first-person collider, built from
485
+ // these world positions, matches the stacked building immediately.
486
+ node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12);
487
+ // Solo: hidden levels above the soloed one keep casting shadows
488
+ // (shadow-caster-only); below-levels can't block the sun, so plain-hide.
489
+ const hidden = !walkthroughMode && levelMode === 'solo' && Boolean(selectedLevel) && id !== selectedLevel;
490
+ const castsWhileHidden = hidden && selectedIdx >= 0 && index > selectedIdx;
491
+ if (castsWhileHidden) {
492
+ applyShadowOnly(node);
493
+ node.visible = true;
494
+ }
495
+ else {
496
+ clearShadowOnly(node);
497
+ node.visible = !hidden;
498
+ }
499
+ });
500
+ }, 5);
501
+ const zoneFills = useRef([]);
502
+ useEffect(() => {
503
+ const built = [];
504
+ for (const entry of zoneEntries) {
505
+ const shape = new THREE.Shape();
506
+ entry.polygon.forEach(([x, z], i) => {
507
+ if (i === 0)
508
+ shape.moveTo(x, -z);
509
+ else
510
+ shape.lineTo(x, -z);
511
+ });
512
+ shape.closePath();
513
+ const floorMaterial = createZoneFloorMaterial(entry.color);
514
+ const floor = new THREE.Mesh(new THREE.ShapeGeometry(shape), floorMaterial);
515
+ floor.rotation.x = -Math.PI / 2;
516
+ floor.position.y = 0.02;
517
+ const wallMaterial = createZoneWallMaterial(entry.color);
518
+ const walls = new THREE.Mesh(createZoneWallGeometry(entry.polygon), wallMaterial);
519
+ const meshes = [floor, walls];
520
+ for (const mesh of meshes) {
521
+ mesh.visible = false;
522
+ mesh.layers.set(ZONE_LAYER);
523
+ // Visual helpers only — never participate in picking. Hover/selection
524
+ // resolves against the real building geometry + point-in-polygon, so the
525
+ // tall wall helpers can't occlude items or fight at shared boundaries.
526
+ mesh.raycast = NO_RAYCAST;
527
+ entry.node.add(mesh);
528
+ }
529
+ built.push({
530
+ id: entry.id,
531
+ levelId: entry.levelId,
532
+ meshes,
533
+ uniforms: [
534
+ floorMaterial.userData.uOpacity,
535
+ wallMaterial.userData.uOpacity,
536
+ ],
537
+ });
538
+ }
539
+ zoneFills.current = built;
540
+ return () => {
541
+ for (const { meshes } of built) {
542
+ for (const mesh of meshes) {
543
+ mesh.removeFromParent();
544
+ mesh.geometry.dispose();
545
+ mesh.material.dispose();
546
+ }
547
+ }
548
+ zoneFills.current = [];
549
+ };
550
+ }, [zoneEntries]);
551
+ useEffect(() => {
552
+ for (const [name, action] of Object.entries(actions)) {
553
+ if (!action)
554
+ continue;
555
+ // Ambient item loops (a fan's spin, `<id>: loop`) repeat; door/window
556
+ // open clips play once and hold their end pose. GlbInteractive plays the
557
+ // loops, gated on the item's toggle.
558
+ if (name.endsWith(': loop')) {
559
+ action.loop = THREE.LoopRepeat;
560
+ action.clampWhenFinished = false;
561
+ }
562
+ else {
563
+ action.loop = THREE.LoopOnce;
564
+ action.clampWhenFinished = true;
565
+ }
566
+ }
567
+ }, [actions]);
568
+ const openIds = useRef(new Set());
569
+ const toggleOpenable = useCallback((node) => {
570
+ const extras = node.userData;
571
+ const clipName = extras.clips?.[0];
572
+ if (!extras.openable || !clipName)
573
+ return;
574
+ const action = actions[clipName];
575
+ if (!action)
576
+ return;
577
+ const id = extras.pascalId;
578
+ const willOpen = !openIds.current.has(id);
579
+ action.enabled = true;
580
+ action.paused = false;
581
+ action.loop = THREE.LoopOnce;
582
+ action.clampWhenFinished = true;
583
+ action.timeScale = willOpen ? 1 : -1;
584
+ action.play();
585
+ if (willOpen)
586
+ openIds.current.add(id);
587
+ else
588
+ openIds.current.delete(id);
589
+ }, [actions]);
590
+ // The room whose polygon contains a world point. Resolving by the raycast hit
591
+ // point (rather than a node origin) means any surface inside a room footprint —
592
+ // floor, slab, or furniture — maps to that room.
593
+ const zoneAtPoint = useCallback((worldPoint, levelId) => {
594
+ for (const entry of zoneEntries) {
595
+ if (entry.levelId !== levelId)
596
+ continue;
597
+ _local.copy(worldPoint);
598
+ entry.node.worldToLocal(_local);
599
+ if (pointInPolygonInclusive(_local.x, _local.z, entry.polygon))
600
+ return entry;
601
+ }
602
+ return null;
603
+ }, [zoneEntries]);
604
+ // The room the cursor points at, found by intersecting the pointer ray with the
605
+ // level's floor plane — independent of what 3D object the ray actually hits.
606
+ // This is the editor's model: zone helpers and walls are ignored, so adjacent
607
+ // rooms never fight and an item against a wall still maps to its own room.
608
+ const zoneAtRay = useCallback((ray, levelId) => {
609
+ const levelNode = identity.get(levelId);
610
+ const floorY = levelNode ? levelNode.getWorldPosition(_floorPlanePoint).y : 0;
611
+ _floorPlane.setFromNormalAndCoplanarPoint(_up, _floorPlanePoint.set(0, floorY, 0));
612
+ if (!ray.intersectPlane(_floorPlane, _floorHit))
613
+ return null;
614
+ return zoneAtPoint(_floorHit, levelId);
615
+ }, [identity, zoneAtPoint]);
616
+ // Resolve a pointer ray to the unit the current drill depth acts on:
617
+ // - building view → the floor the hit object belongs to
618
+ // - level view → the room the cursor points at on the floor
619
+ // - zone view → the first hit node whose hit/footprint is inside the room
620
+ const resolveTarget = useCallback((hits, ray) => {
621
+ const firstNode = hits.length > 0 ? findIdentityAncestor(hits[0].object) : null;
622
+ const toTarget = (object, tid) => {
623
+ const e = object.userData;
624
+ return { object, id: tid, kind: e.kind ?? 'node', label: e.label ?? tid };
625
+ };
626
+ const { selection } = useViewer.getState();
627
+ // Building view → drill to the floor the hit object belongs to.
628
+ if (!selection.levelId) {
629
+ if (!firstNode)
630
+ return null;
631
+ const extras = firstNode.userData;
632
+ const levelId = extras.kind === 'level' ? extras.pascalId : findAncestorLevelId(firstNode);
633
+ const levelObject = levelId ? identity.get(levelId) : undefined;
634
+ return levelObject && levelId ? toTarget(levelObject, levelId) : null;
635
+ }
636
+ // Level view → the room the cursor is over (floor-plane intersection).
637
+ if (!selection.zoneId) {
638
+ const zone = zoneAtRay(ray, selection.levelId);
639
+ return zone ? toTarget(zone.node, zone.id) : null;
640
+ }
641
+ // Zone view → scan through all R3F intersections so room helpers or slabs
642
+ // can't hide a selectable item/structure behind the first hit.
643
+ const activeZone = zoneById.get(selection.zoneId);
644
+ if (!activeZone)
645
+ return null;
646
+ const seen = new Set();
647
+ for (const hit of hits) {
648
+ const node = findIdentityAncestor(hit.object);
649
+ if (!node)
650
+ continue;
651
+ const extras = node.userData;
652
+ const id = extras.pascalId;
653
+ if (!id || seen.has(id))
654
+ continue;
655
+ seen.add(id);
656
+ const kind = extras.kind ?? 'node';
657
+ if (kind === 'site' || kind === 'building' || kind === 'level' || kind === 'zone') {
658
+ continue;
659
+ }
660
+ const hitLevel = findAncestorLevelId(node);
661
+ if (hitLevel !== selection.levelId)
662
+ continue;
663
+ if (hit.point && worldPointInZoneFootprint(hit.point, activeZone)) {
664
+ return toTarget(node, id);
665
+ }
666
+ if (objectFootprintTouchesZone(node, activeZone)) {
667
+ return toTarget(node, id);
668
+ }
669
+ }
670
+ return null;
671
+ }, [identity, zoneAtRay, zoneById]);
672
+ // DOM nodes for each zone's floating room label, plus a group whose transform
673
+ // tracks the zone node so the label rides level stacking.
674
+ const labelGroups = useRef(new Map());
675
+ const labelDivs = useRef(new Map());
676
+ // Per-frame: fade a level's rooms in/out (hidden once a zone is entered, like
677
+ // the editor) with a brightness bump on the hovered room, keep room labels
678
+ // positioned + faded with them, and sync the outline post-FX from the shared
679
+ // selection + local hover.
680
+ const hoveredTarget = useRef(null);
681
+ useFrame((_, delta) => {
682
+ const state = useViewer.getState();
683
+ const { selection, outliner } = state;
684
+ const t = Math.min(1, delta * 8);
685
+ const hoveredZoneId = hoveredTarget.current?.kind === 'zone' ? hoveredTarget.current.id : null;
686
+ // Walkthrough is a first-person tour: no zone tints, no dollhouse cutaway,
687
+ // no selection outline — you're standing inside the real building.
688
+ const walk = state.walkthroughMode;
689
+ // Dollhouse: hide ceilings + roof so the rooms (and their zone tint) are
690
+ // visible from above and the ray reaches their contents — but only when the
691
+ // focused level actually has rooms. Focusing a zone-less floor keeps the
692
+ // building intact (otherwise its roof would just vanish with nothing to show).
693
+ const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId);
694
+ // Shadow-caster-only: hidden roof/ceiling meshes keep casting sun shadows
695
+ // so interiors show window light patches instead of uniform sun flood.
696
+ for (const mesh of occluderOwnMeshes) {
697
+ if (revealing)
698
+ applyShadowOnly(mesh);
699
+ else
700
+ clearShadowOnly(mesh);
701
+ }
702
+ for (const { id, levelId, meshes, uniforms } of zoneFills.current) {
703
+ const show = !walk && selection.levelId != null && levelId === selection.levelId && !selection.zoneId;
704
+ const target = !show ? 0 : id === hoveredZoneId ? 1 : 0.65;
705
+ let visible = false;
706
+ for (const u of uniforms) {
707
+ u.value = lerp(u.value, target, t);
708
+ if (u.value > 0.01)
709
+ visible = true;
710
+ }
711
+ for (const mesh of meshes)
712
+ mesh.visible = visible;
713
+ const group = labelGroups.current.get(id);
714
+ const zoneNode = identity.get(id);
715
+ if (group && zoneNode) {
716
+ group.matrixAutoUpdate = false;
717
+ group.matrix.copy(zoneNode.matrixWorld);
718
+ group.visible = visible;
719
+ }
720
+ const div = labelDivs.current.get(id);
721
+ if (div) {
722
+ div.style.opacity = show ? '1' : '0';
723
+ // Small by default, smoothly zooming up when its room is hovered.
724
+ div.style.transform = `scale(${id === hoveredZoneId ? 1 : 0.82})`;
725
+ }
726
+ }
727
+ outliner.selectedObjects.length = 0;
728
+ outliner.hoveredObjects.length = 0;
729
+ if (walk)
730
+ return;
731
+ const selectedObject = selection.selectedIds[0]
732
+ ? (identity.get(selection.selectedIds[0]) ?? null)
733
+ : null;
734
+ if (selectedObject)
735
+ outliner.selectedObjects.push(selectedObject);
736
+ // Rooms show hover via the fill brightness; everything else uses the outline.
737
+ const hover = hoveredTarget.current;
738
+ if (hover && hover.kind !== 'zone' && hover.object !== selectedObject) {
739
+ outliner.hoveredObjects.push(hover.object);
740
+ }
741
+ });
742
+ // ── Walkthrough: first-person HUD + door/window interaction ────────────────
743
+ const walkDoorRef = useRef(null);
744
+ const lastWalkKey = useRef(null);
745
+ // Each frame in walkthrough, report the floor + room the camera stands in and
746
+ // the openable directly ahead (a forward ray from screen centre) so the host
747
+ // can draw the reticle prompt. Fires the callback only when the state changes.
748
+ useFrame(() => {
749
+ if (!walkthroughMode)
750
+ return;
751
+ camera.getWorldPosition(_walkPos);
752
+ let floor = levels[0] ?? null;
753
+ for (const level of levels) {
754
+ if (_walkPos.y >= level.baseY - 0.5)
755
+ floor = level;
756
+ else
757
+ break;
758
+ }
759
+ const floorLabel = floor ? (floor.node.userData.label ?? floor.id) : null;
760
+ const zone = floor ? zoneAtPoint(_walkPos, floor.id) : null;
761
+ _reticleRaycaster.far = WALK_REACH;
762
+ _reticleRaycaster.setFromCamera(_reticleNdc, camera);
763
+ const hit = _reticleRaycaster.intersectObject(gltf.scene, true)[0];
764
+ let doorNode = null;
765
+ let doorId = '';
766
+ let door = null;
767
+ if (hit) {
768
+ const node = findIdentityAncestor(hit.object);
769
+ const extras = node?.userData;
770
+ if (node && extras?.openable && extras.clips?.length) {
771
+ doorNode = node;
772
+ doorId = extras.pascalId;
773
+ door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) };
774
+ }
775
+ }
776
+ walkDoorRef.current = doorNode;
777
+ const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}`;
778
+ if (key !== lastWalkKey.current) {
779
+ lastWalkKey.current = key;
780
+ onWalkthroughChange?.({ zoneLabel: zone?.label ?? null, floorLabel, door });
781
+ }
782
+ });
783
+ // E or click activates the openable in view. The click also re-locks the
784
+ // pointer via WalkthroughControls — harmless overlap; no selection happens.
785
+ const activateWalkDoor = useCallback(() => {
786
+ if (walkDoorRef.current)
787
+ toggleOpenable(walkDoorRef.current);
788
+ }, [toggleOpenable]);
789
+ useEffect(() => {
790
+ if (!walkthroughMode)
791
+ return;
792
+ const onKey = (event) => {
793
+ if (event.key.toLowerCase() === 'e')
794
+ activateWalkDoor();
795
+ };
796
+ const canvas = document.querySelector('canvas');
797
+ window.addEventListener('keydown', onKey);
798
+ canvas?.addEventListener('click', activateWalkDoor);
799
+ return () => {
800
+ window.removeEventListener('keydown', onKey);
801
+ canvas?.removeEventListener('click', activateWalkDoor);
802
+ };
803
+ }, [walkthroughMode, activateWalkDoor]);
804
+ // Clear the HUD (and stale targeting) whenever walkthrough turns off.
805
+ useEffect(() => {
806
+ if (walkthroughMode)
807
+ return;
808
+ walkDoorRef.current = null;
809
+ lastWalkKey.current = null;
810
+ onWalkthroughChange?.(null);
811
+ }, [walkthroughMode, onWalkthroughChange]);
812
+ const lastHover = useRef(null);
813
+ const handlePointerMove = useCallback((event) => {
814
+ event.stopPropagation();
815
+ if (walkthroughMode)
816
+ return;
817
+ const target = resolveTarget(event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray);
818
+ hoveredTarget.current = target;
819
+ document.body.style.cursor = target ? 'pointer' : 'auto';
820
+ const key = target ? `${target.kind}:${target.id}` : null;
821
+ if (key !== lastHover.current) {
822
+ lastHover.current = key;
823
+ onHoverChange?.(target ? { kind: target.kind, label: target.label } : null);
824
+ }
825
+ }, [resolveTarget, onHoverChange, walkthroughMode]);
826
+ const handlePointerOut = useCallback(() => {
827
+ hoveredTarget.current = null;
828
+ document.body.style.cursor = 'auto';
829
+ if (lastHover.current !== null) {
830
+ lastHover.current = null;
831
+ onHoverChange?.(null);
832
+ }
833
+ }, [onHoverChange]);
834
+ // Drill the building → level → zone → object hierarchy, deselecting back up
835
+ // when the click lands outside the current scope. setSelection's hierarchy
836
+ // guard clears deeper selections automatically when a parent changes.
837
+ const handleClick = useCallback((event) => {
838
+ event.stopPropagation();
839
+ // Walkthrough handles its own door activation (E / canvas click) and never
840
+ // selects — leave the drill hierarchy untouched.
841
+ if (walkthroughMode)
842
+ return;
843
+ const target = resolveTarget(event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray);
844
+ const { selection, setSelection, setLevelMode } = useViewer.getState();
845
+ // Building view → drill into the clicked floor.
846
+ if (!selection.levelId) {
847
+ if (target) {
848
+ setLevelMode('solo');
849
+ setSelection({ levelId: target.id });
850
+ }
851
+ return;
852
+ }
853
+ // Level view → enter the clicked room; clicking outside any room exits to
854
+ // the building.
855
+ if (!selection.zoneId) {
856
+ if (target) {
857
+ setSelection({ zoneId: target.id });
858
+ }
859
+ else {
860
+ setLevelMode('stacked');
861
+ setSelection({ levelId: null });
862
+ }
863
+ return;
864
+ }
865
+ // Zone view → select the clicked node; clicking outside the room exits to
866
+ // the level.
867
+ if (target) {
868
+ setSelection({ selectedIds: [target.id] });
869
+ toggleOpenable(target.object);
870
+ }
871
+ else {
872
+ setSelection({ zoneId: null });
873
+ }
874
+ }, [resolveTarget, toggleOpenable, walkthroughMode]);
875
+ // A click that hits nothing (empty space) steps one level back up the drill
876
+ // hierarchy, like the legacy viewer.
877
+ const handlePointerMissed = useCallback(() => {
878
+ if (useViewer.getState().walkthroughMode)
879
+ return;
880
+ const { selection, setSelection, setLevelMode } = useViewer.getState();
881
+ if (selection.selectedIds.length > 0) {
882
+ setSelection({ selectedIds: [] });
883
+ }
884
+ else if (selection.zoneId) {
885
+ setSelection({ zoneId: null });
886
+ }
887
+ else if (selection.levelId) {
888
+ setLevelMode('stacked');
889
+ setSelection({ levelId: null });
890
+ }
891
+ }, []);
892
+ useEffect(() => () => {
893
+ const { outliner } = useViewer.getState();
894
+ outliner.selectedObjects.length = 0;
895
+ outliner.hoveredObjects.length = 0;
896
+ document.body.style.cursor = 'auto';
897
+ // Restore ceilings/roof — the GLB scene is cached by drei and may be reused.
898
+ for (const mesh of occluderOwnMeshes)
899
+ clearShadowOnly(mesh);
900
+ }, [occluderOwnMeshes]);
901
+ return (_jsxs("group", { ref: rootRef, children: [_jsx("primitive", { object: gltf.scene, onClick: handleClick, onPointerMissed: handlePointerMissed, onPointerMove: handlePointerMove, onPointerOut: handlePointerOut }), interactiveItems?.length ? (_jsx(GlbInteractive, { actions: actions, identity: identity, items: interactiveItems, levelOrder: levelOrder, zones: zoneEntries })) : null, referenceNodes?.length ? (_jsx(GlbReferenceNodes, { identity: identity, nodes: referenceNodes })) : null, replaceNodes?.length ? (_jsx(GlbReplaceInstances, { identity: identity, nodes: replaceNodes })) : null, zoneEntries.map((zone) => (_jsx("group", { ref: (group) => {
902
+ if (group)
903
+ labelGroups.current.set(zone.id, group);
904
+ else
905
+ labelGroups.current.delete(zone.id);
906
+ }, children: _jsx(Html, { center: true, position: [zone.centroid[0], 1, zone.centroid[1]], style: { pointerEvents: 'none', userSelect: 'none' }, zIndexRange: [10, 0], children: _jsx("div", { ref: (div) => {
907
+ if (div)
908
+ labelDivs.current.set(zone.id, div);
909
+ else
910
+ labelDivs.current.delete(zone.id);
911
+ }, style: {
912
+ color: 'white',
913
+ opacity: 0,
914
+ textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`,
915
+ transform: 'scale(0.82)',
916
+ transformOrigin: 'center',
917
+ transition: 'opacity 0.3s ease-in-out, transform 0.2s ease-out',
918
+ whiteSpace: 'nowrap',
919
+ }, children: zone.label }) }) }, zone.id)))] }));
920
+ }