@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -8,11 +8,12 @@ import {
8
8
  } from '@pascal-app/core'
9
9
  import { useViewer } from '@pascal-app/viewer'
10
10
  import { sfxEmitter } from '../../../lib/sfx-bus'
11
+ import useEditor from '../../../store/use-editor'
11
12
  import {
12
13
  findWallSnapTarget,
14
+ getSegmentGridStep,
13
15
  getWallAngleSnapStep,
14
- getWallGridStep,
15
- isWallLongEnough,
16
+ isSegmentLongEnough,
16
17
  snapPointTo45Degrees,
17
18
  snapPointToGrid,
18
19
  type WallPlanPoint,
@@ -20,6 +21,9 @@ import {
20
21
 
21
22
  export type FencePlanPoint = WallPlanPoint
22
23
 
24
+ const FENCE_CORNER_SNAP_RADIUS = 0.28
25
+ const FENCE_SPAN_SNAP_RADIUS = 0.16
26
+
23
27
  type SegmentNode = {
24
28
  start: FencePlanPoint
25
29
  end: FencePlanPoint
@@ -57,46 +61,68 @@ function findFenceSnapTarget(
57
61
  fences: FenceNode[],
58
62
  ignoreFenceIds: string[] = [],
59
63
  ): FencePlanPoint | null {
60
- const radiusSquared = 0.35 ** 2
64
+ const cornerRadiusSquared = FENCE_CORNER_SNAP_RADIUS ** 2
65
+ const spanRadiusSquared = FENCE_SPAN_SNAP_RADIUS ** 2
61
66
  const ignoredFenceIds = new Set(ignoreFenceIds)
62
- let bestTarget: FencePlanPoint | null = null
63
- let bestDistanceSquared = Number.POSITIVE_INFINITY
67
+ let bestCornerTarget: FencePlanPoint | null = null
68
+ let bestCornerDistanceSquared = Number.POSITIVE_INFINITY
69
+ let bestSpanTarget: FencePlanPoint | null = null
70
+ let bestSpanDistanceSquared = Number.POSITIVE_INFINITY
64
71
 
65
72
  for (const fence of fences) {
66
73
  if (ignoredFenceIds.has(fence.id)) {
67
74
  continue
68
75
  }
69
76
 
70
- const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
77
+ for (const candidate of [fence.start, fence.end]) {
78
+ const candidateDistanceSquared = distanceSquared(point, candidate)
79
+ if (
80
+ candidateDistanceSquared > cornerRadiusSquared ||
81
+ candidateDistanceSquared >= bestCornerDistanceSquared
82
+ ) {
83
+ continue
84
+ }
85
+
86
+ bestCornerTarget = candidate
87
+ bestCornerDistanceSquared = candidateDistanceSquared
88
+ }
89
+
71
90
  if (isCurvedWall(fence)) {
72
91
  const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
73
- for (let index = 0; index <= sampleCount; index += 1) {
92
+ for (let index = 1; index < sampleCount; index += 1) {
74
93
  const frame = getWallCurveFrameAt(fence, index / sampleCount)
75
- candidates.push([frame.point.x, frame.point.y])
94
+ const candidate: FencePlanPoint = [frame.point.x, frame.point.y]
95
+ const candidateDistanceSquared = distanceSquared(point, candidate)
96
+ if (
97
+ candidateDistanceSquared > spanRadiusSquared ||
98
+ candidateDistanceSquared >= bestSpanDistanceSquared
99
+ ) {
100
+ continue
101
+ }
102
+
103
+ bestSpanTarget = candidate
104
+ bestSpanDistanceSquared = candidateDistanceSquared
76
105
  }
77
106
  } else {
78
- candidates.push(projectPointOntoSegment(point, fence))
79
- }
80
-
81
- for (const candidate of candidates) {
107
+ const candidate = projectPointOntoSegment(point, fence)
82
108
  if (!candidate) {
83
109
  continue
84
110
  }
85
111
 
86
112
  const candidateDistanceSquared = distanceSquared(point, candidate)
87
113
  if (
88
- candidateDistanceSquared > radiusSquared ||
89
- candidateDistanceSquared >= bestDistanceSquared
114
+ candidateDistanceSquared > spanRadiusSquared ||
115
+ candidateDistanceSquared >= bestSpanDistanceSquared
90
116
  ) {
91
117
  continue
92
118
  }
93
119
 
94
- bestTarget = candidate
95
- bestDistanceSquared = candidateDistanceSquared
120
+ bestSpanTarget = candidate
121
+ bestSpanDistanceSquared = candidateDistanceSquared
96
122
  }
97
123
  }
98
124
 
99
- return bestTarget
125
+ return bestCornerTarget ?? bestSpanTarget
100
126
  }
101
127
 
102
128
  export function snapFenceDraftPoint(args: {
@@ -106,14 +132,25 @@ export function snapFenceDraftPoint(args: {
106
132
  start?: FencePlanPoint
107
133
  angleSnap?: boolean
108
134
  ignoreFenceIds?: string[]
135
+ /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
136
+ step?: number
137
+ /**
138
+ * Optional grid-snap function. When provided, replaces the default
139
+ * local-axis snap — lets the 2D floor-plan keep snapping to the
140
+ * world XZ grid even when the building is rotated. Wall / fence
141
+ * endpoint snap precedence is preserved.
142
+ */
143
+ gridSnap?: (point: FencePlanPoint) => FencePlanPoint
109
144
  }): FencePlanPoint {
110
- const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
111
- const gridStep = getWallGridStep()
145
+ const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args
146
+ const gridStep = step ?? getSegmentGridStep()
112
147
  const angleStep = getWallAngleSnapStep(gridStep)
113
148
  const basePoint =
114
149
  start && angleSnap
115
- ? snapPointTo45Degrees(start, point, gridStep, angleStep)
116
- : snapPointToGrid(point, gridStep)
150
+ ? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap)
151
+ : gridSnap
152
+ ? gridSnap(point)
153
+ : snapPointToGrid(point, gridStep)
117
154
  const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
118
155
 
119
156
  return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
@@ -126,12 +163,17 @@ export function createFenceOnCurrentLevel(
126
163
  const currentLevelId = useViewer.getState().selection.levelId
127
164
  const { createNode, nodes } = useScene.getState()
128
165
 
129
- if (!(currentLevelId && isWallLongEnough(start, end))) {
166
+ if (!(currentLevelId && isSegmentLongEnough(start, end))) {
130
167
  return null
131
168
  }
132
169
 
133
170
  const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
171
+ // Build parameters seeded by a placed preset (height, style, post
172
+ // spacing, …) merge in first; `name`/`start`/`end` always win. The
173
+ // schema parse validates and drops anything unexpected.
174
+ const defaults = useEditor.getState().toolDefaults.fence ?? {}
134
175
  const fence = FenceNode.parse({
176
+ ...defaults,
135
177
  name: `Fence ${fenceCount + 1}`,
136
178
  start,
137
179
  end,
@@ -1,112 +1,48 @@
1
- import type {
2
- BuildingNode,
3
- CeilingNode,
4
- ColumnNode,
5
- DoorNode,
6
- FenceNode,
7
- ItemNode,
8
- RoofNode,
9
- RoofSegmentNode,
10
- SlabNode,
11
- SpawnNode,
12
- StairNode,
13
- StairSegmentNode,
14
- WallNode,
15
- WindowNode,
16
- } from '@pascal-app/core'
17
- import { Vector3 } from 'three'
18
- import { sfxEmitter } from '../../../lib/sfx-bus'
1
+ import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
2
+ import { nodeRegistry } from '@pascal-app/core'
3
+ import { Suspense } from 'react'
19
4
  import useEditor from '../../../store/use-editor'
20
- import { MoveBuildingContent } from '../building/move-building-tool'
21
- import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
22
- import { MoveColumnTool } from '../column/move-column-tool'
23
- import { MoveDoorTool } from '../door/move-door-tool'
24
- import { MoveFenceTool } from '../fence/move-fence-tool'
25
- import { MoveRoofTool } from '../roof/move-roof-tool'
26
- import { MoveSlabTool } from '../slab/move-slab-tool'
27
- import { MoveSpawnTool } from '../spawn/move-spawn-tool'
28
- import { MoveWallTool } from '../wall/move-wall-tool'
29
- import { MoveWindowTool } from '../window/move-window-tool'
30
- import type { PlacementState } from './placement-types'
31
- import { useDraftNode } from './use-draft-node'
32
- import { usePlacementCoordinator } from './use-placement-coordinator'
5
+ import { MoveElevatorTool } from '../elevator/move-elevator-tool'
6
+ import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
7
+ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
33
8
 
34
- function getInitialState(node: {
35
- asset: { attachTo?: string }
36
- parentId: string | null
37
- }): PlacementState {
38
- const attachTo = node.asset.attachTo
39
- if (attachTo === 'wall' || attachTo === 'wall-side') {
40
- return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
41
- }
42
- if (attachTo === 'ceiling') {
43
- return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
44
- }
45
- return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
46
- }
47
-
48
- function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
49
- const draftNode = useDraftNode()
50
-
51
- const meta =
52
- typeof movingNode.metadata === 'object' && movingNode.metadata !== null
53
- ? (movingNode.metadata as Record<string, unknown>)
54
- : {}
55
- const isNew = !!meta.isNew
9
+ /**
10
+ * MoveTool dispatcher. Routes to (in order):
11
+ *
12
+ * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
13
+ * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
14
+ * …).
15
+ * 2. `def.affordanceTools.move` kind-owned move component, lazy-loaded
16
+ * via `getRegistryAffordanceTool`. Covers both generic movers
17
+ * (slab / ceiling / wall / fence / column / item / door / window) and
18
+ * the bespoke roof / roof-segment / stair / stair-segment / building
19
+ * movers ported into `@pascal-app/nodes`.
20
+ * 3. `elevator` is the lone remaining legacy arm its bespoke cab/shaft
21
+ * mover hasn't been ported to a kind-owned affordance yet.
22
+ */
23
+ export const MoveTool: React.FC<{
24
+ onNodeMoved?: (nodeId: AnyNodeId) => void
25
+ onSpawnMoved?: (nodeId: SpawnNode['id']) => void
26
+ }> = ({ onNodeMoved }) => {
27
+ const movingNode = useEditor((state) => state.movingNode)
56
28
 
57
- const cursor = usePlacementCoordinator({
58
- asset: movingNode.asset,
59
- draftNode,
60
- // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
61
- initialState: isNew
62
- ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
63
- : getInitialState(movingNode),
64
- // Preserve the original item's scale so Y-position calculations use the correct height
65
- defaultScale: isNew ? movingNode.scale : undefined,
66
- initDraft: (gridPosition) => {
67
- if (isNew) {
68
- // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
69
- // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
70
- gridPosition.copy(new Vector3(...movingNode.position))
71
- if (!movingNode.asset.attachTo) {
72
- draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
73
- }
74
- } else {
75
- draftNode.adopt(movingNode)
76
- gridPosition.copy(new Vector3(...movingNode.position))
77
- }
78
- },
79
- onCommitted: () => {
80
- sfxEmitter.emit('sfx:item-place')
81
- useEditor.getState().setMovingNode(null)
82
- return false
83
- },
84
- onCancel: () => {
85
- draftNode.destroy()
86
- useEditor.getState().setMovingNode(null)
87
- },
88
- })
29
+ if (!movingNode) return null
89
30
 
90
- return <>{cursor}</>
91
- }
31
+ const def = nodeRegistry.get(movingNode.type)
32
+ if (def?.capabilities?.movable) {
33
+ return <MoveRegistryNodeTool node={movingNode} />
34
+ }
92
35
 
93
- export const MoveTool: React.FC = () => {
94
- const movingNode = useEditor((state) => state.movingNode)
36
+ const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
37
+ if (RegistryMove) {
38
+ return (
39
+ <Suspense fallback={null}>
40
+ <RegistryMove node={movingNode} />
41
+ </Suspense>
42
+ )
43
+ }
95
44
 
96
- if (!movingNode) return null
97
- if (movingNode.type === 'building')
98
- return <MoveBuildingContent node={movingNode as BuildingNode} />
99
- if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
100
- if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
101
- if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
102
- if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
103
- if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
104
- if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
105
- if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
106
- if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
107
- return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
108
- if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
109
- if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
110
- return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
111
- return <MoveItemContent movingNode={movingNode as ItemNode} />
45
+ if (movingNode.type === 'elevator')
46
+ return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
47
+ return null
112
48
  }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { stripTransient } from './placement-math'
3
+
4
+ describe('stripTransient', () => {
5
+ test('removes placement-only metadata flags before commit', () => {
6
+ expect(stripTransient({ isNew: true, isTransient: true, label: 'copy' })).toEqual({
7
+ label: 'copy',
8
+ })
9
+ })
10
+ })
@@ -1,4 +1,5 @@
1
1
  import { type AssetInput, isObject } from '@pascal-app/core'
2
+ import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
2
3
  import useEditor from '../../../store/use-editor'
3
4
 
4
5
  function getGridSnapStep(): number {
@@ -110,11 +111,36 @@ export function isValidWallSideFace(normal: [number, number, number] | undefined
110
111
  return Math.abs(normal[2]) > 0.7
111
112
  }
112
113
 
113
- /**
114
- * Strip the `isTransient` flag from node metadata before committing.
115
- */
114
+ /** Strip placement-only metadata flags before committing a draft. */
116
115
  export function stripTransient(meta: any): any {
117
116
  if (!isObject(meta)) return meta
118
- const { isTransient, ...rest } = meta as Record<string, any>
119
- return rest
117
+ const nextMeta = { ...(meta as Record<string, any>) }
118
+ delete nextMeta.isNew
119
+ delete nextMeta.isTransient
120
+ return nextMeta
121
+ }
122
+
123
+ const _up = new Vector3(0, 1, 0)
124
+ const _normal = new Vector3()
125
+ const _quat = new Quaternion()
126
+ const _euler = new Euler()
127
+
128
+ /**
129
+ * Compute euler rotation that tilts an item so its local +Y aligns with a
130
+ * roof surface normal. The normal is in the hit mesh's local space and is
131
+ * transformed to world space via the mesh's matrixWorld.
132
+ */
133
+ export function calculateRoofRotation(
134
+ normal: [number, number, number] | undefined,
135
+ objectMatrixWorld: Matrix4,
136
+ ): [number, number, number] {
137
+ if (!normal) return [0, 0, 0]
138
+
139
+ _normal.set(normal[0], normal[1], normal[2])
140
+ _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize()
141
+
142
+ _quat.setFromUnitVectors(_up, _normal)
143
+ _euler.setFromQuaternion(_quat, 'XYZ')
144
+
145
+ return [_euler.x, _euler.y, _euler.z]
120
146
  }
@@ -6,16 +6,20 @@ import type {
6
6
  GridEvent,
7
7
  ItemEvent,
8
8
  ItemNode,
9
+ ShelfEvent,
10
+ ShelfNode,
9
11
  WallEvent,
10
12
  WallNode,
11
13
  } from '@pascal-app/core'
12
14
  import {
13
15
  getScaledDimensions,
14
16
  isLowProfileItemSurface,
17
+ nodeRegistry,
15
18
  sceneRegistry,
16
19
  useScene,
17
20
  } from '@pascal-app/core'
18
21
  import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
22
+ import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
19
23
  import {
20
24
  calculateCursorRotation,
21
25
  calculateItemRotation,
@@ -96,16 +100,22 @@ export const floorStrategy = {
96
100
  const [dimX, , dimZ] = dims
97
101
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
98
102
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
99
- // event.localPosition is building-local; the coordinator cursor group is inside the
100
- // building-local ToolManager group, so local coords are correct for both data and visuals.
101
- const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
102
- const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
103
+ // Snap on the world XZ grid (the grid the editor renders) so the
104
+ // item edges land on the visible grid even when the active building
105
+ // is rotated; then project the world point back into building-local
106
+ // for storage. Without this, a rotated building drags placement off
107
+ // the world grid.
108
+ const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
109
+ const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
110
+ const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
111
+ const [x, z] = local
112
+ const y = ctx.gridPosition.y
103
113
 
104
114
  return {
105
- gridPosition: [x, 0, z],
106
- cursorPosition: [x, event.localPosition[1], z],
107
- cursorRotationY: 0,
108
- nodeUpdate: { position: [x, 0, z] },
115
+ gridPosition: [x, y, z],
116
+ cursorPosition: [x, y, z],
117
+ cursorRotationY: rotY,
118
+ nodeUpdate: { position: [x, y, z] },
109
119
  stopPropagation: false,
110
120
  dirtyNodeId: null,
111
121
  }
@@ -123,7 +133,11 @@ export const floorStrategy = {
123
133
  if (ctx.state.surface !== 'floor') return null
124
134
  if (!(ctx.levelId && ctx.draftItem)) return null
125
135
 
126
- const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
136
+ const pos: [number, number, number] = [
137
+ ctx.gridPosition.x,
138
+ ctx.gridPosition.y,
139
+ ctx.gridPosition.z,
140
+ ]
127
141
  const valid = validators.canPlaceOnFloor(
128
142
  ctx.levelId,
129
143
  pos,
@@ -362,16 +376,19 @@ export const ceilingStrategy = {
362
376
  // use the ceiling hit's local position rather than world position.
363
377
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
364
378
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
365
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
379
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
380
+ // void above); everything else hangs its full height below the ceiling.
381
+ const seatY = ctx.asset.recessed ? 0 : -itemHeight
382
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
366
383
 
367
384
  return {
368
385
  stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
369
386
  nodeUpdate: {
370
- position: [x, -itemHeight, z],
387
+ position: [x, seatY, z],
371
388
  parentId: event.node.id,
372
389
  },
373
390
  cursorRotationY: 0,
374
- gridPosition: [x, -itemHeight, z],
391
+ gridPosition: [x, seatY, z],
375
392
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
376
393
  stopPropagation: true,
377
394
  }
@@ -393,10 +410,13 @@ export const ceilingStrategy = {
393
410
 
394
411
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
395
412
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
396
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
413
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
414
+ // void above); everything else hangs its full height below the ceiling.
415
+ const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
416
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
397
417
 
398
418
  return {
399
- gridPosition: [x, -itemHeight, z],
419
+ gridPosition: [x, seatY, z],
400
420
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
401
421
  cursorRotationY: 0,
402
422
  nodeUpdate: null,
@@ -587,6 +607,156 @@ export const itemSurfaceStrategy = {
587
607
  },
588
608
  }
589
609
 
610
+ // ============================================================================
611
+ // SHELF SURFACE STRATEGY
612
+ // ============================================================================
613
+
614
+ /**
615
+ * Resolve the row Y closest to the cursor's local Y. Reads candidate row
616
+ * positions from the kind's `capabilities.surfaces.custom` — the shelf
617
+ * declaration emits one `SurfacePoint` per board's top surface. The
618
+ * strategy stays kind-agnostic at this level: any future "multi-board"
619
+ * kind that declares `surfaces.custom` with upward normals gets the
620
+ * same hit behaviour for free.
621
+ */
622
+ function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
623
+ const def = nodeRegistry.get('shelf')
624
+ const custom = def?.capabilities?.surfaces?.custom
625
+ if (!custom) return null
626
+ const candidates = custom(shelfNode as AnyNode)
627
+ if (candidates.length === 0) return null
628
+ let best = candidates[0]
629
+ let bestDist = Math.abs(best!.position[1] - localY)
630
+ for (let i = 1; i < candidates.length; i++) {
631
+ const c = candidates[i]
632
+ if (!c) continue
633
+ const dist = Math.abs(c.position[1] - localY)
634
+ if (dist < bestDist) {
635
+ best = c
636
+ bestDist = dist
637
+ }
638
+ }
639
+ return best?.position[1] ?? null
640
+ }
641
+
642
+ export const shelfSurfaceStrategy = {
643
+ /**
644
+ * Handle shelf:enter — transition the draft onto the closest shelf
645
+ * row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
646
+ * surface heights from the shelf kind's `surfaces.custom` (one Y per
647
+ * board) instead of `asset.surface.height`. Picks the row whose
648
+ * surface Y is nearest the cursor's local Y so the user can target a
649
+ * specific row by hovering near it.
650
+ */
651
+ enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
652
+ if (ctx.asset.attachTo) return null
653
+ const shelfNode = event.node as ShelfNode
654
+
655
+ if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
656
+ return null
657
+ }
658
+ if (!isUpwardShelfSurfaceHit(event)) return null
659
+
660
+ // Size check: draft footprint must fit on the shelf board (width × depth).
661
+ const ourDims = ctx.draftItem
662
+ ? getScaledDimensions(ctx.draftItem)
663
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
664
+ if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
665
+
666
+ const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
667
+ if (!shelfMesh) return null
668
+
669
+ const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
670
+ const localPos = shelfMesh.worldToLocal(worldPos)
671
+ const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
672
+ if (rowY === null) return null
673
+
674
+ const x = snapToGrid(localPos.x, ourDims[0])
675
+ const z = snapToGrid(localPos.z, ourDims[2])
676
+
677
+ const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
678
+
679
+ const surfaceQuat = new Quaternion()
680
+ shelfMesh.getWorldQuaternion(surfaceQuat)
681
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
682
+ const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
683
+ const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
684
+
685
+ return {
686
+ stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
687
+ nodeUpdate: {
688
+ position: [x, rowY, z],
689
+ parentId: shelfNode.id,
690
+ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
691
+ },
692
+ cursorRotationY: ctx.currentCursorRotationY,
693
+ gridPosition: [x, rowY, z],
694
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
695
+ stopPropagation: true,
696
+ }
697
+ },
698
+
699
+ /**
700
+ * Handle shelf:move — re-derive the closest row each tick so the user
701
+ * can slide between rows without leaving the shelf.
702
+ */
703
+ move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
704
+ if (ctx.state.surface !== 'shelf-surface') return null
705
+ if (!(ctx.state.shelfId && ctx.draftItem)) return null
706
+ if (event.node.id !== ctx.state.shelfId) return null
707
+
708
+ const shelfNode = event.node as ShelfNode
709
+ const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
710
+ if (!shelfMesh) return null
711
+
712
+ const ourDims = getScaledDimensions(ctx.draftItem)
713
+ const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
714
+ const localPos = shelfMesh.worldToLocal(worldPos)
715
+ const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
716
+ if (rowY === null) return null
717
+
718
+ const x = snapToGrid(localPos.x, ourDims[0])
719
+ const z = snapToGrid(localPos.z, ourDims[2])
720
+ const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
721
+
722
+ return {
723
+ gridPosition: [x, rowY, z],
724
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
725
+ cursorRotationY: ctx.currentCursorRotationY,
726
+ nodeUpdate: { position: [x, rowY, z] },
727
+ stopPropagation: true,
728
+ dirtyNodeId: null,
729
+ }
730
+ },
731
+
732
+ /**
733
+ * Handle shelf:click — commit placement on the active row.
734
+ */
735
+ click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
736
+ if (ctx.state.surface !== 'shelf-surface') return null
737
+ if (!(ctx.draftItem && ctx.state.shelfId)) return null
738
+ if (event.node.id !== ctx.state.shelfId) return null
739
+
740
+ return {
741
+ nodeUpdate: {
742
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
743
+ parentId: ctx.state.shelfId,
744
+ metadata: stripTransient(ctx.draftItem.metadata),
745
+ },
746
+ stopPropagation: true,
747
+ dirtyNodeId: null,
748
+ }
749
+ },
750
+ }
751
+
752
+ /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
753
+ * for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
754
+ * via a tiny `ItemEvent`-shaped adapter — the function only reads
755
+ * `event.normal` + `event.object`. */
756
+ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
757
+ return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
758
+ }
759
+
590
760
  // ============================================================================
591
761
  // VALIDATION
592
762
  // ============================================================================
@@ -603,6 +773,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
603
773
  return ctx.state.surfaceItemId !== null
604
774
  }
605
775
 
776
+ // Shelf surface: same — size check already happened on enter
777
+ if (ctx.state.surface === 'shelf-surface') {
778
+ return ctx.state.shelfId !== null
779
+ }
780
+
606
781
  const attachTo = ctx.draftItem.asset.attachTo
607
782
 
608
783
  const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
@@ -635,7 +810,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
635
810
  // Floor (no attachTo)
636
811
  return validators.canPlaceOnFloor(
637
812
  ctx.levelId,
638
- [ctx.gridPosition.x, 0, ctx.gridPosition.z],
813
+ [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
639
814
  alignedDims,
640
815
  ctx.draftItem.rotation,
641
816
  [ctx.draftItem.id],