@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  CeilingNode,
3
+ ColumnNode,
3
4
  DoorNode,
5
+ ElevatorNode,
4
6
  ItemNode,
5
7
  Point2D,
6
8
  RoofNode,
@@ -53,6 +55,16 @@ type CeilingEntry = {
53
55
  holes: Point2D[][]
54
56
  }
55
57
 
58
+ type ColumnEntry = {
59
+ column: ColumnNode
60
+ polygon: Point2D[]
61
+ }
62
+
63
+ type ElevatorEntry = {
64
+ elevator: ElevatorNode
65
+ polygon: Point2D[]
66
+ }
67
+
56
68
  type RoofEntry = {
57
69
  roof: RoofNode
58
70
  segments: Array<{
@@ -71,6 +83,8 @@ type FloorplanSelectionToolContext = {
71
83
  walls: WallEntry[]
72
84
  slabs: SlabEntry[]
73
85
  ceilings: CeilingEntry[]
86
+ columns: ColumnEntry[]
87
+ elevators: ElevatorEntry[]
74
88
  roofs: RoofEntry[]
75
89
  openingHitTolerance: number
76
90
  wallHitTolerance: number
@@ -123,6 +137,20 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
123
137
  return stairHit.stair.id
124
138
  }
125
139
 
140
+ const elevatorHit = context.elevators.find(({ polygon }) =>
141
+ isPointInsidePolygon(context.point, polygon),
142
+ )
143
+ if (elevatorHit) {
144
+ return elevatorHit.elevator.id
145
+ }
146
+
147
+ const columnHit = context.columns.find(({ polygon }) =>
148
+ isPointInsidePolygon(context.point, polygon),
149
+ )
150
+ if (columnHit) {
151
+ return columnHit.column.id
152
+ }
153
+
126
154
  const wallHit = context.walls.find(
127
155
  ({ wall, polygon }) =>
128
156
  isPointInsidePolygon(context.point, polygon) ||
@@ -166,6 +194,8 @@ type FloorplanSelectionBoundsContext = {
166
194
  openings: OpeningPolygonEntry[]
167
195
  slabs: SlabEntry[]
168
196
  ceilings: CeilingEntry[]
197
+ columns: ColumnEntry[]
198
+ elevators: ElevatorEntry[]
169
199
  stairs: StairEntry[]
170
200
  roofs: RoofEntry[]
171
201
  }
@@ -179,6 +209,8 @@ export function getFloorplanSelectionIdsInBounds({
179
209
  openings,
180
210
  slabs,
181
211
  ceilings,
212
+ columns,
213
+ elevators,
182
214
  stairs,
183
215
  roofs,
184
216
  }: FloorplanSelectionBoundsContext) {
@@ -204,6 +236,12 @@ export function getFloorplanSelectionIdsInBounds({
204
236
  const ceilingIds = ceilings
205
237
  .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
206
238
  .map(({ ceiling }) => ceiling.id)
239
+ const columnIds = columns
240
+ .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
241
+ .map(({ column }) => column.id)
242
+ const elevatorIds = elevators
243
+ .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
244
+ .map(({ elevator }) => elevator.id)
207
245
  const stairIds = stairs
208
246
  .filter((stair) =>
209
247
  getStairHitPolygons(stair).some((polygon) =>
@@ -224,6 +262,8 @@ export function getFloorplanSelectionIdsInBounds({
224
262
  ...openingIds,
225
263
  ...slabIds,
226
264
  ...ceilingIds,
265
+ ...columnIds,
266
+ ...elevatorIds,
227
267
  ...stairIds,
228
268
  ...roofIds,
229
269
  ]),
@@ -209,6 +209,14 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
209
209
  return baseSweepAngle
210
210
  }
211
211
 
212
+ function clampFloorplanCircularSweepAngle(sweepAngle: number) {
213
+ if (Math.abs(sweepAngle) >= Math.PI * 2) {
214
+ return Math.sign(sweepAngle || 1) * (Math.PI * 2 - 0.001)
215
+ }
216
+
217
+ return sweepAngle
218
+ }
219
+
212
220
  function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
213
221
  if (
214
222
  (stair.stairType ?? 'straight') !== 'spiral' ||
@@ -230,8 +238,11 @@ function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
230
238
  function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
231
239
  const stairType = stair.stairType ?? 'straight'
232
240
  const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
241
+ const visualSweepAngle = clampFloorplanCircularSweepAngle(
242
+ sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle),
243
+ )
233
244
  const startAngle = -stair.rotation - sweepAngle / 2
234
- const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle)
245
+ const endAngle = startAngle + visualSweepAngle
235
246
  const center = {
236
247
  x: stair.position[0],
237
248
  y: stair.position[2],
@@ -0,0 +1,102 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import { useScene } from '@pascal-app/core'
4
+ import { commitFreshPlacementSubtree } from './fresh-planar-placement'
5
+
6
+ type RafFn = (cb: (time: number) => void) => number
7
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
8
+ cb: (time: number) => void,
9
+ ) => {
10
+ cb(0)
11
+ return 0
12
+ }) as RafFn
13
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
14
+
15
+ const LEVEL_ID = 'level_test' as AnyNodeId
16
+ const SHELF_ID = 'shelf_draft' as AnyNodeId
17
+
18
+ function level(children: AnyNodeId[]): AnyNode {
19
+ return {
20
+ id: LEVEL_ID,
21
+ type: 'level',
22
+ object: 'node',
23
+ parentId: null,
24
+ visible: true,
25
+ metadata: {},
26
+ children,
27
+ level: 0,
28
+ } as AnyNode
29
+ }
30
+
31
+ function shelf(): AnyNode {
32
+ return {
33
+ id: SHELF_ID,
34
+ type: 'shelf',
35
+ object: 'node',
36
+ parentId: LEVEL_ID,
37
+ visible: false,
38
+ metadata: { isNew: true, label: 'draft' },
39
+ children: [],
40
+ position: [0, 0, 0],
41
+ rotation: [0, 0, 0],
42
+ width: 1.2,
43
+ depth: 0.3,
44
+ thickness: 0.04,
45
+ height: 0.9,
46
+ style: 'wall-shelf',
47
+ rows: 1,
48
+ columns: 1,
49
+ withBack: false,
50
+ withSides: true,
51
+ withBottom: false,
52
+ bracketStyle: 'minimal',
53
+ } as AnyNode
54
+ }
55
+
56
+ describe('commitFreshPlacementSubtree', () => {
57
+ beforeEach(() => {
58
+ useScene.setState({
59
+ nodes: {
60
+ [LEVEL_ID]: level([SHELF_ID]),
61
+ [SHELF_ID]: shelf(),
62
+ },
63
+ rootNodeIds: [LEVEL_ID],
64
+ collections: {},
65
+ dirtyNodes: new Set(),
66
+ } as never)
67
+ useScene.temporal.getState().clear()
68
+ useScene.temporal.getState().resume()
69
+ })
70
+
71
+ test('commits a fresh draft as one undoable clean subtree', () => {
72
+ useScene.temporal.getState().pause()
73
+
74
+ const committedId = commitFreshPlacementSubtree(SHELF_ID, {
75
+ position: [2, 0, 3],
76
+ visible: true,
77
+ } as Partial<AnyNode>)
78
+
79
+ expect(committedId).toBeTruthy()
80
+ expect(committedId).not.toBe(SHELF_ID)
81
+ const finalId = committedId as AnyNodeId
82
+ expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
83
+
84
+ const committed = useScene.getState().nodes[finalId] as
85
+ | (AnyNode & { position: [number, number, number]; metadata?: Record<string, unknown> })
86
+ | undefined
87
+ expect(committed?.position).toEqual([2, 0, 3])
88
+ expect(committed?.visible).toBe(true)
89
+ expect(committed?.metadata?.isNew).toBeUndefined()
90
+ expect(committed?.metadata?.label).toBe('draft')
91
+ expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
92
+ finalId,
93
+ ])
94
+
95
+ useScene.temporal.getState().resume()
96
+ useScene.temporal.getState().undo()
97
+
98
+ expect(useScene.getState().nodes[finalId]).toBeUndefined()
99
+ expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
100
+ expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
101
+ })
102
+ })
@@ -0,0 +1,63 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ cloneNodesInto,
5
+ collectSubtree,
6
+ useScene,
7
+ } from '@pascal-app/core'
8
+ import { stripPlacementMetadataFlags } from './placement-metadata'
9
+
10
+ function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
11
+ return {
12
+ ...node,
13
+ metadata: stripPlacementMetadataFlags(node.metadata),
14
+ } as N
15
+ }
16
+
17
+ function parentIdOf(node: AnyNode): AnyNodeId | undefined {
18
+ const parentId = (node as { parentId?: AnyNodeId | null }).parentId
19
+ return parentId ?? undefined
20
+ }
21
+
22
+ /**
23
+ * Finalises a fresh catalog/duplicate draft as a single undoable creation.
24
+ *
25
+ * Fresh drafts already exist in the scene so renderers and move tools can
26
+ * preview real geometry. On commit we delete that draft while history is
27
+ * paused, then create a clean clone at the final cursor position with history
28
+ * resumed. Undo therefore removes the placed node instead of resurrecting the
29
+ * hidden draft at its origin.
30
+ */
31
+ export function commitFreshPlacementSubtree(
32
+ rootId: AnyNodeId,
33
+ rootPatch: Partial<AnyNode>,
34
+ ): AnyNodeId | null {
35
+ const scene = useScene.getState()
36
+ const subtree = collectSubtree(scene.nodes, rootId)
37
+ if (!subtree) return null
38
+
39
+ const root = cleanPlacementMetadata({
40
+ ...subtree.root,
41
+ ...rootPatch,
42
+ } as AnyNode)
43
+ const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
44
+ const parentId = parentIdOf(root)
45
+ const cloned = cloneNodesInto([root, ...descendants], {
46
+ rootId,
47
+ parentId,
48
+ })
49
+
50
+ const temporal = useScene.temporal.getState()
51
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
52
+ if (wasTracking) temporal.pause()
53
+ useScene.getState().deleteNode(rootId)
54
+ temporal.resume()
55
+ useScene
56
+ .getState()
57
+ .createNodes(
58
+ cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
59
+ )
60
+ if (!wasTracking) temporal.pause()
61
+
62
+ return cloned.rootId
63
+ }
@@ -1,6 +1,7 @@
1
- import { useLiveTransforms, useScene } from '@pascal-app/core'
1
+ import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
2
2
 
3
3
  function refreshSceneAfterHistoryJump() {
4
+ useLiveNodeOverrides.getState().clearAll()
4
5
  useLiveTransforms.getState().clearAll()
5
6
 
6
7
  const state = useScene.getState()
@@ -1,5 +1,3 @@
1
- // @ts-expect-error — bun:test is provided by the Bun runtime; editor does not
2
- // depend on @types/bun so the import type is unresolved at compile time.
3
1
  import { describe, expect, test } from 'bun:test'
4
2
  import {
5
3
  type AnyNode,
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
33
33
  return true
34
34
  }
35
35
 
36
+ /**
37
+ * Material field keys per kind, used by the `structure` duplicate preset
38
+ * to strip materials from the cloned subtree. Lookup table replaces the
39
+ * legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
40
+ * in this file as the remaining per-kind dispatch outside the registry.
41
+ *
42
+ * Future: move this to a `capabilities.materialFields` declaration on
43
+ * each kind's `NodeDefinition` so adding a new kind with materials is a
44
+ * registry-only edit. Today the registry doesn't surface material fields
45
+ * in a uniform way (each kind's panel reads / writes them directly), so
46
+ * this map mirrors the legacy behavior 1:1.
47
+ */
48
+ const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
49
+ wall: [
50
+ 'material',
51
+ 'materialPreset',
52
+ 'interiorMaterial',
53
+ 'interiorMaterialPreset',
54
+ 'exteriorMaterial',
55
+ 'exteriorMaterialPreset',
56
+ ],
57
+ slab: ['material', 'materialPreset'],
58
+ ceiling: ['material', 'materialPreset'],
59
+ fence: ['material', 'materialPreset'],
60
+ shelf: ['material', 'materialPreset'],
61
+ 'roof-segment': ['material', 'materialPreset'],
62
+ 'stair-segment': ['material', 'materialPreset'],
63
+ window: ['material', 'materialPreset'],
64
+ door: ['material', 'materialPreset'],
65
+ roof: [
66
+ 'material',
67
+ 'materialPreset',
68
+ 'topMaterial',
69
+ 'topMaterialPreset',
70
+ 'edgeMaterial',
71
+ 'edgeMaterialPreset',
72
+ 'wallMaterial',
73
+ 'wallMaterialPreset',
74
+ ],
75
+ stair: [
76
+ 'material',
77
+ 'materialPreset',
78
+ 'railingMaterial',
79
+ 'railingMaterialPreset',
80
+ 'treadMaterial',
81
+ 'treadMaterialPreset',
82
+ 'sideMaterial',
83
+ 'sideMaterialPreset',
84
+ ],
85
+ }
86
+
36
87
  function stripMaterials(node: AnyNode): AnyNode {
88
+ const fields = MATERIAL_FIELDS_BY_KIND[node.type]
89
+ if (!fields) return node
37
90
  const next = { ...node } as Record<string, unknown>
38
-
39
- switch (node.type) {
40
- case 'wall':
41
- delete next.material
42
- delete next.materialPreset
43
- delete next.interiorMaterial
44
- delete next.interiorMaterialPreset
45
- delete next.exteriorMaterial
46
- delete next.exteriorMaterialPreset
47
- break
48
- case 'slab':
49
- case 'ceiling':
50
- case 'fence':
51
- case 'roof-segment':
52
- case 'stair-segment':
53
- case 'window':
54
- case 'door':
55
- delete next.material
56
- delete next.materialPreset
57
- break
58
- case 'roof':
59
- delete next.material
60
- delete next.materialPreset
61
- delete next.topMaterial
62
- delete next.topMaterialPreset
63
- delete next.edgeMaterial
64
- delete next.edgeMaterialPreset
65
- delete next.wallMaterial
66
- delete next.wallMaterialPreset
67
- break
68
- case 'stair':
69
- delete next.material
70
- delete next.materialPreset
71
- delete next.railingMaterial
72
- delete next.railingMaterialPreset
73
- delete next.treadMaterial
74
- delete next.treadMaterialPreset
75
- delete next.sideMaterial
76
- delete next.sideMaterialPreset
77
- break
78
- }
79
-
91
+ for (const field of fields) delete next[field]
80
92
  return next as AnyNode
81
93
  }
82
94
 
@@ -123,7 +135,7 @@ export function buildLevelDuplicateCreateOps({
123
135
  const keptIds = new Set(filteredNodes.map((node) => node.id))
124
136
 
125
137
  const cleanedNodes = filteredNodes.map((node) => {
126
- if (!('children' in node) || !Array.isArray(node.children)) {
138
+ if (!('children' in node && Array.isArray(node.children))) {
127
139
  return node
128
140
  }
129
141
 
@@ -0,0 +1,11 @@
1
+ import type { LevelNode } from '@pascal-app/core'
2
+
3
+ export function getDefaultLevelName(level: number): string {
4
+ if (level === 0) return 'Ground Floor'
5
+ if (level > 0) return `Floor ${level}`
6
+ return `Basement ${-level}`
7
+ }
8
+
9
+ export function getLevelDisplayName(level: Pick<LevelNode, 'name' | 'level'>): string {
10
+ return level.name || getDefaultLevelName(level.level)
11
+ }