@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createQuickMeasurementPointerScheduler } from './quick-measurement'
3
+
4
+ function pointer(clientX: number, clientY: number): PointerEvent {
5
+ return { clientX, clientY } as PointerEvent
6
+ }
7
+
8
+ function createFrameDriver() {
9
+ let nextId = 1
10
+ const callbacks = new Map<number, FrameRequestCallback>()
11
+ return {
12
+ driver: {
13
+ request: (callback: FrameRequestCallback) => {
14
+ const id = nextId++
15
+ callbacks.set(id, callback)
16
+ return id
17
+ },
18
+ cancel: (frameId: number) => callbacks.delete(frameId),
19
+ },
20
+ pending: () => callbacks.size,
21
+ runNext: (timestamp: number) => {
22
+ const next = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined
23
+ if (!next) throw new Error('No animation frame scheduled')
24
+ callbacks.delete(next[0])
25
+ next[1](timestamp)
26
+ },
27
+ }
28
+ }
29
+
30
+ describe('createQuickMeasurementPointerScheduler', () => {
31
+ test('keeps only the latest pointer event and caps expensive work', () => {
32
+ const frames = createFrameDriver()
33
+ const processed: PointerEvent[] = []
34
+ const scheduler = createQuickMeasurementPointerScheduler(
35
+ (event) => processed.push(event),
36
+ frames.driver,
37
+ )
38
+
39
+ scheduler.enqueue(pointer(1, 1))
40
+ scheduler.enqueue(pointer(8, 4))
41
+ expect(frames.pending()).toBe(1)
42
+ frames.runNext(0)
43
+ expect(processed.map((event) => [event.clientX, event.clientY])).toEqual([[8, 4]])
44
+
45
+ scheduler.enqueue(pointer(12, 4))
46
+ frames.runNext(16)
47
+ expect(processed).toHaveLength(1)
48
+ expect(frames.pending()).toBe(1)
49
+
50
+ scheduler.enqueue(pointer(20, 7))
51
+ frames.runNext(32)
52
+ expect(processed.map((event) => [event.clientX, event.clientY])).toEqual([
53
+ [8, 4],
54
+ [20, 7],
55
+ ])
56
+ })
57
+
58
+ test('ignores sub-pixel jitter and cancels queued work', () => {
59
+ const frames = createFrameDriver()
60
+ const processed: PointerEvent[] = []
61
+ const scheduler = createQuickMeasurementPointerScheduler(
62
+ (event) => processed.push(event),
63
+ frames.driver,
64
+ )
65
+
66
+ scheduler.enqueue(pointer(10, 10))
67
+ frames.runNext(0)
68
+ scheduler.enqueue(pointer(10.5, 10.5))
69
+ frames.runNext(32)
70
+ expect(processed).toHaveLength(1)
71
+
72
+ scheduler.enqueue(pointer(20, 20))
73
+ scheduler.clear()
74
+ expect(frames.pending()).toBe(0)
75
+ expect(processed).toHaveLength(1)
76
+ })
77
+ })
@@ -0,0 +1,109 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type GeometryContext,
5
+ nodeRegistry,
6
+ type QuickMeasurementReport,
7
+ } from '@pascal-app/core'
8
+
9
+ const QUICK_MEASUREMENT_QUERY_INTERVAL_MS = 30
10
+ const QUICK_MEASUREMENT_MIN_DISTANCE_SQ = 1
11
+
12
+ export function createQuickMeasurementPointerScheduler(
13
+ onPointerMove: (event: PointerEvent) => void,
14
+ frameDriver: {
15
+ request: (callback: FrameRequestCallback) => number
16
+ cancel: (frameId: number) => void
17
+ } = {
18
+ request: (callback) => requestAnimationFrame(callback),
19
+ cancel: (frameId) => cancelAnimationFrame(frameId),
20
+ },
21
+ ): {
22
+ enqueue: (event: PointerEvent) => void
23
+ clear: () => void
24
+ } {
25
+ let latestEvent: PointerEvent | null = null
26
+ let frameId: number | null = null
27
+ let lastProcessedAt = Number.NEGATIVE_INFINITY
28
+ let lastClientX = Number.NaN
29
+ let lastClientY = Number.NaN
30
+
31
+ const schedule = () => {
32
+ if (frameId === null) frameId = frameDriver.request(flush)
33
+ }
34
+ const flush = (timestamp: number) => {
35
+ frameId = null
36
+ if (!latestEvent) return
37
+ if (timestamp - lastProcessedAt < QUICK_MEASUREMENT_QUERY_INTERVAL_MS) {
38
+ schedule()
39
+ return
40
+ }
41
+
42
+ const event = latestEvent
43
+ latestEvent = null
44
+ const deltaX = event.clientX - lastClientX
45
+ const deltaY = event.clientY - lastClientY
46
+ if (
47
+ Number.isFinite(lastClientX) &&
48
+ deltaX * deltaX + deltaY * deltaY < QUICK_MEASUREMENT_MIN_DISTANCE_SQ
49
+ ) {
50
+ return
51
+ }
52
+
53
+ lastClientX = event.clientX
54
+ lastClientY = event.clientY
55
+ lastProcessedAt = timestamp
56
+ onPointerMove(event)
57
+ if (latestEvent) schedule()
58
+ }
59
+
60
+ return {
61
+ enqueue: (event) => {
62
+ latestEvent = event
63
+ schedule()
64
+ },
65
+ clear: () => {
66
+ if (frameId !== null) frameDriver.cancel(frameId)
67
+ latestEvent = null
68
+ frameId = null
69
+ lastProcessedAt = Number.NEGATIVE_INFINITY
70
+ lastClientX = Number.NaN
71
+ lastClientY = Number.NaN
72
+ },
73
+ }
74
+ }
75
+
76
+ export function quickMeasurementContext(
77
+ node: AnyNode,
78
+ nodes: Record<AnyNodeId, AnyNode>,
79
+ ): GeometryContext {
80
+ const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
81
+ nodes[id] as N | undefined
82
+ const childIds =
83
+ 'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
84
+ const children = childIds
85
+ .map((id) => nodes[id])
86
+ .filter((child): child is AnyNode => child !== undefined)
87
+ const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
88
+ const siblings =
89
+ parent && 'children' in parent && Array.isArray(parent.children)
90
+ ? (parent.children as AnyNodeId[])
91
+ .map((id) => nodes[id])
92
+ .filter(
93
+ (sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
94
+ )
95
+ : []
96
+
97
+ return { resolve, children, parent, siblings }
98
+ }
99
+
100
+ export function resolveQuickMeasurementReport(
101
+ nodeId: string | null,
102
+ nodes: Record<AnyNodeId, AnyNode>,
103
+ ): QuickMeasurementReport | null {
104
+ if (!nodeId) return null
105
+ const node = nodes[nodeId as AnyNodeId]
106
+ if (!node || node.visible === false) return null
107
+ const quickMeasure = nodeRegistry.get(node.type)?.measurement?.quickMeasure
108
+ return quickMeasure ? quickMeasure(node, quickMeasurementContext(node, nodes)) : null
109
+ }
@@ -80,7 +80,7 @@ export function duplicateRoofSubtree(
80
80
  const scene = useScene.getState()
81
81
  const sourceRoof = scene.nodes[sourceRoofId]
82
82
 
83
- if (!sourceRoof || sourceRoof.type !== 'roof') {
83
+ if (sourceRoof?.type !== 'roof') {
84
84
  throw new Error(`Node "${sourceRoofId}" is not a roof`)
85
85
  }
86
86
 
@@ -105,7 +105,7 @@ export function duplicateRoofSubtree(
105
105
  const segmentClones: RoofSegmentNode[] = []
106
106
  for (const childId of sourceRoof.children ?? []) {
107
107
  const childNode = scene.nodes[childId as AnyNodeId]
108
- if (!childNode || childNode.type !== 'roof-segment') {
108
+ if (childNode?.type !== 'roof-segment') {
109
109
  continue
110
110
  }
111
111
 
@@ -125,7 +125,7 @@ export function duplicateRoofSubtree(
125
125
 
126
126
  const nextScene = useScene.getState()
127
127
  const createdRoof = nextScene.nodes[roofClone.id as AnyNodeId]
128
- if (!createdRoof || createdRoof.type !== 'roof') {
128
+ if (createdRoof?.type !== 'roof') {
129
129
  throw new Error(`Duplicated roof "${roofClone.id}" was not created`)
130
130
  }
131
131
 
@@ -151,7 +151,7 @@ export function duplicateRoofSubtree(
151
151
 
152
152
  const invalidSegment = segmentIds.find((segmentId) => {
153
153
  const segment = nextScene.nodes[segmentId as AnyNodeId]
154
- return !segment || segment.type !== 'roof-segment' || segment.parentId !== createdRoof.id
154
+ return segment?.type !== 'roof-segment' || segment.parentId !== createdRoof.id
155
155
  })
156
156
  if (invalidSegment) {
157
157
  throw new Error(
@@ -179,7 +179,7 @@ export function clearRoofDuplicateMetadata(
179
179
  ) {
180
180
  const scene = useScene.getState()
181
181
  const roofNode = scene.nodes[roofId]
182
- if (!roofNode || roofNode.type !== 'roof') {
182
+ if (roofNode?.type !== 'roof') {
183
183
  return
184
184
  }
185
185
 
@@ -198,7 +198,7 @@ export function clearRoofDuplicateMetadata(
198
198
 
199
199
  for (const childId of roofNode.children ?? []) {
200
200
  const childNode = scene.nodes[childId as AnyNodeId]
201
- if (!childNode || childNode.type !== 'roof-segment') {
201
+ if (childNode?.type !== 'roof-segment') {
202
202
  continue
203
203
  }
204
204
 
@@ -0,0 +1,22 @@
1
+ import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
2
+ import type * as THREE from 'three'
3
+
4
+ export const HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME = '__roof-hover-outline-proxy__'
5
+
6
+ function hoveredRoofSegmentOutlineProxyName(segmentId: string) {
7
+ return `${HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME}:${segmentId}`
8
+ }
9
+
10
+ export function getHoveredRoofSegmentOutlineProxy(segmentId: string): THREE.Object3D | null {
11
+ const segment = useScene.getState().nodes[segmentId as AnyNodeId]
12
+ if (!(segment?.type === 'roof-segment' && segment.parentId)) return null
13
+ return (
14
+ sceneRegistry.nodes
15
+ .get(segment.parentId)
16
+ ?.getObjectByName(hoveredRoofSegmentOutlineProxyName(segmentId)) ?? null
17
+ )
18
+ }
19
+
20
+ export function getHoveredRoofSegmentOutlineProxyName(segmentId: string): string {
21
+ return hoveredRoofSegmentOutlineProxyName(segmentId)
22
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ type AnyNodeId,
3
+ getRoofSegmentWallFaces,
4
+ getScaledDimensions,
5
+ type ItemNode,
6
+ type RoofNode,
7
+ type RoofSegmentNode,
8
+ type RoofSegmentWallFace,
9
+ type RoofWallFaceId,
10
+ sceneRegistry,
11
+ segmentPointToRoofWallFace,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+ import * as THREE from 'three'
15
+
16
+ const worldPoint = new THREE.Vector3()
17
+ const worldNormal = new THREE.Vector3()
18
+ const localPoint = new THREE.Vector3()
19
+ const localNormal = new THREE.Vector3()
20
+ const inverseMatrix = new THREE.Matrix4()
21
+
22
+ export type RoofWallHit = {
23
+ segment: RoofSegmentNode
24
+ face: RoofSegmentWallFace
25
+ /** Face coords of the hit (u along the face, v above the segment base). */
26
+ u: number
27
+ v: number
28
+ }
29
+
30
+ /** Pointer hits more than this far off the wall plane are not wall hits. */
31
+ const PLANE_TOLERANCE = 0.06
32
+ /** Reject faces whose normal disagrees with the hit normal (slope / soffit). */
33
+ const NORMAL_ALIGNMENT = 0.7
34
+ /** A wall face is vertical; slope faces on low pitches have |ny| ≫ 0. */
35
+ const MAX_NORMAL_Y = 0.4
36
+
37
+ /**
38
+ * Resolve a pointer hit on a roof to one of its segments' vertical wall
39
+ * faces (base walls under the roof + the coplanar gable/shed/gambrel end
40
+ * faces). Counterpart of `resolveRoofSegmentHit`, which resolves to the
41
+ * sloped top surface instead.
42
+ *
43
+ * `normal` must be the raw `NodeEvent.normal` (hit-object-local) together
44
+ * with the `object` it came from — roof events can originate from the
45
+ * merged-roof mesh (roof-local frame) or a painted segment mesh
46
+ * (segment-local frame), so the normal is normalised through world space
47
+ * here instead of trusting the event frame.
48
+ *
49
+ * Lives in `@pascal-app/editor` because both the kind-owned door/window
50
+ * tools (in `@pascal-app/nodes`, which depends on editor) and the item
51
+ * placement coordinator (in editor itself) consume it.
52
+ */
53
+ export function resolveRoofWallHit(
54
+ roof: RoofNode,
55
+ position: [number, number, number],
56
+ normal: [number, number, number] | undefined,
57
+ object: THREE.Object3D | undefined,
58
+ ): RoofWallHit | null {
59
+ if (!normal || !object) return null
60
+
61
+ worldPoint.set(position[0], position[1], position[2])
62
+ worldNormal.set(normal[0], normal[1], normal[2])
63
+ object.updateWorldMatrix(true, false)
64
+ worldNormal.transformDirection(object.matrixWorld)
65
+
66
+ const state = useScene.getState()
67
+ let best: { hit: RoofWallHit; score: number } | null = null
68
+
69
+ for (const childId of roof.children ?? []) {
70
+ const segment = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
71
+ if (segment?.type !== 'roof-segment') continue
72
+ const segObj = sceneRegistry.nodes.get(segment.id)
73
+ if (!segObj) continue
74
+ segObj.updateWorldMatrix(true, false)
75
+
76
+ localPoint.copy(worldPoint)
77
+ segObj.worldToLocal(localPoint)
78
+ inverseMatrix.copy(segObj.matrixWorld).invert()
79
+ localNormal.copy(worldNormal).transformDirection(inverseMatrix)
80
+
81
+ if (Math.abs(localNormal.y) > MAX_NORMAL_Y) continue
82
+
83
+ for (const face of getRoofSegmentWallFaces(segment)) {
84
+ const alignment =
85
+ localNormal.x * face.normal[0] +
86
+ localNormal.y * face.normal[1] +
87
+ localNormal.z * face.normal[2]
88
+ if (alignment < NORMAL_ALIGNMENT) continue
89
+
90
+ const { u, v, dist } = segmentPointToRoofWallFace(segment, face.id, [
91
+ localPoint.x,
92
+ localPoint.y,
93
+ localPoint.z,
94
+ ])
95
+ if (Math.abs(dist) > PLANE_TOLERANCE) continue
96
+ if (u < -PLANE_TOLERANCE || u > face.length + PLANE_TOLERANCE) continue
97
+ if (v < -PLANE_TOLERANCE) continue
98
+
99
+ const score = Math.abs(dist)
100
+ if (!best || score < best.score) {
101
+ best = { hit: { segment, face, u, v }, score }
102
+ }
103
+ }
104
+ }
105
+
106
+ return best?.hit ?? null
107
+ }
108
+
109
+ /**
110
+ * Overlap guard for nodes sharing a roof-segment wall face — the
111
+ * roof-host analogue of `hasWallChildOverlap`. Hosted children store
112
+ * FACE-LOCAL coords + an explicit `roofFace`, so siblings compare
113
+ * directly: doors/windows are center-anchored in v, wall items
114
+ * bottom-anchored.
115
+ */
116
+ export function hasRoofFaceChildOverlap(
117
+ segment: RoofSegmentNode,
118
+ faceId: RoofWallFaceId,
119
+ u: number,
120
+ v: number,
121
+ width: number,
122
+ height: number,
123
+ ignoreId?: string,
124
+ ): boolean {
125
+ const nodes = useScene.getState().nodes
126
+ const newLeft = u - width / 2
127
+ const newRight = u + width / 2
128
+ const newBottom = v - height / 2
129
+ const newTop = v + height / 2
130
+
131
+ for (const childId of segment.children ?? []) {
132
+ if (childId === ignoreId) continue
133
+ const child = nodes[childId as AnyNodeId]
134
+ if (!child) continue
135
+ if ((child as { roofFace?: RoofWallFaceId }).roofFace !== faceId) continue
136
+ const position = (child as { position?: [number, number, number] }).position
137
+ if (!position) continue
138
+
139
+ let childW: number
140
+ let childBottom: number
141
+ let childTop: number
142
+ if (child.type === 'door' || child.type === 'window') {
143
+ const opening = child as { width: number; height: number }
144
+ childW = opening.width
145
+ childBottom = position[1] - opening.height / 2
146
+ childTop = position[1] + opening.height / 2
147
+ } else if (child.type === 'item') {
148
+ const item = child as ItemNode
149
+ if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
150
+ const [w, h] = getScaledDimensions(item)
151
+ childW = w
152
+ // Items anchor position[1] at their bottom edge.
153
+ childBottom = position[1]
154
+ childTop = position[1] + h
155
+ } else {
156
+ continue
157
+ }
158
+
159
+ const xOverlap = newLeft < position[0] + childW / 2 && newRight > position[0] - childW / 2
160
+ const yOverlap = newBottom < childTop && newTop > childBottom
161
+ if (xOverlap && yOverlap) return true
162
+ }
163
+ return false
164
+ }
@@ -0,0 +1,196 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ CabinetModuleNode,
6
+ type CabinetModuleNode as CabinetModuleNodeType,
7
+ CabinetNode,
8
+ type CabinetNode as CabinetNodeType,
9
+ type LevelNode,
10
+ MeasurementNode,
11
+ useScene,
12
+ WallNode,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import {
16
+ copySelectedNodesToEditorClipboard,
17
+ getEditorClipboardSnapshot,
18
+ pasteEditorClipboardToLevel,
19
+ } from './scene-clipboard'
20
+
21
+ const sourceLevelId = 'level_clipboard-source' as LevelNode['id']
22
+ const targetLevelId = 'level_clipboard-target' as LevelNode['id']
23
+ const runId = 'cabinet_clipboard-run' as CabinetNodeType['id']
24
+ const leftModuleId = 'cabinet-module_clipboard-left' as CabinetModuleNodeType['id']
25
+ const rightModuleId = 'cabinet-module_clipboard-right' as CabinetModuleNodeType['id']
26
+
27
+ function makeLevel(id: AnyNodeId, children: AnyNodeId[] = []): AnyNode {
28
+ return {
29
+ id,
30
+ type: 'level',
31
+ object: 'node',
32
+ visible: true,
33
+ name: '',
34
+ metadata: {},
35
+ position: [0, 0, 0],
36
+ rotation: 0,
37
+ parentId: null,
38
+ level: 0,
39
+ children,
40
+ } as unknown as AnyNode
41
+ }
42
+
43
+ function seedCabinetRun() {
44
+ const sourceLevel = makeLevel(sourceLevelId, [runId])
45
+ const targetLevel = makeLevel(targetLevelId)
46
+ const run = CabinetNode.parse({
47
+ id: runId,
48
+ parentId: sourceLevelId,
49
+ position: [1, 0, 2],
50
+ rotation: 0,
51
+ children: [leftModuleId, rightModuleId],
52
+ withCountertop: true,
53
+ showPlinth: true,
54
+ })
55
+ const leftModule = CabinetModuleNode.parse({
56
+ id: leftModuleId,
57
+ parentId: runId,
58
+ position: [-0.45, 0.1, 0],
59
+ width: 0.9,
60
+ showPlinth: false,
61
+ withCountertop: false,
62
+ })
63
+ const rightModule = CabinetModuleNode.parse({
64
+ id: rightModuleId,
65
+ parentId: runId,
66
+ position: [0.45, 0.1, 0],
67
+ width: 0.9,
68
+ showPlinth: false,
69
+ withCountertop: false,
70
+ })
71
+
72
+ useScene.setState({
73
+ nodes: {
74
+ [sourceLevel.id]: sourceLevel,
75
+ [targetLevel.id]: targetLevel,
76
+ [run.id]: run as AnyNode,
77
+ [leftModule.id]: leftModule as AnyNode,
78
+ [rightModule.id]: rightModule as AnyNode,
79
+ },
80
+ rootNodeIds: [sourceLevel.id, targetLevel.id],
81
+ } as never)
82
+ useViewer.getState().setSelection({
83
+ levelId: sourceLevelId,
84
+ selectedIds: [],
85
+ })
86
+ }
87
+
88
+ function isPastedCabinetRun(node: AnyNode): node is CabinetNodeType {
89
+ return node.type === 'cabinet' && node.id !== runId
90
+ }
91
+
92
+ function pastedCabinetRun() {
93
+ return Object.values(useScene.getState().nodes).find(isPastedCabinetRun)
94
+ }
95
+
96
+ describe('scene clipboard', () => {
97
+ beforeEach(() => {
98
+ seedCabinetRun()
99
+ useScene.temporal.getState().clear()
100
+ })
101
+
102
+ test('copies a selected cabinet run as one subtree instead of independent modules', () => {
103
+ const copied = copySelectedNodesToEditorClipboard([runId, leftModuleId, rightModuleId])
104
+
105
+ expect(copied).toBe(true)
106
+ expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId])
107
+
108
+ const result = pasteEditorClipboardToLevel(targetLevelId)
109
+ expect(result?.pastedIds).toHaveLength(1)
110
+
111
+ const pastedRun = pastedCabinetRun()
112
+ expect(pastedRun).toBeDefined()
113
+ expect(pastedRun?.parentId).toBe(targetLevelId)
114
+ expect(pastedRun?.children).toHaveLength(2)
115
+
116
+ for (const childId of pastedRun?.children ?? []) {
117
+ const child = useScene.getState().nodes[childId as AnyNodeId]
118
+ expect(child?.type).toBe('cabinet-module')
119
+ expect(child?.parentId).toBe(pastedRun?.id)
120
+ }
121
+
122
+ const sourceRun = useScene.getState().nodes[runId]
123
+ expect(sourceRun?.type).toBe('cabinet')
124
+ expect((sourceRun as CabinetNodeType | undefined)?.children).toEqual([
125
+ leftModuleId,
126
+ rightModuleId,
127
+ ] satisfies CabinetModuleNodeType['id'][])
128
+ })
129
+
130
+ test('promotes a complete module selection to the cabinet run before copying', () => {
131
+ const copied = copySelectedNodesToEditorClipboard([leftModuleId, rightModuleId])
132
+
133
+ expect(copied).toBe(true)
134
+ expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId])
135
+
136
+ const result = pasteEditorClipboardToLevel(targetLevelId)
137
+ expect(result?.pastedIds).toHaveLength(1)
138
+ expect(pastedCabinetRun()?.children).toHaveLength(2)
139
+ })
140
+
141
+ test('remaps a measurement association when its host is copied with it', () => {
142
+ const wall = WallNode.parse({
143
+ id: 'wall_clipboard-host',
144
+ type: 'wall',
145
+ parentId: sourceLevelId,
146
+ start: [0, 0],
147
+ end: [3, 0],
148
+ })
149
+ const measurement = MeasurementNode.parse({
150
+ id: 'measurement_clipboard-associated',
151
+ type: 'measurement',
152
+ parentId: sourceLevelId,
153
+ measurement: {
154
+ kind: 'distance',
155
+ points: [
156
+ {
157
+ kind: 'feature',
158
+ reference: { nodeId: wall.id, featureId: 'wall:face:left', parameters: { t: 0 } },
159
+ fallback: [0, 0, 0],
160
+ },
161
+ [3, 0, 0],
162
+ ],
163
+ },
164
+ })
165
+ useScene.setState((state) => ({
166
+ nodes: {
167
+ ...state.nodes,
168
+ [sourceLevelId]: makeLevel(sourceLevelId, [wall.id, measurement.id]),
169
+ [wall.id]: wall,
170
+ [measurement.id]: measurement,
171
+ },
172
+ }))
173
+
174
+ expect(copySelectedNodesToEditorClipboard([wall.id, measurement.id])).toBe(true)
175
+ expect(pasteEditorClipboardToLevel(targetLevelId)?.pastedIds).toHaveLength(2)
176
+
177
+ const pastedWall = Object.values(useScene.getState().nodes).find(
178
+ (node) => node.type === 'wall' && node.id !== wall.id,
179
+ )
180
+ const pastedMeasurement = Object.values(useScene.getState().nodes).find(
181
+ (node) => node.type === 'measurement' && node.id !== measurement.id,
182
+ )
183
+ expect(pastedWall?.type).toBe('wall')
184
+ expect(pastedMeasurement?.type).toBe('measurement')
185
+ if (
186
+ pastedWall?.type !== 'wall' ||
187
+ pastedMeasurement?.type !== 'measurement' ||
188
+ pastedMeasurement.measurement.kind !== 'distance'
189
+ ) {
190
+ return
191
+ }
192
+ const anchor = pastedMeasurement.measurement.points[0]
193
+ expect(Array.isArray(anchor)).toBe(false)
194
+ if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id)
195
+ })
196
+ })