@pascal-app/editor 0.6.0 → 0.7.0

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 (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -2,32 +2,113 @@
2
2
 
3
3
  import {
4
4
  type AnyNodeId,
5
- type FenceNode,
6
- type WallNode,
7
5
  emitter,
6
+ type FenceNode,
8
7
  type GridEvent,
9
8
  pauseSceneHistory,
10
9
  resumeSceneHistory,
11
10
  useScene,
11
+ type WallNode,
12
12
  } from '@pascal-app/core'
13
- import { Html } from '@react-three/drei'
14
13
  import { useViewer } from '@pascal-app/viewer'
14
+ import { Html } from '@react-three/drei'
15
15
  import { useCallback, useEffect, useRef, useState } from 'react'
16
16
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
18
18
  import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
19
19
  import { CursorSphere } from '../shared/cursor-sphere'
20
- import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
20
+ import {
21
+ formatAngleRadians,
22
+ getAngleToSegmentReference,
23
+ getSegmentAngleReferenceAtPoint,
24
+ } from '../shared/segment-angle'
21
25
  import { isWallLongEnough } from '../wall/wall-drafting'
26
+ import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
22
27
 
23
28
  function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
24
29
  return a[0] === b[0] && a[1] === b[1]
25
30
  }
26
31
 
32
+ type SegmentLike = {
33
+ id: string
34
+ start: FencePlanPoint
35
+ end: FencePlanPoint
36
+ curveOffset?: number
37
+ }
38
+
39
+ type AngleLabelState = {
40
+ label: string
41
+ position: [number, number, number]
42
+ } | null
43
+
44
+ function getEndpointAngleLabel(args: {
45
+ preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
46
+ segments: SegmentLike[]
47
+ nodeId: FenceNode['id']
48
+ }): AngleLabelState {
49
+ const { preview, segments, nodeId } = args
50
+ const endpoints = [
51
+ {
52
+ point: preview.start,
53
+ },
54
+ {
55
+ point: preview.end,
56
+ },
57
+ ]
58
+ const targetSegment: SegmentLike = {
59
+ id: nodeId,
60
+ start: preview.start,
61
+ end: preview.end,
62
+ curveOffset: preview.curveOffset,
63
+ }
64
+
65
+ for (const endpoint of endpoints) {
66
+ const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
67
+ if (!targetReference) continue
68
+
69
+ const connectedSegment = segments.find(
70
+ (segment) =>
71
+ segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
72
+ )
73
+ if (!connectedSegment) continue
74
+
75
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
76
+ if (!connectedReference) continue
77
+
78
+ const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
79
+ if (angle === null) continue
80
+
81
+ return {
82
+ label: formatAngleRadians(angle),
83
+ position: [endpoint.point[0], 0.34, endpoint.point[1]],
84
+ }
85
+ }
86
+
87
+ return null
88
+ }
89
+
90
+ function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
91
+ return [
92
+ ...walls.map((wall) => ({
93
+ id: wall.id,
94
+ start: wall.start,
95
+ end: wall.end,
96
+ curveOffset: wall.curveOffset,
97
+ })),
98
+ ...fences.map((fence) => ({
99
+ id: fence.id,
100
+ start: fence.start,
101
+ end: fence.end,
102
+ curveOffset: fence.curveOffset,
103
+ })),
104
+ ]
105
+ }
106
+
27
107
  type LinkedFenceSnapshot = {
28
108
  id: FenceNode['id']
29
109
  start: FencePlanPoint
30
110
  end: FencePlanPoint
111
+ curveOffset?: number
31
112
  }
32
113
 
33
114
  function getLinkedFenceSnapshots(args: {
@@ -62,6 +143,7 @@ function getLinkedFenceSnapshots(args: {
62
143
  id: node.id,
63
144
  start: [...node.start] as FencePlanPoint,
64
145
  end: [...node.end] as FencePlanPoint,
146
+ curveOffset: node.curveOffset,
65
147
  })
66
148
  }
67
149
 
@@ -77,6 +159,7 @@ function getLinkedFenceUpdates(
77
159
  ) {
78
160
  return linkedFences.map((fence) => ({
79
161
  id: fence.id,
162
+ curveOffset: fence.curveOffset,
80
163
  start: samePoint(fence.start, originalStart)
81
164
  ? nextStart
82
165
  : samePoint(fence.start, originalEnd)
@@ -112,6 +195,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
112
195
  }),
113
196
  )
114
197
  const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
198
+ const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
115
199
 
116
200
  const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
117
201
  const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
@@ -158,27 +242,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
158
242
  const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
159
243
  const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
160
244
  const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
245
+ const linkedUpdates = detachLinkedFences
246
+ ? []
247
+ : getLinkedFenceUpdates(
248
+ linkedOriginalsRef.current,
249
+ originalStart,
250
+ originalEnd,
251
+ nextStart,
252
+ nextEnd,
253
+ )
161
254
  previewRef.current = { start: nextStart, end: nextEnd }
162
255
  setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
163
- applyNodePreview([
164
- { id: nodeId, start: nextStart, end: nextEnd },
165
- ...(detachLinkedFences
166
- ? []
167
- : getLinkedFenceUpdates(
168
- linkedOriginalsRef.current,
169
- originalStart,
170
- originalEnd,
171
- nextStart,
172
- nextEnd,
173
- )),
174
- ])
256
+ setAngleLabel(
257
+ getEndpointAngleLabel({
258
+ preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
259
+ segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
260
+ nodeId,
261
+ }),
262
+ )
263
+ applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
175
264
  }
176
265
 
177
- const restoreOriginal = () => {
266
+ const restoreOriginal = (clearAngleLabel = true) => {
178
267
  applyNodePreview([
179
268
  { id: nodeId, start: originalStart, end: originalEnd },
180
269
  ...linkedOriginalsRef.current,
181
270
  ])
271
+ if (clearAngleLabel) {
272
+ setAngleLabel(null)
273
+ }
182
274
  }
183
275
 
184
276
  const onGridMove = (event: GridEvent) => {
@@ -240,6 +332,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
240
332
  }
241
333
 
242
334
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
335
+ setAngleLabel(null)
243
336
  exitMoveMode()
244
337
  event.nativeEvent?.stopPropagation?.()
245
338
  }
@@ -248,6 +341,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
248
341
  restoreOriginal()
249
342
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
250
343
  resumeSceneHistory(useScene)
344
+ setAngleLabel(null)
251
345
  markToolCancelConsumed()
252
346
  exitMoveMode()
253
347
  }
@@ -290,7 +384,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
290
384
 
291
385
  return () => {
292
386
  if (!wasCommitted) {
293
- restoreOriginal()
387
+ restoreOriginal(false)
294
388
  }
295
389
  resumeSceneHistory(useScene)
296
390
  emitter.off('grid:move', onGridMove)
@@ -322,6 +416,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
322
416
  </div>
323
417
  </div>
324
418
  </Html>
419
+ {angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
325
420
  </group>
326
421
  )
327
422
  }
423
+
424
+ function EndpointAngleLabel({
425
+ label,
426
+ position,
427
+ }: {
428
+ label: string
429
+ position: [number, number, number]
430
+ }) {
431
+ return (
432
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
433
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
434
+ {label}
435
+ </div>
436
+ </Html>
437
+ )
438
+ }
@@ -1,17 +1,25 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, type FenceNode, emitter, type GridEvent, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ type FenceNode,
7
+ type GridEvent,
8
+ type LevelNode,
9
+ sceneRegistry,
10
+ useLiveTransforms,
11
+ useScene,
12
+ type WallNode,
13
+ } from '@pascal-app/core'
4
14
  import { useViewer } from '@pascal-app/viewer'
5
15
  import { useCallback, useEffect, useRef, useState } from 'react'
16
+ import type * as THREE from 'three'
6
17
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
18
  import { sfxEmitter } from '../../../lib/sfx-bus'
8
19
  import useEditor from '../../../store/use-editor'
20
+ import { snapFenceDraftPoint } from './fence-drafting'
9
21
  import { CursorSphere } from '../shared/cursor-sphere'
10
22
 
11
- function snap(value: number) {
12
- return Math.round(value * 2) / 2
13
- }
14
-
15
23
  function samePoint(a: [number, number], b: [number, number]) {
16
24
  return a[0] === b[0] && a[1] === b[1]
17
25
  }
@@ -24,10 +32,11 @@ type LinkedFenceSnapshot = {
24
32
 
25
33
  function getLinkedFenceSnapshots(args: {
26
34
  fenceId: FenceNode['id']
35
+ fenceParentId: string | null
27
36
  originalStart: [number, number]
28
37
  originalEnd: [number, number]
29
38
  }) {
30
- const { fenceId, originalStart, originalEnd } = args
39
+ const { fenceId, fenceParentId, originalStart, originalEnd } = args
31
40
  const { nodes } = useScene.getState()
32
41
  const snapshots: LinkedFenceSnapshot[] = []
33
42
 
@@ -36,6 +45,10 @@ function getLinkedFenceSnapshots(args: {
36
45
  continue
37
46
  }
38
47
 
48
+ if ((node.parentId ?? null) !== fenceParentId) {
49
+ continue
50
+ }
51
+
39
52
  if (
40
53
  !samePoint(node.start, originalStart) &&
41
54
  !samePoint(node.start, originalEnd) &&
@@ -78,12 +91,14 @@ function getLinkedFenceUpdates(
78
91
  }
79
92
 
80
93
  export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
94
+ const activatedAtRef = useRef<number>(Date.now())
81
95
  const previousGridPosRef = useRef<[number, number] | null>(null)
82
96
  const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
83
97
  const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
84
98
  const linkedOriginalsRef = useRef(
85
99
  getLinkedFenceSnapshots({
86
100
  fenceId: node.id,
101
+ fenceParentId: node.parentId ?? null,
87
102
  originalStart: node.start,
88
103
  originalEnd: node.end,
89
104
  }),
@@ -106,10 +121,49 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
106
121
  const nodeId = nodeIdRef.current
107
122
  const originalStart = originalStartRef.current
108
123
  const originalEnd = originalEndRef.current
124
+ const levelNode =
125
+ node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
126
+ ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
127
+ : null
128
+ const levelChildren = levelNode?.children ?? []
129
+ const levelWalls = levelChildren
130
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
131
+ .filter((child): child is WallNode => child?.type === 'wall')
132
+ const levelFences = levelChildren
133
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
134
+ .filter((child): child is FenceNode => child?.type === 'fence')
109
135
 
110
136
  useScene.temporal.getState().pause()
111
137
  let wasCommitted = false
112
138
 
139
+ const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
140
+ const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
141
+ if (!mesh) {
142
+ return
143
+ }
144
+
145
+ mesh.position.set(deltaX, 0, deltaZ)
146
+ }
147
+
148
+ const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
149
+ const originalCenterX = (fence.start[0] + fence.end[0]) / 2
150
+ const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
151
+ useLiveTransforms.getState().set(fence.id, {
152
+ position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
153
+ rotation: 0,
154
+ })
155
+ }
156
+
157
+ const clearPreviewState = () => {
158
+ setMeshOffset(nodeId, 0, 0)
159
+ useLiveTransforms.getState().clear(nodeId)
160
+
161
+ for (const linkedFence of linkedOriginalsRef.current) {
162
+ setMeshOffset(linkedFence.id, 0, 0)
163
+ useLiveTransforms.getState().clear(linkedFence.id)
164
+ }
165
+ }
166
+
113
167
  const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
114
168
  useScene.getState().updateNodes(
115
169
  updates.map((entry) => ({
@@ -127,21 +181,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
127
181
  const centerX = (nextStart[0] + nextEnd[0]) / 2
128
182
  const centerZ = (nextStart[1] + nextEnd[1]) / 2
129
183
  setCursorLocalPos([centerX, 0, centerZ])
130
- applyNodePreview([
131
- { id: nodeId, start: nextStart, end: nextEnd },
132
- ...getLinkedFenceUpdates(
133
- linkedOriginalsRef.current,
134
- originalStart,
135
- originalEnd,
136
- nextStart,
137
- nextEnd,
138
- ),
139
- ])
184
+ const deltaX = nextStart[0] - originalStart[0]
185
+ const deltaZ = nextStart[1] - originalStart[1]
186
+ setMeshOffset(nodeId, deltaX, deltaZ)
187
+ setFenceLiveTransform(node, deltaX, deltaZ)
188
+
189
+ for (const linkedFence of linkedOriginalsRef.current) {
190
+ setMeshOffset(linkedFence.id, deltaX, deltaZ)
191
+ setFenceLiveTransform(
192
+ {
193
+ ...node,
194
+ id: linkedFence.id,
195
+ start: linkedFence.start,
196
+ end: linkedFence.end,
197
+ },
198
+ deltaX,
199
+ deltaZ,
200
+ )
201
+ }
140
202
  }
141
203
 
142
204
  const onGridMove = (event: GridEvent) => {
143
- const localX = snap(event.localPosition[0])
144
- const localZ = snap(event.localPosition[2])
205
+ const [localX, localZ] = snapFenceDraftPoint({
206
+ point: [event.localPosition[0], event.localPosition[2]],
207
+ walls: levelWalls,
208
+ fences: levelFences,
209
+ ignoreFenceIds: [nodeId],
210
+ })
145
211
 
146
212
  if (
147
213
  previousGridPosRef.current &&
@@ -164,17 +230,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
164
230
  }
165
231
 
166
232
  const onGridClick = (event: GridEvent) => {
233
+ if (Date.now() - activatedAtRef.current < 150) {
234
+ event.nativeEvent?.stopPropagation?.()
235
+ return
236
+ }
237
+
167
238
  const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
168
239
 
169
240
  wasCommitted = true
170
241
 
171
- // Restore original baseline while paused so the next resume+update
172
- // registers as a single tracked change (undo reverts to original).
173
- applyNodePreview([
174
- { id: nodeId, start: originalStart, end: originalEnd },
175
- ...linkedOriginalsRef.current,
176
- ])
177
-
178
242
  useScene.temporal.getState().resume()
179
243
  applyNodePreview([
180
244
  { id: nodeId, start: preview.start, end: preview.end },
@@ -186,6 +250,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
186
250
  preview.end,
187
251
  ),
188
252
  ])
253
+ useLiveTransforms.getState().clear(nodeId)
254
+ for (const linkedFence of linkedOriginalsRef.current) {
255
+ useLiveTransforms.getState().clear(linkedFence.id)
256
+ }
189
257
  useScene.temporal.getState().pause()
190
258
 
191
259
  sfxEmitter.emit('sfx:item-place')
@@ -195,10 +263,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
195
263
  }
196
264
 
197
265
  const onCancel = () => {
198
- applyNodePreview([
199
- { id: nodeId, start: originalStart, end: originalEnd },
200
- ...linkedOriginalsRef.current,
201
- ])
266
+ clearPreviewState()
202
267
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
203
268
  useScene.temporal.getState().resume()
204
269
  markToolCancelConsumed()
@@ -211,17 +276,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
211
276
 
212
277
  return () => {
213
278
  if (!wasCommitted) {
214
- applyNodePreview([
215
- { id: nodeId, start: originalStart, end: originalEnd },
216
- ...linkedOriginalsRef.current,
217
- ])
279
+ clearPreviewState()
280
+ } else {
281
+ useLiveTransforms.getState().clear(nodeId)
282
+ for (const linkedFence of linkedOriginalsRef.current) {
283
+ useLiveTransforms.getState().clear(linkedFence.id)
284
+ }
218
285
  }
219
286
  useScene.temporal.getState().resume()
220
287
  emitter.off('grid:move', onGridMove)
221
288
  emitter.off('grid:click', onGridClick)
222
289
  emitter.off('tool:cancel', onCancel)
223
290
  }
224
- }, [exitMoveMode])
291
+ }, [exitMoveMode, node])
225
292
 
226
293
  return (
227
294
  <group>
@@ -1,12 +1,14 @@
1
1
  import type {
2
2
  BuildingNode,
3
3
  CeilingNode,
4
+ ColumnNode,
4
5
  DoorNode,
5
6
  FenceNode,
6
7
  ItemNode,
7
8
  RoofNode,
8
9
  RoofSegmentNode,
9
10
  SlabNode,
11
+ SpawnNode,
10
12
  StairNode,
11
13
  StairSegmentNode,
12
14
  WallNode,
@@ -17,10 +19,12 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
17
19
  import useEditor from '../../../store/use-editor'
18
20
  import { MoveBuildingContent } from '../building/move-building-tool'
19
21
  import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
22
+ import { MoveColumnTool } from '../column/move-column-tool'
20
23
  import { MoveDoorTool } from '../door/move-door-tool'
21
24
  import { MoveFenceTool } from '../fence/move-fence-tool'
22
25
  import { MoveRoofTool } from '../roof/move-roof-tool'
23
26
  import { MoveSlabTool } from '../slab/move-slab-tool'
27
+ import { MoveSpawnTool } from '../spawn/move-spawn-tool'
24
28
  import { MoveWallTool } from '../wall/move-wall-tool'
25
29
  import { MoveWindowTool } from '../window/move-window-tool'
26
30
  import type { PlacementState } from './placement-types'
@@ -86,7 +90,9 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
86
90
  return <>{cursor}</>
87
91
  }
88
92
 
89
- export const MoveTool: React.FC = () => {
93
+ export const MoveTool: React.FC<{
94
+ onSpawnMoved?: (nodeId: SpawnNode['id']) => void
95
+ }> = ({ onSpawnMoved }) => {
90
96
  const movingNode = useEditor((state) => state.movingNode)
91
97
 
92
98
  if (!movingNode) return null
@@ -96,10 +102,13 @@ export const MoveTool: React.FC = () => {
96
102
  if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
97
103
  if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
98
104
  if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
105
+ if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
99
106
  if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
100
107
  if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
101
108
  if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
102
109
  return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
110
+ if (movingNode.type === 'spawn')
111
+ return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
103
112
  if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
104
113
  return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
105
114
  return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -1,4 +1,4 @@
1
- import { isObject } from '@pascal-app/core'
1
+ import { type AssetInput, isObject } from '@pascal-app/core'
2
2
  import useEditor from '../../../store/use-editor'
3
3
 
4
4
  function getGridSnapStep(): number {
@@ -27,6 +27,35 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
27
27
  return Math.round(value / step) * step
28
28
  }
29
29
 
30
+ /**
31
+ * Round a value up to the next multiple of `step`, with a minimum of `step`.
32
+ */
33
+ export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
34
+ return Math.max(step, Math.ceil(value / step) * step)
35
+ }
36
+
37
+ /**
38
+ * Expand an item's scaled dimensions up to the active grid step on the axes
39
+ * the placement grid covers. Used for the placement wireframe, snap math, and
40
+ * collision against the draft so a small item visually reserves a full grid
41
+ * cell.
42
+ *
43
+ * - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact.
44
+ * - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact
45
+ * so wall-thickness offsets aren't disturbed.
46
+ */
47
+ export function getGridAlignedDimensions(
48
+ scaledDims: [number, number, number],
49
+ attachTo: AssetInput['attachTo'] | null | undefined,
50
+ step = getGridSnapStep(),
51
+ ): [number, number, number] {
52
+ const [w, h, d] = scaledDims
53
+ if (attachTo === 'wall' || attachTo === 'wall-side') {
54
+ return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d]
55
+ }
56
+ return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
57
+ }
58
+
30
59
  /**
31
60
  * Calculate cursor rotation in WORLD space from wall normal and orientation.
32
61
  */