@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
@@ -3,6 +3,7 @@ import {
3
3
  type AnyNodeId,
4
4
  generateId,
5
5
  type LevelNode,
6
+ remapMeasurementReferences,
6
7
  type StairNode,
7
8
  useScene,
8
9
  } from '@pascal-app/core'
@@ -30,6 +31,9 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
30
31
  'stair',
31
32
  'spawn',
32
33
  'zone',
34
+ 'cabinet',
35
+ 'cabinet-module',
36
+ 'measurement',
33
37
  ])
34
38
 
35
39
  let clipboardPayload: ClipboardPayload | null = null
@@ -99,6 +103,30 @@ function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
99
103
  return nodes[parentId]?.type === 'level'
100
104
  }
101
105
 
106
+ function getPromotedCabinetRunId(
107
+ nodes: Record<AnyNodeId, AnyNode>,
108
+ node: AnyNode,
109
+ selectedIds: Set<AnyNodeId>,
110
+ ) {
111
+ if (node.type !== 'cabinet-module') return null
112
+
113
+ const parentId = node.parentId as AnyNodeId | null
114
+ const parent = parentId ? nodes[parentId] : null
115
+ if (parent?.type !== 'cabinet') return null
116
+ if (!parentId) return null
117
+ if (selectedIds.has(parentId)) return parentId
118
+
119
+ const siblingIds = Array.isArray(parent.children) ? (parent.children as AnyNodeId[]) : []
120
+ const hasOnlySelectedModules =
121
+ siblingIds.length > 0 &&
122
+ siblingIds.every((childId) => {
123
+ const child = nodes[childId]
124
+ return child?.type === 'cabinet-module' && selectedIds.has(childId)
125
+ })
126
+
127
+ return hasOnlySelectedModules ? parentId : null
128
+ }
129
+
102
130
  function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
103
131
  const scene = useScene.getState()
104
132
  const resolvedLevelId =
@@ -114,7 +142,7 @@ function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
114
142
  if (!parentId) return null
115
143
 
116
144
  const building = nodes[parentId]
117
- if (!building || building.type !== 'building') return null
145
+ if (building?.type !== 'building') return null
118
146
 
119
147
  const siblingLevels = building.children
120
148
  .map((childId) => nodes[childId as AnyNodeId])
@@ -165,6 +193,10 @@ function remapNodeReferences(
165
193
  ;(clone as StairNode).toLevelId = nextLevelId
166
194
  }
167
195
 
196
+ if (clone.type === 'measurement') {
197
+ clone.measurement = remapMeasurementReferences(clone.measurement, idMap)
198
+ }
199
+
168
200
  const metadata =
169
201
  clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
170
202
  ? { ...(clone.metadata as Record<string, unknown>) }
@@ -176,11 +208,14 @@ function remapNodeReferences(
176
208
  return AnyNode.parse(clone)
177
209
  }
178
210
 
179
- export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
211
+ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
180
212
  const scene = useScene.getState()
181
- const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
182
213
  const selectedIdSet = new Set(ids)
183
- const rootIds = ids.filter((id) => {
214
+ const promotedIds = ids.map((id) => {
215
+ const node = scene.nodes[id]
216
+ return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : id
217
+ })
218
+ const rootIds = Array.from(new Set(promotedIds)).filter((id) => {
184
219
  const node = scene.nodes[id]
185
220
  return (
186
221
  node &&
@@ -191,7 +226,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
191
226
  })
192
227
 
193
228
  if (rootIds.length === 0) {
194
- return false
229
+ return null
195
230
  }
196
231
 
197
232
  const subtreeIds = new Set<AnyNodeId>()
@@ -199,7 +234,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
199
234
  collectSubtreeIds(scene.nodes, rootId, subtreeIds)
200
235
  }
201
236
 
202
- clipboardPayload = {
237
+ return {
203
238
  copiedAt: Date.now(),
204
239
  nodes: [...subtreeIds]
205
240
  .map((id) => scene.nodes[id])
@@ -207,15 +242,45 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
207
242
  .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
208
243
  rootIds,
209
244
  }
245
+ }
246
+
247
+ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
248
+ const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
249
+ const payload = buildClipboardPayload(ids)
250
+ if (!payload) return false
251
+
252
+ clipboardPayload = payload
210
253
  notifySubscribers()
211
254
 
212
255
  return true
213
256
  }
214
257
 
258
+ /**
259
+ * Clone the given nodes (subtrees included, ids remapped) onto the target /
260
+ * active level in place — the same copy + paste pipeline in one step, WITHOUT
261
+ * touching the user's editor clipboard. Selects the clones. Used by the group
262
+ * action menu's Duplicate.
263
+ */
264
+ export function duplicateNodesToLevel(
265
+ ids: AnyNodeId[],
266
+ targetLevelId?: AnyNodeId,
267
+ ): PasteResult | null {
268
+ const payload = buildClipboardPayload(ids)
269
+ if (!payload) return null
270
+ return applyClipboardPayloadToLevel(payload, targetLevelId)
271
+ }
272
+
215
273
  export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
216
- const payload = clipboardPayload
274
+ if (!clipboardPayload) return null
275
+ return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId)
276
+ }
277
+
278
+ function applyClipboardPayloadToLevel(
279
+ payload: ClipboardPayload,
280
+ targetLevelId?: AnyNodeId,
281
+ ): PasteResult | null {
217
282
  const targetLevel = getPasteTargetLevel(targetLevelId)
218
- if (!payload || !targetLevel) return null
283
+ if (!targetLevel) return null
219
284
 
220
285
  const scene = useScene.getState()
221
286
  const idMap = new Map<AnyNodeId, AnyNodeId>()
package/src/lib/scene.ts CHANGED
@@ -1,16 +1,28 @@
1
1
  'use client'
2
2
 
3
- import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
3
+ import {
4
+ clearSceneHistory,
5
+ nodeRegistry,
6
+ resolveLevelId,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
4
10
  import { useViewer } from '@pascal-app/viewer'
5
11
  import useEditor, {
6
12
  hasCustomPersistedEditorUiState,
7
13
  normalizePersistedEditorUiState,
8
14
  type PersistedEditorUiState,
9
15
  } from '../store/use-editor'
16
+ import { editorHostPanelRegistry } from './plugin-panels'
10
17
 
11
18
  export type SceneGraph = {
12
19
  nodes: Record<string, unknown>
13
20
  rootNodeIds: string[]
21
+ // Document-level scene state that travels with the graph. Optional so older
22
+ // payloads (and callers that only build nodes) stay valid.
23
+ collections?: Record<string, unknown>
24
+ materials?: Record<string, unknown>
25
+ installedPlugins?: string[]
14
26
  }
15
27
 
16
28
  type PersistedSelectionPath = {
@@ -355,10 +367,8 @@ function resetEditorInteractionState() {
355
367
  structureLayer: 'elements',
356
368
  catalogCategory: null,
357
369
  selectedItem: null,
358
- movingNode: null,
359
370
  selectedReferenceId: null,
360
371
  spaces: {},
361
- editingHole: null,
362
372
  hoveredHole: null,
363
373
  isPreviewMode: false,
364
374
  })
@@ -373,13 +383,26 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
373
383
  }
374
384
 
375
385
  export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
386
+ const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds()
376
387
  if (hasUsableSceneGraph(sceneGraph)) {
377
- const { nodes, rootNodeIds } = sceneGraph
378
- useScene.getState().setScene(nodes as any, rootNodeIds as any)
388
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph
389
+ useScene.getState().setScene(nodes as any, rootNodeIds as any, {
390
+ collections: collections as any,
391
+ materials: materials as any,
392
+ installedPlugins: installedPlugins ?? defaultInstalledPlugins,
393
+ hasExplicitPluginInstallState: installedPlugins !== undefined,
394
+ })
379
395
  } else {
380
396
  useScene.getState().clearScene()
397
+ useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false })
381
398
  }
382
399
 
400
+ // The loaded scene is the undo floor. Loading records history entries of
401
+ // its own (`unloadScene` + `setScene`/`clearScene` are tracked writes), so
402
+ // without this reset a few Ctrl+Z presses could step past the load into the
403
+ // pre-load — often empty — state and wipe the whole project.
404
+ clearSceneHistory()
405
+
383
406
  syncEditorSelectionFromCurrentScene()
384
407
  }
385
408
 
@@ -0,0 +1,298 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core'
3
+ import { z } from 'zod'
4
+ import {
5
+ resolveCanvasSelectionNode,
6
+ resolveNodeSelectionTarget,
7
+ resolveSelectedIdsForNodeClick,
8
+ selectionModifiersFromEvent,
9
+ shouldPreserveSelectedRoofHostTarget,
10
+ } from './selection-routing'
11
+
12
+ function registerTestDefinition(kind: string, overrides: Record<string, unknown> = {}) {
13
+ if (nodeRegistry.has(kind)) return
14
+ registerNode({
15
+ kind,
16
+ schemaVersion: 1,
17
+ schema: z.object({ type: z.literal(kind) }) as never,
18
+ category: 'furnish',
19
+ defaults: () => ({ type: kind }) as never,
20
+ capabilities: {},
21
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
22
+ ...overrides,
23
+ } as never)
24
+ }
25
+
26
+ describe('resolveSelectedIdsForNodeClick', () => {
27
+ test('preserves the pre-routing selection when a phase switch clears current ids', () => {
28
+ expect(
29
+ resolveSelectedIdsForNodeClick({
30
+ baseSelectedIds: ['wall_1'],
31
+ currentSelectedIds: [],
32
+ modifierKeys: { meta: true, ctrl: false, shift: false },
33
+ nodeId: 'item_1',
34
+ }),
35
+ ).toEqual(['wall_1', 'item_1'])
36
+ })
37
+
38
+ test('toggles from the pre-routing selection while a modifier is held', () => {
39
+ expect(
40
+ resolveSelectedIdsForNodeClick({
41
+ baseSelectedIds: ['wall_1', 'item_1'],
42
+ currentSelectedIds: [],
43
+ modifierKeys: { meta: false, ctrl: false, shift: true },
44
+ nodeId: 'item_1',
45
+ }),
46
+ ).toEqual(['wall_1'])
47
+ })
48
+ })
49
+
50
+ describe('selectionModifiersFromEvent', () => {
51
+ test('falls back to tracked modifier state when the click event omits keys', () => {
52
+ expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({
53
+ meta: false,
54
+ ctrl: true,
55
+ shift: false,
56
+ })
57
+ })
58
+
59
+ test('prefers explicit event key state over stale tracked modifiers', () => {
60
+ expect(
61
+ selectionModifiersFromEvent(
62
+ { metaKey: false, ctrlKey: false, shiftKey: false },
63
+ { meta: true, ctrl: true, shift: true },
64
+ ),
65
+ ).toEqual({
66
+ meta: false,
67
+ ctrl: false,
68
+ shift: false,
69
+ })
70
+ })
71
+ })
72
+
73
+ describe('resolveNodeSelectionTarget', () => {
74
+ test('routes furniture items to furnish', () => {
75
+ const node = {
76
+ id: 'item_1',
77
+ type: 'item',
78
+ asset: { category: 'furniture' },
79
+ } as unknown as AnyNode
80
+
81
+ expect(resolveNodeSelectionTarget(node)).toEqual({ phase: 'furnish' })
82
+ })
83
+
84
+ test('routes door and window catalog items to structure', () => {
85
+ const node = {
86
+ id: 'item_1',
87
+ type: 'item',
88
+ asset: { category: 'door' },
89
+ } as unknown as AnyNode
90
+
91
+ expect(resolveNodeSelectionTarget(node)).toEqual({
92
+ phase: 'structure',
93
+ structureLayer: 'elements',
94
+ })
95
+ })
96
+ })
97
+
98
+ describe('resolveCanvasSelectionNode', () => {
99
+ // Mirrors the cabinet-module setup: modules proxy to their run for grouped
100
+ // move / rotate, but declare `selectionProxy.bypassDirectPick` so a direct
101
+ // body click selects the clicked module.
102
+ const groupKind = 'selection-routing-proxy-group-test'
103
+ const memberKind = 'selection-routing-proxy-member-test'
104
+
105
+ function registerBypassKinds() {
106
+ registerTestDefinition(groupKind)
107
+ registerTestDefinition(memberKind, {
108
+ selectionProxy: {
109
+ bypassDirectPick: (node: AnyNode, proxyTarget: AnyNode) =>
110
+ (node.type as string) === memberKind && (proxyTarget.type as string) === groupKind,
111
+ },
112
+ })
113
+ }
114
+
115
+ test('keeps proxied members individually selectable when the kind declares bypassDirectPick', () => {
116
+ registerBypassKinds()
117
+ const run = { id: 'group_run', type: groupKind, metadata: {} } as unknown as AnyNode
118
+ const module = {
119
+ id: 'group_member',
120
+ type: memberKind,
121
+ parentId: run.id,
122
+ metadata: { nodeSelectionProxyId: run.id },
123
+ } as unknown as AnyNode
124
+
125
+ expect(
126
+ resolveCanvasSelectionNode({
127
+ node: module,
128
+ nodes: {
129
+ [run.id]: run,
130
+ [module.id]: module,
131
+ },
132
+ selectedIds: [],
133
+ }),
134
+ ).toBe(module)
135
+ })
136
+
137
+ test('keeps nested proxied members leaf-selectable by default', () => {
138
+ registerBypassKinds()
139
+ const rootRun = { id: 'group_root_run', type: groupKind, metadata: {} } as unknown as AnyNode
140
+ const legRun = {
141
+ id: 'group_leg_run',
142
+ type: groupKind,
143
+ parentId: rootRun.id,
144
+ metadata: { nodeSelectionProxyId: rootRun.id },
145
+ } as unknown as AnyNode
146
+ const nestedCornerBase = {
147
+ id: 'group_nested_member',
148
+ type: memberKind,
149
+ parentId: legRun.id,
150
+ metadata: { nodeSelectionProxyId: legRun.id },
151
+ } as unknown as AnyNode
152
+
153
+ expect(
154
+ resolveCanvasSelectionNode({
155
+ node: nestedCornerBase,
156
+ nodes: {
157
+ [rootRun.id]: rootRun,
158
+ [legRun.id]: legRun,
159
+ [nestedCornerBase.id]: nestedCornerBase,
160
+ },
161
+ selectedIds: [],
162
+ }),
163
+ ).toBe(nestedCornerBase)
164
+ })
165
+
166
+ test('follows the proxy when the kind declares no bypass', () => {
167
+ const kind = 'selection-routing-proxy-no-bypass-test'
168
+ registerTestDefinition(kind)
169
+ const group = { id: 'plain_group', type: kind, metadata: {} } as unknown as AnyNode
170
+ const member = {
171
+ id: 'plain_member',
172
+ type: kind,
173
+ parentId: group.id,
174
+ metadata: { nodeSelectionProxyId: group.id },
175
+ } as unknown as AnyNode
176
+
177
+ expect(
178
+ resolveCanvasSelectionNode({
179
+ node: member,
180
+ nodes: {
181
+ [group.id]: group,
182
+ [member.id]: member,
183
+ },
184
+ selectedIds: [],
185
+ }),
186
+ ).toBe(group)
187
+ })
188
+
189
+ test('keeps parent-frame children routed to their parent when that parent is solely selected', () => {
190
+ const kind = 'selection-routing-parent-frame-test'
191
+ registerTestDefinition(kind, {
192
+ capabilities: {
193
+ movable: {
194
+ axes: ['x', 'z'],
195
+ gridSnap: true,
196
+ parentFrame: {
197
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
198
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
199
+ },
200
+ },
201
+ },
202
+ })
203
+
204
+ const parent = { id: 'parent_1', type: groupKind, metadata: {} } as unknown as AnyNode
205
+ const child = {
206
+ id: 'child_1',
207
+ type: kind,
208
+ parentId: parent.id,
209
+ metadata: {},
210
+ } as unknown as AnyNode
211
+
212
+ expect(
213
+ resolveCanvasSelectionNode({
214
+ node: child,
215
+ nodes: {
216
+ [parent.id]: parent,
217
+ [child.id]: child,
218
+ },
219
+ selectedIds: [parent.id],
220
+ }),
221
+ ).toBe(parent)
222
+ })
223
+
224
+ test('prefers an explicit selection proxy before parent-frame routing', () => {
225
+ const kind = 'selection-routing-proxy-before-parent-frame-test'
226
+ registerTestDefinition(kind, {
227
+ capabilities: {
228
+ movable: {
229
+ axes: ['x', 'z'],
230
+ gridSnap: true,
231
+ parentFrame: {
232
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
233
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
234
+ },
235
+ },
236
+ },
237
+ })
238
+
239
+ const root = { id: 'root_1', type: groupKind, metadata: {} } as unknown as AnyNode
240
+ const proxyGroup = { id: 'proxy_1', type: groupKind, metadata: {} } as unknown as AnyNode
241
+ const child = {
242
+ id: 'child_proxy_1',
243
+ type: kind,
244
+ parentId: root.id,
245
+ metadata: { nodeSelectionProxyId: proxyGroup.id },
246
+ } as unknown as AnyNode
247
+
248
+ expect(
249
+ resolveCanvasSelectionNode({
250
+ node: child,
251
+ nodes: {
252
+ [root.id]: root,
253
+ [proxyGroup.id]: proxyGroup,
254
+ [child.id]: child,
255
+ },
256
+ selectedIds: [root.id],
257
+ }),
258
+ ).toBe(proxyGroup)
259
+ })
260
+ })
261
+
262
+ describe('shouldPreserveSelectedRoofHostTarget', () => {
263
+ test('keeps the roof host target while that roof is the sole armed selection', () => {
264
+ const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
265
+
266
+ expect(
267
+ shouldPreserveSelectedRoofHostTarget({
268
+ node,
269
+ selectedIds: ['roof_1'],
270
+ armedRoofId: 'roof_1',
271
+ }),
272
+ ).toBe(true)
273
+ })
274
+
275
+ test('falls back to segment targeting when the roof host is not armed', () => {
276
+ const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
277
+
278
+ expect(
279
+ shouldPreserveSelectedRoofHostTarget({
280
+ node,
281
+ selectedIds: ['roof_1'],
282
+ armedRoofId: null,
283
+ }),
284
+ ).toBe(false)
285
+ })
286
+
287
+ test('falls back to segment targeting when the roof is no longer the sole selection', () => {
288
+ const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
289
+
290
+ expect(
291
+ shouldPreserveSelectedRoofHostTarget({
292
+ node,
293
+ selectedIds: ['roof_1', 'wall_1'],
294
+ armedRoofId: 'roof_1',
295
+ }),
296
+ ).toBe(false)
297
+ })
298
+ })
@@ -0,0 +1,175 @@
1
+ import {
2
+ type AnyNode,
3
+ type ItemNode,
4
+ nodeRegistry,
5
+ resolveSelectionProxyId,
6
+ } from '@pascal-app/core'
7
+
8
+ export type SelectionModifierKeys = {
9
+ meta: boolean
10
+ ctrl: boolean
11
+ shift: boolean
12
+ }
13
+
14
+ export type NodeSelectionTarget = {
15
+ phase: 'site' | 'structure' | 'furnish'
16
+ structureLayer?: 'zones' | 'elements'
17
+ }
18
+
19
+ function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean {
20
+ if (node.id === target.id) return false
21
+ // Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind
22
+ // keeps a proxy for grouped affordances but wants a direct body click to
23
+ // select the clicked node itself.
24
+ return nodeRegistry.get(node.type)?.selectionProxy?.bypassDirectPick?.(node, target) ?? false
25
+ }
26
+
27
+ export function resolveCanvasSelectionNode({
28
+ node,
29
+ nodes,
30
+ selectedIds,
31
+ }: {
32
+ node: AnyNode
33
+ nodes: Readonly<Record<string, AnyNode | undefined>>
34
+ selectedIds: readonly string[]
35
+ }): AnyNode {
36
+ const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node
37
+ let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget
38
+ const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
39
+ if (parentFrame) {
40
+ const parent = parentFrame.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
41
+ if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) {
42
+ target = parent
43
+ }
44
+ }
45
+ return target
46
+ }
47
+
48
+ export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean {
49
+ return keys.meta || keys.ctrl || keys.shift
50
+ }
51
+
52
+ export function selectionModifiersFromEvent(
53
+ event?: {
54
+ metaKey?: boolean
55
+ ctrlKey?: boolean
56
+ shiftKey?: boolean
57
+ nativeEvent?: {
58
+ metaKey?: boolean
59
+ ctrlKey?: boolean
60
+ shiftKey?: boolean
61
+ }
62
+ } | null,
63
+ fallback?: Partial<SelectionModifierKeys>,
64
+ ): SelectionModifierKeys {
65
+ const fromEvent = (
66
+ key: keyof SelectionModifierKeys,
67
+ eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey',
68
+ ) => {
69
+ if (typeof event?.[eventKey] === 'boolean') return event[eventKey]
70
+ if (typeof event?.nativeEvent?.[eventKey] === 'boolean') return event.nativeEvent[eventKey]
71
+ return Boolean(fallback?.[key])
72
+ }
73
+
74
+ return {
75
+ meta: fromEvent('meta', 'metaKey'),
76
+ ctrl: fromEvent('ctrl', 'ctrlKey'),
77
+ shift: fromEvent('shift', 'shiftKey'),
78
+ }
79
+ }
80
+
81
+ export function resolveSelectedIdsForNodeClick({
82
+ baseSelectedIds,
83
+ currentSelectedIds,
84
+ modifierKeys,
85
+ nodeId,
86
+ }: {
87
+ baseSelectedIds?: readonly string[]
88
+ currentSelectedIds: readonly string[]
89
+ modifierKeys: SelectionModifierKeys
90
+ nodeId: string
91
+ }): string[] {
92
+ if (isSelectionModifierActive(modifierKeys)) {
93
+ const selectedIds = baseSelectedIds ?? currentSelectedIds
94
+ if (selectedIds.includes(nodeId)) {
95
+ return selectedIds.filter((id) => id !== nodeId)
96
+ }
97
+ return [...selectedIds, nodeId]
98
+ }
99
+
100
+ return [nodeId]
101
+ }
102
+
103
+ export function shouldPreserveSelectedRoofHostTarget({
104
+ node,
105
+ selectedIds,
106
+ armedRoofId,
107
+ }: {
108
+ node: AnyNode
109
+ selectedIds: readonly string[]
110
+ armedRoofId: string | null
111
+ }): boolean {
112
+ return (
113
+ node.type === 'roof' &&
114
+ armedRoofId === node.id &&
115
+ selectedIds.length === 1 &&
116
+ selectedIds[0] === node.id
117
+ )
118
+ }
119
+
120
+ export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null {
121
+ if (node.type === 'building') {
122
+ return { phase: 'site' }
123
+ }
124
+
125
+ if (node.type === 'zone') {
126
+ return {
127
+ phase: 'structure',
128
+ structureLayer: 'zones',
129
+ }
130
+ }
131
+
132
+ if (node.type === 'item') {
133
+ const item = node as ItemNode
134
+ if (item.asset.category === 'door' || item.asset.category === 'window') {
135
+ return {
136
+ phase: 'structure',
137
+ structureLayer: 'elements',
138
+ }
139
+ }
140
+ return { phase: 'furnish' }
141
+ }
142
+
143
+ if (
144
+ node.type === 'wall' ||
145
+ node.type === 'fence' ||
146
+ node.type === 'column' ||
147
+ node.type === 'elevator' ||
148
+ node.type === 'slab' ||
149
+ node.type === 'ceiling' ||
150
+ node.type === 'roof' ||
151
+ node.type === 'roof-segment' ||
152
+ node.type === 'stair' ||
153
+ node.type === 'stair-segment' ||
154
+ node.type === 'spawn' ||
155
+ node.type === 'window' ||
156
+ node.type === 'door'
157
+ ) {
158
+ return {
159
+ phase: 'structure',
160
+ structureLayer: 'elements',
161
+ }
162
+ }
163
+
164
+ const def = nodeRegistry.get(node.type)
165
+ if (!def) return null
166
+
167
+ if (def.category === 'furnish') {
168
+ return { phase: 'furnish' }
169
+ }
170
+
171
+ return {
172
+ phase: 'structure',
173
+ structureLayer: 'elements',
174
+ }
175
+ }
@@ -1,2 +1,3 @@
1
1
  export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus'
2
2
  export { playSFX, SFX, type SFXName, updateSFXVolumes } from '../sfx-player'
3
+ export { movementSfxStepKey } from './movement-tick'