@pascal-app/editor 0.6.0 → 0.8.0

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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
3
+ import { emitter, sceneRegistry } from '@pascal-app/core'
4
4
  import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
5
5
  import type { CameraControls } from '@react-three/drei'
6
6
  import { useThree } from '@react-three/fiber'
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
28
28
 
29
29
  const THUMBNAIL_WIDTH = 1920
30
30
  const THUMBNAIL_HEIGHT = 1080
31
- const AUTO_SAVE_DELAY = 10_000
32
31
 
33
32
  export interface SnapshotCameraData {
34
33
  position: [number, number, number]
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
49
48
  const mainCamera = useThree((state) => state.camera)
50
49
  const controls = useThree((state) => state.controls) as CameraControls | null
51
50
  const isGenerating = useRef(false)
52
- const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
53
- const pendingAutoRef = useRef(false)
54
51
  const onThumbnailCaptureRef = useRef(onThumbnailCapture)
55
52
 
56
53
  const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
@@ -61,7 +58,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
61
58
  onThumbnailCaptureRef.current = onThumbnailCapture
62
59
  }, [onThumbnailCapture])
63
60
 
64
- // Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
61
+ // Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
65
62
  useEffect(() => {
66
63
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
67
64
  cam.layers.disable(EDITOR_LAYER)
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
75
72
  if (!mounted) return
76
73
 
77
74
  // pass() handles MRT internally for all material types, including custom
78
- // shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
75
+ // shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
79
76
  // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
80
77
  const scenePass = pass(scene, cam)
81
78
  scenePass.setMRT(
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
117
114
  const ao = (denoisePass as any).r
118
115
  const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
119
116
 
120
- // FXAA requires a texture node as input, convertToTexture renders finalOutput
121
- // into an intermediate RT so FXAA can sample it with neighbor UV offsets.
117
+ // FXAA requires a texture node as input; convertToTexture renders finalOutput
118
+ // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
122
119
  const aaOutput = fxaa(convertToTexture(finalOutput))
123
120
 
124
121
  const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
125
122
  pipeline.outputNode = aaOutput
126
123
  pipelineRef.current = pipeline
127
124
 
128
- // Dedicated render target, pipeline outputs here instead of the canvas,
125
+ // Dedicated render target pipeline outputs here instead of the canvas,
129
126
  // so R3F's main render loop can never overwrite our capture.
130
127
  const { width, height } = gl.domElement
131
128
  renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
176
173
  thumbnailCamera.aspect = width / height
177
174
  thumbnailCamera.updateProjectionMatrix()
178
175
 
179
- // Capture camera data for snapshot storage.
176
+ // Capture camera data for snapshot storage
180
177
  const pos = mainCamera.position
181
178
  let tgt: [number, number, number] | null = null
182
179
  if (controls && 'getTarget' in controls) {
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
192
189
  ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
193
190
  }
194
191
 
195
- // For auto-save, snap levels to stacked positions and reset levelMode.
192
+ // For auto-save: snap levels to stacked positions and reset levelMode
196
193
  let restoreLevelMode: (() => void) | null = null
197
194
  let restoreLevels: () => void = () => {}
198
195
  if (snapLevels) {
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
205
202
  }
206
203
 
207
204
  // Hide scan and guide nodes directly so they are excluded from the
208
- // thumbnail regardless of whether ScanSystem or GuideSystem listeners are
205
+ // thumbnail regardless of whether ScanSystem/GuideSystem listeners are
209
206
  // registered. Returns a function that restores the original visibility.
210
207
  const restoreNodeVisibility = (() => {
211
208
  const saved = new Map<THREE.Object3D, boolean>()
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
231
228
  if (pipelineRef.current && renderTargetRef.current) {
232
229
  const rt = renderTargetRef.current
233
230
 
234
- // Resize RT if the canvas dimensions changed.
231
+ // Resize RT if the canvas dimensions changed
235
232
  if (rt.width !== width || rt.height !== height) {
236
233
  rt.setSize(width, height)
237
234
  }
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
248
245
  emitter.emit('thumbnail:after-capture', undefined)
249
246
 
250
247
  // Restore level positions, levelMode, and node visibility immediately after the
251
- // render, before the async GPU readback.
248
+ // render before the async GPU readback.
252
249
  restoreLevels()
253
250
  restoreLevelMode?.()
254
251
  restoreNodeVisibility()
@@ -265,18 +262,49 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
265
262
  )) as Uint8Array
266
263
 
267
264
  const actualBytesPerRow = width * 4
265
+ const tightTotal = actualBytesPerRow * height
268
266
  const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
267
+ // Two readback shapes to handle:
268
+ // - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
269
+ // when width*4 isn't already a multiple of 256.
270
+ // - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
271
+ // (OpenGL framebuffer convention).
272
+ // `isWebGPURenderer` lies — it stays true even when the renderer
273
+ // falls back to the WebGL backend. Inspect the actual backend
274
+ // instead (presence of a GPU device, or backend constructor name).
275
+ const backend = (renderer as any).backend
276
+ const isWebGPU =
277
+ !!backend?.device ||
278
+ backend?.isWebGPUBackend === true ||
279
+ backend?.constructor?.name === 'WebGPUBackend'
269
280
  let tightPixels: Uint8ClampedArray
270
- if (paddedBytesPerRow === actualBytesPerRow) {
271
- tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
281
+ if (isWebGPU) {
282
+ // WebGPU: depad rows if needed; orientation is already top-down.
283
+ if (paddedBytesPerRow === actualBytesPerRow) {
284
+ tightPixels = new Uint8ClampedArray(
285
+ pixels.buffer,
286
+ pixels.byteOffset,
287
+ Math.min(pixels.byteLength, tightTotal),
288
+ )
289
+ } else {
290
+ tightPixels = new Uint8ClampedArray(tightTotal)
291
+ for (let row = 0; row < height; row++) {
292
+ tightPixels.set(
293
+ pixels.subarray(
294
+ row * paddedBytesPerRow,
295
+ row * paddedBytesPerRow + actualBytesPerRow,
296
+ ),
297
+ row * actualBytesPerRow,
298
+ )
299
+ }
300
+ }
272
301
  } else {
273
- tightPixels = new Uint8ClampedArray(width * height * 4)
302
+ // WebGL2: tight buffer in bottom-up order — flip rows.
303
+ tightPixels = new Uint8ClampedArray(tightTotal)
274
304
  for (let row = 0; row < height; row++) {
305
+ const srcStart = (height - 1 - row) * actualBytesPerRow
275
306
  tightPixels.set(
276
- pixels.subarray(
277
- row * paddedBytesPerRow,
278
- row * paddedBytesPerRow + actualBytesPerRow,
279
- ),
307
+ pixels.subarray(srcStart, srcStart + actualBytesPerRow),
280
308
  row * actualBytesPerRow,
281
309
  )
282
310
  }
@@ -308,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
308
336
  offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
309
337
  blob = await offscreen.convertToBlob({ type: 'image/png' })
310
338
  } else {
339
+ // Standard: center-crop to 1920×1080 aspect ratio
311
340
  const srcAspect = width / height
312
341
  const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
313
342
  let sx = 0,
@@ -333,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
333
362
  if (captureMode !== undefined) cameraData.captureMode = captureMode
334
363
  cameraData.resolution = { w: outW, h: outH }
335
364
  } else {
336
- // Fallback: plain render directly to the canvas.
365
+ // Fallback: plain render directly to the canvas
337
366
  emitter.emit('thumbnail:before-capture', undefined)
338
367
  gl.render(scene, thumbnailCamera)
339
368
  emitter.emit('thumbnail:after-capture', undefined)
@@ -419,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
419
448
  )
420
449
 
421
450
  // Thumbnail request via emitter. Two call shapes:
422
- // - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
451
+ // - user-driven capture: `{ projectId, captureMode, cropRegion }` captures
423
452
  // the current pose with the supplied crop.
424
- // - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
425
- // their true positions first for a consistent auto-thumbnail angle.
453
+ // - host-driven auto-save: `{ projectId, snapLevels: true }` snaps levels
454
+ // to their true positions first for a consistent auto-thumbnail angle.
455
+ // The caller owns policy (when to fire, whether the tab is visible).
426
456
  useEffect(() => {
427
457
  if (!onThumbnailCapture) return
428
458
 
@@ -438,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
438
468
  return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
439
469
  }, [generate, onThumbnailCapture])
440
470
 
441
- // OSS adaptation: keep local debounced auto-capture behavior because the
442
- // community host-side autosave hook is not part of this repo.
443
- useEffect(() => {
444
- if (!onThumbnailCapture) return
445
-
446
- const triggerNow = () => {
447
- void generate(true)
448
- }
449
-
450
- const scheduleOrDefer = () => {
451
- if (document.visibilityState === 'visible') {
452
- triggerNow()
453
- } else {
454
- pendingAutoRef.current = true
455
- }
456
- }
457
-
458
- const onSceneChange = () => {
459
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
460
- debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
461
- }
462
-
463
- const onVisibilityChange = () => {
464
- if (document.visibilityState === 'visible' && pendingAutoRef.current) {
465
- pendingAutoRef.current = false
466
- triggerNow()
467
- }
468
- }
469
-
470
- const unsubscribe = useScene.subscribe((state, prevState) => {
471
- if (state.nodes !== prevState.nodes) onSceneChange()
472
- })
473
-
474
- document.addEventListener('visibilitychange', onVisibilityChange)
475
-
476
- return () => {
477
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
478
- unsubscribe()
479
- document.removeEventListener('visibilitychange', onVisibilityChange)
480
- }
481
- }, [generate, onThumbnailCapture])
482
-
483
- // Go-to-camera: animate camera to a saved snapshot position or target.
471
+ // Go-to-camera: animate camera to a saved snapshot position/target
484
472
  useEffect(() => {
485
473
  const handler = ({
486
474
  position,
@@ -0,0 +1,257 @@
1
+ 'use client'
2
+
3
+ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
4
+ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
5
+ import { getPlanPointDistance } from '../../lib/floorplan'
6
+ import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
7
+ import type { WallPlanPoint } from '../tools/wall/wall-drafting'
8
+
9
+ type UseFloorplanBackgroundPlacementArgs = {
10
+ activePolygonDraftPoints: WallPlanPoint[]
11
+ ceilingDraftPoints: WallPlanPoint[]
12
+ clearFencePlacementDraft: () => void
13
+ clearRoofPlacementDraft: () => void
14
+ emitFloorplanGridEvent: (
15
+ type: 'click' | 'double-click' | 'move',
16
+ planPoint: WallPlanPoint,
17
+ event: ReactMouseEvent<SVGSVGElement>,
18
+ ) => WallPlanPoint
19
+ fenceDraftStart: WallPlanPoint | null
20
+ fences: FenceNode[]
21
+ findClosestWallPoint: (
22
+ point: WallPlanPoint,
23
+ walls: WallNode[],
24
+ options?: { canUseWall?: (wall: WallNode) => boolean },
25
+ ) => {
26
+ normal: [number, number, number]
27
+ point: WallPlanPoint
28
+ t: number
29
+ wall: WallNode
30
+ } | null
31
+ floorplanOpeningLocalY: number
32
+ getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint
33
+ handleCeilingPlacementPoint: (point: WallPlanPoint) => void
34
+ handleSlabPlacementPoint: (point: WallPlanPoint) => void
35
+ handleWallPlacementPoint: (point: WallPlanPoint) => void
36
+ handleZonePlacementPoint: (point: WallPlanPoint) => void
37
+ isCeilingBuildActive: boolean
38
+ isFenceBuildActive: boolean
39
+ isFloorplanGridInteractionActive: boolean
40
+ isOpeningPlacementActive: boolean
41
+ isPolygonBuildActive: boolean
42
+ isRoofBuildActive: boolean
43
+ isWallBuildActive: boolean
44
+ isZoneBuildActive: boolean
45
+ roofDraftStart: WallPlanPoint | null
46
+ setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
47
+ setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
48
+ setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
49
+ setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
50
+ setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
51
+ shiftPressed: boolean
52
+ snapWallDraftPoint: (args: {
53
+ point: WallPlanPoint
54
+ walls: WallNode[]
55
+ start?: WallPlanPoint
56
+ angleSnap: boolean
57
+ }) => WallPlanPoint
58
+ snapPolygonDraftPoint: (args: {
59
+ point: WallPlanPoint
60
+ start?: WallPlanPoint
61
+ angleSnap: boolean
62
+ }) => WallPlanPoint
63
+ toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
64
+ walls: WallNode[]
65
+ }
66
+
67
+ export function useFloorplanBackgroundPlacement({
68
+ activePolygonDraftPoints,
69
+ ceilingDraftPoints,
70
+ clearFencePlacementDraft,
71
+ clearRoofPlacementDraft,
72
+ emitFloorplanGridEvent,
73
+ fenceDraftStart,
74
+ fences,
75
+ findClosestWallPoint,
76
+ floorplanOpeningLocalY,
77
+ getSnappedFloorplanPoint,
78
+ handleCeilingPlacementPoint,
79
+ handleSlabPlacementPoint,
80
+ handleWallPlacementPoint,
81
+ handleZonePlacementPoint,
82
+ isCeilingBuildActive,
83
+ isFenceBuildActive,
84
+ isFloorplanGridInteractionActive,
85
+ isOpeningPlacementActive,
86
+ isPolygonBuildActive,
87
+ isRoofBuildActive,
88
+ isWallBuildActive,
89
+ isZoneBuildActive,
90
+ roofDraftStart,
91
+ setCursorPoint,
92
+ setFenceDraftEnd,
93
+ setFenceDraftStart,
94
+ setRoofDraftEnd,
95
+ setRoofDraftStart,
96
+ shiftPressed,
97
+ snapWallDraftPoint,
98
+ snapPolygonDraftPoint,
99
+ toPoint2D,
100
+ walls,
101
+ }: UseFloorplanBackgroundPlacementArgs) {
102
+ const handleBackgroundPlacementClick = useCallback(
103
+ (
104
+ planPoint: WallPlanPoint,
105
+ event: ReactMouseEvent<SVGSVGElement>,
106
+ draftStart: WallPlanPoint | null,
107
+ ) => {
108
+ if (isOpeningPlacementActive) {
109
+ const closest = findClosestWallPoint(planPoint, walls, {
110
+ canUseWall: (wall) => !isCurvedWall(wall),
111
+ })
112
+ if (closest) {
113
+ const dx = closest.wall.end[0] - closest.wall.start[0]
114
+ const dz = closest.wall.end[1] - closest.wall.start[1]
115
+ const length = Math.sqrt(dx * dx + dz * dz)
116
+ const distance = closest.t * length
117
+
118
+ emitter.emit('wall:click', {
119
+ node: closest.wall,
120
+ point: { x: closest.point[0], y: 0, z: closest.point[1] },
121
+ localPosition: [distance, floorplanOpeningLocalY, 0],
122
+ normal: closest.normal,
123
+ stopPropagation: () => {},
124
+ } as any)
125
+ }
126
+ return true
127
+ }
128
+
129
+ if (isCeilingBuildActive) {
130
+ emitFloorplanGridEvent('click', planPoint, event)
131
+
132
+ const snappedPoint = snapPolygonDraftPoint({
133
+ point: planPoint,
134
+ start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
135
+ angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed,
136
+ })
137
+
138
+ handleCeilingPlacementPoint(snappedPoint)
139
+ return true
140
+ }
141
+
142
+ if (isRoofBuildActive) {
143
+ const snappedPoint = getSnappedFloorplanPoint(planPoint)
144
+ emitFloorplanGridEvent('click', snappedPoint, event)
145
+ setCursorPoint(snappedPoint)
146
+
147
+ if (roofDraftStart) {
148
+ clearRoofPlacementDraft()
149
+ } else {
150
+ setRoofDraftStart(snappedPoint)
151
+ setRoofDraftEnd(snappedPoint)
152
+ }
153
+ return true
154
+ }
155
+
156
+ if (isFenceBuildActive) {
157
+ emitFloorplanGridEvent('click', planPoint, event)
158
+
159
+ const snappedPoint = snapFenceDraftPoint({
160
+ point: planPoint,
161
+ walls,
162
+ fences,
163
+ start: fenceDraftStart ?? undefined,
164
+ angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
165
+ })
166
+
167
+ setCursorPoint(snappedPoint)
168
+
169
+ if (!fenceDraftStart) {
170
+ setFenceDraftStart(snappedPoint)
171
+ setFenceDraftEnd(snappedPoint)
172
+ } else if (
173
+ getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
174
+ ) {
175
+ clearFencePlacementDraft()
176
+ } else {
177
+ setFenceDraftEnd(snappedPoint)
178
+ }
179
+ return true
180
+ }
181
+
182
+ if (isFloorplanGridInteractionActive) {
183
+ const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
184
+ setCursorPoint(snappedPoint)
185
+ return true
186
+ }
187
+
188
+ if (isPolygonBuildActive) {
189
+ const snappedPoint = snapPolygonDraftPoint({
190
+ point: planPoint,
191
+ start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
192
+ angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
193
+ })
194
+
195
+ if (isZoneBuildActive) {
196
+ handleZonePlacementPoint(snappedPoint)
197
+ } else {
198
+ handleSlabPlacementPoint(snappedPoint)
199
+ }
200
+ return true
201
+ }
202
+
203
+ if (!isWallBuildActive) {
204
+ return false
205
+ }
206
+
207
+ const snappedPoint = snapWallDraftPoint({
208
+ point: planPoint,
209
+ walls,
210
+ start: draftStart ?? undefined,
211
+ angleSnap: Boolean(draftStart) && !shiftPressed,
212
+ })
213
+
214
+ handleWallPlacementPoint(snappedPoint)
215
+ return true
216
+ },
217
+ [
218
+ activePolygonDraftPoints,
219
+ ceilingDraftPoints,
220
+ clearFencePlacementDraft,
221
+ clearRoofPlacementDraft,
222
+ emitFloorplanGridEvent,
223
+ fenceDraftStart,
224
+ fences,
225
+ findClosestWallPoint,
226
+ floorplanOpeningLocalY,
227
+ getSnappedFloorplanPoint,
228
+ handleCeilingPlacementPoint,
229
+ handleSlabPlacementPoint,
230
+ handleZonePlacementPoint,
231
+ isCeilingBuildActive,
232
+ isFenceBuildActive,
233
+ isFloorplanGridInteractionActive,
234
+ isOpeningPlacementActive,
235
+ isPolygonBuildActive,
236
+ isRoofBuildActive,
237
+ isWallBuildActive,
238
+ isZoneBuildActive,
239
+ roofDraftStart,
240
+ setCursorPoint,
241
+ setFenceDraftEnd,
242
+ setFenceDraftStart,
243
+ setRoofDraftEnd,
244
+ setRoofDraftStart,
245
+ shiftPressed,
246
+ snapWallDraftPoint,
247
+ snapPolygonDraftPoint,
248
+ toPoint2D,
249
+ walls,
250
+ handleWallPlacementPoint,
251
+ ],
252
+ )
253
+
254
+ return {
255
+ handleBackgroundPlacementClick,
256
+ }
257
+ }
@@ -0,0 +1,171 @@
1
+ 'use client'
2
+
3
+ import type {
4
+ AnyNode,
5
+ CeilingNode,
6
+ DoorNode,
7
+ ItemNode,
8
+ Point2D,
9
+ RoofNode,
10
+ RoofSegmentNode,
11
+ SlabNode,
12
+ StairNode,
13
+ StairSegmentNode,
14
+ WallNode,
15
+ WindowNode,
16
+ } from '@pascal-app/core'
17
+ import { useCallback } from 'react'
18
+ import {
19
+ getFloorplanHitNodeId,
20
+ getFloorplanSelectionIdsInBounds,
21
+ } from '../../lib/floorplan/selection-tool'
22
+ import type { FloorplanSelectionBounds } from '../../lib/floorplan/types'
23
+ import type { WallPlanPoint } from '../tools/wall/wall-drafting'
24
+
25
+ type OpeningNode = WindowNode | DoorNode
26
+
27
+ type WallPolygonEntry = {
28
+ wall: WallNode
29
+ polygon: Point2D[]
30
+ }
31
+
32
+ type OpeningPolygonEntry = {
33
+ opening: OpeningNode
34
+ polygon: Point2D[]
35
+ }
36
+
37
+ type SlabPolygonEntry = {
38
+ slab: SlabNode
39
+ polygon: Point2D[]
40
+ holes: Point2D[][]
41
+ }
42
+
43
+ type CeilingPolygonEntry = {
44
+ ceiling: CeilingNode
45
+ polygon: Point2D[]
46
+ holes: Point2D[][]
47
+ }
48
+
49
+ type FloorplanRoofEntry = {
50
+ roof: RoofNode
51
+ segments: Array<{
52
+ polygon: Point2D[]
53
+ segment: RoofSegmentNode
54
+ }>
55
+ }
56
+
57
+ type FloorplanItemEntry = {
58
+ item: ItemNode
59
+ polygon: Point2D[]
60
+ }
61
+
62
+ type FloorplanStairSegmentEntry = {
63
+ polygon: Point2D[]
64
+ segment: StairSegmentNode | AnyNode
65
+ }
66
+
67
+ type FloorplanStairEntry = {
68
+ hitPolygons: Point2D[][]
69
+ stair: StairNode
70
+ segments: FloorplanStairSegmentEntry[]
71
+ }
72
+
73
+ type UseFloorplanHitTestingArgs = {
74
+ ceilingPolygons: CeilingPolygonEntry[]
75
+ displaySlabPolygons: SlabPolygonEntry[]
76
+ displayWallPolygons: WallPolygonEntry[]
77
+ floorplanItemEntries: FloorplanItemEntry[]
78
+ floorplanOpeningHitTolerance: number
79
+ floorplanRoofEntries: FloorplanRoofEntry[]
80
+ floorplanStairEntries: FloorplanStairEntry[]
81
+ floorplanWallHitTolerance: number
82
+ getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
83
+ isFloorplanItemContextActive: boolean
84
+ openingsPolygons: OpeningPolygonEntry[]
85
+ phase: 'site' | 'structure' | 'furnish'
86
+ toPoint2D: (point: WallPlanPoint) => Point2D
87
+ }
88
+
89
+ export function useFloorplanHitTesting({
90
+ ceilingPolygons,
91
+ displaySlabPolygons,
92
+ displayWallPolygons,
93
+ floorplanItemEntries,
94
+ floorplanOpeningHitTolerance,
95
+ floorplanRoofEntries,
96
+ floorplanStairEntries,
97
+ floorplanWallHitTolerance,
98
+ getOpeningCenterLine,
99
+ isFloorplanItemContextActive,
100
+ openingsPolygons,
101
+ phase,
102
+ toPoint2D,
103
+ }: UseFloorplanHitTestingArgs) {
104
+ const getFloorplanHitIdAtPoint = useCallback(
105
+ (planPoint: WallPlanPoint) => {
106
+ const point = toPoint2D(planPoint)
107
+ return getFloorplanHitNodeId({
108
+ point,
109
+ ceilings: ceilingPolygons,
110
+ phase,
111
+ isItemContextActive: isFloorplanItemContextActive,
112
+ items: floorplanItemEntries,
113
+ openings: openingsPolygons,
114
+ roofs: floorplanRoofEntries,
115
+ stairs: floorplanStairEntries,
116
+ walls: displayWallPolygons,
117
+ slabs: displaySlabPolygons,
118
+ openingHitTolerance: floorplanOpeningHitTolerance,
119
+ wallHitTolerance: floorplanWallHitTolerance,
120
+ getOpeningCenterLine,
121
+ })
122
+ },
123
+ [
124
+ ceilingPolygons,
125
+ displaySlabPolygons,
126
+ displayWallPolygons,
127
+ floorplanItemEntries,
128
+ floorplanOpeningHitTolerance,
129
+ floorplanRoofEntries,
130
+ floorplanStairEntries,
131
+ floorplanWallHitTolerance,
132
+ getOpeningCenterLine,
133
+ isFloorplanItemContextActive,
134
+ openingsPolygons,
135
+ phase,
136
+ toPoint2D,
137
+ ],
138
+ )
139
+
140
+ const getFloorplanSelectionIdsInBoundsForArea = useCallback(
141
+ (bounds: FloorplanSelectionBounds) =>
142
+ getFloorplanSelectionIdsInBounds({
143
+ bounds,
144
+ ceilings: ceilingPolygons,
145
+ phase,
146
+ isItemContextActive: isFloorplanItemContextActive,
147
+ items: floorplanItemEntries,
148
+ walls: displayWallPolygons,
149
+ openings: openingsPolygons,
150
+ roofs: floorplanRoofEntries,
151
+ slabs: displaySlabPolygons,
152
+ stairs: floorplanStairEntries,
153
+ }),
154
+ [
155
+ ceilingPolygons,
156
+ displaySlabPolygons,
157
+ displayWallPolygons,
158
+ floorplanItemEntries,
159
+ floorplanRoofEntries,
160
+ floorplanStairEntries,
161
+ isFloorplanItemContextActive,
162
+ openingsPolygons,
163
+ phase,
164
+ ],
165
+ )
166
+
167
+ return {
168
+ getFloorplanHitIdAtPoint,
169
+ getFloorplanSelectionIdsInBounds: getFloorplanSelectionIdsInBoundsForArea,
170
+ }
171
+ }