@pascal-app/viewer 1.0.3 → 1.0.5

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 (276) hide show
  1. package/dist/capture/capture-runtime.js +1 -1
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/renderers/node-renderer.d.ts +1 -1
  4. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  5. package/dist/components/renderers/node-renderer.js +12 -4
  6. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  7. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -1
  8. package/dist/components/viewer/frame-limiter.d.ts +1 -0
  9. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  10. package/dist/components/viewer/frame-limiter.js +22 -3
  11. package/dist/components/viewer/glb-interactive.d.ts +25 -1
  12. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  13. package/dist/components/viewer/glb-interactive.js +253 -86
  14. package/dist/components/viewer/glb-mechanisms.d.ts +10 -0
  15. package/dist/components/viewer/glb-mechanisms.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-mechanisms.js +19 -0
  17. package/dist/components/viewer/glb-reference-nodes.js +1 -1
  18. package/dist/components/viewer/glb-replace-instances.js +1 -1
  19. package/dist/components/viewer/glb-scene.d.ts +45 -27
  20. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  21. package/dist/components/viewer/glb-scene.js +421 -508
  22. package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
  23. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  24. package/dist/components/viewer/glb-walkthrough-controller.js +13 -2
  25. package/dist/components/viewer/index.d.ts +24 -1
  26. package/dist/components/viewer/index.d.ts.map +1 -1
  27. package/dist/components/viewer/index.js +111 -37
  28. package/dist/components/viewer/lights.d.ts.map +1 -1
  29. package/dist/components/viewer/lights.js +113 -22
  30. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  31. package/dist/components/viewer/perf-monitor.js +6 -0
  32. package/dist/components/viewer/perf-panel.d.ts.map +1 -1
  33. package/dist/components/viewer/perf-panel.js +1 -1
  34. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  35. package/dist/components/viewer/post-processing.js +104 -36
  36. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  37. package/dist/components/viewer/registered-systems.js +17 -3
  38. package/dist/components/viewer/render-error.d.ts +18 -0
  39. package/dist/components/viewer/render-error.d.ts.map +1 -0
  40. package/dist/components/viewer/render-error.js +29 -0
  41. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  42. package/dist/components/viewer/scene-bvh.js +1 -1
  43. package/dist/components/viewer/scene-environment.d.ts.map +1 -1
  44. package/dist/components/viewer/scene-environment.js +31 -11
  45. package/dist/components/viewer/scene-ground-replacement.d.ts +6 -0
  46. package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -1
  47. package/dist/components/viewer/scene-ground-replacement.js +15 -0
  48. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  49. package/dist/components/viewer/selection-manager.js +25 -7
  50. package/dist/components/viewer/underground-ambiance.d.ts +9 -0
  51. package/dist/components/viewer/underground-ambiance.d.ts.map +1 -0
  52. package/dist/components/viewer/underground-ambiance.js +105 -0
  53. package/dist/components/viewer/viewer-camera.d.ts +4 -0
  54. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  55. package/dist/components/viewer/viewer-camera.js +12 -2
  56. package/dist/components/viewer/viewer-presentations.d.ts.map +1 -1
  57. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  58. package/dist/hooks/use-gltf-ktx2.js +2 -0
  59. package/dist/hooks/use-node-events.d.ts.map +1 -1
  60. package/dist/index.d.ts +36 -16
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +34 -19
  63. package/dist/lib/asset-url.d.ts.map +1 -1
  64. package/dist/lib/asset-url.js +7 -1
  65. package/dist/lib/backdrop.d.ts +17 -0
  66. package/dist/lib/backdrop.d.ts.map +1 -1
  67. package/dist/lib/backdrop.js +18 -1
  68. package/dist/lib/construction-dust.d.ts +57 -0
  69. package/dist/lib/construction-dust.d.ts.map +1 -0
  70. package/dist/lib/construction-dust.js +187 -0
  71. package/dist/lib/csg-utils.js +1 -1
  72. package/dist/lib/display-state.d.ts +25 -0
  73. package/dist/lib/display-state.d.ts.map +1 -0
  74. package/dist/lib/display-state.js +1 -0
  75. package/dist/lib/foundation-materials.d.ts +26 -0
  76. package/dist/lib/foundation-materials.d.ts.map +1 -0
  77. package/dist/lib/foundation-materials.js +65 -0
  78. package/dist/lib/gltf-unlit.d.ts +10 -0
  79. package/dist/lib/gltf-unlit.d.ts.map +1 -0
  80. package/dist/lib/gltf-unlit.js +23 -0
  81. package/dist/lib/ground-window.d.ts +33 -0
  82. package/dist/lib/ground-window.d.ts.map +1 -0
  83. package/dist/lib/ground-window.js +114 -0
  84. package/dist/lib/hero-pose.d.ts +13 -3
  85. package/dist/lib/hero-pose.d.ts.map +1 -1
  86. package/dist/lib/hero-pose.js +72 -5
  87. package/dist/lib/ink-edges.d.ts +6 -3
  88. package/dist/lib/ink-edges.d.ts.map +1 -1
  89. package/dist/lib/ink-edges.js +17 -7
  90. package/dist/lib/key-light-override.d.ts +11 -0
  91. package/dist/lib/key-light-override.d.ts.map +1 -0
  92. package/dist/lib/key-light-override.js +14 -0
  93. package/dist/lib/ktx2-loader.d.ts +7 -0
  94. package/dist/lib/ktx2-loader.d.ts.map +1 -1
  95. package/dist/lib/ktx2-loader.js +35 -0
  96. package/dist/lib/layer-pass.d.ts.map +1 -1
  97. package/dist/lib/layers.d.ts +11 -7
  98. package/dist/lib/layers.d.ts.map +1 -1
  99. package/dist/lib/layers.js +16 -6
  100. package/dist/lib/live-frame-hold.d.ts +14 -0
  101. package/dist/lib/live-frame-hold.d.ts.map +1 -0
  102. package/dist/lib/live-frame-hold.js +28 -0
  103. package/dist/lib/materials.d.ts +19 -7
  104. package/dist/lib/materials.d.ts.map +1 -1
  105. package/dist/lib/materials.js +65 -9
  106. package/dist/lib/merged-outline-node.d.ts +5 -0
  107. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  108. package/dist/lib/merged-outline-node.js +80 -2
  109. package/dist/lib/node-top-surface-height.d.ts +6 -0
  110. package/dist/lib/node-top-surface-height.d.ts.map +1 -0
  111. package/dist/lib/node-top-surface-height.js +74 -0
  112. package/dist/lib/perf-probe-state.d.ts +39 -0
  113. package/dist/lib/perf-probe-state.d.ts.map +1 -0
  114. package/dist/lib/perf-probe-state.js +17 -0
  115. package/dist/lib/pointer-events.d.ts +26 -2
  116. package/dist/lib/pointer-events.d.ts.map +1 -1
  117. package/dist/lib/pointer-events.js +99 -12
  118. package/dist/lib/post-processing-resources.d.ts.map +1 -1
  119. package/dist/lib/procedural-emission.d.ts +7 -0
  120. package/dist/lib/procedural-emission.d.ts.map +1 -0
  121. package/dist/lib/procedural-emission.js +54 -0
  122. package/dist/lib/reveal-candidates.d.ts +80 -0
  123. package/dist/lib/reveal-candidates.d.ts.map +1 -0
  124. package/dist/lib/reveal-candidates.js +524 -0
  125. package/dist/lib/reveal-ghost.d.ts +9 -0
  126. package/dist/lib/reveal-ghost.d.ts.map +1 -0
  127. package/dist/lib/reveal-ghost.js +62 -0
  128. package/dist/lib/reveal-pending.d.ts +12 -0
  129. package/dist/lib/reveal-pending.d.ts.map +1 -0
  130. package/dist/lib/reveal-pending.js +12 -0
  131. package/dist/lib/reveal-pose.d.ts +70 -0
  132. package/dist/lib/reveal-pose.d.ts.map +1 -0
  133. package/dist/lib/reveal-pose.js +248 -0
  134. package/dist/lib/reveal-schedule.d.ts +208 -0
  135. package/dist/lib/reveal-schedule.d.ts.map +1 -0
  136. package/dist/lib/reveal-schedule.js +410 -0
  137. package/dist/lib/reveal-texture-scale.d.ts +5 -0
  138. package/dist/lib/reveal-texture-scale.d.ts.map +1 -0
  139. package/dist/lib/reveal-texture-scale.js +113 -0
  140. package/dist/lib/reveal-windows.d.ts +23 -0
  141. package/dist/lib/reveal-windows.d.ts.map +1 -0
  142. package/dist/lib/reveal-windows.js +31 -0
  143. package/dist/lib/reveal-wipe.d.ts +71 -0
  144. package/dist/lib/reveal-wipe.d.ts.map +1 -0
  145. package/dist/lib/reveal-wipe.js +105 -0
  146. package/dist/lib/scene-pass-mrt.d.ts +12 -0
  147. package/dist/lib/scene-pass-mrt.d.ts.map +1 -0
  148. package/dist/lib/scene-pass-mrt.js +23 -0
  149. package/dist/lib/scene-support-height.d.ts +4 -0
  150. package/dist/lib/scene-support-height.d.ts.map +1 -0
  151. package/dist/lib/scene-support-height.js +34 -0
  152. package/dist/lib/scene-themes.d.ts +30 -8
  153. package/dist/lib/scene-themes.d.ts.map +1 -1
  154. package/dist/lib/scene-themes.js +36 -0
  155. package/dist/lib/scene-visibility.d.ts +17 -1
  156. package/dist/lib/scene-visibility.d.ts.map +1 -1
  157. package/dist/lib/scene-visibility.js +53 -3
  158. package/dist/lib/scripted-opening.d.ts +11 -0
  159. package/dist/lib/scripted-opening.d.ts.map +1 -0
  160. package/dist/lib/scripted-opening.js +20 -0
  161. package/dist/lib/site-surfaces.d.ts +32 -0
  162. package/dist/lib/site-surfaces.d.ts.map +1 -0
  163. package/dist/lib/site-surfaces.js +70 -0
  164. package/dist/lib/snapshot-pipeline.d.ts +34 -3
  165. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  166. package/dist/lib/snapshot-pipeline.js +247 -117
  167. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
  168. package/dist/lib/terrain-perimeter-fill.js +22 -14
  169. package/dist/lib/tsl-compat.d.ts +2 -2
  170. package/dist/lib/tsl-compat.d.ts.map +1 -1
  171. package/dist/lib/underground-ambiance.d.ts +168 -0
  172. package/dist/lib/underground-ambiance.d.ts.map +1 -0
  173. package/dist/lib/underground-ambiance.js +334 -0
  174. package/dist/lib/zone-geometry.d.ts +11 -0
  175. package/dist/lib/zone-geometry.d.ts.map +1 -0
  176. package/dist/lib/zone-geometry.js +46 -0
  177. package/dist/store/use-item-light-pool.d.ts +14 -10
  178. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  179. package/dist/store/use-item-light-pool.js +52 -26
  180. package/dist/store/use-viewer.d.ts +194 -26
  181. package/dist/store/use-viewer.d.ts.map +1 -1
  182. package/dist/store/use-viewer.js +164 -21
  183. package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
  184. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  185. package/dist/systems/ceiling/ceiling-system.js +88 -6
  186. package/dist/systems/construction-reveal/construction-dust.d.ts +16 -0
  187. package/dist/systems/construction-reveal/construction-dust.d.ts.map +1 -0
  188. package/dist/systems/construction-reveal/construction-dust.js +92 -0
  189. package/dist/systems/construction-reveal/construction-reveal.d.ts +276 -0
  190. package/dist/systems/construction-reveal/construction-reveal.d.ts.map +1 -0
  191. package/dist/systems/construction-reveal/construction-reveal.js +2383 -0
  192. package/dist/systems/construction-reveal/dust-material.d.ts +10 -0
  193. package/dist/systems/construction-reveal/dust-material.d.ts.map +1 -0
  194. package/dist/systems/construction-reveal/dust-material.js +17 -0
  195. package/dist/systems/construction-reveal/ghost-dress.d.ts +10 -0
  196. package/dist/systems/construction-reveal/ghost-dress.d.ts.map +1 -0
  197. package/dist/systems/construction-reveal/ghost-dress.js +24 -0
  198. package/dist/systems/door/door-system.d.ts.map +1 -1
  199. package/dist/systems/door/door-system.js +55 -17
  200. package/dist/systems/elevator/elevator-interaction-system.d.ts +7 -0
  201. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
  202. package/dist/systems/elevator/elevator-interaction-system.js +18 -1
  203. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
  204. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  205. package/dist/systems/floor-elevation/floor-elevation-system.js +33 -5
  206. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  207. package/dist/systems/geometry/geometry-system.js +71 -11
  208. package/dist/systems/interactive/control-widget.d.ts.map +1 -1
  209. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  210. package/dist/systems/interactive/interactive-system.js +52 -17
  211. package/dist/systems/interactive/procedural-controls.d.ts +13 -0
  212. package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
  213. package/dist/systems/interactive/procedural-controls.js +17 -0
  214. package/dist/systems/interactive/scripted-clips.d.ts +19 -0
  215. package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
  216. package/dist/systems/interactive/scripted-clips.js +134 -0
  217. package/dist/systems/item/item-system.d.ts.map +1 -1
  218. package/dist/systems/item/item-system.js +9 -3
  219. package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
  220. package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
  221. package/dist/systems/item/object-cut-invalidation.js +68 -0
  222. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  223. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  224. package/dist/systems/item-light/item-light-system.js +48 -53
  225. package/dist/systems/level/level-hold.d.ts +4 -0
  226. package/dist/systems/level/level-hold.d.ts.map +1 -0
  227. package/dist/systems/level/level-hold.js +14 -0
  228. package/dist/systems/level/level-system.d.ts.map +1 -1
  229. package/dist/systems/level/level-system.js +19 -6
  230. package/dist/systems/level/level-utils.d.ts +19 -6
  231. package/dist/systems/level/level-utils.d.ts.map +1 -1
  232. package/dist/systems/level/level-utils.js +68 -9
  233. package/dist/systems/level/shadow-only-levels.d.ts +9 -0
  234. package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
  235. package/dist/systems/level/shadow-only-levels.js +7 -0
  236. package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts +103 -0
  237. package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts.map +1 -0
  238. package/dist/systems/roof/__fixtures__/roof-plane-cases.js +317 -0
  239. package/dist/systems/roof/roof-materials.d.ts +16 -2
  240. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  241. package/dist/systems/roof/roof-materials.js +64 -31
  242. package/dist/systems/roof/roof-system.d.ts +21 -4
  243. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  244. package/dist/systems/roof/roof-system.js +374 -49
  245. package/dist/systems/slab/slab-system.d.ts +1 -1
  246. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  247. package/dist/systems/slab/slab-system.js +49 -19
  248. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  249. package/dist/systems/stair/stair-system.js +133 -354
  250. package/dist/systems/wall/wall-cutout-cache.d.ts +39 -3
  251. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
  252. package/dist/systems/wall/wall-cutout-cache.js +141 -19
  253. package/dist/systems/wall/wall-cutout.d.ts +6 -0
  254. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  255. package/dist/systems/wall/wall-cutout.js +42 -28
  256. package/dist/systems/wall/wall-finish-data.d.ts +22 -0
  257. package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
  258. package/dist/systems/wall/wall-finish-data.js +17 -0
  259. package/dist/systems/wall/wall-material-variant.d.ts.map +1 -1
  260. package/dist/systems/wall/wall-materials.d.ts +9 -4
  261. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  262. package/dist/systems/wall/wall-materials.js +135 -153
  263. package/dist/systems/wall/wall-placeholder-sweep.d.ts.map +1 -1
  264. package/dist/systems/wall/wall-system.d.ts +33 -2
  265. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  266. package/dist/systems/wall/wall-system.js +621 -159
  267. package/dist/systems/window/corner-frame.d.ts +10 -0
  268. package/dist/systems/window/corner-frame.d.ts.map +1 -0
  269. package/dist/systems/window/corner-frame.js +17 -0
  270. package/dist/systems/window/window-system.d.ts +1 -1
  271. package/dist/systems/window/window-system.d.ts.map +1 -1
  272. package/dist/systems/window/window-system.js +71 -27
  273. package/package.json +10 -10
  274. package/dist/systems/fence/fence-system.d.ts +0 -22
  275. package/dist/systems/fence/fence-system.d.ts.map +0 -1
  276. package/dist/systems/fence/fence-system.js +0 -357
@@ -1,24 +1,22 @@
1
1
  'use client';
2
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';
3
+ import { bakePolicyOf, containsPoint, distanceToBoundary, EXPLODED_GAP, itemInteraction, itemPrompt, operateItem, polygonInteriorPoint, useInteractive, } from '@pascal-app/core';
4
+ import { operableParts, ProceduralMotionController, } from '@pascal-app/core/procedural-items';
5
5
  import { useFrame, useThree } from '@react-three/fiber';
6
6
  import { useCallback, useEffect, useMemo, useRef } from 'react';
7
7
  import * as THREE from 'three';
8
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
9
  import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';
12
10
  import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2';
13
- import { ZONE_LAYER } from '../../lib/layers';
14
11
  import { createSurfaceRoleMaterial } from '../../lib/materials';
15
12
  import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only';
16
13
  import useViewer from '../../store/use-viewer';
14
+ import { useClipActions } from '../../systems/interactive/scripted-clips';
15
+ import { resolveLevelVisibility } from '../../systems/level/level-utils';
17
16
  import { GlbInteractive } from './glb-interactive';
17
+ import { bakedLoopMechanisms } from './glb-mechanisms';
18
18
  import { GlbReferenceNodes } from './glb-reference-nodes';
19
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
20
  /** Baked `kind` → surface role, so monochrome can recolor by role like the
23
21
  * parametric viewer (textures-off collapses each face to its themed clay). */
24
22
  const ROLE_BY_KIND = {
@@ -32,6 +30,8 @@ const ROLE_BY_KIND = {
32
30
  door: 'joinery',
33
31
  item: 'furnishing',
34
32
  };
33
+ /** Kinds a pointer passes through to what lies behind (they frame, they don't get picked). */
34
+ const PASS_THROUGH_KINDS = new Set(['site', 'building', 'level', 'zone', 'ceiling', 'spawn']);
35
35
  function findIdentityAncestor(object) {
36
36
  let current = object;
37
37
  while (current) {
@@ -41,6 +41,16 @@ function findIdentityAncestor(object) {
41
41
  }
42
42
  return null;
43
43
  }
44
+ function findProceduralMotionAncestor(object) {
45
+ let current = object;
46
+ while (current) {
47
+ const motion = current.userData.proceduralMotion;
48
+ if (motion)
49
+ return motion;
50
+ current = current.parent;
51
+ }
52
+ return undefined;
53
+ }
44
54
  function findAncestorLevelId(object) {
45
55
  let current = object.parent;
46
56
  while (current) {
@@ -52,150 +62,142 @@ function findAncestorLevelId(object) {
52
62
  return null;
53
63
  }
54
64
  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
65
  const _camBox = new THREE.Box3();
63
66
  const _camCenter = new THREE.Vector3();
64
67
  const _camSize = new THREE.Vector3();
65
- const _camPoint = new THREE.Vector3();
66
68
  const _walkPos = new THREE.Vector3();
67
69
  const _reticleNdc = new THREE.Vector2(0, 0);
68
70
  const _reticleRaycaster = new THREE.Raycaster();
69
- /** How far ahead (metres) a door/window counts as "in view" for activation. */
71
+ /** How far ahead (metres) an interactive part counts as "in view" for activation. */
70
72
  const WALK_REACH = 3;
71
73
  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;
74
+ /**
75
+ * The baked node under a pointer that a click can land on: the nearest hit
76
+ * whose identity is not a kind that only frames (site, building, level, zone,
77
+ * ceiling) and is not on a hidden floor, with the hit in its level's local XZ.
78
+ */
79
+ export function resolveGlbPick(hits, identity) {
80
+ for (const hit of hits) {
81
+ const node = findIdentityAncestor(hit.object);
82
+ const extras = node?.userData;
83
+ if (!(node && extras?.pascalId) || PASS_THROUGH_KINDS.has(extras.kind ?? ''))
84
+ continue;
85
+ const levelId = findAncestorLevelId(node);
86
+ const level = levelId ? identity.get(levelId) : undefined;
87
+ if (level && !level.visible)
88
+ continue;
89
+ let point = null;
90
+ if (level) {
91
+ level.updateWorldMatrix(true, false);
92
+ _local.copy(hit.point);
93
+ level.worldToLocal(_local);
94
+ point = [_local.x, _local.z];
95
+ }
96
+ return { nodeId: extras.pascalId, point, hitObject: hit.object };
130
97
  }
131
- return false;
98
+ return null;
132
99
  }
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;
100
+ function pointInZone(x, z, zone) {
101
+ const polygon = [{ outer: zone.polygon, holes: zone.holes }];
102
+ return (containsPoint(polygon, [x, z]) || distanceToBoundary(polygon, [x, z]) <= ZONE_FOOTPRINT_EPSILON);
182
103
  }
183
104
  /**
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"`).
105
+ * GLB-consuming viewer scene. Loads a baked artifact and drives the viewer's
106
+ * presentation and interaction with no parametric scene graph. A pointer
107
+ * becomes a `GlbPickEvent` the host runs through its selection rules (the
108
+ * same rules as the parametric viewer); the shared `useViewer.selection` and
109
+ * `hoveredId` then drive the outline, and openables play their baked clips
110
+ * when clicked. The host disables the parametric `SelectionManager`
111
+ * (`selectionManager="custom"`).
192
112
  */
193
- export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes, onLevelsChange, onIdentityChange, onHoverChange, onWalkthroughChange, }) {
113
+ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes, onObjectsChange, onPick, onWalkthroughChange, }) {
194
114
  const gltf = useGLTFKTX2(url);
195
115
  const rootRef = useRef(null);
196
- const { actions } = useAnimations(gltf.animations, rootRef);
116
+ const actions = useClipActions(gltf.animations, rootRef);
117
+ const proceduralPlayback = useMemo(() => {
118
+ const byNode = new Map();
119
+ gltf.scene.traverse((object) => {
120
+ const extras = object.userData;
121
+ if (extras.kind === 'procedural-item' && extras.pascalId && !byNode.has(extras.pascalId))
122
+ byNode.set(extras.pascalId, { motions: [], clips: new Map() });
123
+ const motion = extras.proceduralMotion;
124
+ if (!motion?.clip)
125
+ return;
126
+ const clip = gltf.animations.find((entry) => entry.name === motion.clip);
127
+ if (!clip)
128
+ return;
129
+ const entry = byNode.get(motion.nodeId) ?? { motions: [], clips: new Map() };
130
+ if (motion.kind === 'spin') {
131
+ entry.motions.push({
132
+ id: motion.groupId,
133
+ partId: motion.partId,
134
+ kind: 'spin',
135
+ axis: 'y',
136
+ pivot: [0, 0, 0],
137
+ amount: (2 * Math.PI) / clip.duration,
138
+ delay: 0,
139
+ duration: 0,
140
+ easing: 'linear',
141
+ });
142
+ entry.clips.set(motion.clip, {
143
+ partId: motion.partId,
144
+ kind: 'spin',
145
+ groupId: entry.clips.get(motion.clip)?.groupId ?? motion.groupId,
146
+ });
147
+ }
148
+ else if (motion.activeWindow &&
149
+ !entry.motions.some((item) => item.partId === motion.partId)) {
150
+ entry.motions.push({
151
+ id: motion.groupId,
152
+ partId: motion.partId,
153
+ kind: 'hinge',
154
+ axis: 'y',
155
+ pivot: [0, 0, 0],
156
+ amount: 1,
157
+ delay: motion.activeWindow[0],
158
+ duration: motion.activeWindow[1] - motion.activeWindow[0],
159
+ easing: 'linear',
160
+ });
161
+ entry.clips.set(motion.clip, {
162
+ partId: motion.partId,
163
+ kind: 'finite',
164
+ groupId: motion.groupId,
165
+ });
166
+ }
167
+ byNode.set(motion.nodeId, entry);
168
+ });
169
+ // Procedural actions need a separate mixer so drei cannot advance their assigned times.
170
+ const mixer = new THREE.AnimationMixer(gltf.scene);
171
+ const entries = new Map([...byNode].map(([nodeId, entry]) => {
172
+ const spinParts = [
173
+ ...new Set(entry.motions.filter((motion) => motion.kind === 'spin').map((motion) => motion.partId)),
174
+ ];
175
+ const initial = Object.fromEntries(spinParts.map((partId) => [partId, true]));
176
+ return [
177
+ nodeId,
178
+ {
179
+ controller: new ProceduralMotionController(entry.motions, initial),
180
+ clips: entry.clips,
181
+ spinParts,
182
+ sequence: 0,
183
+ },
184
+ ];
185
+ }));
186
+ const proceduralActions = new Map(gltf.animations
187
+ .filter((clip) => [...entries.values()].some((entry) => entry.clips.has(clip.name)))
188
+ .map((clip) => {
189
+ const action = mixer.clipAction(clip);
190
+ action.enabled = true;
191
+ action.paused = true;
192
+ action.loop = clip.name.endsWith(': loop') ? THREE.LoopRepeat : THREE.LoopOnce;
193
+ action.clampWhenFinished = true;
194
+ action.setEffectiveWeight(1);
195
+ action.play();
196
+ return [clip.name, action];
197
+ }));
198
+ return { entries, mixer, actions: proceduralActions };
199
+ }, [gltf.scene, gltf.animations]);
197
200
  const camera = useThree((state) => state.camera);
198
- const raycaster = useThree((state) => state.raycaster);
199
201
  const controls = useThree((state) => state.controls);
200
202
  const walkthroughMode = useViewer((s) => s.walkthroughMode);
201
203
  const textures = useViewer((s) => s.textures);
@@ -211,7 +213,7 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
211
213
  return;
212
214
  object.traverse((child) => {
213
215
  const mesh = child;
214
- if (!mesh.isMesh || mesh.layers.isEnabled(ZONE_LAYER))
216
+ if (!mesh.isMesh)
215
217
  return;
216
218
  const ud = mesh.userData;
217
219
  if (!ud.__bakedMaterial)
@@ -312,15 +314,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
312
314
  }
313
315
  if (extras.kind === 'zone' && extras.polygon && extras.polygon.length >= 3) {
314
316
  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
- ];
317
+ const holes = extras.holes ?? [];
318
+ const centroid = polygonInteriorPoint({ polygon, holes });
319
319
  zoneList.push({
320
320
  id: extras.pascalId,
321
321
  node: object,
322
322
  levelId: findAncestorLevelId(object),
323
323
  polygon,
324
+ holes,
324
325
  label: extras.label ?? extras.pascalId,
325
326
  color: extras.color ?? '#3b82f6',
326
327
  centroid,
@@ -338,9 +339,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
338
339
  levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)),
339
340
  };
340
341
  }, [gltf.scene]);
341
- const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]);
342
342
  // Level pascalIds bottom-to-top, for the interactive light pool's level factor.
343
343
  const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels]);
344
+ // Loop clips no other controller plays (a plugin kind's mechanism) run on
345
+ // click and E. As in the editor they start stopped and never persist.
346
+ const loopMechanisms = useMemo(() => bakedLoopMechanisms(identity, new Set([
347
+ ...proceduralPlayback.entries.keys(),
348
+ ...(interactiveItems ?? []).map((item) => item.pascalId),
349
+ ])), [identity, proceduralPlayback, interactiveItems]);
344
350
  // The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted
345
351
  // on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding
346
352
  // the whole occluder node would hide them too, so collect just the occluder's
@@ -363,71 +369,20 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
363
369
  }
364
370
  return meshes;
365
371
  }, [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);
372
+ // Open on the building: its saved view, else a fit of its bounds. Later moves
373
+ // belong to the host's navigation (`camera-controls:frame`), never to a click.
373
374
  useEffect(() => {
374
375
  if (!controls)
375
376
  return;
376
- const flyToBookmark = (bookmark) => {
377
+ const bookmark = rootNode?.userData?.camera;
378
+ if (bookmark) {
377
379
  const { position: p, target: t } = bookmark;
378
380
  controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true);
379
381
  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
382
  return;
400
383
  }
401
- let bookmarkNode = null;
402
384
  _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(rootNode ?? gltf.scene);
425
- }
426
- const bookmark = bookmarkNode?.userData?.camera;
427
- if (bookmark) {
428
- flyToBookmark(bookmark);
429
- return;
430
- }
385
+ _camBox.setFromObject(rootNode ?? gltf.scene);
431
386
  if (_camBox.isEmpty())
432
387
  return;
433
388
  _camBox.getCenter(_camCenter);
@@ -435,47 +390,21 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
435
390
  const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15);
436
391
  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
392
  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]);
393
+ }, [controls, rootNode, gltf.scene]);
458
394
  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.
395
+ onObjectsChange?.(identity);
396
+ return () => onObjectsChange?.(new Map());
397
+ }, [identity, onObjectsChange]);
398
+ // Apply the viewer's level display to the baked floors each frame, by the
399
+ // parametric LevelSystem's rule (solo, and the levels above the current one
400
+ // hidden when the host asks). Walkthrough always shows the full stacked
401
+ // building (you're standing inside it) — and the first-person collider is
402
+ // built from the visible meshes, so a hidden floor would otherwise drop the
403
+ // player through the world.
475
404
  useFrame((_, delta) => {
476
405
  if (levels.length === 0)
477
406
  return;
478
- const { levelMode, selection, walkthroughMode } = useViewer.getState();
407
+ const { levelMode, hideLevelsAboveSelection, selection, walkthroughMode } = useViewer.getState();
479
408
  const selectedLevel = selection.levelId;
480
409
  const selectedIdx = selectedLevel ? levels.findIndex((l) => l.id === selectedLevel) : -1;
481
410
  levels.forEach(({ id, node, baseY }, index) => {
@@ -486,77 +415,72 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
486
415
  node.position.y = walkthroughMode
487
416
  ? targetY
488
417
  : lerp(node.position.y, targetY, Math.min(1, delta * 12));
489
- // Solo: hidden levels above the soloed one keep casting shadows
490
- // (shadow-caster-only); below-levels can't block the sun, so plain-hide.
491
- const hidden = !walkthroughMode && levelMode === 'solo' && Boolean(selectedLevel) && id !== selectedLevel;
492
- const castsWhileHidden = hidden && selectedIdx >= 0 && index > selectedIdx;
493
- if (castsWhileHidden) {
418
+ const { visible, shadowOnly } = walkthroughMode
419
+ ? { visible: true, shadowOnly: false }
420
+ : resolveLevelVisibility({
421
+ levelMode,
422
+ hideAbove: hideLevelsAboveSelection,
423
+ hasSelectedLevel: selectedIdx >= 0,
424
+ isSelected: id === selectedLevel,
425
+ index,
426
+ selectedIndex: selectedIdx >= 0 ? selectedIdx : undefined,
427
+ nodeVisible: true,
428
+ revealing: false,
429
+ });
430
+ if (shadowOnly)
494
431
  applyShadowOnly(node);
495
- node.visible = true;
496
- }
497
- else {
432
+ else
498
433
  clearShadowOnly(node);
499
- node.visible = !hidden;
500
- }
434
+ node.visible = visible;
501
435
  });
502
436
  }, 5);
503
- const zoneFills = useRef([]);
504
437
  useEffect(() => {
505
- const built = [];
506
- for (const entry of zoneEntries) {
507
- const shape = new THREE.Shape();
508
- entry.polygon.forEach(([x, z], i) => {
509
- if (i === 0)
510
- shape.moveTo(x, -z);
511
- else
512
- shape.lineTo(x, -z);
513
- });
514
- shape.closePath();
515
- const floorMaterial = createZoneFloorMaterial(entry.color);
516
- const floor = new THREE.Mesh(new THREE.ShapeGeometry(shape), floorMaterial);
517
- floor.rotation.x = -Math.PI / 2;
518
- floor.position.y = 0.02;
519
- const wallMaterial = createZoneWallMaterial(entry.color);
520
- const walls = new THREE.Mesh(createZoneWallGeometry(entry.polygon), wallMaterial);
521
- const meshes = [floor, walls];
522
- for (const mesh of meshes) {
523
- mesh.visible = false;
524
- mesh.layers.set(ZONE_LAYER);
525
- // Visual helpers only — never participate in picking. Hover/selection
526
- // resolves against the real building geometry + point-in-polygon, so the
527
- // tall wall helpers can't occlude items or fight at shared boundaries.
528
- mesh.raycast = NO_RAYCAST;
529
- entry.node.add(mesh);
530
- }
531
- built.push({
532
- id: entry.id,
533
- levelId: entry.levelId,
534
- meshes,
535
- uniforms: [
536
- floorMaterial.userData.uOpacity,
537
- wallMaterial.userData.uOpacity,
538
- ],
539
- });
540
- }
541
- zoneFills.current = built;
438
+ for (const [nodeId, entry] of proceduralPlayback.entries)
439
+ useInteractive
440
+ .getState()
441
+ .initProcedural(nodeId, [...new Set(entry.controller.motions.map((motion) => motion.partId))], entry.spinParts);
542
442
  return () => {
543
- for (const { meshes } of built) {
544
- for (const mesh of meshes) {
545
- mesh.removeFromParent();
546
- mesh.geometry.dispose();
547
- mesh.material.dispose();
443
+ for (const nodeId of proceduralPlayback.entries.keys())
444
+ useInteractive.getState().removeProcedural(nodeId);
445
+ proceduralPlayback.mixer.stopAllAction();
446
+ };
447
+ }, [proceduralPlayback]);
448
+ useFrame((_, delta) => {
449
+ for (const [nodeId, entry] of proceduralPlayback.entries) {
450
+ const command = useInteractive.getState().procedural[nodeId]?.motionCommand;
451
+ if (command && command.sequence > entry.sequence) {
452
+ entry.controller.command(command);
453
+ entry.sequence = command.sequence;
454
+ }
455
+ const frame = entry.controller.tick(delta);
456
+ for (const [clipName, binding] of entry.clips) {
457
+ const action = proceduralPlayback.actions.get(clipName);
458
+ if (!action)
459
+ continue;
460
+ if (binding.kind === 'finite')
461
+ action.time = frame.times[binding.partId] ?? 0;
462
+ else {
463
+ const motion = entry.controller.motions.find((item) => item.id === binding.groupId);
464
+ if (motion)
465
+ action.time =
466
+ ((frame.spins[motion.id]?.phase ?? 0) / (2 * Math.PI)) * action.getClip().duration;
548
467
  }
549
468
  }
550
- zoneFills.current = [];
551
- };
552
- }, [zoneEntries]);
469
+ }
470
+ proceduralPlayback.mixer.update(0);
471
+ }, -1);
553
472
  useEffect(() => {
473
+ const scriptedPrefixes = (interactiveItems ?? [])
474
+ .filter((item) => item.scripted)
475
+ .map((item) => `${item.pascalId}: `);
554
476
  for (const [name, action] of Object.entries(actions)) {
555
477
  if (!action)
556
478
  continue;
557
- // Ambient item loops (a fan's spin, `<id>: loop`) repeat; door/window
558
- // open clips play once and hold their end pose. GlbInteractive plays the
559
- // loops, gated on the item's toggle.
479
+ if (proceduralPlayback.actions.has(name))
480
+ continue;
481
+ // ScriptedClips owns each authored clip's playback mode.
482
+ if (scriptedPrefixes.some((prefix) => name.startsWith(prefix)))
483
+ continue;
560
484
  if (name.endsWith(': loop')) {
561
485
  action.loop = THREE.LoopRepeat;
562
486
  action.clampWhenFinished = false;
@@ -566,11 +490,87 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
566
490
  action.clampWhenFinished = true;
567
491
  }
568
492
  }
569
- }, [actions]);
493
+ }, [actions, proceduralPlayback, interactiveItems]);
494
+ useEffect(() => {
495
+ if (loopMechanisms.size === 0)
496
+ return;
497
+ const apply = (running) => {
498
+ for (const [id, clips] of loopMechanisms) {
499
+ for (const name of clips) {
500
+ const action = actions[name];
501
+ if (!action)
502
+ continue;
503
+ if (running[id]) {
504
+ action.enabled = true;
505
+ action.paused = false;
506
+ if (!action.isRunning())
507
+ action.play();
508
+ }
509
+ else {
510
+ action.stop();
511
+ }
512
+ }
513
+ }
514
+ };
515
+ apply(useInteractive.getState().mechanisms);
516
+ const unsubscribe = useInteractive.subscribe((state, previous) => {
517
+ if (state.mechanisms !== previous.mechanisms)
518
+ apply(state.mechanisms);
519
+ });
520
+ return () => {
521
+ unsubscribe();
522
+ for (const id of loopMechanisms.keys())
523
+ useInteractive.getState().removeMechanism(id);
524
+ };
525
+ }, [actions, loopMechanisms]);
526
+ // Items the walkthrough operates through their toggles; others fall through to loop clips.
527
+ const toggleableItem = useCallback((pascalId) => interactiveItems?.find((item) => item.pascalId === pascalId &&
528
+ item.interactive.controls.some((control) => control.kind === 'toggle')), [interactiveItems]);
529
+ // A click opens and closes an item with an `open` clip (an authored object,
530
+ // a scripted window or door), like a door.
531
+ const toggleOpenControl = useCallback((identityNode) => {
532
+ const item = toggleableItem(identityNode.userData.pascalId);
533
+ if (!item || itemInteraction(item.interactive).kind !== 'open')
534
+ return false;
535
+ operateItem(item.pascalId, item.interactive);
536
+ return true;
537
+ }, [toggleableItem]);
538
+ const toggleLoopMechanism = useCallback((identityNode) => {
539
+ const id = identityNode.userData.pascalId;
540
+ if (!(id && loopMechanisms.has(id)))
541
+ return false;
542
+ const state = useInteractive.getState();
543
+ state.setMechanism(id, !state.mechanisms[id]);
544
+ return true;
545
+ }, [loopMechanisms]);
570
546
  const openIds = useRef(new Set());
547
+ const toggleProcedural = useCallback((hit, identityNode) => {
548
+ const extras = identityNode.userData;
549
+ if (extras.kind !== 'procedural-item')
550
+ return false;
551
+ const part = findProceduralMotionAncestor(hit);
552
+ if (part?.clip) {
553
+ useInteractive.getState().toggleProceduralPart(part.nodeId, part.partId);
554
+ return true;
555
+ }
556
+ const nodeId = extras.pascalId;
557
+ const entry = proceduralPlayback.entries.get(nodeId);
558
+ if (!entry)
559
+ return false;
560
+ const parts = [...new Set(entry.controller.motions.map((motion) => motion.partId))];
561
+ if (parts.length === 0) {
562
+ useInteractive.getState().toggleProceduralLights(nodeId);
563
+ return true;
564
+ }
565
+ const active = useInteractive.getState().procedural[nodeId]?.parts;
566
+ useInteractive
567
+ .getState()
568
+ .setProceduralParts(nodeId, parts, !parts.some((partId) => active?.[partId]));
569
+ return true;
570
+ }, [proceduralPlayback]);
571
571
  const toggleOpenable = useCallback((node) => {
572
572
  const extras = node.userData;
573
- const clipName = extras.clips?.[0];
573
+ const clipName = extras.clips?.find((name) => name.endsWith(': open'));
574
574
  if (!extras.openable || !clipName)
575
575
  return;
576
576
  const action = actions[clipName];
@@ -598,100 +598,24 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
598
598
  continue;
599
599
  _local.copy(worldPoint);
600
600
  entry.node.worldToLocal(_local);
601
- if (pointInPolygonInclusive(_local.x, _local.z, entry.polygon))
601
+ if (pointInZone(_local.x, _local.z, entry))
602
602
  return entry;
603
603
  }
604
604
  return null;
605
605
  }, [zoneEntries]);
606
- // The room the cursor points at, found by intersecting the pointer ray with the
607
- // level's floor plane — independent of what 3D object the ray actually hits.
608
- // This is the editor's model: zone helpers and walls are ignored, so adjacent
609
- // rooms never fight and an item against a wall still maps to its own room.
610
- const zoneAtRay = useCallback((ray, levelId) => {
611
- const levelNode = identity.get(levelId);
612
- const floorY = levelNode ? levelNode.getWorldPosition(_floorPlanePoint).y : 0;
613
- _floorPlane.setFromNormalAndCoplanarPoint(_up, _floorPlanePoint.set(0, floorY, 0));
614
- if (!ray.intersectPlane(_floorPlane, _floorHit))
615
- return null;
616
- return zoneAtPoint(_floorHit, levelId);
617
- }, [identity, zoneAtPoint]);
618
- // Resolve a pointer ray to the unit the current drill depth acts on:
619
- // - building view → the floor the hit object belongs to
620
- // - level view → the room the cursor points at on the floor
621
- // - zone view → the first hit node whose hit/footprint is inside the room
622
- const resolveTarget = useCallback((hits, ray) => {
623
- const firstNode = hits.length > 0 ? findIdentityAncestor(hits[0].object) : null;
624
- const toTarget = (object, tid) => {
625
- const e = object.userData;
626
- return { object, id: tid, kind: e.kind ?? 'node', label: e.label ?? tid };
627
- };
628
- const { selection } = useViewer.getState();
629
- // Building view → drill to the floor the hit object belongs to.
630
- if (!selection.levelId) {
631
- if (!firstNode)
632
- return null;
633
- const extras = firstNode.userData;
634
- const levelId = extras.kind === 'level' ? extras.pascalId : findAncestorLevelId(firstNode);
635
- const levelObject = levelId ? identity.get(levelId) : undefined;
636
- return levelObject && levelId ? toTarget(levelObject, levelId) : null;
637
- }
638
- // Level view → the room the cursor is over (floor-plane intersection).
639
- if (!selection.zoneId) {
640
- const zone = zoneAtRay(ray, selection.levelId);
641
- return zone ? toTarget(zone.node, zone.id) : null;
642
- }
643
- // Zone view → scan through all R3F intersections so room helpers or slabs
644
- // can't hide a selectable item/structure behind the first hit.
645
- const activeZone = zoneById.get(selection.zoneId);
646
- if (!activeZone)
647
- return null;
648
- const seen = new Set();
649
- for (const hit of hits) {
650
- const node = findIdentityAncestor(hit.object);
651
- if (!node)
652
- continue;
653
- const extras = node.userData;
654
- const id = extras.pascalId;
655
- if (!id || seen.has(id))
656
- continue;
657
- seen.add(id);
658
- const kind = extras.kind ?? 'node';
659
- if (kind === 'site' || kind === 'building' || kind === 'level' || kind === 'zone') {
660
- continue;
661
- }
662
- const hitLevel = findAncestorLevelId(node);
663
- if (hitLevel !== selection.levelId)
664
- continue;
665
- if (hit.point && worldPointInZoneFootprint(hit.point, activeZone)) {
666
- return toTarget(node, id);
667
- }
668
- if (objectFootprintTouchesZone(node, activeZone)) {
669
- return toTarget(node, id);
670
- }
671
- }
672
- return null;
673
- }, [identity, zoneAtRay, zoneById]);
674
- // DOM nodes for each zone's floating room label, plus a group whose transform
675
- // tracks the zone node so the label rides level stacking.
676
- const labelGroups = useRef(new Map());
677
- const labelDivs = useRef(new Map());
678
- // Per-frame: fade a level's rooms in/out (hidden once a zone is entered, like
679
- // the editor) with a brightness bump on the hovered room, keep room labels
680
- // positioned + faded with them, and sync the outline post-FX from the shared
681
- // selection + local hover.
682
- const hoveredTarget = useRef(null);
683
- useFrame((_, delta) => {
606
+ // Per-frame: hide the focused floor's ceilings and roof when it has rooms
607
+ // (dollhouse), and sync the outline post-FX from the shared selection and
608
+ // hover. Rooms highlight through the host's room layer, not the outline.
609
+ useFrame(() => {
684
610
  const state = useViewer.getState();
685
611
  const { selection, outliner } = state;
686
- const t = Math.min(1, delta * 8);
687
- const hoveredZoneId = hoveredTarget.current?.kind === 'zone' ? hoveredTarget.current.id : null;
688
- // Walkthrough is a first-person tour: no zone tints, no dollhouse cutaway,
689
- // no selection outline — you're standing inside the real building.
612
+ // Walkthrough is a first-person tour: no dollhouse cutaway, no selection
613
+ // outline — you're standing inside the real building.
690
614
  const walk = state.walkthroughMode;
691
- // Dollhouse: hide ceilings + roof so the rooms (and their zone tint) are
692
- // visible from above and the ray reaches their contents — but only when the
693
- // focused level actually has rooms. Focusing a zone-less floor keeps the
694
- // building intact (otherwise its roof would just vanish with nothing to show).
615
+ // Dollhouse: hide ceilings + roof so the rooms are visible from above and
616
+ // the ray reaches their contents — but only when the focused level actually
617
+ // has rooms. Focusing a zone-less floor keeps the building intact
618
+ // (otherwise its roof would just vanish with nothing to show).
695
619
  const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId);
696
620
  // Shadow-caster-only: hidden roof/ceiling meshes keep casting sun shadows
697
621
  // so interiors show window light patches instead of uniform sun flood.
@@ -701,47 +625,23 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
701
625
  else
702
626
  clearShadowOnly(mesh);
703
627
  }
704
- for (const { id, levelId, meshes, uniforms } of zoneFills.current) {
705
- const show = !walk && selection.levelId != null && levelId === selection.levelId && !selection.zoneId;
706
- const target = !show ? 0 : id === hoveredZoneId ? 1 : 0.65;
707
- let visible = false;
708
- for (const u of uniforms) {
709
- u.value = lerp(u.value, target, t);
710
- if (u.value > 0.01)
711
- visible = true;
712
- }
713
- for (const mesh of meshes)
714
- mesh.visible = visible;
715
- const group = labelGroups.current.get(id);
716
- const zoneNode = identity.get(id);
717
- if (group && zoneNode) {
718
- group.matrixAutoUpdate = false;
719
- group.matrix.copy(zoneNode.matrixWorld);
720
- group.visible = visible;
721
- }
722
- const div = labelDivs.current.get(id);
723
- if (div) {
724
- div.style.opacity = show ? '1' : '0';
725
- // Small by default, smoothly zooming up when its room is hovered.
726
- div.style.transform = `scale(${id === hoveredZoneId ? 1 : 0.82})`;
727
- }
728
- }
729
628
  outliner.selectedObjects.length = 0;
730
629
  outliner.hoveredObjects.length = 0;
731
630
  if (walk)
732
631
  return;
733
- const selectedObject = selection.selectedIds[0]
734
- ? (identity.get(selection.selectedIds[0]) ?? null)
735
- : null;
736
- if (selectedObject)
737
- outliner.selectedObjects.push(selectedObject);
738
- // Rooms show hover via the fill brightness; everything else uses the outline.
739
- const hover = hoveredTarget.current;
740
- if (hover && hover.kind !== 'zone' && hover.object !== selectedObject) {
741
- outliner.hoveredObjects.push(hover.object);
632
+ for (const id of selection.selectedIds) {
633
+ const object = identity.get(id);
634
+ if (object)
635
+ outliner.selectedObjects.push(object);
636
+ }
637
+ const hovered = state.hoveredId ? identity.get(state.hoveredId) : undefined;
638
+ if (hovered &&
639
+ hovered.userData.kind !== 'zone' &&
640
+ !outliner.selectedObjects.includes(hovered)) {
641
+ outliner.hoveredObjects.push(hovered);
742
642
  }
743
643
  });
744
- // ── Walkthrough: first-person HUD + door/window interaction ────────────────
644
+ // ── Walkthrough: first-person HUD + interaction ────────────────────────────
745
645
  const walkDoorRef = useRef(null);
746
646
  const lastWalkKey = useRef(null);
747
647
  // Each frame in walkthrough, report the floor + room the camera stands in and
@@ -769,11 +669,51 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
769
669
  if (hit) {
770
670
  const node = findIdentityAncestor(hit.object);
771
671
  const extras = node?.userData;
772
- if (node && extras?.openable && extras.clips?.length) {
773
- doorNode = node;
672
+ const lightOnly = extras?.pascalId &&
673
+ interactiveItems?.some((item) => item.pascalId === extras.pascalId &&
674
+ item.procedural?.lights.length &&
675
+ operableParts(item.procedural.recipe ?? { parts: item.procedural.parts }).length === 0);
676
+ const item = toggleableItem(extras?.pascalId);
677
+ if (node && extras?.kind === 'procedural-item' && (extras.clips?.length || lightOnly)) {
678
+ doorNode = { hit: hit.object, node };
679
+ const part = findProceduralMotionAncestor(hit.object);
680
+ const state = useInteractive.getState().procedural[extras.pascalId];
681
+ const isOpen = lightOnly
682
+ ? (state?.lightsOn ?? useInteractive.getState().lampDefault)
683
+ : part
684
+ ? Boolean(state?.parts[part.partId])
685
+ : Object.values(state?.parts ?? {}).some(Boolean);
686
+ const clips = part?.clip ? [part.clip] : extras.clips;
687
+ const isSpin = part
688
+ ? part.kind === 'spin'
689
+ : (clips?.every((clip) => clip.endsWith(': loop')) ?? false);
690
+ const label = lightOnly
691
+ ? (extras.label ?? 'Lights')
692
+ : (part?.partId.replaceAll('_', ' ') ?? extras.label ?? 'Item');
693
+ door = {
694
+ label,
695
+ isOpen,
696
+ verb: lightOnly || isSpin ? (isOpen ? 'turn off' : 'turn on') : isOpen ? 'close' : 'open',
697
+ };
698
+ doorId = `${extras.pascalId}:${part?.partId ?? 'all'}`;
699
+ }
700
+ else if (node && item) {
701
+ doorNode = { hit: hit.object, node };
702
+ doorId = item.pascalId;
703
+ const prompt = itemPrompt(item.pascalId, item.label, item.interactive);
704
+ door = { label: prompt.label, isOpen: prompt.isOn, verb: prompt.verb };
705
+ }
706
+ else if (node && extras?.openable && extras.clips?.length) {
707
+ doorNode = { hit: hit.object, node };
774
708
  doorId = extras.pascalId;
775
709
  door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) };
776
710
  }
711
+ else if (node && extras?.pascalId && loopMechanisms.has(extras.pascalId)) {
712
+ doorNode = { hit: hit.object, node };
713
+ doorId = extras.pascalId;
714
+ const isOpen = Boolean(useInteractive.getState().mechanisms[doorId]);
715
+ door = { label: extras.label ?? 'Item', isOpen, verb: isOpen ? 'turn off' : 'turn on' };
716
+ }
777
717
  }
778
718
  walkDoorRef.current = doorNode;
779
719
  const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}`;
@@ -785,9 +725,17 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
785
725
  // E or click activates the openable in view. The click also re-locks the
786
726
  // pointer through the walkthrough controller; no selection happens.
787
727
  const activateWalkDoor = useCallback(() => {
788
- if (walkDoorRef.current)
789
- toggleOpenable(walkDoorRef.current);
790
- }, [toggleOpenable]);
728
+ const target = walkDoorRef.current;
729
+ if (!target)
730
+ return;
731
+ const item = toggleableItem(target.node.userData.pascalId);
732
+ if (item) {
733
+ operateItem(item.pascalId, item.interactive);
734
+ return;
735
+ }
736
+ if (!(toggleProcedural(target.hit, target.node) || toggleLoopMechanism(target.node)))
737
+ toggleOpenable(target.node);
738
+ }, [toggleableItem, toggleLoopMechanism, toggleOpenable, toggleProcedural]);
791
739
  useEffect(() => {
792
740
  if (!walkthroughMode)
793
741
  return;
@@ -814,83 +762,57 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
814
762
  const lastHover = useRef(null);
815
763
  const handlePointerMove = useCallback((event) => {
816
764
  event.stopPropagation();
817
- if (walkthroughMode)
765
+ if (walkthroughMode || !onPick)
818
766
  return;
819
- const target = resolveTarget(event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray);
820
- hoveredTarget.current = target;
821
- document.body.style.cursor = target ? 'pointer' : 'auto';
822
- const key = target ? `${target.kind}:${target.id}` : null;
823
- if (key !== lastHover.current) {
824
- lastHover.current = key;
825
- onHoverChange?.(target ? { kind: target.kind, label: target.label } : null);
826
- }
827
- }, [resolveTarget, onHoverChange, walkthroughMode]);
767
+ const pick = resolveGlbPick(event.intersections, identity);
768
+ document.body.style.cursor = pick ? 'pointer' : 'auto';
769
+ const key = pick ? `${pick.nodeId}:${pick.point?.join(',') ?? ''}` : null;
770
+ if (key === lastHover.current)
771
+ return;
772
+ lastHover.current = key;
773
+ onPick({ type: 'hover', pick, modifiers: modifiersOf(event.nativeEvent) });
774
+ }, [identity, onPick, walkthroughMode]);
828
775
  const handlePointerOut = useCallback(() => {
829
- hoveredTarget.current = null;
830
776
  document.body.style.cursor = 'auto';
831
- if (lastHover.current !== null) {
832
- lastHover.current = null;
833
- onHoverChange?.(null);
834
- }
835
- }, [onHoverChange]);
836
- // Drill the building → level → zone → object hierarchy, deselecting back up
837
- // when the click lands outside the current scope. setSelection's hierarchy
838
- // guard clears deeper selections automatically when a parent changes.
777
+ if (lastHover.current === null)
778
+ return;
779
+ lastHover.current = null;
780
+ onPick?.({ type: 'hover', pick: null, modifiers: NO_MODIFIERS });
781
+ }, [onPick]);
782
+ // The host's rules decide the selection; an openable the click selects (a
783
+ // door, a window, an item with an `open` clip, a mechanism) also plays.
839
784
  const handleClick = useCallback((event) => {
840
785
  event.stopPropagation();
841
786
  // Walkthrough handles its own door activation (E / canvas click) and never
842
- // selects — leave the drill hierarchy untouched.
843
- if (walkthroughMode)
787
+ // selects.
788
+ if (walkthroughMode || !onPick)
844
789
  return;
845
- const target = resolveTarget(event.intersections.map((hit) => ({ object: hit.object, point: hit.point })), event.ray);
846
- const { selection, setSelection, setLevelMode } = useViewer.getState();
847
- // Building view → drill into the clicked floor.
848
- if (!selection.levelId) {
849
- if (target) {
850
- setLevelMode('solo');
851
- setSelection({ levelId: target.id });
852
- }
790
+ const pick = resolveGlbPick(event.intersections, identity);
791
+ onPick({ type: 'click', pick, modifiers: modifiersOf(event.nativeEvent) });
792
+ if (!pick || !useViewer.getState().selection.selectedIds.includes(pick.nodeId))
853
793
  return;
854
- }
855
- // Level view → enter the clicked room; clicking outside any room exits to
856
- // the building.
857
- if (!selection.zoneId) {
858
- if (target) {
859
- setSelection({ zoneId: target.id });
860
- }
861
- else {
862
- setLevelMode('stacked');
863
- setSelection({ levelId: null });
864
- }
794
+ const object = identity.get(pick.nodeId);
795
+ if (!object)
865
796
  return;
866
- }
867
- // Zone view → select the clicked node; clicking outside the room exits to
868
- // the level.
869
- if (target) {
870
- setSelection({ selectedIds: [target.id] });
871
- toggleOpenable(target.object);
872
- }
873
- else {
874
- setSelection({ zoneId: null });
875
- }
876
- }, [resolveTarget, toggleOpenable, walkthroughMode]);
877
- // A click that hits nothing (empty space) steps one level back up the drill
878
- // hierarchy, like the legacy viewer.
879
- const handlePointerMissed = useCallback(() => {
880
- if (useViewer.getState().walkthroughMode)
797
+ if (!(toggleOpenControl(object) ||
798
+ toggleProcedural(pick.hitObject, object) ||
799
+ toggleLoopMechanism(object)))
800
+ toggleOpenable(object);
801
+ }, [
802
+ identity,
803
+ onPick,
804
+ toggleLoopMechanism,
805
+ toggleOpenControl,
806
+ toggleOpenable,
807
+ toggleProcedural,
808
+ walkthroughMode,
809
+ ]);
810
+ // A click on empty space clears the room and the element (the floor stays).
811
+ const handlePointerMissed = useCallback((event) => {
812
+ if (useViewer.getState().walkthroughMode || !onPick)
881
813
  return;
882
- const { selection, setSelection, setLevelMode } = useViewer.getState();
883
- if (selection.selectedIds.length > 0) {
884
- setSelection({ selectedIds: [] });
885
- }
886
- else if (selection.zoneId) {
887
- setSelection({ zoneId: null });
888
- }
889
- else if (selection.levelId) {
890
- setLevelMode('stacked');
891
- setSelection({ levelId: null });
892
- }
893
- }, []);
814
+ onPick({ type: 'click', pick: null, modifiers: modifiersOf(event) });
815
+ }, [onPick]);
894
816
  useEffect(() => () => {
895
817
  const { outliner } = useViewer.getState();
896
818
  outliner.selectedObjects.length = 0;
@@ -900,23 +822,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
900
822
  for (const mesh of occluderOwnMeshes)
901
823
  clearShadowOnly(mesh);
902
824
  }, [occluderOwnMeshes]);
903
- 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) => {
904
- if (group)
905
- labelGroups.current.set(zone.id, group);
906
- else
907
- labelGroups.current.delete(zone.id);
908
- }, 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) => {
909
- if (div)
910
- labelDivs.current.set(zone.id, div);
911
- else
912
- labelDivs.current.delete(zone.id);
913
- }, style: {
914
- color: 'white',
915
- opacity: 0,
916
- textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`,
917
- transform: 'scale(0.82)',
918
- transformOrigin: 'center',
919
- transition: 'opacity 0.3s ease-in-out, transform 0.2s ease-out',
920
- whiteSpace: 'nowrap',
921
- }, children: zone.label }) }) }, zone.id)))] }));
825
+ 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] }));
826
+ }
827
+ const NO_MODIFIERS = {
828
+ alt: false,
829
+ ctrl: false,
830
+ meta: false,
831
+ shift: false,
832
+ };
833
+ function modifiersOf(event) {
834
+ return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
922
835
  }