@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
@@ -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
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"item-system.d.ts","sourceRoot":"","sources":["../../../src/systems/item/item-system.tsx"],"names":[],"mappings":"AAcA;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,YA8CtB,CAAA"}
1
+ {"version":3,"file":"item-system.d.ts","sourceRoot":"","sources":["../../../src/systems/item/item-system.tsx"],"names":[],"mappings":"AAmBA;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,YAsDtB,CAAA"}
@@ -1,5 +1,7 @@
1
- import { sceneRegistry, useScene, } from '@pascal-app/core';
1
+ import { getEffectiveNode, getWallBodyCenterOffset, getWallLocalFaceZ, sceneRegistry, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
+ import { useEffect } from 'react';
4
+ import { initializeObjectCutInvalidation } from './object-cut-invalidation';
3
5
  // ============================================================================
4
6
  // ITEM SYSTEM
5
7
  // ============================================================================
@@ -11,6 +13,7 @@ import { useFrame } from '@react-three/fiber';
11
13
  * mark at priority 2.
12
14
  */
13
15
  export const ItemSystem = () => {
16
+ useEffect(initializeObjectCutInvalidation, []);
14
17
  const dirtyNodes = useScene((state) => state.dirtyNodes);
15
18
  const clearDirty = useScene((state) => state.clearDirty);
16
19
  useFrame(() => {
@@ -25,6 +28,10 @@ export const ItemSystem = () => {
25
28
  const mesh = sceneRegistry.nodes.get(id);
26
29
  if (!mesh)
27
30
  return;
31
+ const host = item.parentId ? nodes[item.parentId] : undefined;
32
+ if (item.asset.attachTo === 'wall' && host?.type === 'wall') {
33
+ mesh.position.z = item.position[2] + getWallBodyCenterOffset(getEffectiveNode(host));
34
+ }
28
35
  if (item.asset.attachTo === 'wall-side') {
29
36
  // Wall-attached item: offset Z by half the host wall's thickness.
30
37
  // Roof-segment wall faces share the convention — the face frame's
@@ -37,8 +44,7 @@ export const ItemSystem = () => {
37
44
  ? (parent.wallThickness ?? 0.1)
38
45
  : undefined;
39
46
  if (thickness !== undefined) {
40
- const side = item.side === 'front' ? 1 : -1;
41
- mesh.position.z = (thickness / 2) * side;
47
+ mesh.position.z = getWallLocalFaceZ(parent?.type === 'wall' ? getEffectiveNode(parent) : { thickness }, item.side === 'front' ? 'a' : 'b');
42
48
  }
43
49
  }
44
50
  // Hold the mark until the model settles (loaded, terminally failed, or
@@ -0,0 +1,3 @@
1
+ /** Geometry remains derived; only dirty marks cross into the scene store. */
2
+ export declare function initializeObjectCutInvalidation(): () => void;
3
+ //# sourceMappingURL=object-cut-invalidation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"object-cut-invalidation.d.ts","sourceRoot":"","sources":["../../../src/systems/item/object-cut-invalidation.ts"],"names":[],"mappings":"AAUA,6EAA6E;AAC7E,wBAAgB,+BAA+B,eA6D9C"}
@@ -0,0 +1,68 @@
1
+ import { getEffectiveCutterNode, resolveCutterHost, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
2
+ /** Geometry remains derived; only dirty marks cross into the scene store. */
3
+ export function initializeObjectCutInvalidation() {
4
+ let previous = new Map();
5
+ let cutterIds = [];
6
+ const collect = (nodes) => {
7
+ cutterIds = [];
8
+ for (const node of Object.values(nodes)) {
9
+ if (node.type === 'item' && node.source?.manifest.cutters?.length)
10
+ cutterIds.push(node.id);
11
+ }
12
+ };
13
+ // Drags fire every frame: a scene without cutters pays nothing here.
14
+ const refresh = () => {
15
+ if (!cutterIds.length && !previous.size)
16
+ return;
17
+ const state = useScene.getState();
18
+ const nodes = { ...state.nodes };
19
+ for (const id of new Set([
20
+ ...useLiveNodeOverrides.getState().overrides.keys(),
21
+ ...useLiveTransforms.getState().transforms.keys(),
22
+ ])) {
23
+ if (nodes[id])
24
+ nodes[id] = getEffectiveCutterNode(nodes[id]);
25
+ }
26
+ const owners = new Map();
27
+ for (const id of cutterIds) {
28
+ const node = nodes[id];
29
+ if (node?.type !== 'item' || !node.source?.manifest.cutters?.length)
30
+ continue;
31
+ for (const cutter of node.source.manifest.cutters) {
32
+ const host = resolveCutterHost(node, cutter.host === 'mounted' ? 'cutout' : `cut:${cutter.host}`, nodes);
33
+ if (!host)
34
+ continue;
35
+ const entries = owners.get(host.id) ?? [];
36
+ entries.push([node.id, node.source.artifact, node.position, node.rotation, node.scale]);
37
+ owners.set(host.id, entries);
38
+ }
39
+ }
40
+ const next = new Map([...owners].map(([id, entries]) => [id, JSON.stringify(entries)]));
41
+ const changed = [...new Set([...previous.keys(), ...next.keys()])].filter((id) => previous.get(id) !== next.get(id));
42
+ previous = next;
43
+ for (const id of changed)
44
+ if (nodes[id])
45
+ state.markDirty(id);
46
+ };
47
+ collect(useScene.getState().nodes);
48
+ refresh();
49
+ const unsubscribeScene = useScene.subscribe((state, before) => {
50
+ if (state.nodes === before.nodes)
51
+ return;
52
+ collect(state.nodes);
53
+ refresh();
54
+ });
55
+ const unsubscribeLive = useLiveNodeOverrides.subscribe((state, before) => {
56
+ if (state.overrides !== before.overrides)
57
+ refresh();
58
+ });
59
+ const unsubscribeTransforms = useLiveTransforms.subscribe((state, before) => {
60
+ if (state.transforms !== before.transforms)
61
+ refresh();
62
+ });
63
+ return () => {
64
+ unsubscribeScene();
65
+ unsubscribeLive();
66
+ unsubscribeTransforms();
67
+ };
68
+ }
@@ -1,2 +1,2 @@
1
- export declare function ItemLightSystem(): import("react").JSX.Element;
1
+ export declare function ItemLightSystem(): import("react").JSX.Element | null;
2
2
  //# sourceMappingURL=item-light-system.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"item-light-system.d.ts","sourceRoot":"","sources":["../../../src/systems/item-light/item-light-system.tsx"],"names":[],"mappings":"AAuFA,wBAAgB,eAAe,gCA8N9B"}
1
+ {"version":3,"file":"item-light-system.d.ts","sourceRoot":"","sources":["../../../src/systems/item-light/item-light-system.tsx"],"names":[],"mappings":"AAgGA,wBAAgB,eAAe,uCA0M9B"}