@pascal-app/viewer 1.0.3 → 1.0.4

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 (164) hide show
  1. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  2. package/dist/components/renderers/node-renderer.js +8 -3
  3. package/dist/components/viewer/frame-limiter.d.ts +1 -0
  4. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  5. package/dist/components/viewer/frame-limiter.js +22 -3
  6. package/dist/components/viewer/glb-interactive.d.ts +25 -1
  7. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  8. package/dist/components/viewer/glb-interactive.js +252 -85
  9. package/dist/components/viewer/glb-mechanisms.d.ts +10 -0
  10. package/dist/components/viewer/glb-mechanisms.d.ts.map +1 -0
  11. package/dist/components/viewer/glb-mechanisms.js +19 -0
  12. package/dist/components/viewer/glb-scene.d.ts +45 -27
  13. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  14. package/dist/components/viewer/glb-scene.js +420 -506
  15. package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
  16. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  17. package/dist/components/viewer/glb-walkthrough-controller.js +12 -1
  18. package/dist/components/viewer/index.d.ts +14 -0
  19. package/dist/components/viewer/index.d.ts.map +1 -1
  20. package/dist/components/viewer/index.js +84 -26
  21. package/dist/components/viewer/lights.d.ts.map +1 -1
  22. package/dist/components/viewer/lights.js +6 -1
  23. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  24. package/dist/components/viewer/perf-monitor.js +6 -0
  25. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  26. package/dist/components/viewer/post-processing.js +8 -3
  27. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  28. package/dist/components/viewer/registered-systems.js +16 -2
  29. package/dist/components/viewer/render-error.d.ts +18 -0
  30. package/dist/components/viewer/render-error.d.ts.map +1 -0
  31. package/dist/components/viewer/render-error.js +29 -0
  32. package/dist/components/viewer/scene-ground-replacement.d.ts +6 -0
  33. package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -1
  34. package/dist/components/viewer/scene-ground-replacement.js +15 -0
  35. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  36. package/dist/components/viewer/selection-manager.js +10 -0
  37. package/dist/components/viewer/viewer-camera.d.ts +4 -0
  38. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  39. package/dist/components/viewer/viewer-camera.js +11 -1
  40. package/dist/index.d.ts +22 -11
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +20 -14
  43. package/dist/lib/asset-url.d.ts.map +1 -1
  44. package/dist/lib/asset-url.js +7 -1
  45. package/dist/lib/display-state.d.ts +25 -0
  46. package/dist/lib/display-state.d.ts.map +1 -0
  47. package/dist/lib/display-state.js +1 -0
  48. package/dist/lib/hero-pose.d.ts +12 -2
  49. package/dist/lib/hero-pose.d.ts.map +1 -1
  50. package/dist/lib/hero-pose.js +72 -5
  51. package/dist/lib/ink-edges.d.ts +4 -2
  52. package/dist/lib/ink-edges.d.ts.map +1 -1
  53. package/dist/lib/ink-edges.js +17 -7
  54. package/dist/lib/key-light-override.d.ts +11 -0
  55. package/dist/lib/key-light-override.d.ts.map +1 -0
  56. package/dist/lib/key-light-override.js +14 -0
  57. package/dist/lib/live-frame-hold.d.ts +14 -0
  58. package/dist/lib/live-frame-hold.d.ts.map +1 -0
  59. package/dist/lib/live-frame-hold.js +28 -0
  60. package/dist/lib/materials.d.ts +14 -7
  61. package/dist/lib/materials.d.ts.map +1 -1
  62. package/dist/lib/materials.js +31 -5
  63. package/dist/lib/node-top-surface-height.d.ts +6 -0
  64. package/dist/lib/node-top-surface-height.d.ts.map +1 -0
  65. package/dist/lib/node-top-surface-height.js +74 -0
  66. package/dist/lib/perf-probe-state.d.ts +39 -0
  67. package/dist/lib/perf-probe-state.d.ts.map +1 -0
  68. package/dist/lib/perf-probe-state.js +17 -0
  69. package/dist/lib/pointer-events.d.ts +8 -0
  70. package/dist/lib/pointer-events.d.ts.map +1 -1
  71. package/dist/lib/pointer-events.js +27 -4
  72. package/dist/lib/procedural-emission.d.ts +7 -0
  73. package/dist/lib/procedural-emission.d.ts.map +1 -0
  74. package/dist/lib/procedural-emission.js +54 -0
  75. package/dist/lib/scene-support-height.d.ts +4 -0
  76. package/dist/lib/scene-support-height.d.ts.map +1 -0
  77. package/dist/lib/scene-support-height.js +34 -0
  78. package/dist/lib/scene-visibility.d.ts +6 -0
  79. package/dist/lib/scene-visibility.d.ts.map +1 -1
  80. package/dist/lib/scene-visibility.js +15 -3
  81. package/dist/lib/scripted-opening.d.ts +11 -0
  82. package/dist/lib/scripted-opening.d.ts.map +1 -0
  83. package/dist/lib/scripted-opening.js +20 -0
  84. package/dist/lib/snapshot-pipeline.d.ts +32 -2
  85. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  86. package/dist/lib/snapshot-pipeline.js +215 -105
  87. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
  88. package/dist/lib/terrain-perimeter-fill.js +22 -14
  89. package/dist/lib/zone-geometry.d.ts +11 -0
  90. package/dist/lib/zone-geometry.d.ts.map +1 -0
  91. package/dist/lib/zone-geometry.js +46 -0
  92. package/dist/store/use-item-light-pool.d.ts +14 -10
  93. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  94. package/dist/store/use-item-light-pool.js +52 -26
  95. package/dist/store/use-viewer.d.ts +11 -0
  96. package/dist/store/use-viewer.d.ts.map +1 -1
  97. package/dist/store/use-viewer.js +13 -6
  98. package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
  99. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  100. package/dist/systems/ceiling/ceiling-system.js +86 -6
  101. package/dist/systems/door/door-system.d.ts.map +1 -1
  102. package/dist/systems/door/door-system.js +55 -17
  103. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
  104. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  105. package/dist/systems/floor-elevation/floor-elevation-system.js +33 -5
  106. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  107. package/dist/systems/geometry/geometry-system.js +71 -11
  108. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  109. package/dist/systems/interactive/interactive-system.js +40 -15
  110. package/dist/systems/interactive/procedural-controls.d.ts +13 -0
  111. package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
  112. package/dist/systems/interactive/procedural-controls.js +17 -0
  113. package/dist/systems/interactive/scripted-clips.d.ts +19 -0
  114. package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
  115. package/dist/systems/interactive/scripted-clips.js +134 -0
  116. package/dist/systems/item/item-system.d.ts.map +1 -1
  117. package/dist/systems/item/item-system.js +9 -3
  118. package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
  119. package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
  120. package/dist/systems/item/object-cut-invalidation.js +68 -0
  121. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  122. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  123. package/dist/systems/item-light/item-light-system.js +47 -52
  124. package/dist/systems/level/level-system.d.ts.map +1 -1
  125. package/dist/systems/level/level-system.js +3 -5
  126. package/dist/systems/level/level-utils.d.ts +7 -4
  127. package/dist/systems/level/level-utils.d.ts.map +1 -1
  128. package/dist/systems/level/level-utils.js +17 -6
  129. package/dist/systems/level/shadow-only-levels.d.ts +9 -0
  130. package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
  131. package/dist/systems/level/shadow-only-levels.js +7 -0
  132. package/dist/systems/roof/roof-materials.d.ts +14 -2
  133. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  134. package/dist/systems/roof/roof-materials.js +54 -15
  135. package/dist/systems/roof/roof-system.d.ts +13 -3
  136. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  137. package/dist/systems/roof/roof-system.js +188 -13
  138. package/dist/systems/slab/slab-system.d.ts +1 -1
  139. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  140. package/dist/systems/slab/slab-system.js +49 -19
  141. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  142. package/dist/systems/stair/stair-system.js +133 -354
  143. package/dist/systems/wall/wall-cutout-cache.d.ts +7 -0
  144. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
  145. package/dist/systems/wall/wall-cutout-cache.js +46 -17
  146. package/dist/systems/wall/wall-cutout.d.ts +6 -0
  147. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  148. package/dist/systems/wall/wall-cutout.js +42 -28
  149. package/dist/systems/wall/wall-finish-data.d.ts +22 -0
  150. package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
  151. package/dist/systems/wall/wall-finish-data.js +17 -0
  152. package/dist/systems/wall/wall-materials.d.ts +9 -4
  153. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  154. package/dist/systems/wall/wall-materials.js +99 -152
  155. package/dist/systems/wall/wall-system.d.ts +28 -2
  156. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  157. package/dist/systems/wall/wall-system.js +582 -143
  158. package/dist/systems/window/window-system.d.ts +1 -1
  159. package/dist/systems/window/window-system.d.ts.map +1 -1
  160. package/dist/systems/window/window-system.js +41 -22
  161. package/package.json +5 -5
  162. package/dist/systems/fence/fence-system.d.ts +0 -22
  163. package/dist/systems/fence/fence-system.d.ts.map +0 -1
  164. package/dist/systems/fence/fence-system.js +0 -357
@@ -1,20 +1,20 @@
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, 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
20
  /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
@@ -32,6 +32,8 @@ const ROLE_BY_KIND = {
32
32
  door: 'joinery',
33
33
  item: 'furnishing',
34
34
  };
35
+ /** Kinds a pointer passes through to what lies behind (they frame, they don't get picked). */
36
+ const PASS_THROUGH_KINDS = new Set(['site', 'building', 'level', 'zone', 'ceiling', 'spawn']);
35
37
  function findIdentityAncestor(object) {
36
38
  let current = object;
37
39
  while (current) {
@@ -41,6 +43,16 @@ function findIdentityAncestor(object) {
41
43
  }
42
44
  return null;
43
45
  }
46
+ function findProceduralMotionAncestor(object) {
47
+ let current = object;
48
+ while (current) {
49
+ const motion = current.userData.proceduralMotion;
50
+ if (motion)
51
+ return motion;
52
+ current = current.parent;
53
+ }
54
+ return undefined;
55
+ }
44
56
  function findAncestorLevelId(object) {
45
57
  let current = object.parent;
46
58
  while (current) {
@@ -52,150 +64,142 @@ function findAncestorLevelId(object) {
52
64
  return null;
53
65
  }
54
66
  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
67
  const _camBox = new THREE.Box3();
63
68
  const _camCenter = new THREE.Vector3();
64
69
  const _camSize = new THREE.Vector3();
65
- const _camPoint = new THREE.Vector3();
66
70
  const _walkPos = new THREE.Vector3();
67
71
  const _reticleNdc = new THREE.Vector2(0, 0);
68
72
  const _reticleRaycaster = new THREE.Raycaster();
69
- /** How far ahead (metres) a door/window counts as "in view" for activation. */
73
+ /** How far ahead (metres) an interactive part counts as "in view" for activation. */
70
74
  const WALK_REACH = 3;
71
75
  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;
76
+ /**
77
+ * The baked node under a pointer that a click can land on: the nearest hit
78
+ * whose identity is not a kind that only frames (site, building, level, zone,
79
+ * ceiling) and is not on a hidden floor, with the hit in its level's local XZ.
80
+ */
81
+ export function resolveGlbPick(hits, identity) {
82
+ for (const hit of hits) {
83
+ const node = findIdentityAncestor(hit.object);
84
+ const extras = node?.userData;
85
+ if (!(node && extras?.pascalId) || PASS_THROUGH_KINDS.has(extras.kind ?? ''))
86
+ continue;
87
+ const levelId = findAncestorLevelId(node);
88
+ const level = levelId ? identity.get(levelId) : undefined;
89
+ if (level && !level.visible)
90
+ continue;
91
+ let point = null;
92
+ if (level) {
93
+ level.updateWorldMatrix(true, false);
94
+ _local.copy(hit.point);
95
+ level.worldToLocal(_local);
96
+ point = [_local.x, _local.z];
97
+ }
98
+ return { nodeId: extras.pascalId, point, hitObject: hit.object };
130
99
  }
131
- return false;
100
+ return null;
132
101
  }
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;
102
+ function pointInZone(x, z, zone) {
103
+ const polygon = [{ outer: zone.polygon, holes: zone.holes }];
104
+ return (containsPoint(polygon, [x, z]) || distanceToBoundary(polygon, [x, z]) <= ZONE_FOOTPRINT_EPSILON);
182
105
  }
183
106
  /**
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"`).
107
+ * GLB-consuming viewer scene. Loads a baked artifact and drives the viewer's
108
+ * presentation and interaction with no parametric scene graph. A pointer
109
+ * becomes a `GlbPickEvent` the host runs through its selection rules (the
110
+ * same rules as the parametric viewer); the shared `useViewer.selection` and
111
+ * `hoveredId` then drive the outline, and openables play their baked clips
112
+ * when clicked. The host disables the parametric `SelectionManager`
113
+ * (`selectionManager="custom"`).
192
114
  */
193
- export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes, onLevelsChange, onIdentityChange, onHoverChange, onWalkthroughChange, }) {
115
+ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes, onObjectsChange, onPick, onWalkthroughChange, }) {
194
116
  const gltf = useGLTFKTX2(url);
195
117
  const rootRef = useRef(null);
196
- const { actions } = useAnimations(gltf.animations, rootRef);
118
+ const actions = useClipActions(gltf.animations, rootRef);
119
+ const proceduralPlayback = useMemo(() => {
120
+ const byNode = new Map();
121
+ gltf.scene.traverse((object) => {
122
+ const extras = object.userData;
123
+ if (extras.kind === 'procedural-item' && extras.pascalId && !byNode.has(extras.pascalId))
124
+ byNode.set(extras.pascalId, { motions: [], clips: new Map() });
125
+ const motion = extras.proceduralMotion;
126
+ if (!motion?.clip)
127
+ return;
128
+ const clip = gltf.animations.find((entry) => entry.name === motion.clip);
129
+ if (!clip)
130
+ return;
131
+ const entry = byNode.get(motion.nodeId) ?? { motions: [], clips: new Map() };
132
+ if (motion.kind === 'spin') {
133
+ entry.motions.push({
134
+ id: motion.groupId,
135
+ partId: motion.partId,
136
+ kind: 'spin',
137
+ axis: 'y',
138
+ pivot: [0, 0, 0],
139
+ amount: (2 * Math.PI) / clip.duration,
140
+ delay: 0,
141
+ duration: 0,
142
+ easing: 'linear',
143
+ });
144
+ entry.clips.set(motion.clip, {
145
+ partId: motion.partId,
146
+ kind: 'spin',
147
+ groupId: entry.clips.get(motion.clip)?.groupId ?? motion.groupId,
148
+ });
149
+ }
150
+ else if (motion.activeWindow &&
151
+ !entry.motions.some((item) => item.partId === motion.partId)) {
152
+ entry.motions.push({
153
+ id: motion.groupId,
154
+ partId: motion.partId,
155
+ kind: 'hinge',
156
+ axis: 'y',
157
+ pivot: [0, 0, 0],
158
+ amount: 1,
159
+ delay: motion.activeWindow[0],
160
+ duration: motion.activeWindow[1] - motion.activeWindow[0],
161
+ easing: 'linear',
162
+ });
163
+ entry.clips.set(motion.clip, {
164
+ partId: motion.partId,
165
+ kind: 'finite',
166
+ groupId: motion.groupId,
167
+ });
168
+ }
169
+ byNode.set(motion.nodeId, entry);
170
+ });
171
+ // Procedural actions need a separate mixer so drei cannot advance their assigned times.
172
+ const mixer = new THREE.AnimationMixer(gltf.scene);
173
+ const entries = new Map([...byNode].map(([nodeId, entry]) => {
174
+ const spinParts = [
175
+ ...new Set(entry.motions.filter((motion) => motion.kind === 'spin').map((motion) => motion.partId)),
176
+ ];
177
+ const initial = Object.fromEntries(spinParts.map((partId) => [partId, true]));
178
+ return [
179
+ nodeId,
180
+ {
181
+ controller: new ProceduralMotionController(entry.motions, initial),
182
+ clips: entry.clips,
183
+ spinParts,
184
+ sequence: 0,
185
+ },
186
+ ];
187
+ }));
188
+ const proceduralActions = new Map(gltf.animations
189
+ .filter((clip) => [...entries.values()].some((entry) => entry.clips.has(clip.name)))
190
+ .map((clip) => {
191
+ const action = mixer.clipAction(clip);
192
+ action.enabled = true;
193
+ action.paused = true;
194
+ action.loop = clip.name.endsWith(': loop') ? THREE.LoopRepeat : THREE.LoopOnce;
195
+ action.clampWhenFinished = true;
196
+ action.setEffectiveWeight(1);
197
+ action.play();
198
+ return [clip.name, action];
199
+ }));
200
+ return { entries, mixer, actions: proceduralActions };
201
+ }, [gltf.scene, gltf.animations]);
197
202
  const camera = useThree((state) => state.camera);
198
- const raycaster = useThree((state) => state.raycaster);
199
203
  const controls = useThree((state) => state.controls);
200
204
  const walkthroughMode = useViewer((s) => s.walkthroughMode);
201
205
  const textures = useViewer((s) => s.textures);
@@ -211,7 +215,7 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
211
215
  return;
212
216
  object.traverse((child) => {
213
217
  const mesh = child;
214
- if (!mesh.isMesh || mesh.layers.isEnabled(ZONE_LAYER))
218
+ if (!mesh.isMesh)
215
219
  return;
216
220
  const ud = mesh.userData;
217
221
  if (!ud.__bakedMaterial)
@@ -312,15 +316,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
312
316
  }
313
317
  if (extras.kind === 'zone' && extras.polygon && extras.polygon.length >= 3) {
314
318
  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
+ const holes = extras.holes ?? [];
320
+ const centroid = polygonInteriorPoint({ polygon, holes });
319
321
  zoneList.push({
320
322
  id: extras.pascalId,
321
323
  node: object,
322
324
  levelId: findAncestorLevelId(object),
323
325
  polygon,
326
+ holes,
324
327
  label: extras.label ?? extras.pascalId,
325
328
  color: extras.color ?? '#3b82f6',
326
329
  centroid,
@@ -338,9 +341,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
338
341
  levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)),
339
342
  };
340
343
  }, [gltf.scene]);
341
- const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]);
342
344
  // Level pascalIds bottom-to-top, for the interactive light pool's level factor.
343
345
  const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels]);
346
+ // Loop clips no other controller plays (a plugin kind's mechanism) run on
347
+ // click and E. As in the editor they start stopped and never persist.
348
+ const loopMechanisms = useMemo(() => bakedLoopMechanisms(identity, new Set([
349
+ ...proceduralPlayback.entries.keys(),
350
+ ...(interactiveItems ?? []).map((item) => item.pascalId),
351
+ ])), [identity, proceduralPlayback, interactiveItems]);
344
352
  // The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted
345
353
  // on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding
346
354
  // the whole occluder node would hide them too, so collect just the occluder's
@@ -363,71 +371,20 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
363
371
  }
364
372
  return meshes;
365
373
  }, [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);
374
+ // Open on the building: its saved view, else a fit of its bounds. Later moves
375
+ // belong to the host's navigation (`camera-controls:frame`), never to a click.
373
376
  useEffect(() => {
374
377
  if (!controls)
375
378
  return;
376
- const flyToBookmark = (bookmark) => {
379
+ const bookmark = rootNode?.userData?.camera;
380
+ if (bookmark) {
377
381
  const { position: p, target: t } = bookmark;
378
382
  controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true);
379
383
  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
384
  return;
400
385
  }
401
- let bookmarkNode = null;
402
386
  _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
- }
387
+ _camBox.setFromObject(rootNode ?? gltf.scene);
431
388
  if (_camBox.isEmpty())
432
389
  return;
433
390
  _camBox.getCenter(_camCenter);
@@ -435,47 +392,21 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
435
392
  const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15);
436
393
  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
394
  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]);
395
+ }, [controls, rootNode, gltf.scene]);
458
396
  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.
397
+ onObjectsChange?.(identity);
398
+ return () => onObjectsChange?.(new Map());
399
+ }, [identity, onObjectsChange]);
400
+ // Apply the viewer's level display to the baked floors each frame, by the
401
+ // parametric LevelSystem's rule (solo, and the levels above the current one
402
+ // hidden when the host asks). Walkthrough always shows the full stacked
403
+ // building (you're standing inside it) — and the first-person collider is
404
+ // built from the visible meshes, so a hidden floor would otherwise drop the
405
+ // player through the world.
475
406
  useFrame((_, delta) => {
476
407
  if (levels.length === 0)
477
408
  return;
478
- const { levelMode, selection, walkthroughMode } = useViewer.getState();
409
+ const { levelMode, hideLevelsAboveSelection, selection, walkthroughMode } = useViewer.getState();
479
410
  const selectedLevel = selection.levelId;
480
411
  const selectedIdx = selectedLevel ? levels.findIndex((l) => l.id === selectedLevel) : -1;
481
412
  levels.forEach(({ id, node, baseY }, index) => {
@@ -486,77 +417,71 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
486
417
  node.position.y = walkthroughMode
487
418
  ? targetY
488
419
  : 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) {
420
+ const { visible, shadowOnly } = walkthroughMode
421
+ ? { visible: true, shadowOnly: false }
422
+ : resolveLevelVisibility({
423
+ levelMode,
424
+ hideAbove: hideLevelsAboveSelection,
425
+ hasSelectedLevel: selectedIdx >= 0,
426
+ isSelected: id === selectedLevel,
427
+ index,
428
+ selectedIndex: selectedIdx >= 0 ? selectedIdx : undefined,
429
+ nodeVisible: true,
430
+ });
431
+ if (shadowOnly)
494
432
  applyShadowOnly(node);
495
- node.visible = true;
496
- }
497
- else {
433
+ else
498
434
  clearShadowOnly(node);
499
- node.visible = !hidden;
500
- }
435
+ node.visible = visible;
501
436
  });
502
437
  }, 5);
503
- const zoneFills = useRef([]);
504
438
  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;
439
+ for (const [nodeId, entry] of proceduralPlayback.entries)
440
+ useInteractive
441
+ .getState()
442
+ .initProcedural(nodeId, [...new Set(entry.controller.motions.map((motion) => motion.partId))], entry.spinParts);
542
443
  return () => {
543
- for (const { meshes } of built) {
544
- for (const mesh of meshes) {
545
- mesh.removeFromParent();
546
- mesh.geometry.dispose();
547
- mesh.material.dispose();
444
+ for (const nodeId of proceduralPlayback.entries.keys())
445
+ useInteractive.getState().removeProcedural(nodeId);
446
+ proceduralPlayback.mixer.stopAllAction();
447
+ };
448
+ }, [proceduralPlayback]);
449
+ useFrame((_, delta) => {
450
+ for (const [nodeId, entry] of proceduralPlayback.entries) {
451
+ const command = useInteractive.getState().procedural[nodeId]?.motionCommand;
452
+ if (command && command.sequence > entry.sequence) {
453
+ entry.controller.command(command);
454
+ entry.sequence = command.sequence;
455
+ }
456
+ const frame = entry.controller.tick(delta);
457
+ for (const [clipName, binding] of entry.clips) {
458
+ const action = proceduralPlayback.actions.get(clipName);
459
+ if (!action)
460
+ continue;
461
+ if (binding.kind === 'finite')
462
+ action.time = frame.times[binding.partId] ?? 0;
463
+ else {
464
+ const motion = entry.controller.motions.find((item) => item.id === binding.groupId);
465
+ if (motion)
466
+ action.time =
467
+ ((frame.spins[motion.id]?.phase ?? 0) / (2 * Math.PI)) * action.getClip().duration;
548
468
  }
549
469
  }
550
- zoneFills.current = [];
551
- };
552
- }, [zoneEntries]);
470
+ }
471
+ proceduralPlayback.mixer.update(0);
472
+ }, -1);
553
473
  useEffect(() => {
474
+ const scriptedPrefixes = (interactiveItems ?? [])
475
+ .filter((item) => item.scripted)
476
+ .map((item) => `${item.pascalId}: `);
554
477
  for (const [name, action] of Object.entries(actions)) {
555
478
  if (!action)
556
479
  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.
480
+ if (proceduralPlayback.actions.has(name))
481
+ continue;
482
+ // ScriptedClips owns each authored clip's playback mode.
483
+ if (scriptedPrefixes.some((prefix) => name.startsWith(prefix)))
484
+ continue;
560
485
  if (name.endsWith(': loop')) {
561
486
  action.loop = THREE.LoopRepeat;
562
487
  action.clampWhenFinished = false;
@@ -566,11 +491,87 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
566
491
  action.clampWhenFinished = true;
567
492
  }
568
493
  }
569
- }, [actions]);
494
+ }, [actions, proceduralPlayback, interactiveItems]);
495
+ useEffect(() => {
496
+ if (loopMechanisms.size === 0)
497
+ return;
498
+ const apply = (running) => {
499
+ for (const [id, clips] of loopMechanisms) {
500
+ for (const name of clips) {
501
+ const action = actions[name];
502
+ if (!action)
503
+ continue;
504
+ if (running[id]) {
505
+ action.enabled = true;
506
+ action.paused = false;
507
+ if (!action.isRunning())
508
+ action.play();
509
+ }
510
+ else {
511
+ action.stop();
512
+ }
513
+ }
514
+ }
515
+ };
516
+ apply(useInteractive.getState().mechanisms);
517
+ const unsubscribe = useInteractive.subscribe((state, previous) => {
518
+ if (state.mechanisms !== previous.mechanisms)
519
+ apply(state.mechanisms);
520
+ });
521
+ return () => {
522
+ unsubscribe();
523
+ for (const id of loopMechanisms.keys())
524
+ useInteractive.getState().removeMechanism(id);
525
+ };
526
+ }, [actions, loopMechanisms]);
527
+ // Items the walkthrough operates through their toggles; others fall through to loop clips.
528
+ const toggleableItem = useCallback((pascalId) => interactiveItems?.find((item) => item.pascalId === pascalId &&
529
+ item.interactive.controls.some((control) => control.kind === 'toggle')), [interactiveItems]);
530
+ // A click opens and closes an item with an `open` clip (an authored object,
531
+ // a scripted window or door), like a door.
532
+ const toggleOpenControl = useCallback((identityNode) => {
533
+ const item = toggleableItem(identityNode.userData.pascalId);
534
+ if (!item || itemInteraction(item.interactive).kind !== 'open')
535
+ return false;
536
+ operateItem(item.pascalId, item.interactive);
537
+ return true;
538
+ }, [toggleableItem]);
539
+ const toggleLoopMechanism = useCallback((identityNode) => {
540
+ const id = identityNode.userData.pascalId;
541
+ if (!(id && loopMechanisms.has(id)))
542
+ return false;
543
+ const state = useInteractive.getState();
544
+ state.setMechanism(id, !state.mechanisms[id]);
545
+ return true;
546
+ }, [loopMechanisms]);
570
547
  const openIds = useRef(new Set());
548
+ const toggleProcedural = useCallback((hit, identityNode) => {
549
+ const extras = identityNode.userData;
550
+ if (extras.kind !== 'procedural-item')
551
+ return false;
552
+ const part = findProceduralMotionAncestor(hit);
553
+ if (part?.clip) {
554
+ useInteractive.getState().toggleProceduralPart(part.nodeId, part.partId);
555
+ return true;
556
+ }
557
+ const nodeId = extras.pascalId;
558
+ const entry = proceduralPlayback.entries.get(nodeId);
559
+ if (!entry)
560
+ return false;
561
+ const parts = [...new Set(entry.controller.motions.map((motion) => motion.partId))];
562
+ if (parts.length === 0) {
563
+ useInteractive.getState().toggleProceduralLights(nodeId);
564
+ return true;
565
+ }
566
+ const active = useInteractive.getState().procedural[nodeId]?.parts;
567
+ useInteractive
568
+ .getState()
569
+ .setProceduralParts(nodeId, parts, !parts.some((partId) => active?.[partId]));
570
+ return true;
571
+ }, [proceduralPlayback]);
571
572
  const toggleOpenable = useCallback((node) => {
572
573
  const extras = node.userData;
573
- const clipName = extras.clips?.[0];
574
+ const clipName = extras.clips?.find((name) => name.endsWith(': open'));
574
575
  if (!extras.openable || !clipName)
575
576
  return;
576
577
  const action = actions[clipName];
@@ -598,100 +599,24 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
598
599
  continue;
599
600
  _local.copy(worldPoint);
600
601
  entry.node.worldToLocal(_local);
601
- if (pointInPolygonInclusive(_local.x, _local.z, entry.polygon))
602
+ if (pointInZone(_local.x, _local.z, entry))
602
603
  return entry;
603
604
  }
604
605
  return null;
605
606
  }, [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) => {
607
+ // Per-frame: hide the focused floor's ceilings and roof when it has rooms
608
+ // (dollhouse), and sync the outline post-FX from the shared selection and
609
+ // hover. Rooms highlight through the host's room layer, not the outline.
610
+ useFrame(() => {
684
611
  const state = useViewer.getState();
685
612
  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.
613
+ // Walkthrough is a first-person tour: no dollhouse cutaway, no selection
614
+ // outline — you're standing inside the real building.
690
615
  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).
616
+ // Dollhouse: hide ceilings + roof so the rooms are visible from above and
617
+ // the ray reaches their contents — but only when the focused level actually
618
+ // has rooms. Focusing a zone-less floor keeps the building intact
619
+ // (otherwise its roof would just vanish with nothing to show).
695
620
  const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId);
696
621
  // Shadow-caster-only: hidden roof/ceiling meshes keep casting sun shadows
697
622
  // so interiors show window light patches instead of uniform sun flood.
@@ -701,47 +626,23 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
701
626
  else
702
627
  clearShadowOnly(mesh);
703
628
  }
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
629
  outliner.selectedObjects.length = 0;
730
630
  outliner.hoveredObjects.length = 0;
731
631
  if (walk)
732
632
  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);
633
+ for (const id of selection.selectedIds) {
634
+ const object = identity.get(id);
635
+ if (object)
636
+ outliner.selectedObjects.push(object);
637
+ }
638
+ const hovered = state.hoveredId ? identity.get(state.hoveredId) : undefined;
639
+ if (hovered &&
640
+ hovered.userData.kind !== 'zone' &&
641
+ !outliner.selectedObjects.includes(hovered)) {
642
+ outliner.hoveredObjects.push(hovered);
742
643
  }
743
644
  });
744
- // ── Walkthrough: first-person HUD + door/window interaction ────────────────
645
+ // ── Walkthrough: first-person HUD + interaction ────────────────────────────
745
646
  const walkDoorRef = useRef(null);
746
647
  const lastWalkKey = useRef(null);
747
648
  // Each frame in walkthrough, report the floor + room the camera stands in and
@@ -769,11 +670,51 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
769
670
  if (hit) {
770
671
  const node = findIdentityAncestor(hit.object);
771
672
  const extras = node?.userData;
772
- if (node && extras?.openable && extras.clips?.length) {
773
- doorNode = node;
673
+ const lightOnly = extras?.pascalId &&
674
+ interactiveItems?.some((item) => item.pascalId === extras.pascalId &&
675
+ item.procedural?.lights.length &&
676
+ operableParts(item.procedural.recipe ?? { parts: item.procedural.parts }).length === 0);
677
+ const item = toggleableItem(extras?.pascalId);
678
+ if (node && extras?.kind === 'procedural-item' && (extras.clips?.length || lightOnly)) {
679
+ doorNode = { hit: hit.object, node };
680
+ const part = findProceduralMotionAncestor(hit.object);
681
+ const state = useInteractive.getState().procedural[extras.pascalId];
682
+ const isOpen = lightOnly
683
+ ? (state?.lightsOn ?? useInteractive.getState().lampDefault)
684
+ : part
685
+ ? Boolean(state?.parts[part.partId])
686
+ : Object.values(state?.parts ?? {}).some(Boolean);
687
+ const clips = part?.clip ? [part.clip] : extras.clips;
688
+ const isSpin = part
689
+ ? part.kind === 'spin'
690
+ : (clips?.every((clip) => clip.endsWith(': loop')) ?? false);
691
+ const label = lightOnly
692
+ ? (extras.label ?? 'Lights')
693
+ : (part?.partId.replaceAll('_', ' ') ?? extras.label ?? 'Item');
694
+ door = {
695
+ label,
696
+ isOpen,
697
+ verb: lightOnly || isSpin ? (isOpen ? 'turn off' : 'turn on') : isOpen ? 'close' : 'open',
698
+ };
699
+ doorId = `${extras.pascalId}:${part?.partId ?? 'all'}`;
700
+ }
701
+ else if (node && item) {
702
+ doorNode = { hit: hit.object, node };
703
+ doorId = item.pascalId;
704
+ const prompt = itemPrompt(item.pascalId, item.label, item.interactive);
705
+ door = { label: prompt.label, isOpen: prompt.isOn, verb: prompt.verb };
706
+ }
707
+ else if (node && extras?.openable && extras.clips?.length) {
708
+ doorNode = { hit: hit.object, node };
774
709
  doorId = extras.pascalId;
775
710
  door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) };
776
711
  }
712
+ else if (node && extras?.pascalId && loopMechanisms.has(extras.pascalId)) {
713
+ doorNode = { hit: hit.object, node };
714
+ doorId = extras.pascalId;
715
+ const isOpen = Boolean(useInteractive.getState().mechanisms[doorId]);
716
+ door = { label: extras.label ?? 'Item', isOpen, verb: isOpen ? 'turn off' : 'turn on' };
717
+ }
777
718
  }
778
719
  walkDoorRef.current = doorNode;
779
720
  const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}`;
@@ -785,9 +726,17 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
785
726
  // E or click activates the openable in view. The click also re-locks the
786
727
  // pointer through the walkthrough controller; no selection happens.
787
728
  const activateWalkDoor = useCallback(() => {
788
- if (walkDoorRef.current)
789
- toggleOpenable(walkDoorRef.current);
790
- }, [toggleOpenable]);
729
+ const target = walkDoorRef.current;
730
+ if (!target)
731
+ return;
732
+ const item = toggleableItem(target.node.userData.pascalId);
733
+ if (item) {
734
+ operateItem(item.pascalId, item.interactive);
735
+ return;
736
+ }
737
+ if (!(toggleProcedural(target.hit, target.node) || toggleLoopMechanism(target.node)))
738
+ toggleOpenable(target.node);
739
+ }, [toggleableItem, toggleLoopMechanism, toggleOpenable, toggleProcedural]);
791
740
  useEffect(() => {
792
741
  if (!walkthroughMode)
793
742
  return;
@@ -814,83 +763,57 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
814
763
  const lastHover = useRef(null);
815
764
  const handlePointerMove = useCallback((event) => {
816
765
  event.stopPropagation();
817
- if (walkthroughMode)
766
+ if (walkthroughMode || !onPick)
818
767
  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]);
768
+ const pick = resolveGlbPick(event.intersections, identity);
769
+ document.body.style.cursor = pick ? 'pointer' : 'auto';
770
+ const key = pick ? `${pick.nodeId}:${pick.point?.join(',') ?? ''}` : null;
771
+ if (key === lastHover.current)
772
+ return;
773
+ lastHover.current = key;
774
+ onPick({ type: 'hover', pick, modifiers: modifiersOf(event.nativeEvent) });
775
+ }, [identity, onPick, walkthroughMode]);
828
776
  const handlePointerOut = useCallback(() => {
829
- hoveredTarget.current = null;
830
777
  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.
778
+ if (lastHover.current === null)
779
+ return;
780
+ lastHover.current = null;
781
+ onPick?.({ type: 'hover', pick: null, modifiers: NO_MODIFIERS });
782
+ }, [onPick]);
783
+ // The host's rules decide the selection; an openable the click selects (a
784
+ // door, a window, an item with an `open` clip, a mechanism) also plays.
839
785
  const handleClick = useCallback((event) => {
840
786
  event.stopPropagation();
841
787
  // Walkthrough handles its own door activation (E / canvas click) and never
842
- // selects — leave the drill hierarchy untouched.
843
- if (walkthroughMode)
788
+ // selects.
789
+ if (walkthroughMode || !onPick)
844
790
  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
- }
791
+ const pick = resolveGlbPick(event.intersections, identity);
792
+ onPick({ type: 'click', pick, modifiers: modifiersOf(event.nativeEvent) });
793
+ if (!pick || !useViewer.getState().selection.selectedIds.includes(pick.nodeId))
853
794
  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
- }
795
+ const object = identity.get(pick.nodeId);
796
+ if (!object)
865
797
  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)
798
+ if (!(toggleOpenControl(object) ||
799
+ toggleProcedural(pick.hitObject, object) ||
800
+ toggleLoopMechanism(object)))
801
+ toggleOpenable(object);
802
+ }, [
803
+ identity,
804
+ onPick,
805
+ toggleLoopMechanism,
806
+ toggleOpenControl,
807
+ toggleOpenable,
808
+ toggleProcedural,
809
+ walkthroughMode,
810
+ ]);
811
+ // A click on empty space clears the room and the element (the floor stays).
812
+ const handlePointerMissed = useCallback((event) => {
813
+ if (useViewer.getState().walkthroughMode || !onPick)
881
814
  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
- }, []);
815
+ onPick({ type: 'click', pick: null, modifiers: modifiersOf(event) });
816
+ }, [onPick]);
894
817
  useEffect(() => () => {
895
818
  const { outliner } = useViewer.getState();
896
819
  outliner.selectedObjects.length = 0;
@@ -900,23 +823,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
900
823
  for (const mesh of occluderOwnMeshes)
901
824
  clearShadowOnly(mesh);
902
825
  }, [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)))] }));
826
+ 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] }));
827
+ }
828
+ const NO_MODIFIERS = {
829
+ alt: false,
830
+ ctrl: false,
831
+ meta: false,
832
+ shift: false,
833
+ };
834
+ function modifiersOf(event) {
835
+ return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
922
836
  }