@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,13 +1,13 @@
1
- import { Color, Matrix4, UnsignedByteType } from 'three';
1
+ import { Color, Matrix4, SRGBColorSpace, UnsignedByteType } from 'three';
2
2
  import { ssgi } from 'three/addons/tsl/display/SSGINode.js';
3
3
  import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js';
4
4
  import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js';
5
- import { convertToTexture, diffuseColor, float, mix, mrt, normalView, output, sample, saturation, screenUV, smoothstep, uniform, vec3, vec4, } from 'three/tsl';
5
+ import { clamp, convertToTexture, diffuseColor, float, mix, mrt, normalView, output, renderOutput, sample, saturation, screenUV, smoothstep, uniform, vec2, vec3, vec4, } from 'three/tsl';
6
6
  import { PassNode, RenderPipeline, RenderTarget, } from 'three/webgpu';
7
7
  import { GRADE_PARAMS, SSGI_PARAMS } from '../components/viewer/post-processing';
8
8
  import { backdropGradient, deepSkyColor, horizonHazeColor } from './backdrop';
9
9
  import { edgeColorFor, edgeOpacityScaleFor } from './edge-style';
10
- import { inkedEdges } from './ink-edges';
10
+ import { createEdgeDepthSampler, inkedEdges } from './ink-edges';
11
11
  import { getSceneTheme } from './scene-themes';
12
12
  import { packNormalToRGB, unpackRGBToNormal } from './tsl-compat';
13
13
  export const THUMBNAIL_WIDTH = 1920;
@@ -29,6 +29,11 @@ function clampSnapshotSize(width, height) {
29
29
  const scale = SNAPSHOT_MAX_EDGE / maxEdge;
30
30
  return { w: Math.round(width * scale), h: Math.round(height * scale) };
31
31
  }
32
+ function srgbHexToVec3(hex) {
33
+ const color = new Color(hex);
34
+ const { r, g, b } = color.getRGB({ r: 0, g: 0, b: 0 }, SRGBColorSpace);
35
+ return vec3(r, g, b);
36
+ }
32
37
  class SnapshotScenePass extends PassNode {
33
38
  updateBefore(frame) {
34
39
  const renderer = frame.renderer;
@@ -44,7 +49,7 @@ class SnapshotScenePass extends PassNode {
44
49
  }
45
50
  }
46
51
  }
47
- export async function createSnapshotPipeline({ renderer, scene, camera, atmosphere = null, }) {
52
+ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphere = null, studioBackdrop, }) {
48
53
  try {
49
54
  if (renderer.init)
50
55
  await renderer.init();
@@ -74,6 +79,7 @@ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphe
74
79
  }));
75
80
  const scenePassColor = scenePass.getTextureNode('output');
76
81
  const scenePassDepth = scenePass.getTextureNode('depth');
82
+ const sampleEdgeDepth = createEdgeDepthSampler(scenePassDepth, camera);
77
83
  const scenePassNormal = scenePass.getTextureNode('normal');
78
84
  scenePass.getTexture('diffuseColor').type = UnsignedByteType;
79
85
  scenePass.getTexture('normal').type = UnsignedByteType;
@@ -99,7 +105,7 @@ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphe
99
105
  denoisePass.radius.value = 4;
100
106
  // Same far-field AO fade as the viewport pipeline — without it the
101
107
  // horizon picks up a visible AO line in captures.
102
- const aoFarFade = smoothstep(float(0.9994), float(0.9998), scenePassDepth.sample(screenUV).r);
108
+ const aoFarFade = smoothstep(float(0.9994), float(0.9998), sampleEdgeDepth(screenUV));
103
109
  const ao = mix(denoisePass.r, float(1), aoFarFade);
104
110
  const aoRgb = scenePassColor.rgb.mul(ao);
105
111
  // Ink edges, mirroring the viewport pipeline (AO → ink → grade) so
@@ -110,7 +116,7 @@ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphe
110
116
  const inkRadius = Math.max(1, Math.round(renderer.domElement.height / 1080));
111
117
  const inkedRgb = inkedEdges({
112
118
  sceneRgb: aoRgb,
113
- depthTex: scenePassDepth,
119
+ sampleDepth: sampleEdgeDepth,
114
120
  normalTex: scenePassNormal,
115
121
  inkColor: inkColorUniform,
116
122
  radius: inkRadius,
@@ -137,11 +143,32 @@ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphe
137
143
  const bgGradient = mix(ungradedBgGradient, gradeRgb(ungradedBgGradient), gradeMixUniform);
138
144
  const alpha = scenePassColor.a;
139
145
  const finalOutput = vec4(mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform), mix(alpha, float(1), bgMixUniform));
140
- // FXAA requires a texture node as input; convertToTexture renders finalOutput
141
- // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
142
- const aaOutput = fxaa(convertToTexture(finalOutput));
143
146
  const pipeline = new RenderPipeline(renderer);
144
- pipeline.outputNode = aaOutput;
147
+ if (studioBackdrop) {
148
+ // Composite in display space: the scene gets the renderer's own output
149
+ // transform (tone mapping + sRGB), the backdrop is already sRGB — so
150
+ // the pipeline's final transform is off.
151
+ const { width, height } = renderer.domElement;
152
+ const aspect = width / height;
153
+ const [cx, cy] = studioBackdrop.center;
154
+ const radius = Math.max(...[
155
+ [0, 0],
156
+ [1, 0],
157
+ [0, 1],
158
+ [1, 1],
159
+ ].map(([x, y]) => Math.hypot((x - cx) * aspect, y - cy)));
160
+ const offset = screenUV.sub(vec2(cx, cy)).mul(vec2(aspect, 1));
161
+ const t = smoothstep(float(0), float(1), clamp(offset.length().div(radius), 0, 1));
162
+ const backdrop = mix(srgbHexToVec3(studioBackdrop.inner), srgbHexToVec3(studioBackdrop.outer), t);
163
+ const sceneDisplay = renderOutput(vec4(sceneRgb, 1), renderer.toneMapping, renderer.outputColorSpace).rgb;
164
+ pipeline.outputColorTransform = false;
165
+ pipeline.outputNode = fxaa(convertToTexture(vec4(mix(backdrop, sceneDisplay, alpha), 1)));
166
+ }
167
+ else {
168
+ // FXAA requires a texture node as input; convertToTexture renders finalOutput
169
+ // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
170
+ pipeline.outputNode = fxaa(convertToTexture(finalOutput));
171
+ }
145
172
  // Dedicated render target — pipeline outputs here instead of the canvas,
146
173
  // so R3F's main render loop can never overwrite our capture.
147
174
  const { width, height } = renderer.domElement;
@@ -166,7 +193,7 @@ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphe
166
193
  bgProjInvUniform.value.copy(captureCamera.projectionMatrixInverse);
167
194
  bgCamWorldUniform.value.copy(captureCamera.matrixWorld);
168
195
  },
169
- capture: async ({ captureMode, cropRegion, standardSize }) => {
196
+ capture: async ({ captureMode, cropRegion, standardSize, mime }) => {
170
197
  const standardW = standardSize?.w ?? THUMBNAIL_WIDTH;
171
198
  const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT;
172
199
  const { width: captureWidth, height: captureHeight } = renderer.domElement;
@@ -184,100 +211,13 @@ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphe
184
211
  // Let callers restore visibility and other capture policy immediately
185
212
  // after the render, before the asynchronous GPU readback begins.
186
213
  await Promise.resolve();
187
- // Read pixels from the RT asynchronously.
188
- // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
189
- // depad the rows before constructing ImageData.
190
- const pixels = (await renderer.readRenderTargetPixelsAsync(renderTarget, 0, 0, captureWidth, captureHeight));
191
- const actualBytesPerRow = captureWidth * 4;
192
- const tightTotal = actualBytesPerRow * captureHeight;
193
- const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256;
194
- // Two readback shapes to handle:
195
- // - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
196
- // when width*4 isn't already a multiple of 256.
197
- // - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
198
- // (OpenGL framebuffer convention).
199
- // `isWebGPURenderer` lies — it stays true even when the renderer
200
- // falls back to the WebGL backend. Inspect the actual backend
201
- // instead (presence of a GPU device, or backend constructor name).
202
- const backend = renderer.backend;
203
- const isWebGPU = !!backend?.device ||
204
- backend?.isWebGPUBackend === true ||
205
- backend?.constructor?.name === 'WebGPUBackend';
206
- let tightPixels;
207
- if (isWebGPU) {
208
- // WebGPU: depad rows if needed; orientation is already top-down.
209
- if (paddedBytesPerRow === actualBytesPerRow) {
210
- tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, Math.min(pixels.byteLength, tightTotal));
211
- }
212
- else {
213
- tightPixels = new Uint8ClampedArray(tightTotal);
214
- for (let row = 0; row < captureHeight; row++) {
215
- tightPixels.set(pixels.subarray(row * paddedBytesPerRow, row * paddedBytesPerRow + actualBytesPerRow), row * actualBytesPerRow);
216
- }
217
- }
218
- }
219
- else {
220
- // WebGL2: tight buffer in bottom-up order — flip rows.
221
- tightPixels = new Uint8ClampedArray(tightTotal);
222
- for (let row = 0; row < captureHeight; row++) {
223
- const srcStart = (captureHeight - 1 - row) * actualBytesPerRow;
224
- tightPixels.set(pixels.subarray(srcStart, srcStart + actualBytesPerRow), row * actualBytesPerRow);
225
- }
226
- }
227
- const imageData = new ImageData(tightPixels, captureWidth, captureHeight);
228
- const srcCanvas = new OffscreenCanvas(captureWidth, captureHeight);
229
- srcCanvas.getContext('2d').putImageData(imageData, 0, 0);
230
- let outW;
231
- let outH;
232
- let blob;
233
- if (captureMode === 'viewport') {
234
- ;
235
- ({ w: outW, h: outH } = clampSnapshotSize(captureWidth, captureHeight));
236
- const offscreen = new OffscreenCanvas(outW, outH);
237
- const ctx = offscreen.getContext('2d');
238
- if (outW !== captureWidth || outH !== captureHeight)
239
- ctx.imageSmoothingQuality = 'high';
240
- ctx.drawImage(srcCanvas, 0, 0, captureWidth, captureHeight, 0, 0, outW, outH);
241
- blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY });
242
- }
243
- else if (captureMode === 'area' && cropRegion) {
244
- const sx = Math.round(cropRegion.x * captureWidth);
245
- const sy = Math.round(cropRegion.y * captureHeight);
246
- const sourceW = Math.round(cropRegion.width * captureWidth);
247
- const sourceH = Math.round(cropRegion.height * captureHeight);
248
- ({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH));
249
- const offscreen = new OffscreenCanvas(outW, outH);
250
- const ctx = offscreen.getContext('2d');
251
- if (outW !== sourceW || outH !== sourceH)
252
- ctx.imageSmoothingQuality = 'high';
253
- ctx.drawImage(srcCanvas, sx, sy, sourceW, sourceH, 0, 0, outW, outH);
254
- blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY });
255
- }
256
- else {
257
- // Standard: center-crop to the requested aspect (default 1920×1080)
258
- const srcAspect = captureWidth / captureHeight;
259
- const dstAspect = standardW / standardH;
260
- let sx = 0;
261
- let sy = 0;
262
- let sWidth = captureWidth;
263
- let sHeight = captureHeight;
264
- if (srcAspect > dstAspect) {
265
- sWidth = Math.round(captureHeight * dstAspect);
266
- sx = Math.round((captureWidth - sWidth) / 2);
267
- }
268
- else if (srcAspect < dstAspect) {
269
- sHeight = Math.round(captureWidth / dstAspect);
270
- sy = Math.round((captureHeight - sHeight) / 2);
271
- }
272
- outW = standardW;
273
- outH = standardH;
274
- const offscreen = new OffscreenCanvas(outW, outH);
275
- offscreen
276
- .getContext('2d')
277
- .drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH);
278
- blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY });
279
- }
280
- return { blob, outW, outH };
214
+ return encodeCapture(renderer, renderTarget, captureWidth, captureHeight, {
215
+ captureMode,
216
+ cropRegion,
217
+ standardW,
218
+ standardH,
219
+ mime,
220
+ });
281
221
  },
282
222
  dispose: () => {
283
223
  pipeline.dispose();
@@ -292,3 +232,173 @@ export async function createSnapshotPipeline({ renderer, scene, camera, atmosphe
292
232
  return null;
293
233
  }
294
234
  }
235
+ /**
236
+ * The frame in `renderTarget`, read back and encoded: WebGPU's 256-byte row
237
+ * padding undone (the WebGL fallback's bottom-up rows flipped), then cut to
238
+ * the capture mode — the viewport as is, an area, or the standard aspect.
239
+ * Shared by the post-processed pipeline and the plain one.
240
+ */
241
+ async function encodeCapture(renderer, renderTarget, captureWidth, captureHeight, { captureMode, cropRegion, standardW, standardH, mime, }) {
242
+ const encoding = mime ? { type: mime } : { type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY };
243
+ // Read pixels from the RT asynchronously.
244
+ // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
245
+ // depad the rows before constructing ImageData.
246
+ const pixels = (await renderer.readRenderTargetPixelsAsync(renderTarget, 0, 0, captureWidth, captureHeight));
247
+ const actualBytesPerRow = captureWidth * 4;
248
+ const tightTotal = actualBytesPerRow * captureHeight;
249
+ const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256;
250
+ // Two readback shapes to handle:
251
+ // - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
252
+ // when width*4 isn't already a multiple of 256.
253
+ // - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
254
+ // (OpenGL framebuffer convention).
255
+ // `isWebGPURenderer` lies — it stays true even when the renderer
256
+ // falls back to the WebGL backend. Inspect the actual backend
257
+ // instead (presence of a GPU device, or backend constructor name).
258
+ const backend = renderer.backend;
259
+ const isWebGPU = !!backend?.device ||
260
+ backend?.isWebGPUBackend === true ||
261
+ backend?.constructor?.name === 'WebGPUBackend';
262
+ let tightPixels;
263
+ if (isWebGPU) {
264
+ // WebGPU: depad rows if needed; orientation is already top-down.
265
+ if (paddedBytesPerRow === actualBytesPerRow) {
266
+ tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, Math.min(pixels.byteLength, tightTotal));
267
+ }
268
+ else {
269
+ tightPixels = new Uint8ClampedArray(tightTotal);
270
+ for (let row = 0; row < captureHeight; row++) {
271
+ tightPixels.set(pixels.subarray(row * paddedBytesPerRow, row * paddedBytesPerRow + actualBytesPerRow), row * actualBytesPerRow);
272
+ }
273
+ }
274
+ }
275
+ else {
276
+ // WebGL2: tight buffer in bottom-up order — flip rows.
277
+ tightPixels = new Uint8ClampedArray(tightTotal);
278
+ for (let row = 0; row < captureHeight; row++) {
279
+ const srcStart = (captureHeight - 1 - row) * actualBytesPerRow;
280
+ tightPixels.set(pixels.subarray(srcStart, srcStart + actualBytesPerRow), row * actualBytesPerRow);
281
+ }
282
+ }
283
+ const imageData = new ImageData(tightPixels, captureWidth, captureHeight);
284
+ const srcCanvas = new OffscreenCanvas(captureWidth, captureHeight);
285
+ srcCanvas.getContext('2d').putImageData(imageData, 0, 0);
286
+ let outW;
287
+ let outH;
288
+ let blob;
289
+ if (captureMode === 'viewport') {
290
+ ;
291
+ ({ w: outW, h: outH } = clampSnapshotSize(captureWidth, captureHeight));
292
+ const offscreen = new OffscreenCanvas(outW, outH);
293
+ const ctx = offscreen.getContext('2d');
294
+ if (outW !== captureWidth || outH !== captureHeight)
295
+ ctx.imageSmoothingQuality = 'high';
296
+ ctx.drawImage(srcCanvas, 0, 0, captureWidth, captureHeight, 0, 0, outW, outH);
297
+ blob = await offscreen.convertToBlob(encoding);
298
+ }
299
+ else if (captureMode === 'area' && cropRegion) {
300
+ const sx = Math.round(cropRegion.x * captureWidth);
301
+ const sy = Math.round(cropRegion.y * captureHeight);
302
+ const sourceW = Math.round(cropRegion.width * captureWidth);
303
+ const sourceH = Math.round(cropRegion.height * captureHeight);
304
+ ({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH));
305
+ const offscreen = new OffscreenCanvas(outW, outH);
306
+ const ctx = offscreen.getContext('2d');
307
+ if (outW !== sourceW || outH !== sourceH)
308
+ ctx.imageSmoothingQuality = 'high';
309
+ ctx.drawImage(srcCanvas, sx, sy, sourceW, sourceH, 0, 0, outW, outH);
310
+ blob = await offscreen.convertToBlob(encoding);
311
+ }
312
+ else {
313
+ // Standard: center-crop to the requested aspect (default 1920×1080)
314
+ const srcAspect = captureWidth / captureHeight;
315
+ const dstAspect = standardW / standardH;
316
+ let sx = 0;
317
+ let sy = 0;
318
+ let sWidth = captureWidth;
319
+ let sHeight = captureHeight;
320
+ if (srcAspect > dstAspect) {
321
+ sWidth = Math.round(captureHeight * dstAspect);
322
+ sx = Math.round((captureWidth - sWidth) / 2);
323
+ }
324
+ else if (srcAspect < dstAspect) {
325
+ sHeight = Math.round(captureWidth / dstAspect);
326
+ sy = Math.round((captureHeight - sHeight) / 2);
327
+ }
328
+ outW = standardW;
329
+ outH = standardH;
330
+ const offscreen = new OffscreenCanvas(outW, outH);
331
+ offscreen.getContext('2d').drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH);
332
+ blob = await offscreen.convertToBlob(encoding);
333
+ }
334
+ return { blob, outW, outH };
335
+ }
336
+ /**
337
+ * A capture WITHOUT the post-processing stack: the scene as the renderer
338
+ * draws it, into an offscreen target, read back and encoded like the
339
+ * post-processed frames. The sheets' orthographic elevation captures use
340
+ * it (2026-09-10): an SSGI pass built for a second, orthographic camera
341
+ * never delivered a frame and held the generator's guard forever.
342
+ */
343
+ export async function createPlainSnapshotPipeline({ renderer, scene, camera, }) {
344
+ try {
345
+ if (renderer.init)
346
+ await renderer.init();
347
+ const { width, height } = renderer.domElement;
348
+ const renderTarget = new RenderTarget(width, height, { depthBuffer: true });
349
+ let transparent = true;
350
+ const background = new Color('#ffffff');
351
+ return {
352
+ applyEnvironment: ({ theme, transparent: alpha }) => {
353
+ transparent = alpha;
354
+ background.set(getSceneTheme(theme).background);
355
+ },
356
+ capture: async ({ captureMode, cropRegion, standardSize }) => {
357
+ const standardW = standardSize?.w ?? THUMBNAIL_WIDTH;
358
+ const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT;
359
+ const { width: captureWidth, height: captureHeight } = renderer.domElement;
360
+ if (renderTarget.width !== captureWidth || renderTarget.height !== captureHeight) {
361
+ renderTarget.setSize(captureWidth, captureHeight);
362
+ }
363
+ const previousBackground = scene.background;
364
+ const previousClear = renderer.getClearColor(new Color());
365
+ const previousAlpha = renderer.getClearAlpha();
366
+ try {
367
+ scene.background = null;
368
+ renderer.setClearColor(background, transparent ? 0 : 1);
369
+ renderer.setRenderTarget(renderTarget);
370
+ // outside the frame loop the async render is the one that submits
371
+ // its work and settles once the GPU took it
372
+ const asyncRender = renderer.renderAsync;
373
+ if (asyncRender)
374
+ await asyncRender.call(renderer, scene, camera);
375
+ else
376
+ renderer.render(scene, camera);
377
+ }
378
+ finally {
379
+ renderer.setRenderTarget(null);
380
+ scene.background = previousBackground;
381
+ renderer.setClearColor(previousClear, previousAlpha);
382
+ }
383
+ await Promise.resolve();
384
+ // a readback that never returns must not hold a caller for the session
385
+ return Promise.race([
386
+ encodeCapture(renderer, renderTarget, captureWidth, captureHeight, {
387
+ captureMode,
388
+ cropRegion,
389
+ standardW,
390
+ standardH,
391
+ }),
392
+ new Promise((_, reject) => setTimeout(() => reject(new Error('the plain capture readback did not return within 12 s')), 12000)),
393
+ ]);
394
+ },
395
+ dispose: () => {
396
+ renderTarget.dispose();
397
+ },
398
+ };
399
+ }
400
+ catch (error) {
401
+ console.error('[thumbnail] Failed to build the plain capture pipeline.', error);
402
+ return null;
403
+ }
404
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"terrain-perimeter-fill.d.ts","sourceRoot":"","sources":["../../src/lib/terrain-perimeter-fill.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAA+C,MAAM,OAAO,CAAA;AAGnF,MAAM,MAAM,qBAAqB,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAE5D,wBAAgB,iCAAiC,CAC/C,MAAM,EAAE,SAAS,qBAAqB,EAAE,EACxC,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,SAAO,GACb,cAAc,GAAG,IAAI,CA2EvB"}
1
+ {"version":3,"file":"terrain-perimeter-fill.d.ts","sourceRoot":"","sources":["../../src/lib/terrain-perimeter-fill.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAA+C,MAAM,OAAO,CAAA;AAGnF,MAAM,MAAM,qBAAqB,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAE5D,wBAAgB,iCAAiC,CAC/C,MAAM,EAAE,SAAS,qBAAqB,EAAE,EACxC,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,SAAO,GACb,cAAc,GAAG,IAAI,CAkFvB"}
@@ -13,32 +13,39 @@ export function buildTerrainPerimeterFillGeometry(points, bottomY, topY, epsilon
13
13
  }
14
14
  const counterClockwise = signedArea > 0;
15
15
  const positions = [];
16
- const push = (point, y) => {
16
+ const uvs = [];
17
+ const push = (point, y, u = point.x, v = point.z) => {
17
18
  positions.push(point.x, y, point.z);
19
+ uvs.push(u, v);
18
20
  };
21
+ let perimeter = 0;
19
22
  for (let index = 0; index < points.length; index += 1) {
20
23
  const next = (index + 1) % points.length;
21
24
  const a = points[index];
22
25
  const b = points[next];
23
26
  const ay = bottomY[index];
24
27
  const by = bottomY[next];
25
- if (ay >= topY - epsilon && by >= topY - epsilon)
28
+ const length = Math.hypot(b.x - a.x, b.z - a.z);
29
+ const ua = perimeter;
30
+ perimeter += length;
31
+ const ub = perimeter;
32
+ if (length < epsilon || (ay >= topY - epsilon && by >= topY - epsilon))
26
33
  continue;
27
34
  if (counterClockwise) {
28
- push(a, topY);
29
- push(b, by);
30
- push(a, ay);
31
- push(a, topY);
32
- push(b, topY);
33
- push(b, by);
35
+ push(a, topY, ua, topY);
36
+ push(b, by, ub, by);
37
+ push(a, ay, ua, ay);
38
+ push(a, topY, ua, topY);
39
+ push(b, topY, ub, topY);
40
+ push(b, by, ub, by);
34
41
  }
35
42
  else {
36
- push(a, topY);
37
- push(a, ay);
38
- push(b, by);
39
- push(a, topY);
40
- push(b, by);
41
- push(b, topY);
43
+ push(a, topY, ua, topY);
44
+ push(a, ay, ua, ay);
45
+ push(b, by, ub, by);
46
+ push(a, topY, ua, topY);
47
+ push(b, by, ub, by);
48
+ push(b, topY, ub, topY);
42
49
  }
43
50
  }
44
51
  const faces = ShapeUtils.triangulateShape(points.map((point) => new Vector2(point.x, point.z)), []);
@@ -69,6 +76,7 @@ export function buildTerrainPerimeterFillGeometry(points, bottomY, topY, epsilon
69
76
  return null;
70
77
  const geometry = new BufferGeometry();
71
78
  geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
79
+ geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2));
72
80
  geometry.computeVertexNormals();
73
81
  ensureRenderableGeometryAttributes(geometry);
74
82
  return geometry;
@@ -0,0 +1,11 @@
1
+ import type { Ring } from '@pascal-app/core';
2
+ import * as THREE from 'three';
3
+ type ZoneFootprint = {
4
+ polygon: Ring;
5
+ holes?: Ring[];
6
+ };
7
+ export declare function createZoneShape({ polygon, holes }: ZoneFootprint): THREE.Shape;
8
+ /** Vertical quads along each polygon edge (UV.y 0 at the floor, 1 at the top). */
9
+ export declare function createZoneWallGeometry(footprint: ZoneFootprint): THREE.BufferGeometry;
10
+ export {};
11
+ //# sourceMappingURL=zone-geometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zone-geometry.d.ts","sourceRoot":"","sources":["../../src/lib/zone-geometry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAC5C,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;CAAE,CAAA;AAItD,wBAAgB,eAAe,CAAC,EAAE,OAAO,EAAE,KAAU,EAAE,EAAE,aAAa,GAAG,KAAK,CAAC,KAAK,CAiBnF;AAED,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,aAAa,GAAG,KAAK,CAAC,cAAc,CAiCrF"}
@@ -0,0 +1,46 @@
1
+ import * as THREE from 'three';
2
+ const Y_OFFSET = 0.01;
3
+ const ZONE_WALL_HEIGHT = 2.3;
4
+ export function createZoneShape({ polygon, holes = [] }) {
5
+ const shape = new THREE.Shape();
6
+ const draw = (path, ring) => {
7
+ ring.forEach(([x, z], i) => {
8
+ if (i === 0)
9
+ path.moveTo(x, -z);
10
+ else
11
+ path.lineTo(x, -z);
12
+ });
13
+ path.closePath();
14
+ };
15
+ draw(shape, polygon);
16
+ for (const ring of holes) {
17
+ if (ring.length < 3)
18
+ continue;
19
+ const path = new THREE.Path();
20
+ draw(path, ring);
21
+ shape.holes.push(path);
22
+ }
23
+ return shape;
24
+ }
25
+ /** Vertical quads along each polygon edge (UV.y 0 at the floor, 1 at the top). */
26
+ export function createZoneWallGeometry(footprint) {
27
+ const positions = [];
28
+ const uvs = [];
29
+ const indices = [];
30
+ for (const polygon of [footprint.polygon, ...(footprint.holes ?? [])]) {
31
+ for (let i = 0; i < polygon.length; i++) {
32
+ const [cx, cz] = polygon[i];
33
+ const [nx, nz] = polygon[(i + 1) % polygon.length];
34
+ const base = positions.length / 3;
35
+ positions.push(cx, Y_OFFSET, cz, nx, Y_OFFSET, nz, nx, Y_OFFSET + ZONE_WALL_HEIGHT, nz, cx, Y_OFFSET + ZONE_WALL_HEIGHT, cz);
36
+ uvs.push(0, 0, 1, 0, 1, 1, 0, 1);
37
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
38
+ }
39
+ }
40
+ const geometry = new THREE.BufferGeometry();
41
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
42
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
43
+ geometry.setIndex(indices);
44
+ geometry.computeVertexNormals();
45
+ return geometry;
46
+ }
@@ -1,16 +1,20 @@
1
- import type { AnyNodeId, Interactive, LightEffect } from '@pascal-app/core';
2
- export type LightRegistration = {
1
+ import { type AnyNodeId, type Interactive, type LightEffect } from '@pascal-app/core';
2
+ import type { Object3D, Vector3 } from 'three';
3
+ export type LightSource = {
4
+ key: string;
3
5
  nodeId: AnyNodeId;
4
- effect: LightEffect;
5
- toggleIndex: number;
6
- sliderIndex: number;
7
- sliderMin: number;
8
- sliderMax: number;
9
- hasSlider: boolean;
6
+ color: string;
7
+ distance: number;
8
+ getWorldPosition: (out: Vector3) => boolean;
9
+ getIntensity: () => number;
10
+ isEligible: () => boolean;
10
11
  };
12
+ export declare function catalogLightSource(key: string, nodeId: AnyNodeId, effect: LightEffect, interactive: Interactive, localScale?: readonly [number, number, number]): LightSource;
11
13
  type ItemLightPoolStore = {
12
- registrations: Map<string, LightRegistration>;
13
- register: (key: string, nodeId: AnyNodeId, effect: LightEffect, interactive: Interactive) => void;
14
+ registrations: Map<string, LightSource>;
15
+ bakedCanvases: Set<Object3D>;
16
+ setBakedCanvas: (scene: Object3D, active: boolean) => void;
17
+ register: (source: LightSource) => void;
14
18
  unregister: (key: string) => void;
15
19
  };
16
20
  export declare const useItemLightPool: import("zustand").UseBoundStore<import("zustand").StoreApi<ItemLightPoolStore>>;
@@ -1 +1 @@
1
- {"version":3,"file":"use-item-light-pool.d.ts","sourceRoot":"","sources":["../../src/store/use-item-light-pool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,WAAW,EAAiB,MAAM,kBAAkB,CAAA;AAG1F,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE,SAAS,CAAA;IACjB,MAAM,EAAE,WAAW,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,KAAK,kBAAkB,GAAG;IACxB,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAA;IAC7C,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,KAAK,IAAI,CAAA;IACjG,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAClC,CAAA;AAED,eAAO,MAAM,gBAAgB,iFAiC1B,CAAA"}
1
+ {"version":3,"file":"use-item-light-pool.d.ts","sourceRoot":"","sources":["../../src/store/use-item-light-pool.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,WAAW,EAIjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,OAAO,CAAA;AAG9C,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,SAAS,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAA;IAC3C,YAAY,EAAE,MAAM,MAAM,CAAA;IAC1B,UAAU,EAAE,MAAM,OAAO,CAAA;CAC1B,CAAA;AAED,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,WAAW,EACnB,WAAW,EAAE,WAAW,EACxB,UAAU,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAC7C,WAAW,CAwCb;AAED,KAAK,kBAAkB,GAAG;IACxB,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;IACvC,aAAa,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAA;IAC5B,cAAc,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAA;IAC1D,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAA;IACvC,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAClC,CAAA;AAED,eAAO,MAAM,gBAAgB,iFAkB1B,CAAA"}
@@ -1,30 +1,56 @@
1
+ import { sceneRegistry, useInteractive, } from '@pascal-app/core';
1
2
  import { create } from 'zustand';
3
+ export function catalogLightSource(key, nodeId, effect, interactive, localScale) {
4
+ const toggleIndex = interactive.controls.findIndex((control) => control.kind === 'toggle');
5
+ const sliderIndex = interactive.controls.findIndex((control) => control.kind === 'slider');
6
+ const slider = sliderIndex >= 0 ? interactive.controls[sliderIndex] : null;
7
+ return {
8
+ key,
9
+ nodeId,
10
+ color: effect.color,
11
+ distance: effect.distance ?? 0,
12
+ getWorldPosition: (out) => {
13
+ const object = sceneRegistry.nodes.get(nodeId);
14
+ if (!object)
15
+ return false;
16
+ if (localScale) {
17
+ out.set(effect.offset[0] * localScale[0], effect.offset[1] * localScale[1], effect.offset[2] * localScale[2]);
18
+ object.localToWorld(out);
19
+ return true;
20
+ }
21
+ object.getWorldPosition(out);
22
+ out.set(out.x + effect.offset[0], out.y + effect.offset[1], out.z + effect.offset[2]);
23
+ return true;
24
+ },
25
+ getIntensity: () => {
26
+ const values = useInteractive.getState().items[nodeId]?.controlValues;
27
+ if (toggleIndex >= 0 && !values?.[toggleIndex])
28
+ return effect.intensityRange[0];
29
+ const raw = slider ? (values?.[sliderIndex] ?? slider.min) : 1;
30
+ const fraction = slider && slider.max > slider.min ? (raw - slider.min) / (slider.max - slider.min) : 1;
31
+ return (effect.intensityRange[0] + (effect.intensityRange[1] - effect.intensityRange[0]) * fraction);
32
+ },
33
+ isEligible: () => {
34
+ const values = useInteractive.getState().items[nodeId]?.controlValues;
35
+ return toggleIndex < 0 || Boolean(values?.[toggleIndex]);
36
+ },
37
+ };
38
+ }
2
39
  export const useItemLightPool = create((set) => ({
3
40
  registrations: new Map(),
4
- register: (key, nodeId, effect, interactive) => {
5
- const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle');
6
- const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider');
7
- const sliderControl = sliderIndex >= 0 ? interactive.controls[sliderIndex] : null;
8
- const registration = {
9
- nodeId,
10
- effect,
11
- toggleIndex,
12
- sliderIndex,
13
- hasSlider: sliderControl !== null,
14
- sliderMin: sliderControl?.min ?? 0,
15
- sliderMax: sliderControl?.max ?? 1,
16
- };
17
- set((s) => {
18
- const next = new Map(s.registrations);
19
- next.set(key, registration);
20
- return { registrations: next };
21
- });
22
- },
23
- unregister: (key) => {
24
- set((s) => {
25
- const next = new Map(s.registrations);
26
- next.delete(key);
27
- return { registrations: next };
28
- });
29
- },
41
+ bakedCanvases: new Set(),
42
+ setBakedCanvas: (scene, active) => set((state) => {
43
+ const bakedCanvases = new Set(state.bakedCanvases);
44
+ if (active)
45
+ bakedCanvases.add(scene);
46
+ else
47
+ bakedCanvases.delete(scene);
48
+ return { bakedCanvases };
49
+ }),
50
+ register: (source) => set((state) => ({ registrations: new Map(state.registrations).set(source.key, source) })),
51
+ unregister: (key) => set((state) => {
52
+ const registrations = new Map(state.registrations);
53
+ registrations.delete(key);
54
+ return { registrations };
55
+ }),
30
56
  }));