@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 +1 @@
1
- {"version":3,"file":"node-renderer.d.ts","sourceRoot":"","sources":["../../../src/components/renderers/node-renderer.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,OAAO,EAGZ,KAAK,cAAc,EAEpB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,KAAK,aAAa,EAAkB,MAAM,OAAO,CAAA;AAO1D,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,cAAc,CAAC,OAAO,CAAC,GAC9B,aAAa,CAAC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,IAAI,CASzC;AAED,eAAO,MAAM,YAAY,GAAI,YAAY;IAAE,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,uCA0BjE,CAAA"}
1
+ {"version":3,"file":"node-renderer.d.ts","sourceRoot":"","sources":["../../../src/components/renderers/node-renderer.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,OAAO,EAIZ,KAAK,cAAc,EAEpB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,KAAK,aAAa,EAAqD,MAAM,OAAO,CAAA;AAO7F,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,cAAc,CAAC,OAAO,CAAC,GAC9B,aAAa,CAAC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,IAAI,CASzC;AAED,eAAO,MAAM,YAAY,GAAI,YAAY;IAAE,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,uCA+BjE,CAAA"}
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { isNodeKindEnabled, nodeRegistry, useScene, } from '@pascal-app/core';
4
- import { lazy, Suspense } from 'react';
3
+ import { isNodeKindEnabled, nodeRegistry, onRegistryChange, useScene, } from '@pascal-app/core';
4
+ import { lazy, Suspense, useCallback, useSyncExternalStore } from 'react';
5
5
  import { ParametricNodeRenderer } from './parametric-node-renderer';
6
6
  // Cache lazy components by their RendererSource so React.lazy isn't re-invoked
7
7
  // on every render — that would create a new Suspense boundary each time.
@@ -21,11 +21,16 @@ export function getRegistryRenderer(source) {
21
21
  export const NodeRenderer = ({ nodeId }) => {
22
22
  const node = useScene((state) => state.nodes[nodeId]);
23
23
  const installedPlugins = useScene((state) => state.installedPlugins);
24
+ // Plugins register after the first mount (async discovery). Subscribe to this
25
+ // node's own kind only: a registration re-renders the nodes of that kind and
26
+ // leaves every other mounted node alone.
27
+ const kind = node?.type;
28
+ const readDefinition = useCallback(() => (kind ? nodeRegistry.get(kind) : undefined), [kind]);
29
+ const def = useSyncExternalStore(onRegistryChange, readDefinition, readDefinition);
24
30
  if (!node)
25
31
  return null;
26
32
  if (!isNodeKindEnabled(node.type, installedPlugins))
27
33
  return null;
28
- const def = nodeRegistry.get(node.type);
29
34
  if (!def)
30
35
  return null;
31
36
  // Two-checkbox dispatch (see wiki/architecture/node-definitions.md):
@@ -1,6 +1,7 @@
1
1
  type FrameLimiterProps = {
2
2
  fps?: number;
3
3
  paused?: boolean;
4
+ onFrameError?: (cause: unknown) => void;
4
5
  };
5
6
  export type FrameClock = {
6
7
  sample: (wallTimeMs: number, intervalMs: number) => number | null;
@@ -1 +1 @@
1
- {"version":3,"file":"frame-limiter.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/frame-limiter.tsx"],"names":[],"mappings":"AAKA,KAAK,iBAAiB,GAAG;IACvB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IACjE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAA;CAClC,CAAA;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,SAAI,GAAG,UAAU,CAwB5D;AAgBD,QAAA,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAoF7C,CAAA;AAED,eAAe,YAAY,CAAA"}
1
+ {"version":3,"file":"frame-limiter.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/frame-limiter.tsx"],"names":[],"mappings":"AAKA,KAAK,iBAAiB,GAAG;IACvB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CACxC,CAAA;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IACjE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAA;CAClC,CAAA;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,SAAI,GAAG,UAAU,CAwB5D;AAgBD,QAAA,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAmG7C,CAAA;AAED,eAAe,YAAY,CAAA"}
@@ -40,8 +40,10 @@ const DRAW_DISABLED = typeof window !== 'undefined' &&
40
40
  new Set((new URLSearchParams(window.location.search).get('disable') ?? '')
41
41
  .split(',')
42
42
  .map((s) => s.trim())).has('draw');
43
- const FrameLimiter = ({ fps = 50, paused = false }) => {
44
- const { advance, set, frameloop: initFrameloop } = useThree();
43
+ const FrameLimiter = ({ fps = 50, paused = false, onFrameError }) => {
44
+ const { advance: advanceFrame, set, frameloop: initFrameloop } = useThree();
45
+ const onFrameErrorRef = useRef(onFrameError);
46
+ onFrameErrorRef.current = onFrameError;
45
47
  const nextFrameTimeRef = useRef(0);
46
48
  const renderer = useThree((state) => state.gl);
47
49
  const size = useThree((state) => state.size);
@@ -55,6 +57,23 @@ const FrameLimiter = ({ fps = 50, paused = false }) => {
55
57
  let raf = null;
56
58
  let timer = null;
57
59
  let sizeSynced = false;
60
+ // A system that throws inside useFrame runs outside React's error
61
+ // boundaries: report the first failure to the host and keep the loop alive.
62
+ let frameErrorReported = false;
63
+ function advance(frameTime) {
64
+ try {
65
+ advanceFrame(frameTime);
66
+ }
67
+ catch (error) {
68
+ if (frameErrorReported)
69
+ return;
70
+ frameErrorReported = true;
71
+ if (onFrameErrorRef.current)
72
+ onFrameErrorRef.current(error);
73
+ else
74
+ console.error('[viewer] a frame callback threw', error);
75
+ }
76
+ }
58
77
  const effectiveFps = Number.isFinite(fps) && fps > 0 ? fps : 50;
59
78
  const interval = 1000 / effectiveFps;
60
79
  function syncSize() {
@@ -115,7 +134,7 @@ const FrameLimiter = ({ fps = 50, paused = false }) => {
115
134
  set({ frameloop: initFrameloop });
116
135
  };
117
136
  }, [
118
- advance,
137
+ advanceFrame,
119
138
  dpr,
120
139
  fps,
121
140
  initFrameloop,
@@ -1,5 +1,6 @@
1
1
  import { type AnyNodeId, type Interactive, type SceneGraph } from '@pascal-app/core';
2
- import { type AnimationAction, type Object3D } from 'three';
2
+ import { type EvaluatedLight, type ProceduralItemNode } from '@pascal-app/core/procedural-items';
3
+ import { type AnimationAction, type Object3D, Vector3 } from 'three';
3
4
  /** An interactive item recovered from the scene graph so the baked GLB can be
4
5
  * re-lit / re-animated by joining on `pascalId`. The GLB carries the geometry
5
6
  * + identity; the effects + controls live in the DB scene graph (no sidecar). */
@@ -9,17 +10,29 @@ export type GlbInteractiveItem = {
9
10
  /** Item height (world units) for placing the controls overlay above it. */
10
11
  height: number;
11
12
  interactive: Interactive;
13
+ /** Built from a script: its clips keep the script's names (`<pascalId>: <name>`
14
+ * in the GLB) and its toggles start at their own defaults. */
15
+ scripted?: boolean;
16
+ localScale?: [number, number, number];
17
+ procedural?: {
18
+ lights: EvaluatedLight[];
19
+ parts: ProceduralItemNode['recipe']['parts'];
20
+ /** v2: the recipe's parts and joints, so non-fixed joints get controls too. */
21
+ recipe?: Pick<ProceduralItemNode['recipe'], 'parts' | 'joints'>;
22
+ };
12
23
  };
13
24
  /** A baked zone's identity node + its local floor polygon (from `extras`). */
14
25
  export type GlbZoneRef = {
15
26
  id: string;
16
27
  node: Object3D;
17
28
  polygon: [number, number][];
29
+ holes?: [number, number][][];
18
30
  };
19
31
  /** Pull the interactive items out of a scene graph. Only items that actually
20
32
  * carry effects (light / animation) are returned — everything else baked
21
33
  * faithfully and needs no runtime help. */
22
34
  export declare function buildGlbInteractiveItems(sceneGraph: SceneGraph | null | undefined): GlbInteractiveItem[];
35
+ export declare function buildGlbLightRegs(items: GlbInteractiveItem[], identity: Map<string, Object3D>): GlbLightReg[];
23
36
  /**
24
37
  * Re-creates the item-driven interactivity the parametric viewer has — pooled
25
38
  * lights, ambient animation, and the controls overlay — on top of a baked GLB.
@@ -38,4 +51,15 @@ export declare function GlbInteractive({ items, identity, zones, actions, levelO
38
51
  * lights when nothing is focused (mirrors the parametric level factor). */
39
52
  levelOrder: string[];
40
53
  }): import("react").JSX.Element;
54
+ export type GlbLightReg = {
55
+ key: string;
56
+ nodeId: AnyNodeId;
57
+ object: Object3D;
58
+ color: string;
59
+ distance: number;
60
+ getWorldPosition: (out: Vector3) => void;
61
+ getIntensity: () => number;
62
+ isOn: () => boolean;
63
+ levelId: string | null;
64
+ };
41
65
  //# sourceMappingURL=glb-interactive.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"glb-interactive.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/glb-interactive.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,SAAS,EACd,KAAK,WAAW,EAGhB,KAAK,UAAU,EAGhB,MAAM,kBAAkB,CAAA;AAIzB,OAAO,EACL,KAAK,eAAe,EAGpB,KAAK,QAAQ,EAGd,MAAM,OAAO,CAAA;AAKd;;kFAEkF;AAClF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,SAAS,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,WAAW,CAAA;CACzB,CAAA;AAED,8EAA8E;AAC9E,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,QAAQ,CAAA;IACd,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAA;CAC5B,CAAA;AAED;;4CAE4C;AAC5C,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GACxC,kBAAkB,EAAE,CAuBtB;AAID;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,OAAO,EACP,UAAU,GACX,EAAE;IACD,KAAK,EAAE,kBAAkB,EAAE,CAAA;IAC3B,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IAC/B,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB;8BAC0B;IAC1B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC,CAAA;IAC/C;gFAC4E;IAC5E,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB,+BA6FA"}
1
+ {"version":3,"file":"glb-interactive.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/glb-interactive.tsx"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,SAAS,EAEd,KAAK,WAAW,EAGhB,KAAK,UAAU,EAOhB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,KAAK,cAAc,EAEnB,KAAK,kBAAkB,EACxB,MAAM,mCAAmC,CAAA;AAI1C,OAAO,EACL,KAAK,eAAe,EAGpB,KAAK,QAAQ,EAEb,OAAO,EACR,MAAM,OAAO,CAAA;AAcd;;kFAEkF;AAClF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,SAAS,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,WAAW,CAAA;IACxB;mEAC+D;IAC/D,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,UAAU,CAAC,EAAE;QACX,MAAM,EAAE,cAAc,EAAE,CAAA;QACxB,KAAK,EAAE,kBAAkB,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,CAAA;QAC5C,+EAA+E;QAC/E,MAAM,CAAC,EAAE,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC,CAAA;KAChE,CAAA;CACF,CAAA;AAED,8EAA8E;AAC9E,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,QAAQ,CAAA;IACd,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAA;IAC3B,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,EAAE,CAAA;CAC7B,CAAA;AAED;;4CAE4C;AAC5C,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GACxC,kBAAkB,EAAE,CAiEtB;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,kBAAkB,EAAE,EAC3B,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,GAC9B,WAAW,EAAE,CAiFf;AAID;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,OAAO,EACP,UAAU,GACX,EAAE;IACD,KAAK,EAAE,kBAAkB,EAAE,CAAA;IAC3B,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IAC/B,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB;8BAC0B;IAC1B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC,CAAA;IAC/C;gFAC4E;IAC5E,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB,+BA0FA;AA8CD,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,SAAS,CAAA;IACjB,MAAM,EAAE,QAAQ,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;IACxC,YAAY,EAAE,MAAM,MAAM,CAAA;IAC1B,IAAI,EAAE,MAAM,OAAO,CAAA;IACnB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,CAAA"}
@@ -1,13 +1,19 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { pointInPolygon, useInteractive, } from '@pascal-app/core';
3
+ import { containsPoint, isScriptedNode, scriptedOrigin, scriptedSize, scriptInteractive, useInteractive, } from '@pascal-app/core';
4
+ import { evaluateRecipe, } from '@pascal-app/core/procedural-items';
4
5
  import { Html } from '@react-three/drei';
5
- import { createPortal, useFrame } from '@react-three/fiber';
6
- import { useEffect, useMemo, useRef, useState } from 'react';
6
+ import { createPortal, useFrame, useThree } from '@react-three/fiber';
7
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
7
8
  import { LoopRepeat, MathUtils, Vector3, } from 'three';
8
9
  import { useShallow } from 'zustand/react/shallow';
10
+ import { SCENE_LAYER } from '../../lib/layers';
11
+ import { decorateProceduralEmission, proceduralSlotMeshes, setProceduralEmission, } from '../../lib/procedural-emission';
12
+ import { useItemLightPool } from '../../store/use-item-light-pool';
9
13
  import useViewer from '../../store/use-viewer';
10
14
  import { ControlWidget } from '../../systems/interactive/control-widget';
15
+ import { proceduralControlDescriptors } from '../../systems/interactive/procedural-controls';
16
+ import { ScriptedClips } from '../../systems/interactive/scripted-clips';
11
17
  /** Pull the interactive items out of a scene graph. Only items that actually
12
18
  * carry effects (light / animation) are returned — everything else baked
13
19
  * faithfully and needs no runtime help. */
@@ -18,6 +24,48 @@ export function buildGlbInteractiveItems(sceneGraph) {
18
24
  const items = [];
19
25
  for (const [id, raw] of Object.entries(nodes)) {
20
26
  const node = raw;
27
+ if (node?.type === 'procedural-item') {
28
+ const procedural = raw;
29
+ const evaluation = evaluateRecipe(procedural.recipe, procedural.parameters);
30
+ if (!evaluation.lights.length && !evaluation.motions.length)
31
+ continue;
32
+ items.push({
33
+ pascalId: id,
34
+ label: procedural.name ?? id,
35
+ height: evaluation.max[1],
36
+ interactive: { controls: [], effects: [] },
37
+ procedural: {
38
+ lights: evaluation.lights,
39
+ parts: procedural.recipe.parts,
40
+ recipe: procedural.recipe,
41
+ },
42
+ });
43
+ continue;
44
+ }
45
+ // A node built from a script: its controls come from what the script
46
+ // emitted, as in the editor.
47
+ if (isScriptedNode(raw)) {
48
+ const scripted = raw;
49
+ const interactive = scriptInteractive(scripted.source.manifest, scriptedOrigin(scripted));
50
+ if (!interactive)
51
+ continue;
52
+ const [, height] = scriptedSize(scripted.source.manifest);
53
+ items.push({
54
+ pascalId: id,
55
+ label: scripted.name ??
56
+ (scripted.type === 'item'
57
+ ? scripted.asset.name
58
+ : `${scripted.type[0].toUpperCase()}${scripted.type.slice(1)}`),
59
+ // A window or door is placed by its centre, everything else by its base.
60
+ height: scripted.type === 'window' || scripted.type === 'door'
61
+ ? height / 2
62
+ : height * (scripted.type === 'item' ? (scripted.scale?.[1] ?? 1) : 1),
63
+ interactive,
64
+ scripted: true,
65
+ localScale: scripted.type === 'item' ? scripted.scale : [1, 1, 1],
66
+ });
67
+ continue;
68
+ }
21
69
  if (node?.type !== 'item')
22
70
  continue;
23
71
  const interactive = node.asset?.interactive;
@@ -34,6 +82,86 @@ export function buildGlbInteractiveItems(sceneGraph) {
34
82
  }
35
83
  return items;
36
84
  }
85
+ export function buildGlbLightRegs(items, identity) {
86
+ const regs = [];
87
+ for (const item of items) {
88
+ const object = identity.get(item.pascalId);
89
+ if (!object)
90
+ continue;
91
+ if (item.procedural) {
92
+ const groups = new Map();
93
+ object.traverse((child) => {
94
+ const motion = child.userData.proceduralMotion;
95
+ if (motion?.groupId)
96
+ groups.set(motion.groupId, child);
97
+ });
98
+ for (const light of item.procedural.lights) {
99
+ const motion = light.motionGroup ? groups.get(light.motionGroup) : undefined;
100
+ const local = new Vector3(...light.position);
101
+ // A nested group's position is relative to its parent group: the design-space pivot
102
+ // is the sum along its chain of motion groups.
103
+ for (let group = motion; group && group !== object; group = group.parent ?? undefined)
104
+ if (group.userData.proceduralMotion)
105
+ local.sub(group.position);
106
+ regs.push({
107
+ key: `${item.pascalId}:procedural:${light.id}`,
108
+ nodeId: item.pascalId,
109
+ object,
110
+ color: light.color,
111
+ distance: light.distance,
112
+ getWorldPosition: (out) => {
113
+ const anchor = motion ?? object;
114
+ anchor.updateWorldMatrix(true, false);
115
+ out.copy(local).applyMatrix4(anchor.matrixWorld);
116
+ },
117
+ getIntensity: () => light.intensity,
118
+ isOn: () => useInteractive.getState().procedural[item.pascalId]?.lightsOn ??
119
+ useInteractive.getState().lampDefault,
120
+ levelId: findLevelId(object),
121
+ });
122
+ }
123
+ }
124
+ const controls = item.interactive.controls;
125
+ const toggleIndex = controls.findIndex((control) => control.kind === 'toggle');
126
+ const sliderIndex = controls.findIndex((control) => control.kind === 'slider');
127
+ const slider = sliderIndex >= 0 ? controls[sliderIndex] : null;
128
+ item.interactive.effects.forEach((effect, index) => {
129
+ if (effect.kind !== 'light')
130
+ return;
131
+ regs.push({
132
+ key: `${item.pascalId}:${index}`,
133
+ nodeId: item.pascalId,
134
+ object,
135
+ color: effect.color,
136
+ distance: effect.distance ?? 0,
137
+ getWorldPosition: (out) => {
138
+ object.updateWorldMatrix(true, false);
139
+ if (item.scripted) {
140
+ const scale = item.localScale ?? [1, 1, 1];
141
+ out
142
+ .set(effect.offset[0] * scale[0], effect.offset[1] * scale[1], effect.offset[2] * scale[2])
143
+ .applyMatrix4(object.matrixWorld);
144
+ return;
145
+ }
146
+ object.getWorldPosition(out);
147
+ out.set(out.x + effect.offset[0], out.y + effect.offset[1], out.z + effect.offset[2]);
148
+ },
149
+ getIntensity: () => {
150
+ const values = useInteractive.getState().items[item.pascalId]?.controlValues;
151
+ const raw = slider ? (values?.[sliderIndex] ?? slider.min) : 1;
152
+ const fraction = slider && slider.max > slider.min ? (raw - slider.min) / (slider.max - slider.min) : 1;
153
+ return MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], fraction);
154
+ },
155
+ isOn: () => {
156
+ const values = useInteractive.getState().items[item.pascalId]?.controlValues;
157
+ return toggleIndex < 0 || Boolean(values?.[toggleIndex]);
158
+ },
159
+ levelId: findLevelId(object),
160
+ });
161
+ });
162
+ }
163
+ return regs;
164
+ }
37
165
  const _itemPos = new Vector3();
38
166
  /**
39
167
  * Re-creates the item-driven interactivity the parametric viewer has — pooled
@@ -43,18 +171,26 @@ const _itemPos = new Vector3();
43
171
  * the GLB itself, so the artifact stays integrator-clean.
44
172
  */
45
173
  export function GlbInteractive({ items, identity, zones, actions, levelOrder, }) {
46
- // Seed control state for every interactive item. The viewer shows a baked
47
- // scene "lit": toggles default ON (the editor defaults them off) and sliders
48
- // to their authored default, so lamps glow and fans spin on load. Explicit
49
- // overlay toggles then win. Cleared on unmount so the global store never
50
- // carries state across scenes.
174
+ const scene = useThree((state) => state.scene);
175
+ useLayoutEffect(() => {
176
+ useItemLightPool.getState().setBakedCanvas(scene, true);
177
+ return () => useItemLightPool.getState().setBakedCanvas(scene, false);
178
+ }, [scene]);
179
+ // Baked catalog animation toggles start on; light toggles follow the current
180
+ // theme; an authored object's toggles keep their own defaults, as in the
181
+ // editor. Clear per-item state on unmount so it cannot carry into another scene.
51
182
  useEffect(() => {
52
183
  const store = useInteractive.getState();
53
184
  for (const item of items) {
54
- store.initItem(item.pascalId, item.interactive);
55
- item.interactive.controls.forEach((control, i) => {
56
- if (control.kind === 'toggle')
57
- store.setControlValue(item.pascalId, i, true);
185
+ store.initItem(item.pascalId, item.interactive, true);
186
+ if (item.scripted)
187
+ continue;
188
+ const lampIndex = item.interactive.effects.some((effect) => effect.kind === 'light')
189
+ ? item.interactive.controls.findIndex((control) => control.kind === 'toggle')
190
+ : -1;
191
+ item.interactive.controls.forEach((control, index) => {
192
+ if (control.kind === 'toggle' && index !== lampIndex)
193
+ store.setControlValue(item.pascalId, index, true);
58
194
  });
59
195
  }
60
196
  return () => {
@@ -64,40 +200,12 @@ export function GlbInteractive({ items, identity, zones, actions, levelOrder, })
64
200
  };
65
201
  }, [items]);
66
202
  const animationItems = useMemo(() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')), [items]);
67
- // Light registrations: one per item with a light effect, joined to its baked
68
- // node. Fed to a fixed pool (below) rather than mounting a light per item.
69
- const lightRegs = useMemo(() => {
70
- const regs = [];
71
- for (const item of items) {
72
- const effect = item.interactive.effects.find((e) => e.kind === 'light');
73
- if (!effect)
74
- continue;
75
- const object = identity.get(item.pascalId);
76
- if (!object)
77
- continue;
78
- const controls = item.interactive.controls;
79
- const toggleIndex = controls.findIndex((c) => c.kind === 'toggle');
80
- const sliderIndex = controls.findIndex((c) => c.kind === 'slider');
81
- const slider = sliderIndex >= 0 ? controls[sliderIndex] : null;
82
- regs.push({
83
- key: item.pascalId,
84
- object,
85
- effect,
86
- toggleIndex,
87
- sliderIndex,
88
- hasSlider: !!slider,
89
- sliderMin: slider?.min ?? 0,
90
- sliderMax: slider?.max ?? 1,
91
- levelId: findLevelId(object),
92
- });
93
- }
94
- return regs;
95
- }, [items, identity]);
203
+ const lightRegs = useMemo(() => buildGlbLightRegs(items, identity), [items, identity]);
96
204
  const levelIndexById = useMemo(() => new Map(levelOrder.map((id, i) => [id, i])), [levelOrder]);
97
- // Controls overlay is scoped to the focused zone (matches the parametric
98
- // viewer). Project the zone's baked-local polygon into world space once so an
99
- // item's world position can be point-tested regardless of level stacking.
205
+ // Project the zone's baked-local polygon into world space so focused zone
206
+ // membership still works after level stacking moves its parent.
100
207
  const focusedZoneId = useViewer((s) => s.selection.zoneId);
208
+ const selectedIds = useViewer((s) => s.selection.selectedIds);
101
209
  const worldPolygon = useMemo(() => {
102
210
  if (!focusedZoneId)
103
211
  return null;
@@ -105,16 +213,51 @@ export function GlbInteractive({ items, identity, zones, actions, levelOrder, })
105
213
  if (!zone)
106
214
  return null;
107
215
  zone.node.updateWorldMatrix(true, false);
108
- return zone.polygon.map(([x, z]) => {
216
+ const project = (ring) => ring.map(([x, z]) => {
109
217
  const v = new Vector3(x, 0, z).applyMatrix4(zone.node.matrixWorld);
110
218
  return [v.x, v.z];
111
219
  });
220
+ return { outer: project(zone.polygon), holes: (zone.holes ?? []).map(project) };
112
221
  }, [focusedZoneId, zones]);
113
- return (_jsxs(_Fragment, { children: [_jsx(GlbItemLights, { levelIndexById: levelIndexById, regs: lightRegs }), animationItems.map((item) => (_jsx(GlbItemAnimation, { actions: actions, item: item }, item.pascalId))), items.map((item) => {
222
+ return (_jsxs(_Fragment, { children: [_jsx(GlbItemLights, { levelIndexById: levelIndexById, regs: lightRegs }), animationItems.map((item) => item.scripted ? (_jsx(GlbScriptedClips, { actions: actions, item: item }, item.pascalId)) : (_jsx(GlbItemAnimation, { actions: actions, item: item }, item.pascalId))), items
223
+ .filter((item) => item.procedural?.lights.length)
224
+ .map((item) => {
225
+ const object = identity.get(item.pascalId);
226
+ return object ? (_jsx(GlbProceduralEmission, { item: item, object: object }, item.pascalId)) : null;
227
+ }), items
228
+ .filter((item) => worldPolygon || selectedIds.includes(item.pascalId))
229
+ .map((item) => {
114
230
  const object = identity.get(item.pascalId);
115
- return object ? (_jsx(GlbItemControls, { item: item, object: object, worldPolygon: worldPolygon }, item.pascalId)) : null;
231
+ return object ? (_jsx(GlbItemControls, { isSelected: selectedIds.includes(item.pascalId), item: item, object: object, worldPolygon: worldPolygon }, item.pascalId)) : null;
116
232
  })] }));
117
233
  }
234
+ function GlbProceduralEmission({ item, object }) {
235
+ useEffect(() => {
236
+ const lights = item.procedural?.lights ?? [];
237
+ const restore = decorateProceduralEmission(object, lights, true);
238
+ const update = () => {
239
+ const on = useInteractive.getState().procedural[item.pascalId]?.lightsOn ??
240
+ useInteractive.getState().lampDefault;
241
+ const slots = new Set(lights.map((light) => light.emissiveSlot).filter(Boolean));
242
+ for (const mesh of proceduralSlotMeshes(object, slots)) {
243
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
244
+ for (const material of materials)
245
+ setProceduralEmission(material, on);
246
+ }
247
+ };
248
+ update();
249
+ const unsubscribe = useInteractive.subscribe((state, previous) => {
250
+ if (state.procedural[item.pascalId] !== previous.procedural[item.pascalId] ||
251
+ state.lampDefault !== previous.lampDefault)
252
+ update();
253
+ });
254
+ return () => {
255
+ unsubscribe();
256
+ restore();
257
+ };
258
+ }, [item, object]);
259
+ return null;
260
+ }
118
261
  // ── Pooled item lights ──────────────────────────────────────────────────────
119
262
  //
120
263
  // Mirrors the parametric `ItemLightSystem`: a fixed pool of point lights is
@@ -142,15 +285,21 @@ function findLevelId(object) {
142
285
  }
143
286
  return null;
144
287
  }
145
- function scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState) {
146
- // Toggled-off lights contribute no illumination — drop them from the pool.
147
- if (reg.toggleIndex >= 0 && !interactiveState.items[reg.key]?.controlValues?.[reg.toggleIndex]) {
148
- return Number.POSITIVE_INFINITY;
288
+ function isRendered(object) {
289
+ let current = object;
290
+ while (current) {
291
+ if (!current.visible || !current.layers.isEnabled(SCENE_LAYER))
292
+ return false;
293
+ current = current.parent;
149
294
  }
150
- reg.object.getWorldPosition(_lightWorld);
151
- _lightWorld.x += reg.effect.offset[0];
152
- _lightWorld.y += reg.effect.offset[1];
153
- _lightWorld.z += reg.effect.offset[2];
295
+ return true;
296
+ }
297
+ function scoreReg(reg, selectedLevelId, levelMode, levelIndexById) {
298
+ if (!reg.isOn() || !isRendered(reg.object))
299
+ return Number.POSITIVE_INFINITY;
300
+ if (selectedLevelId && reg.levelId !== selectedLevelId && levelMode === 'solo')
301
+ return Number.POSITIVE_INFINITY;
302
+ reg.getWorldPosition(_lightWorld);
154
303
  _dir.copy(_lightWorld).sub(_camPos).normalize();
155
304
  const angular = 1 - _camFwd.dot(_dir);
156
305
  const dist = _camPos.distanceTo(_lightWorld) / 200;
@@ -173,7 +322,6 @@ function GlbItemLights({ regs, levelIndexById, }) {
173
322
  const regByKey = useMemo(() => new Map(regs.map((r) => [r.key, r])), [regs]);
174
323
  useFrame(({ camera }, delta) => {
175
324
  const dt = Math.min(delta, 0.1);
176
- const interactiveState = useInteractive.getState();
177
325
  camera.getWorldPosition(_camPos);
178
326
  camera.getWorldDirection(_camFwd);
179
327
  const camMoved = _camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST ||
@@ -188,7 +336,7 @@ function GlbItemLights({ regs, levelIndexById, }) {
188
336
  const levelMode = viewer.levelMode;
189
337
  const scored = regs.map((reg) => ({
190
338
  key: reg.key,
191
- score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState),
339
+ score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById),
192
340
  }));
193
341
  scored.sort((a, b) => a.score - b.score);
194
342
  const scoreByKey = new Map(scored.map((s) => [s.key, s.score]));
@@ -244,8 +392,8 @@ function GlbItemLights({ regs, levelIndexById, }) {
244
392
  const light = lightRefs.current[freeSlot];
245
393
  const reg = regByKey.get(key);
246
394
  if (light && reg) {
247
- light.color.set(reg.effect.color);
248
- light.distance = reg.effect.distance ?? 0;
395
+ light.color.set(reg.color);
396
+ light.distance = reg.distance;
249
397
  }
250
398
  }
251
399
  }
@@ -283,8 +431,8 @@ function GlbItemLights({ regs, levelIndexById, }) {
283
431
  if (slot.key) {
284
432
  const reg = regByKey.get(slot.key);
285
433
  if (reg) {
286
- light.color.set(reg.effect.color);
287
- light.distance = reg.effect.distance ?? 0;
434
+ light.color.set(reg.color);
435
+ light.distance = reg.distance;
288
436
  }
289
437
  }
290
438
  }
@@ -299,24 +447,12 @@ function GlbItemLights({ regs, levelIndexById, }) {
299
447
  slot.key = null;
300
448
  continue;
301
449
  }
302
- reg.object.getWorldPosition(_lightWorld);
303
- light.position.set(_lightWorld.x + reg.effect.offset[0], _lightWorld.y + reg.effect.offset[1], _lightWorld.z + reg.effect.offset[2]);
304
- const values = interactiveState.items[reg.key]?.controlValues;
305
- const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true;
306
- let t = 1;
307
- if (reg.hasSlider) {
308
- const raw = values?.[reg.sliderIndex] ?? reg.sliderMin;
309
- t =
310
- reg.sliderMax > reg.sliderMin
311
- ? (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin)
312
- : 1;
313
- }
314
- const targetIntensity = isOn
315
- ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)
316
- : reg.effect.intensityRange[0];
450
+ reg.getWorldPosition(_lightWorld);
451
+ light.position.copy(_lightWorld);
452
+ const targetIntensity = reg.isOn() && isRendered(reg.object) ? reg.getIntensity() : 0;
317
453
  light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12);
318
454
  }
319
- });
455
+ }, 6);
320
456
  return (_jsx(_Fragment, { children: Array.from({ length: POOL_SIZE }, (_, i) => (_jsx("pointLight", { castShadow: false, intensity: 0, ref: (el) => {
321
457
  lightRefs.current[i] = el;
322
458
  } }, i))) }));
@@ -345,16 +481,47 @@ function GlbItemAnimation({ item, actions, }) {
345
481
  }, [actions, item.pascalId, isOn]);
346
482
  return null;
347
483
  }
484
+ /** Plays an authored object's baked clips through the shared `ScriptedClips`,
485
+ * addressing each by the name the script gave it. */
486
+ function GlbScriptedClips({ item, actions, }) {
487
+ // drei creates each action on first read, once the scene root is mounted, so
488
+ // forward reads instead of copying the actions during render.
489
+ const scriptActions = useMemo(() => {
490
+ const prefix = `${item.pascalId}: `;
491
+ const forwarded = {};
492
+ for (const name of Object.keys(actions)) {
493
+ if (!name.startsWith(prefix))
494
+ continue;
495
+ Object.defineProperty(forwarded, name.slice(prefix.length), {
496
+ enumerable: true,
497
+ get: () => actions[name],
498
+ });
499
+ }
500
+ return forwarded;
501
+ }, [actions, item.pascalId]);
502
+ return (_jsx(ScriptedClips, { actions: scriptActions, interactive: item.interactive, nodeId: item.pascalId }));
503
+ }
348
504
  const FADE_MS = 300;
349
- /** Controls overlay for one item — fades in while the item sits inside the
350
- * focused zone, portaled above the baked node. */
351
- function GlbItemControls({ item, object, worldPolygon, }) {
505
+ /** Controls overlay for a selected item or one inside the focused zone. */
506
+ function GlbItemControls({ item, object, worldPolygon, isSelected, }) {
352
507
  const controlValues = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues));
508
+ const proceduralState = useInteractive((s) => s.procedural[item.pascalId]);
509
+ const lampDefault = useInteractive((s) => s.lampDefault);
353
510
  const setControlValue = useInteractive((s) => s.setControlValue);
354
- let visible = false;
355
- if (worldPolygon?.length) {
511
+ const togglePart = useInteractive((s) => s.toggleProceduralPart);
512
+ const toggleLights = useInteractive((s) => s.toggleProceduralLights);
513
+ const descriptors = item.procedural
514
+ ? proceduralControlDescriptors(item.procedural.recipe ?? { parts: item.procedural.parts }, proceduralState, (partId) => togglePart(item.pascalId, partId), () => toggleLights(item.pascalId), lampDefault)
515
+ : item.interactive.controls.map((control, index) => ({
516
+ key: String(index),
517
+ control,
518
+ value: controlValues?.[index] ?? false,
519
+ onChange: (value) => setControlValue(item.pascalId, index, value),
520
+ }));
521
+ let visible = isSelected;
522
+ if (worldPolygon) {
356
523
  object.getWorldPosition(_itemPos);
357
- visible = pointInPolygon(_itemPos.x, _itemPos.z, worldPolygon);
524
+ visible = visible || containsPoint([worldPolygon], [_itemPos.x, _itemPos.z]);
358
525
  }
359
526
  // Fade in on mount and fade out before unmounting the <Html>.
360
527
  const [mounted, setMounted] = useState(false);
@@ -375,7 +542,7 @@ function GlbItemControls({ item, object, worldPolygon, }) {
375
542
  const timeout = setTimeout(() => setMounted(false), FADE_MS);
376
543
  return () => clearTimeout(timeout);
377
544
  }, [visible]);
378
- if (!(mounted && controlValues))
545
+ if (!(mounted && descriptors.length))
379
546
  return null;
380
547
  return createPortal(_jsx(Html, { center: true, distanceFactor: 8, eps: -1, position: [0, item.height + 0.3, 0], zIndexRange: [20, 0], children: _jsx("div", { onClick: (e) => e.stopPropagation(), onPointerDown: (e) => e.stopPropagation(), onPointerUp: (e) => e.stopPropagation(), style: {
381
548
  display: 'flex',
@@ -390,5 +557,5 @@ function GlbItemControls({ item, object, worldPolygon, }) {
390
557
  userSelect: 'none',
391
558
  opacity: shown ? 1 : 0,
392
559
  transition: `opacity ${FADE_MS}ms ease`,
393
- }, children: item.interactive.controls.map((control, i) => (_jsx(ControlWidget, { control: control, onChange: (v) => setControlValue(item.pascalId, i, v), value: controlValues[i] ?? false }, i))) }) }), object);
560
+ }, children: descriptors.map((descriptor) => (_jsx(ControlWidget, { control: descriptor.control, onChange: descriptor.onChange, value: descriptor.value }, descriptor.key))) }) }), object);
394
561
  }
@@ -0,0 +1,10 @@
1
+ import type { Object3D } from 'three';
2
+ /**
3
+ * Baked nodes whose loop clips no other controller plays (a plugin kind's
4
+ * mechanism, such as an articulated asset's joints), read from the GLB alone:
5
+ * `pascalId` → its `: loop` clip names. `owned` holds the nodes procedural
6
+ * playback or the scene graph's interactive items already drive; openables
7
+ * keep their open/close path.
8
+ */
9
+ export declare function bakedLoopMechanisms(identity: ReadonlyMap<string, Object3D>, owned: ReadonlySet<string>): Map<string, string[]>;
10
+ //# sourceMappingURL=glb-mechanisms.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"glb-mechanisms.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/glb-mechanisms.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAErC;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EACvC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,GACzB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAWvB"}