@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
@@ -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,115 +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'
33
-
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
56
-
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
- })
89
-
90
- return <>{cursor}</>
91
- }
5
+ import { MoveElevatorTool } from '../elevator/move-elevator-tool'
6
+ import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
7
+ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
92
8
 
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
+ */
93
23
  export const MoveTool: React.FC<{
24
+ onNodeMoved?: (nodeId: AnyNodeId) => void
94
25
  onSpawnMoved?: (nodeId: SpawnNode['id']) => void
95
- }> = ({ onSpawnMoved }) => {
26
+ }> = ({ onNodeMoved }) => {
96
27
  const movingNode = useEditor((state) => state.movingNode)
97
28
 
98
29
  if (!movingNode) return null
99
- if (movingNode.type === 'building')
100
- return <MoveBuildingContent node={movingNode as BuildingNode} />
101
- if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
102
- if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
103
- if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
104
- if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
105
- if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
106
- if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
107
- if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
108
- if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
109
- return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
110
- if (movingNode.type === 'spawn')
111
- return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
112
- if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
113
- return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
114
- return <MoveItemContent movingNode={movingNode as ItemNode} />
30
+
31
+ const def = nodeRegistry.get(movingNode.type)
32
+ if (def?.capabilities?.movable) {
33
+ return <MoveRegistryNodeTool node={movingNode} />
34
+ }
35
+
36
+ const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
37
+ if (RegistryMove) {
38
+ return (
39
+ <Suspense fallback={null}>
40
+ <RegistryMove node={movingNode} />
41
+ </Suspense>
42
+ )
43
+ }
44
+
45
+ if (movingNode.type === 'elevator')
46
+ return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
47
+ return null
115
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 {
@@ -10,9 +11,7 @@ function positiveModulo(value: number, divisor: number): number {
10
11
  }
11
12
 
12
13
  /**
13
- * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
14
- * For items with dimensions like 2.5, the center would be at 1.25 from the edge,
15
- * which doesn't align with 0.5 grid. This adds an offset so edges align instead.
14
+ * Snaps a position to the active grid step, aligning item edges to grid lines.
16
15
  */
17
16
  export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
18
17
  const halfDim = dimension / 2
@@ -21,7 +20,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
21
20
  }
22
21
 
23
22
  /**
24
- * Snap a value to 0.5 increments (used for wall-local positions).
23
+ * Snap a value to the active grid step (used for wall-local positions).
25
24
  */
26
25
  export function snapToHalf(value: number, step = getGridSnapStep()): number {
27
26
  return Math.round(value / step) * step
@@ -112,11 +111,36 @@ export function isValidWallSideFace(normal: [number, number, number] | undefined
112
111
  return Math.abs(normal[2]) > 0.7
113
112
  }
114
113
 
115
- /**
116
- * Strip the `isTransient` flag from node metadata before committing.
117
- */
114
+ /** Strip placement-only metadata flags before committing a draft. */
118
115
  export function stripTransient(meta: any): any {
119
116
  if (!isObject(meta)) return meta
120
- const { isTransient, ...rest } = meta as Record<string, any>
121
- 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]
122
146
  }