@pascal-app/editor 0.9.2 → 1.0.0-beta.1

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -1,47 +1,25 @@
1
1
  'use client'
2
2
 
3
- import { emitter, sceneRegistry } from '@pascal-app/core'
3
+ import { emitter } from '@pascal-app/core'
4
4
  import {
5
- backdropGradient,
6
- deepSkyColor,
5
+ computeHeroFraming,
6
+ createSnapshotPipeline,
7
7
  GRID_LAYER,
8
- getSceneTheme,
9
- horizonHazeColor,
10
- packNormalToRGB,
11
- SSGI_PARAMS,
8
+ heroCameraPose,
9
+ type SnapshotPipeline,
12
10
  snapLevelsToTruePositions,
13
- unpackRGBToNormal,
11
+ THUMBNAIL_HEIGHT,
12
+ THUMBNAIL_WIDTH,
13
+ temporarilyHideNodeTypes,
14
14
  useViewer,
15
15
  } from '@pascal-app/viewer'
16
16
  import type { CameraControls } from '@react-three/drei'
17
17
  import { useThree } from '@react-three/fiber'
18
18
  import { useCallback, useEffect, useRef } from 'react'
19
19
  import * as THREE from 'three'
20
- import { UnsignedByteType } from 'three'
21
- import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
22
- import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
23
- import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
24
- import {
25
- convertToTexture,
26
- diffuseColor,
27
- float,
28
- mix,
29
- mrt,
30
- normalView,
31
- output,
32
- pass,
33
- sample,
34
- screenUV,
35
- smoothstep,
36
- uniform,
37
- vec4,
38
- } from 'three/tsl'
39
- import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu'
20
+ import type { WebGPURenderer } from 'three/webgpu'
40
21
  import { EDITOR_LAYER } from '../../lib/constants'
41
22
 
42
- const THUMBNAIL_WIDTH = 1920
43
- const THUMBNAIL_HEIGHT = 1080
44
-
45
23
  export interface SnapshotCameraData {
46
24
  position: [number, number, number]
47
25
  target: [number, number, number] | null
@@ -64,20 +42,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
64
42
  const onThumbnailCaptureRef = useRef(onThumbnailCapture)
65
43
 
66
44
  const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
67
- const pipelineRef = useRef<RenderPipeline | null>(null)
68
- const renderTargetRef = useRef<RenderTarget | null>(null)
69
-
70
- // Backdrop compositing for scene snapshots (studio renders, project
71
- // thumbnails): theme background + sky gradient, same world-ray math as the
72
- // viewport backdrop in viewer's post-processing. Uniform-driven so the one
73
- // cached pipeline serves both opaque and transparent (preset/item) captures.
74
- const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff')))
75
- const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff')))
76
- const bgSkyDeepUniform = useRef(uniform(new THREE.Color('#ffffff')))
77
- const bgHazeUniform = useRef(uniform(new THREE.Color('#ffffff')))
78
- const bgProjInvUniform = useRef(uniform(new THREE.Matrix4()))
79
- const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4()))
80
- const bgMixUniform = useRef(uniform(1))
45
+ const pipelineRef = useRef<SnapshotPipeline | null>(null)
81
46
 
82
47
  useEffect(() => {
83
48
  onThumbnailCaptureRef.current = onThumbnailCapture
@@ -93,116 +58,24 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
93
58
  let mounted = true
94
59
 
95
60
  const buildPipeline = async () => {
96
- try {
97
- if ((gl as any).init) await (gl as any).init()
98
- if (!mounted) return
99
-
100
- // pass() handles MRT internally for all material types, including custom
101
- // shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
102
- // pass() also respects camera.layers, so EDITOR_LAYER + GRID_LAYER objects are filtered.
103
- const scenePass = pass(scene, cam)
104
- scenePass.setMRT(
105
- mrt({
106
- output,
107
- diffuseColor,
108
- normal: packNormalToRGB(normalView),
109
- }),
110
- )
111
-
112
- const scenePassColor = scenePass.getTextureNode('output')
113
- const scenePassDepth = scenePass.getTextureNode('depth')
114
- const scenePassNormal = scenePass.getTextureNode('normal')
115
-
116
- scenePass.getTexture('diffuseColor').type = UnsignedByteType
117
- scenePass.getTexture('normal').type = UnsignedByteType
118
-
119
- const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)))
120
-
121
- const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
122
- giPass.sliceCount.value = SSGI_PARAMS.sliceCount
123
- giPass.stepCount.value = SSGI_PARAMS.stepCount
124
- giPass.radius.value = SSGI_PARAMS.radius
125
- giPass.expFactor.value = SSGI_PARAMS.expFactor
126
- giPass.thickness.value = SSGI_PARAMS.thickness
127
- giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
128
- giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
129
- giPass.giIntensity.value = SSGI_PARAMS.giIntensity
130
- giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
131
- giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
132
- giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
133
-
134
- // r185: SSGI's AO lives in its own single-channel texture (getAONode)
135
- // rather than the alpha of one packed rgba texture.
136
- const aoTexture = (giPass as any).getAONode()
137
- const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1))
138
- const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
139
- denoisePass.index.value = 0
140
- denoisePass.radius.value = 4
141
-
142
- // Same far-field AO fade as the viewport pipeline — without it the
143
- // horizon picks up a visible AO line in captures.
144
- const aoFarFade = smoothstep(
145
- float(0.9994),
146
- float(0.9998),
147
- scenePassDepth.sample(screenUV).r,
148
- )
149
- const ao = mix((denoisePass as any).r, float(1), aoFarFade)
150
- const sceneRgb = scenePassColor.rgb.mul(ao)
151
-
152
- // Per-pixel world ray from the capture camera → sky gradient above the
153
- // horizon (dir.y = 0), flat background below — mirrors the viewport
154
- // backdrop. bgMix 0 bypasses it and keeps the capture transparent.
155
- const ndc = vec4(
156
- screenUV.x.mul(2).sub(1),
157
- float(1).sub(screenUV.y).mul(2).sub(1),
158
- 1,
159
- 1,
160
- ) as any
161
- const viewRay = (bgProjInvUniform.current as any).mul(ndc)
162
- const worldDir = (bgCamWorldUniform.current as any)
163
- .mul(vec4(viewRay.xyz, 0))
164
- .xyz.normalize()
165
- const bgGradient = backdropGradient({
166
- dirY: worldDir.y,
167
- background: bgColorUniform.current,
168
- haze: bgHazeUniform.current,
169
- sky: bgSkyUniform.current,
170
- skyDeep: bgSkyDeepUniform.current,
171
- })
172
- const alpha = scenePassColor.a
173
- const finalOutput = vec4(
174
- mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform.current),
175
- mix(alpha, float(1), bgMixUniform.current),
176
- )
177
-
178
- // FXAA requires a texture node as input; convertToTexture renders finalOutput
179
- // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
180
- const aaOutput = fxaa(convertToTexture(finalOutput))
181
-
182
- const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
183
- pipeline.outputNode = aaOutput
184
- pipelineRef.current = pipeline
185
-
186
- // Dedicated render target — pipeline outputs here instead of the canvas,
187
- // so R3F's main render loop can never overwrite our capture.
188
- const { width, height } = gl.domElement
189
- renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
190
- } catch (error) {
191
- console.error(
192
- '[thumbnail] Failed to build post-processing pipeline, will use fallback render.',
193
- error,
194
- )
61
+ const pipeline = await createSnapshotPipeline({
62
+ renderer: gl as unknown as WebGPURenderer,
63
+ scene,
64
+ camera: cam,
65
+ })
66
+ if (!mounted) {
67
+ pipeline?.dispose()
68
+ return
195
69
  }
70
+ pipelineRef.current = pipeline
196
71
  }
197
72
 
198
- buildPipeline()
73
+ void buildPipeline()
199
74
 
200
75
  return () => {
201
76
  mounted = false
202
77
  pipelineRef.current?.dispose()
203
78
  pipelineRef.current = null
204
- renderTargetRef.current?.dispose()
205
- renderTargetRef.current = null
206
79
  }
207
80
  }, [gl, scene])
208
81
 
@@ -242,16 +115,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
242
115
  // uniforms below.
243
116
  thumbnailCamera.updateMatrixWorld()
244
117
 
245
- const theme = getSceneTheme(useViewer.getState().sceneTheme)
246
- bgColorUniform.current.value.set(theme.background)
247
- bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background)
248
- bgSkyDeepUniform.current.value.set(deepSkyColor(theme.backgroundSky ?? theme.background))
249
- bgHazeUniform.current.value.set(
250
- horizonHazeColor(theme.backgroundSky ?? theme.background, theme.appearance),
251
- )
252
- bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse)
253
- bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld)
254
- bgMixUniform.current.value = transparent ? 0 : 1
118
+ const pipeline = pipelineRef.current
119
+ pipeline?.applyEnvironment({
120
+ theme: useViewer.getState().sceneTheme,
121
+ transparent,
122
+ grade: useViewer.getState().shading === 'rendered',
123
+ // Preset/item captures stay clean; scene captures mirror the canvas.
124
+ edges: transparent ? 'off' : useViewer.getState().edges,
125
+ camera: thumbnailCamera,
126
+ })
255
127
 
256
128
  // Capture camera data for snapshot storage
257
129
  const pos = mainCamera.position
@@ -286,166 +158,66 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
286
158
  // are registered. Spawn renders on SCENE_LAYER for occlusion, so the
287
159
  // thumbnail camera's layer mask can't filter it either. Returns a
288
160
  // function that restores the original visibility.
289
- const restoreNodeVisibility = (() => {
290
- const saved = new Map<THREE.Object3D, boolean>()
291
- for (const type of ['scan', 'guide', 'spawn'] as const) {
292
- const ids = sceneRegistry.byType[type]!
293
- ids.forEach((id) => {
294
- const node = sceneRegistry.nodes.get(id)
295
- if (node) {
296
- saved.set(node, node.visible)
297
- node.visible = false
298
- }
161
+ const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn'])
162
+
163
+ // Auto-save shots don't copy the user's mid-edit camera — they re-pose
164
+ // onto the same computed hero angle the published thumbnail uses, so a
165
+ // project's card never shows a half-zoomed working view. Measured after
166
+ // the level snap so stacked positions frame correctly. User-driven
167
+ // captures (captureMode set) keep the exact viewport pose.
168
+ if (snapLevels) {
169
+ const framing = computeHeroFraming()
170
+ if (framing) {
171
+ const pose = heroCameraPose({
172
+ boxes: framing.boxes,
173
+ aim: framing.aim,
174
+ azimuthRad: framing.azimuthRad,
175
+ aspect: width / height,
299
176
  })
300
- }
301
- return () => {
302
- saved.forEach((wasVisible, node) => {
303
- node.visible = wasVisible
177
+ thumbnailCamera.position.set(pose.position[0], pose.position[1], pose.position[2])
178
+ thumbnailCamera.lookAt(pose.target[0], pose.target[1], pose.target[2])
179
+ thumbnailCamera.updateMatrixWorld()
180
+ pipeline?.applyEnvironment({
181
+ theme: useViewer.getState().sceneTheme,
182
+ transparent,
183
+ grade: useViewer.getState().shading === 'rendered',
184
+ edges: transparent ? 'off' : useViewer.getState().edges,
185
+ camera: thumbnailCamera,
304
186
  })
187
+ cameraData.position = pose.position
188
+ cameraData.target = pose.target
305
189
  }
306
- })()
190
+ }
307
191
 
308
192
  let blob: Blob
309
193
 
310
- if (pipelineRef.current && renderTargetRef.current) {
311
- const rt = renderTargetRef.current
312
-
313
- // Resize RT if the canvas dimensions changed
314
- if (rt.width !== width || rt.height !== height) {
315
- rt.setSize(width, height)
316
- }
317
-
318
- const renderer = gl as unknown as WebGPURenderer
194
+ if (pipeline) {
195
+ let capturePromise: ReturnType<SnapshotPipeline['capture']>
319
196
 
320
197
  // Notify other systems (wall cutouts, selection manager) to restore
321
198
  // their overrides before capture and re-apply them after.
322
199
  try {
323
200
  emitter.emit('thumbnail:before-capture', undefined)
324
- ;(renderer as any).setClearAlpha(0)
325
- renderer.setRenderTarget(rt)
326
- pipelineRef.current.render()
201
+ capturePromise = pipeline.capture({
202
+ captureMode,
203
+ cropRegion,
204
+ standardSize,
205
+ })
327
206
  } finally {
328
207
  // Restore level positions, levelMode, and node visibility immediately
329
208
  // after the render — before the async GPU readback. Runs in `finally`
330
209
  // so a render failure can't leave helpers permanently hidden.
331
- renderer.setRenderTarget(null)
332
210
  emitter.emit('thumbnail:after-capture', undefined)
333
211
  restoreLevels()
334
212
  restoreLevelMode?.()
335
213
  restoreNodeVisibility()
336
214
  }
337
215
 
338
- // Read pixels from the RT asynchronously.
339
- // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
340
- // depad the rows before constructing ImageData.
341
- const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
342
- rt,
343
- 0,
344
- 0,
345
- width,
346
- height,
347
- )) as Uint8Array
348
-
349
- const actualBytesPerRow = width * 4
350
- const tightTotal = actualBytesPerRow * height
351
- const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
352
- // Two readback shapes to handle:
353
- // - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
354
- // when width*4 isn't already a multiple of 256.
355
- // - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
356
- // (OpenGL framebuffer convention).
357
- // `isWebGPURenderer` lies — it stays true even when the renderer
358
- // falls back to the WebGL backend. Inspect the actual backend
359
- // instead (presence of a GPU device, or backend constructor name).
360
- const backend = (renderer as any).backend
361
- const isWebGPU =
362
- !!backend?.device ||
363
- backend?.isWebGPUBackend === true ||
364
- backend?.constructor?.name === 'WebGPUBackend'
365
- let tightPixels: Uint8ClampedArray
366
- if (isWebGPU) {
367
- // WebGPU: depad rows if needed; orientation is already top-down.
368
- if (paddedBytesPerRow === actualBytesPerRow) {
369
- tightPixels = new Uint8ClampedArray(
370
- pixels.buffer,
371
- pixels.byteOffset,
372
- Math.min(pixels.byteLength, tightTotal),
373
- )
374
- } else {
375
- tightPixels = new Uint8ClampedArray(tightTotal)
376
- for (let row = 0; row < height; row++) {
377
- tightPixels.set(
378
- pixels.subarray(
379
- row * paddedBytesPerRow,
380
- row * paddedBytesPerRow + actualBytesPerRow,
381
- ),
382
- row * actualBytesPerRow,
383
- )
384
- }
385
- }
386
- } else {
387
- // WebGL2: tight buffer in bottom-up order — flip rows.
388
- tightPixels = new Uint8ClampedArray(tightTotal)
389
- for (let row = 0; row < height; row++) {
390
- const srcStart = (height - 1 - row) * actualBytesPerRow
391
- tightPixels.set(
392
- pixels.subarray(srcStart, srcStart + actualBytesPerRow),
393
- row * actualBytesPerRow,
394
- )
395
- }
396
- }
397
-
398
- const imageData = new ImageData(
399
- tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
400
- width,
401
- height,
402
- )
403
- const srcCanvas = new OffscreenCanvas(width, height)
404
- srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
405
-
406
- let outW: number
407
- let outH: number
408
-
409
- if (captureMode === 'viewport') {
410
- outW = width
411
- outH = height
412
- const offscreen = new OffscreenCanvas(outW, outH)
413
- offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0)
414
- blob = await offscreen.convertToBlob({ type: 'image/png' })
415
- } else if (captureMode === 'area' && cropRegion) {
416
- const sx = Math.round(cropRegion.x * width)
417
- const sy = Math.round(cropRegion.y * height)
418
- outW = Math.round(cropRegion.width * width)
419
- outH = Math.round(cropRegion.height * height)
420
- const offscreen = new OffscreenCanvas(outW, outH)
421
- offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
422
- blob = await offscreen.convertToBlob({ type: 'image/png' })
423
- } else {
424
- // Standard: center-crop to the requested aspect (default 1920×1080)
425
- const srcAspect = width / height
426
- const dstAspect = standardW / standardH
427
- let sx = 0,
428
- sy = 0,
429
- sWidth = width,
430
- sHeight = height
431
- if (srcAspect > dstAspect) {
432
- sWidth = Math.round(height * dstAspect)
433
- sx = Math.round((width - sWidth) / 2)
434
- } else if (srcAspect < dstAspect) {
435
- sHeight = Math.round(width / dstAspect)
436
- sy = Math.round((height - sHeight) / 2)
437
- }
438
- outW = standardW
439
- outH = standardH
440
- const offscreen = new OffscreenCanvas(outW, outH)
441
- offscreen
442
- .getContext('2d')!
443
- .drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
444
- blob = await offscreen.convertToBlob({ type: 'image/png' })
445
- }
216
+ const result = await capturePromise
217
+ blob = result.blob
446
218
 
447
219
  if (captureMode !== undefined) cameraData.captureMode = captureMode
448
- cameraData.resolution = { w: outW, h: outH }
220
+ cameraData.resolution = { w: result.outW, h: result.outH }
449
221
  } else {
450
222
  // Fallback: plain render directly to the canvas
451
223
  try {
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { collectRegistrySelectionIdsInBounds } from './use-floorplan-hit-testing'
3
+
4
+ const identityMatrix = {
5
+ inverse() {
6
+ return this
7
+ },
8
+ }
9
+
10
+ function pointFactory() {
11
+ return {
12
+ x: 0,
13
+ y: 0,
14
+ matrixTransform() {
15
+ return pointFactoryWithPosition(this.x, this.y)
16
+ },
17
+ }
18
+ }
19
+
20
+ function pointFactoryWithPosition(x: number, y: number) {
21
+ return {
22
+ x,
23
+ y,
24
+ matrixTransform() {
25
+ return pointFactoryWithPosition(x, y)
26
+ },
27
+ }
28
+ }
29
+
30
+ function geometry(x: number, y: number, width: number, height: number) {
31
+ return {
32
+ getBBox: () => ({ x, y, width, height }),
33
+ getScreenCTM: () => identityMatrix,
34
+ } as unknown as SVGGraphicsElement
35
+ }
36
+
37
+ function entry(nodeId: string, children: SVGGraphicsElement[]) {
38
+ return {
39
+ dataset: { nodeId },
40
+ querySelectorAll: () => children,
41
+ } as unknown as SVGGElement
42
+ }
43
+
44
+ function scene(entries: SVGGElement[]) {
45
+ return {
46
+ getScreenCTM: () => identityMatrix,
47
+ ownerSVGElement: {
48
+ createSVGPoint: pointFactory,
49
+ },
50
+ querySelectorAll: () => entries,
51
+ } as unknown as SVGGElement
52
+ }
53
+
54
+ describe('collectRegistrySelectionIdsInBounds', () => {
55
+ test('selects rendered registry geometry and preserves candidate order', () => {
56
+ const renderedScene = scene([
57
+ entry('wall_1', [geometry(0, 0, 4, 0.2)]),
58
+ entry('door_1', [geometry(1, 1, 0.9, 0.9)]),
59
+ entry('outside_1', [geometry(20, 20, 1, 1)]),
60
+ ])
61
+
62
+ expect(
63
+ collectRegistrySelectionIdsInBounds(renderedScene, { minX: -1, minY: -1, maxX: 5, maxY: 5 }, [
64
+ 'door_1',
65
+ 'outside_1',
66
+ 'wall_1',
67
+ ]),
68
+ ).toEqual(['door_1', 'wall_1'])
69
+ })
70
+
71
+ test('ignores rendered entries outside the selectable registry candidates', () => {
72
+ const renderedScene = scene([entry('zone_1', [geometry(0, 0, 2, 2)])])
73
+
74
+ expect(
75
+ collectRegistrySelectionIdsInBounds(
76
+ renderedScene,
77
+ { minX: -1, minY: -1, maxX: 3, maxY: 3 },
78
+ [],
79
+ ),
80
+ ).toEqual([])
81
+ })
82
+ })