@pascal-app/viewer 1.0.1 → 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 (173) 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 +26 -0
  19. package/dist/components/viewer/index.d.ts.map +1 -1
  20. package/dist/components/viewer/index.js +108 -29
  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 +19 -1
  38. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  39. package/dist/components/viewer/viewer-camera.js +53 -5
  40. package/dist/index.d.ts +28 -15
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +26 -18
  43. package/dist/lib/asset-url.d.ts.map +1 -1
  44. package/dist/lib/asset-url.js +7 -1
  45. package/dist/lib/csg-utils.d.ts +1 -1
  46. package/dist/lib/csg-utils.d.ts.map +1 -1
  47. package/dist/lib/csg-utils.js +1 -1
  48. package/dist/lib/display-state.d.ts +25 -0
  49. package/dist/lib/display-state.d.ts.map +1 -0
  50. package/dist/lib/display-state.js +1 -0
  51. package/dist/lib/hero-pose.d.ts +12 -2
  52. package/dist/lib/hero-pose.d.ts.map +1 -1
  53. package/dist/lib/hero-pose.js +72 -5
  54. package/dist/lib/ink-edges.d.ts +4 -2
  55. package/dist/lib/ink-edges.d.ts.map +1 -1
  56. package/dist/lib/ink-edges.js +17 -7
  57. package/dist/lib/key-light-override.d.ts +11 -0
  58. package/dist/lib/key-light-override.d.ts.map +1 -0
  59. package/dist/lib/key-light-override.js +14 -0
  60. package/dist/lib/live-frame-hold.d.ts +14 -0
  61. package/dist/lib/live-frame-hold.d.ts.map +1 -0
  62. package/dist/lib/live-frame-hold.js +28 -0
  63. package/dist/lib/materials.d.ts +14 -7
  64. package/dist/lib/materials.d.ts.map +1 -1
  65. package/dist/lib/materials.js +31 -5
  66. package/dist/lib/node-top-surface-height.d.ts +6 -0
  67. package/dist/lib/node-top-surface-height.d.ts.map +1 -0
  68. package/dist/lib/node-top-surface-height.js +74 -0
  69. package/dist/lib/perf-probe-state.d.ts +39 -0
  70. package/dist/lib/perf-probe-state.d.ts.map +1 -0
  71. package/dist/lib/perf-probe-state.js +17 -0
  72. package/dist/lib/pointer-events.d.ts +8 -0
  73. package/dist/lib/pointer-events.d.ts.map +1 -1
  74. package/dist/lib/pointer-events.js +27 -4
  75. package/dist/lib/procedural-emission.d.ts +7 -0
  76. package/dist/lib/procedural-emission.d.ts.map +1 -0
  77. package/dist/lib/procedural-emission.js +54 -0
  78. package/dist/lib/renderer-capability.d.ts +2 -1
  79. package/dist/lib/renderer-capability.d.ts.map +1 -1
  80. package/dist/lib/renderer-capability.js +2 -2
  81. package/dist/lib/scene-support-height.d.ts +4 -0
  82. package/dist/lib/scene-support-height.d.ts.map +1 -0
  83. package/dist/lib/scene-support-height.js +34 -0
  84. package/dist/lib/scene-visibility.d.ts +6 -0
  85. package/dist/lib/scene-visibility.d.ts.map +1 -1
  86. package/dist/lib/scene-visibility.js +15 -3
  87. package/dist/lib/scripted-opening.d.ts +11 -0
  88. package/dist/lib/scripted-opening.d.ts.map +1 -0
  89. package/dist/lib/scripted-opening.js +20 -0
  90. package/dist/lib/snapshot-pipeline.d.ts +32 -2
  91. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  92. package/dist/lib/snapshot-pipeline.js +215 -105
  93. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
  94. package/dist/lib/terrain-perimeter-fill.js +22 -14
  95. package/dist/lib/zone-geometry.d.ts +11 -0
  96. package/dist/lib/zone-geometry.d.ts.map +1 -0
  97. package/dist/lib/zone-geometry.js +46 -0
  98. package/dist/store/use-item-light-pool.d.ts +14 -10
  99. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  100. package/dist/store/use-item-light-pool.js +52 -26
  101. package/dist/store/use-viewer.d.ts +11 -0
  102. package/dist/store/use-viewer.d.ts.map +1 -1
  103. package/dist/store/use-viewer.js +13 -6
  104. package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
  105. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  106. package/dist/systems/ceiling/ceiling-system.js +86 -6
  107. package/dist/systems/door/door-system.d.ts.map +1 -1
  108. package/dist/systems/door/door-system.js +73 -22
  109. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
  110. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  111. package/dist/systems/floor-elevation/floor-elevation-system.js +148 -5
  112. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  113. package/dist/systems/geometry/geometry-system.js +71 -11
  114. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  115. package/dist/systems/interactive/interactive-system.js +40 -15
  116. package/dist/systems/interactive/procedural-controls.d.ts +13 -0
  117. package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
  118. package/dist/systems/interactive/procedural-controls.js +17 -0
  119. package/dist/systems/interactive/scripted-clips.d.ts +19 -0
  120. package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
  121. package/dist/systems/interactive/scripted-clips.js +134 -0
  122. package/dist/systems/item/item-system.d.ts.map +1 -1
  123. package/dist/systems/item/item-system.js +9 -3
  124. package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
  125. package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
  126. package/dist/systems/item/object-cut-invalidation.js +68 -0
  127. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  128. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  129. package/dist/systems/item-light/item-light-system.js +47 -52
  130. package/dist/systems/level/level-system.d.ts.map +1 -1
  131. package/dist/systems/level/level-system.js +17 -19
  132. package/dist/systems/level/level-utils.d.ts +28 -1
  133. package/dist/systems/level/level-utils.d.ts.map +1 -1
  134. package/dist/systems/level/level-utils.js +36 -4
  135. package/dist/systems/level/shadow-only-levels.d.ts +9 -0
  136. package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
  137. package/dist/systems/level/shadow-only-levels.js +7 -0
  138. package/dist/systems/roof/roof-materials.d.ts +14 -2
  139. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  140. package/dist/systems/roof/roof-materials.js +54 -15
  141. package/dist/systems/roof/roof-system.d.ts +13 -3
  142. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  143. package/dist/systems/roof/roof-system.js +188 -13
  144. package/dist/systems/slab/slab-system.d.ts +1 -1
  145. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  146. package/dist/systems/slab/slab-system.js +49 -19
  147. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  148. package/dist/systems/stair/stair-system.js +133 -354
  149. package/dist/systems/wall/wall-cutout-cache.d.ts +11 -1
  150. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
  151. package/dist/systems/wall/wall-cutout-cache.js +58 -20
  152. package/dist/systems/wall/wall-cutout.d.ts +9 -1
  153. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  154. package/dist/systems/wall/wall-cutout.js +46 -32
  155. package/dist/systems/wall/wall-finish-data.d.ts +22 -0
  156. package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
  157. package/dist/systems/wall/wall-finish-data.js +17 -0
  158. package/dist/systems/wall/wall-materials.d.ts +17 -3
  159. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  160. package/dist/systems/wall/wall-materials.js +109 -156
  161. package/dist/systems/wall/wall-system.d.ts +43 -4
  162. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  163. package/dist/systems/wall/wall-system.js +601 -163
  164. package/dist/systems/window/window-system.d.ts +1 -1
  165. package/dist/systems/window/window-system.d.ts.map +1 -1
  166. package/dist/systems/window/window-system.js +44 -24
  167. package/dist/xr/presentation-context.d.ts +7 -0
  168. package/dist/xr/presentation-context.d.ts.map +1 -0
  169. package/dist/xr/presentation-context.js +10 -0
  170. package/package.json +6 -6
  171. package/dist/systems/fence/fence-system.d.ts +0 -22
  172. package/dist/systems/fence/fence-system.d.ts.map +0 -1
  173. package/dist/systems/fence/fence-system.js +0 -357
@@ -1,5 +1,7 @@
1
1
  import { getEffectiveNode, getFloorStackedPosition, nodeRegistry, sceneRegistry, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
+ import { useEffect, useMemo } from 'react';
4
+ import { Euler, Matrix4, Quaternion, Vector3 } from 'three';
3
5
  function withLiveTransform(node, liveTransform) {
4
6
  if (!liveTransform)
5
7
  return node;
@@ -15,6 +17,16 @@ function withLiveTransform(node, liveTransform) {
15
17
  ...(rotation !== undefined ? { rotation } : {}),
16
18
  };
17
19
  }
20
+ function restoreMountedExitPose(saved) {
21
+ saved.mesh.matrixAutoUpdate = saved.autoUpdate;
22
+ saved.mesh.scale.copy(saved.scale);
23
+ if (!saved.mesh.parent || saved.mesh.parent === saved.parent) {
24
+ saved.mesh.position.copy(saved.position);
25
+ saved.mesh.quaternion.copy(saved.quaternion);
26
+ }
27
+ saved.mesh.updateMatrix();
28
+ saved.mesh.updateMatrixWorld(true);
29
+ }
18
30
  /**
19
31
  * Generic floor-elevation system.
20
32
  *
@@ -31,11 +43,67 @@ function withLiveTransform(node, liveTransform) {
31
43
  * Runs at priority 1 — before the priority-2 systems (`GeometrySystem`,
32
44
  * `ItemSystem`) so the dirty mark survives long enough for those to do
33
45
  * their own work. Kinds with no geometry/system have no downstream dirty
34
- * consumer, so this system clears their dirty mark after applying the lift.
46
+ * consumer, so this system clears their dirty mark after applying the lift,
47
+ * at priority 2 like every other consumer: the node batch snapshots marks at
48
+ * priority 1, and a mark cleared there could run before that snapshot.
35
49
  */
36
50
  export const FloorElevationSystem = () => {
37
51
  const dirtyNodes = useScene((s) => s.dirtyNodes);
38
52
  const clearDirty = useScene((s) => s.clearDirty);
53
+ const consumed = useMemo(() => new Set(), []);
54
+ // Meshes already lifted once. A mesh mounts at its base Y, and a remount (entering Preview
55
+ // builds a fresh viewer tree) raises no dirty mark, so the lift would never be applied.
56
+ const mounted = useMemo(() => ({ revision: -1, meshes: new WeakSet() }), []);
57
+ const preview = useMemo(() => ({
58
+ local: new Matrix4(),
59
+ target: new Matrix4(),
60
+ position: new Vector3(),
61
+ rotation: new Euler(),
62
+ quaternion: new Quaternion(),
63
+ unit: new Vector3(1, 1, 1),
64
+ saved: new Map(),
65
+ }), []);
66
+ useEffect(() => {
67
+ const restore = (id) => {
68
+ const saved = preview.saved.get(id);
69
+ if (!saved)
70
+ return;
71
+ restoreMountedExitPose(saved);
72
+ preview.saved.delete(id);
73
+ };
74
+ const sync = () => {
75
+ const { nodes } = useScene.getState();
76
+ const overrides = useLiveNodeOverrides.getState().overrides;
77
+ for (const id of preview.saved.keys()) {
78
+ const node = nodes[id];
79
+ const patch = overrides.get(id);
80
+ if (!node || !patch?.parentId || patch.parentId === node.parentId)
81
+ restore(id);
82
+ }
83
+ for (const [id, patch] of overrides) {
84
+ const node = nodes[id];
85
+ if (!node || !patch.parentId || patch.parentId === node.parentId || preview.saved.has(id))
86
+ continue;
87
+ const mesh = sceneRegistry.nodes.get(id);
88
+ if (!mesh?.parent)
89
+ continue;
90
+ preview.saved.set(id, {
91
+ mesh,
92
+ parent: mesh.parent,
93
+ position: mesh.position.clone(),
94
+ quaternion: mesh.quaternion.clone(),
95
+ scale: mesh.scale.clone(),
96
+ autoUpdate: mesh.matrixAutoUpdate,
97
+ });
98
+ }
99
+ };
100
+ const unsubscribe = useLiveNodeOverrides.subscribe(sync);
101
+ return () => {
102
+ unsubscribe();
103
+ for (const id of preview.saved.keys())
104
+ restore(id);
105
+ };
106
+ }, [preview]);
39
107
  useFrame(() => {
40
108
  // Nodes with a live preview (override / transform) are reapplied EVERY
41
109
  // frame, not only while dirty: the React commit that rebinds the group's
@@ -45,7 +113,8 @@ export const FloorElevationSystem = () => {
45
113
  // during group drags over elevated slabs).
46
114
  const overrides = useLiveNodeOverrides.getState().overrides;
47
115
  const transforms = useLiveTransforms.getState().transforms;
48
- if (dirtyNodes.size === 0 && overrides.size === 0 && transforms.size === 0)
116
+ const registryChanged = sceneRegistry.revision !== mounted.revision;
117
+ if (dirtyNodes.size === 0 && overrides.size === 0 && transforms.size === 0 && !registryChanged)
49
118
  return;
50
119
  const nodes = useScene.getState().nodes;
51
120
  const applyLift = (id) => {
@@ -64,9 +133,13 @@ export const FloorElevationSystem = () => {
64
133
  const position = effectiveNode.position;
65
134
  if (!position)
66
135
  return;
67
- if (!(def.geometry || def.system) && dirtyNodes.has(id)) {
68
- clearDirty(id);
69
- }
136
+ if (effectiveNode.parentId !== node.parentId)
137
+ return;
138
+ // A mounted object still loading (a scripted column's artifact) keeps its mark, so
139
+ // scene-ready and bakes wait for it; settling marks the node dirty again.
140
+ const settled = def.capabilities?.batchable?.settled?.(mesh.userData) ?? true;
141
+ if (!(def.geometry || def.system) && dirtyNodes.has(id) && settled)
142
+ consumed.add(id);
70
143
  // `applies === false` means the kind opts OUT of floor stacking for this
71
144
  // node: its Y belongs to a host frame (a wall/ceiling-mounted item, a
72
145
  // cabinet module inside a run, a wall duct terminal). `getFloorPlacedElevation`
@@ -98,6 +171,20 @@ export const FloorElevationSystem = () => {
98
171
  });
99
172
  mesh.position.y = visualPosition[1];
100
173
  };
174
+ if (registryChanged) {
175
+ mounted.revision = sceneRegistry.revision;
176
+ for (const [kind, ids] of Object.entries(sceneRegistry.byType)) {
177
+ if (!nodeRegistry.get(kind)?.capabilities?.floorPlaced)
178
+ continue;
179
+ for (const id of ids) {
180
+ const mesh = sceneRegistry.nodes.get(id);
181
+ if (!mesh || mounted.meshes.has(mesh))
182
+ continue;
183
+ mounted.meshes.add(mesh);
184
+ applyLift(id);
185
+ }
186
+ }
187
+ }
101
188
  dirtyNodes.forEach((id) => {
102
189
  applyLift(id);
103
190
  });
@@ -110,5 +197,61 @@ export const FloorElevationSystem = () => {
110
197
  applyLift(id);
111
198
  });
112
199
  }, 1);
200
+ useFrame(() => {
201
+ for (const id of consumed)
202
+ clearDirty(id);
203
+ consumed.clear();
204
+ }, 2);
205
+ // PostProcessing draws at priority 1 after this system; later callbacks would show one wrong frame per move.
206
+ useFrame(() => {
207
+ const nodes = useScene.getState().nodes;
208
+ for (const [id, patch] of useLiveNodeOverrides.getState().overrides) {
209
+ const node = nodes[id];
210
+ if (!node || !patch.parentId || patch.parentId === node.parentId)
211
+ continue;
212
+ const effective = getEffectiveNode(node);
213
+ if (effective.parentId === node.parentId || !effective.position)
214
+ continue;
215
+ if (!effective.parentId || nodes[effective.parentId]?.type !== 'level')
216
+ continue;
217
+ const mesh = sceneRegistry.nodes.get(id);
218
+ const level = sceneRegistry.nodes.get(effective.parentId);
219
+ if (!mesh?.parent || !level)
220
+ continue;
221
+ const previous = preview.saved.get(id);
222
+ if (previous?.mesh !== mesh) {
223
+ if (previous)
224
+ restoreMountedExitPose(previous);
225
+ preview.saved.set(id, {
226
+ mesh,
227
+ parent: mesh.parent,
228
+ position: mesh.position.clone(),
229
+ quaternion: mesh.quaternion.clone(),
230
+ scale: mesh.scale.clone(),
231
+ autoUpdate: mesh.matrixAutoUpdate,
232
+ });
233
+ }
234
+ const position = getFloorStackedPosition({
235
+ node: effective,
236
+ nodes,
237
+ position: effective.position,
238
+ });
239
+ const rotation = typeof effective.rotation === 'number'
240
+ ? [0, effective.rotation, 0]
241
+ : (effective.rotation ?? [0, 0, 0]);
242
+ level.updateWorldMatrix(true, false);
243
+ mesh.parent.updateWorldMatrix(true, false);
244
+ // The override's parent is logical; the mesh still inherits the mounted host and surface wrapper.
245
+ preview.local
246
+ .copy(mesh.parent.matrixWorld)
247
+ .invert()
248
+ .multiply(level.matrixWorld)
249
+ .multiply(preview.target.compose(preview.position.fromArray(position), preview.quaternion.setFromEuler(preview.rotation.set(rotation[0], rotation[1], rotation[2])), preview.unit));
250
+ // Keep the full matrix: a rotated child beneath nonuniform scale can require shear.
251
+ mesh.matrixAutoUpdate = false;
252
+ mesh.matrix.copy(preview.local);
253
+ mesh.updateMatrixWorld(true);
254
+ }
255
+ }, 1);
113
256
  return null;
114
257
  };
@@ -1 +1 @@
1
- {"version":3,"file":"geometry-system.d.ts","sourceRoot":"","sources":["../../../src/systems/geometry/geometry-system.tsx"],"names":[],"mappings":"AAkBA,OAAO,EAAa,KAAK,KAAK,EAA4B,KAAK,QAAQ,EAAE,MAAM,OAAO,CAAA;AAUtF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,cAAc,YAuM1B,CAAA;AAiJD,eAAe,cAAc,CAAA;AAE7B,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,EAAE,KAAK,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI,CAU7D;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,uBAAuB,CAAC,EAC3C,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,KAAK,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAKT"}
1
+ {"version":3,"file":"geometry-system.d.ts","sourceRoot":"","sources":["../../../src/systems/geometry/geometry-system.tsx"],"names":[],"mappings":"AAkBA,OAAO,EAAa,KAAK,KAAK,EAA4B,KAAK,QAAQ,EAAE,MAAM,OAAO,CAAA;AAatF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,cAAc,YA8N1B,CAAA;AA+KD,eAAe,cAAc,CAAA;AAE7B,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,EAAE,KAAK,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI,CAY7D;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,uBAAuB,CAAC,EAC3C,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,KAAK,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAKT"}
@@ -4,8 +4,10 @@ import { useFrame } from '@react-three/fiber';
4
4
  import { useEffect, useRef } from 'react';
5
5
  import { FrontSide } from 'three';
6
6
  import { disposeObject3DResources } from '../../lib/dispose-object3d';
7
- import { createSurfaceRoleMaterial, } from '../../lib/materials';
7
+ import { createSurfaceRoleMaterial, materialCastsShadow, } from '../../lib/materials';
8
+ import { createNodeTopSurfaceHeightSampler } from '../../lib/node-top-surface-height';
8
9
  import { timeSpan } from '../../lib/perf-tracks';
10
+ import { createSceneSupportHeightSampler } from '../../lib/scene-support-height';
9
11
  import useViewer from '../../store/use-viewer';
10
12
  /**
11
13
  * Generic geometry system.
@@ -55,6 +57,22 @@ export const GeometrySystem = () => {
55
57
  // but its geometry inputs are unchanged — e.g. an item reparenting onto a
56
58
  // shelf dirties the shelf without altering its boards.
57
59
  const builtGeometryKeyRef = useRef(new Map());
60
+ const liveBuildIds = useRef(new Set());
61
+ useEffect(() => {
62
+ const unsubscribe = useLiveNodeOverrides.subscribe((state, previous) => {
63
+ for (const id of new Set([...state.overrides.keys(), ...previous.overrides.keys()])) {
64
+ if (state.overrides.get(id) === previous.overrides.get(id))
65
+ continue;
66
+ const node = useScene.getState().nodes[id];
67
+ if (node && nodeRegistry.get(node.type)?.geometry)
68
+ liveBuildIds.current.add(id);
69
+ }
70
+ });
71
+ return () => {
72
+ unsubscribe();
73
+ liveBuildIds.current.clear();
74
+ };
75
+ }, []);
58
76
  // Re-mark every geometry-backed node dirty whenever a viewer appearance
59
77
  // value changes, so `def.geometry` builders re-run and pick up the new
60
78
  // shading / texture / preset / theme. These four are deliberate re-run
@@ -92,19 +110,23 @@ export const GeometrySystem = () => {
92
110
  }
93
111
  }, [sceneMaterials]);
94
112
  useFrame(() => {
95
- if (dirtyNodes.size === 0)
113
+ if (dirtyNodes.size === 0 && liveBuildIds.current.size === 0)
96
114
  return;
97
115
  const nodes = useScene.getState().nodes;
98
116
  let rebuiltGeometry = false;
99
117
  const batches = new Map();
100
118
  const dirtyIds = [];
101
- dirtyNodes.forEach((id) => {
119
+ for (const id of new Set([...dirtyNodes, ...liveBuildIds.current])) {
102
120
  const node = nodes[id];
103
- if (!node)
104
- return;
121
+ if (!node) {
122
+ liveBuildIds.current.delete(id);
123
+ continue;
124
+ }
105
125
  const def = nodeRegistry.get(node.type);
106
- if (!def?.geometry)
107
- return;
126
+ if (!def?.geometry) {
127
+ liveBuildIds.current.delete(id);
128
+ continue;
129
+ }
108
130
  dirtyIds.push(id);
109
131
  const parentId = (node.parentId ?? null);
110
132
  const key = `${node.type}::${parentId ?? ''}`;
@@ -113,7 +135,7 @@ export const GeometrySystem = () => {
113
135
  existing.ids.push(id);
114
136
  else
115
137
  batches.set(key, { kind: node.type, parentId, ids: [id] });
116
- });
138
+ }
117
139
  // Phase 2 — for each batch whose kind declares `computeLevelData`,
118
140
  // collect every sibling in the level + run the precompute once.
119
141
  const levelDataByBatch = new Map();
@@ -170,7 +192,9 @@ export const GeometrySystem = () => {
170
192
  const childLiveOverrideKey = liveChildOverrideKey(node);
171
193
  const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}|${childLiveOverrideKey}`;
172
194
  if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) {
173
- clearDirty(id);
195
+ liveBuildIds.current.delete(id);
196
+ if (dirtyNodes.has(id))
197
+ clearDirty(id);
174
198
  continue;
175
199
  }
176
200
  }
@@ -199,7 +223,9 @@ export const GeometrySystem = () => {
199
223
  // because the renderer doesn't necessarily re-render on the rebuild
200
224
  // tick, R3F wouldn't re-apply the props, leaving the group stuck at
201
225
  // origin. Geometry builders are expected to emit local-space children.
202
- clearDirty(id);
226
+ liveBuildIds.current.delete(id);
227
+ if (dirtyNodes.has(id))
228
+ clearDirty(id);
203
229
  }
204
230
  if (rebuiltGeometry)
205
231
  bumpGeometryRevision();
@@ -281,7 +307,38 @@ function buildGeometryContext(node, nodes, levelData, materials) {
281
307
  noteLevelBaseConsumer(node.type);
282
308
  return levelId ? levelBaseElevationAt(nodes, levelId, x, z) : 0;
283
309
  };
284
- return { resolve, children, siblings, parent, levelBaseAt, levelData, materials };
310
+ const surfaceSamplers = new Map();
311
+ const surfaceHeightAt = (hostId, x, z) => {
312
+ if (!levelId)
313
+ return null;
314
+ if (!surfaceSamplers.has(hostId)) {
315
+ surfaceSamplers.set(hostId, createNodeTopSurfaceHeightSampler(hostId, levelId));
316
+ }
317
+ return surfaceSamplers.get(hostId)?.(x, z) ?? null;
318
+ };
319
+ const supportSamplers = new Map();
320
+ const supportHeightAt = (x, z, selectedHostId) => {
321
+ if (!levelId)
322
+ return levelBaseAt(x, z);
323
+ const key = selectedHostId ?? 'auto';
324
+ let sampler = supportSamplers.get(key);
325
+ if (!sampler) {
326
+ sampler = createSceneSupportHeightSampler(nodes, levelId, selectedHostId);
327
+ supportSamplers.set(key, sampler);
328
+ }
329
+ return sampler(x, z);
330
+ };
331
+ return {
332
+ resolve,
333
+ children,
334
+ siblings,
335
+ parent,
336
+ levelBaseAt,
337
+ surfaceHeightAt,
338
+ supportHeightAt,
339
+ levelData,
340
+ materials,
341
+ };
285
342
  }
286
343
  function disposeChildren(group) {
287
344
  // Only dispose meshes the geometry builder produced on the previous
@@ -330,6 +387,9 @@ export function markGeometryBuildOutput(child) {
330
387
  // example a cabinet sink faucet handle), while disposal still removes only
331
388
  // top-level builder children from the registered group.
332
389
  child.traverse((object) => {
390
+ const mesh = object;
391
+ if (mesh.isMesh)
392
+ mesh.castShadow = mesh.castShadow && materialCastsShadow(mesh.material);
333
393
  object.userData = {
334
394
  ...object.userData,
335
395
  __fromGeometry: true,
@@ -1 +1 @@
1
- {"version":3,"file":"interactive-system.d.ts","sourceRoot":"","sources":["../../../src/systems/interactive/interactive-system.tsx"],"names":[],"mappings":"AAiCA,eAAO,MAAM,iBAAiB,mCAsB7B,CAAA"}
1
+ {"version":3,"file":"interactive-system.d.ts","sourceRoot":"","sources":["../../../src/systems/interactive/interactive-system.tsx"],"names":[],"mappings":"AAmCA,eAAO,MAAM,iBAAiB,mCAmC7B,CAAA"}
@@ -1,27 +1,27 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { pointInPolygon, sceneRegistry, useInteractive, useScene, } from '@pascal-app/core';
4
+ import { evaluateRecipe, operableParts, } from '@pascal-app/core/procedural-items';
4
5
  import { Html } from '@react-three/drei';
5
6
  import { createPortal, useFrame } from '@react-three/fiber';
6
- import { useEffect, useState } from 'react';
7
+ import { useEffect, useMemo, useState } from 'react';
7
8
  import { Vector3 } from 'three';
8
9
  import { useShallow } from 'zustand/react/shallow';
9
10
  import useViewer from '../../store/use-viewer';
10
11
  import { ControlWidget } from './control-widget';
12
+ import { proceduralControlDescriptors } from './procedural-controls';
11
13
  const _tempVec = new Vector3();
12
- // ---- Parent: one overlay per interactive item inside the selected zone ----
14
+ // ---- Parent: overlays for selected interactive items and the selected zone ----
13
15
  //
14
- // The <Html> overlays only exist while a zone is selected and the item sits
15
- // inside it. Mounting them unconditionally is not an option: each drei <Html>
16
+ // Mounting <Html> overlays unconditionally is not an option: each drei <Html>
16
17
  // repositions and re-sorts its DOM element on every camera-move frame, and
17
18
  // with `occlude` it also raycasts the entire scene per overlay per frame. On
18
19
  // large scenes (hundreds of interactive items) that starves the frame budget
19
20
  // and the display/z-index churn makes the whole DOM UI flicker.
20
21
  //
21
- // The child components stay rendered (returning null) so an overlay can fade
22
- // out before its <Html> unmounts.
23
22
  export const InteractiveSystem = () => {
24
23
  const zoneId = useViewer((s) => s.selection.zoneId);
24
+ const selectedIds = useViewer((s) => s.selection.selectedIds);
25
25
  const zonePolygon = useScene((s) => {
26
26
  if (!zoneId)
27
27
  return null;
@@ -29,13 +29,17 @@ export const InteractiveSystem = () => {
29
29
  return z?.polygon ?? null;
30
30
  });
31
31
  const interactiveNodeIds = useScene(useShallow((state) => Object.values(state.nodes)
32
- .filter((n) => n.type === 'item' && n.asset.interactive != null)
32
+ .filter((n) => (n.type === 'item' && n.asset.interactive != null) ||
33
+ (n.type === 'procedural-item' &&
34
+ (operableParts(n.recipe).length > 0 || n.recipe.parts.some((part) => part.light))))
33
35
  .map((n) => n.id)));
34
- return (_jsx(_Fragment, { children: interactiveNodeIds.map((id) => (_jsx(ItemControlsOverlay, { nodeId: id, zonePolygon: zonePolygon }, id))) }));
36
+ return (_jsx(_Fragment, { children: interactiveNodeIds
37
+ .filter((id) => zonePolygon?.length || selectedIds.includes(id))
38
+ .map((id) => (_jsx(ItemControlsOverlay, { isSelected: selectedIds.includes(id), nodeId: id, zonePolygon: zonePolygon }, id))) }));
35
39
  };
36
40
  // ---- Child: polls sceneRegistry then portals controls into the item group ----
37
41
  const FADE_MS = 300;
38
- const ItemControlsOverlay = ({ nodeId, zonePolygon, }) => {
42
+ const ItemControlsOverlay = ({ nodeId, zonePolygon, isSelected, }) => {
39
43
  const node = useScene((state) => state.nodes[nodeId]);
40
44
  const [itemObj, setItemObj] = useState(null);
41
45
  useFrame(() => {
@@ -46,11 +50,34 @@ const ItemControlsOverlay = ({ nodeId, zonePolygon, }) => {
46
50
  setItemObj(obj);
47
51
  });
48
52
  const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues));
53
+ const proceduralState = useInteractive((state) => state.procedural[nodeId]);
54
+ const lampDefault = useInteractive((state) => state.lampDefault);
49
55
  const setControlValue = useInteractive((state) => state.setControlValue);
50
- let visible = false;
56
+ const togglePart = useInteractive((state) => state.toggleProceduralPart);
57
+ const toggleLights = useInteractive((state) => state.toggleProceduralLights);
58
+ const proceduralHeight = useMemo(() => node?.type === 'procedural-item' ? evaluateRecipe(node.recipe, node.parameters).max[1] : 0, [
59
+ node?.type === 'procedural-item' ? node.recipe : null,
60
+ node?.type === 'procedural-item' ? node.parameters : null,
61
+ ]);
62
+ let descriptors = [];
63
+ let height = 0;
64
+ if (node?.type === 'item' && node.asset.interactive && controlValues) {
65
+ descriptors = node.asset.interactive.controls.map((control, i) => ({
66
+ key: String(i),
67
+ control,
68
+ value: controlValues[i] ?? false,
69
+ onChange: (value) => setControlValue(nodeId, i, value),
70
+ }));
71
+ height = node.asset.dimensions[1];
72
+ }
73
+ else if (node?.type === 'procedural-item') {
74
+ descriptors = proceduralControlDescriptors(node.recipe, proceduralState, (partId) => togglePart(nodeId, partId), () => toggleLights(nodeId), lampDefault);
75
+ height = proceduralHeight;
76
+ }
77
+ let visible = isSelected;
51
78
  if (itemObj && zonePolygon?.length) {
52
79
  itemObj.getWorldPosition(_tempVec);
53
- visible = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon);
80
+ visible = visible || pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon);
54
81
  }
55
82
  // Fade in on mount and fade out before unmounting the <Html>.
56
83
  const [mounted, setMounted] = useState(false);
@@ -73,10 +100,8 @@ const ItemControlsOverlay = ({ nodeId, zonePolygon, }) => {
73
100
  const timeout = setTimeout(() => setMounted(false), FADE_MS);
74
101
  return () => clearTimeout(timeout);
75
102
  }, [visible]);
76
- if (!(mounted && itemObj && controlValues && node?.asset.interactive))
103
+ if (!(mounted && itemObj && descriptors.length))
77
104
  return null;
78
- const { controls } = node.asset.interactive;
79
- const [, height] = node.asset.dimensions;
80
105
  return createPortal(_jsx(Html, { center: true, distanceFactor: 8, eps: -1, position: [0, height + 0.3, 0], zIndexRange: [20, 0], children: _jsx("div", { style: {
81
106
  display: 'flex',
82
107
  flexDirection: 'column',
@@ -90,5 +115,5 @@ const ItemControlsOverlay = ({ nodeId, zonePolygon, }) => {
90
115
  userSelect: 'none',
91
116
  opacity: shown ? 1 : 0,
92
117
  transition: `opacity ${FADE_MS}ms ease`,
93
- }, children: controls.map((control, i) => (_jsx(ControlWidget, { control: control, onChange: (v) => setControlValue(nodeId, i, v), value: controlValues[i] ?? false }, i))) }) }), itemObj);
118
+ }, children: descriptors.map((descriptor) => (_jsx(ControlWidget, { control: descriptor.control, onChange: descriptor.onChange, value: descriptor.value }, descriptor.key))) }) }), itemObj);
94
119
  };
@@ -0,0 +1,13 @@
1
+ import type { Control, ControlValue } from '@pascal-app/core';
2
+ import { type ProceduralItemNode } from '@pascal-app/core/procedural-items';
3
+ export type ControlDescriptor = {
4
+ key: string;
5
+ control: Control;
6
+ value: ControlValue;
7
+ onChange: (value: ControlValue) => void;
8
+ };
9
+ export declare function proceduralControlDescriptors(recipe: Pick<ProceduralItemNode['recipe'], 'parts' | 'joints'>, state: {
10
+ parts: Record<string, boolean>;
11
+ lightsOn: boolean;
12
+ } | undefined, togglePart: (partId: string) => void, toggleLights: () => void, lampDefault?: boolean): ControlDescriptor[];
13
+ //# sourceMappingURL=procedural-controls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"procedural-controls.d.ts","sourceRoot":"","sources":["../../../src/systems/interactive/procedural-controls.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAiB,KAAK,kBAAkB,EAAE,MAAM,mCAAmC,CAAA;AAE1F,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,YAAY,CAAA;IACnB,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;CACxC,CAAA;AAED,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC,EAC9D,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,EACxE,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,EACpC,YAAY,EAAE,MAAM,IAAI,EACxB,WAAW,UAAQ,GAClB,iBAAiB,EAAE,CAerB"}
@@ -0,0 +1,17 @@
1
+ import { operableParts } from '@pascal-app/core/procedural-items';
2
+ export function proceduralControlDescriptors(recipe, state, togglePart, toggleLights, lampDefault = false) {
3
+ const controls = operableParts(recipe).map((part) => ({
4
+ key: part.id,
5
+ control: { kind: 'toggle', label: part.label },
6
+ value: state?.parts[part.id] ?? part.kind === 'spin',
7
+ onChange: () => togglePart(part.id),
8
+ }));
9
+ if (recipe.parts.some((part) => part.light))
10
+ controls.push({
11
+ key: 'lights',
12
+ control: { kind: 'toggle', label: 'Lights' },
13
+ value: state?.lightsOn ?? lampDefault,
14
+ onChange: toggleLights,
15
+ });
16
+ return controls;
17
+ }
@@ -0,0 +1,19 @@
1
+ import { type AnyNodeId, type Interactive } from '@pascal-app/core';
2
+ import { type RefObject } from 'react';
3
+ import { type AnimationAction, type AnimationClip, Object3D } from 'three';
4
+ /** Actions keyed by the clip names the script wrote (`open`, `loop`, `Princess twirl`). */
5
+ export type ScriptedClipActions = Record<string, AnimationAction | null | undefined>;
6
+ /** Own the action cache so replacing clips cannot retain bindings to an old artifact. */
7
+ export declare function useClipActions(clips: AnimationClip[], root: RefObject<Object3D | null>): Record<string, AnimationAction | null>;
8
+ /**
9
+ * An authored object's clips, each driven by its own toggle: an open-close
10
+ * effect plays `open` once and holds, closing plays `close` or `open` reversed;
11
+ * an ambient effect plays its clip while its toggle is on (always, for `loop`).
12
+ * Shared by the live item renderer and the baked viewer.
13
+ */
14
+ export declare function ScriptedClips({ nodeId, interactive, actions, }: {
15
+ nodeId: AnyNodeId;
16
+ interactive: Interactive;
17
+ actions: ScriptedClipActions;
18
+ }): import("react").JSX.Element;
19
+ //# sourceMappingURL=scripted-clips.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scripted-clips.d.ts","sourceRoot":"","sources":["../../../src/systems/interactive/scripted-clips.tsx"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,WAAW,EAEjB,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EAAE,KAAK,SAAS,EAA+C,MAAM,OAAO,CAAA;AACnF,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,aAAa,EAIlB,QAAQ,EACT,MAAM,OAAO,CAAA;AAEd,2FAA2F;AAC3F,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,GAAG,SAAS,CAAC,CAAA;AAEpF,yFAAyF;AACzF,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,IAAI,EAAE,SAAS,CAAC,QAAQ,GAAG,IAAI,CAAC,0CAoCtF;AAQD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAC5B,MAAM,EACN,WAAW,EACX,OAAO,GACR,EAAE;IACD,MAAM,EAAE,SAAS,CAAA;IACjB,WAAW,EAAE,WAAW,CAAA;IACxB,OAAO,EAAE,mBAAmB,CAAA;CAC7B,+BAoBA"}
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useInteractive, } from '@pascal-app/core';
4
+ import { useFrame } from '@react-three/fiber';
5
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
6
+ import { AnimationMixer, LoopOnce, LoopRepeat, Object3D, } from 'three';
7
+ /** Own the action cache so replacing clips cannot retain bindings to an old artifact. */
8
+ export function useClipActions(clips, root) {
9
+ const { mixer, actions, cached, lifecycle } = useMemo(() => {
10
+ const mixer = new AnimationMixer(new Object3D());
11
+ const cached = new Map();
12
+ const lifecycle = { active: true };
13
+ const actions = {};
14
+ for (const clip of clips) {
15
+ Object.defineProperty(actions, clip.name, {
16
+ enumerable: true,
17
+ configurable: true,
18
+ get: () => {
19
+ if (!lifecycle.active || !root.current)
20
+ return null;
21
+ let action = cached.get(clip.name);
22
+ if (!action) {
23
+ action = mixer.clipAction(clip, root.current);
24
+ cached.set(clip.name, action);
25
+ }
26
+ return action;
27
+ },
28
+ });
29
+ }
30
+ return { mixer, actions, cached, lifecycle };
31
+ }, [clips, root]);
32
+ useFrame((_, delta) => mixer.update(delta));
33
+ useLayoutEffect(() => {
34
+ lifecycle.active = true;
35
+ return () => {
36
+ // React's development prop diff can read old getters after cleanup.
37
+ lifecycle.active = false;
38
+ mixer.stopAllAction();
39
+ const roots = new Set([...cached.values()].map((action) => action.getRoot()));
40
+ cached.clear();
41
+ for (const object of roots)
42
+ mixer.uncacheRoot(object);
43
+ };
44
+ }, [mixer, cached, lifecycle]);
45
+ return actions;
46
+ }
47
+ /** Whether an effect's toggle is on; an effect without one always runs. */
48
+ const useEffectControl = (nodeId, control) => useInteractive((s) => control === undefined ? true : Boolean(s.items[nodeId]?.controlValues[control]));
49
+ /**
50
+ * An authored object's clips, each driven by its own toggle: an open-close
51
+ * effect plays `open` once and holds, closing plays `close` or `open` reversed;
52
+ * an ambient effect plays its clip while its toggle is on (always, for `loop`).
53
+ * Shared by the live item renderer and the baked viewer.
54
+ */
55
+ export function ScriptedClips({ nodeId, interactive, actions, }) {
56
+ const effects = interactive.effects.filter((effect) => effect.kind === 'animation');
57
+ return (_jsx(_Fragment, { children: effects.map((effect) => effect.mode === 'open-close' ? (_jsx(OpenCloseClip, { actions: actions, effect: effect, nodeId: nodeId }, effect.clips.on)) : (_jsx(PlayClip, { actions: actions, effect: effect, nodeId: nodeId }, effect.clips.on ?? effect.clips.loop))) }));
58
+ }
59
+ const PlayClip = ({ nodeId, effect, actions, }) => {
60
+ const on = useEffectControl(nodeId, effect.control);
61
+ const name = effect.clips.on ?? effect.clips.loop;
62
+ useEffect(() => {
63
+ const action = name ? actions[name] : undefined;
64
+ return () => {
65
+ action?.stop();
66
+ };
67
+ }, [actions, name]);
68
+ useEffect(() => {
69
+ const action = name ? actions[name] : undefined;
70
+ if (!action)
71
+ return;
72
+ if (on) {
73
+ action.setLoop(LoopRepeat, Number.POSITIVE_INFINITY);
74
+ action.clampWhenFinished = false;
75
+ action.paused = false;
76
+ action.play();
77
+ }
78
+ else {
79
+ // Hold the pose where it was, like pausing a music box.
80
+ action.paused = true;
81
+ }
82
+ }, [actions, name, on]);
83
+ return null;
84
+ };
85
+ const OpenCloseClip = ({ nodeId, effect, actions, }) => {
86
+ const isOpen = useEffectControl(nodeId, effect.control);
87
+ const mounted = useRef(false);
88
+ useEffect(() => {
89
+ const open = effect.clips.on ? actions[effect.clips.on] : undefined;
90
+ const close = effect.clips.off ? actions[effect.clips.off] : undefined;
91
+ return () => {
92
+ open?.stop();
93
+ close?.stop();
94
+ mounted.current = false;
95
+ };
96
+ }, [actions, effect.clips.on, effect.clips.off]);
97
+ useEffect(() => {
98
+ const open = effect.clips.on ? actions[effect.clips.on] : undefined;
99
+ const close = effect.clips.off ? actions[effect.clips.off] : undefined;
100
+ if (!open)
101
+ return;
102
+ const first = !mounted.current;
103
+ mounted.current = true;
104
+ for (const action of [open, close]) {
105
+ if (!action)
106
+ continue;
107
+ action.setLoop(LoopOnce, 1);
108
+ action.clampWhenFinished = true;
109
+ }
110
+ if (isOpen) {
111
+ close?.stop();
112
+ open.paused = false;
113
+ open.timeScale = 1;
114
+ if (!open.isRunning())
115
+ open.reset();
116
+ open.play();
117
+ // Already open when the scene loads: hold the open pose, no swing.
118
+ if (first)
119
+ open.time = open.getClip().duration;
120
+ return;
121
+ }
122
+ if (first)
123
+ return;
124
+ if (close) {
125
+ open.stop();
126
+ close.reset().play();
127
+ return;
128
+ }
129
+ open.paused = false;
130
+ open.timeScale = -1;
131
+ open.play();
132
+ }, [actions, effect.clips.on, effect.clips.off, isOpen]);
133
+ return null;
134
+ };