@pascal-app/editor 0.5.1 → 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 (150) hide show
  1. package/package.json +12 -7
  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 +29 -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 +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -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,9 +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
241
+
170
242
  useScene.temporal.getState().resume()
171
243
  applyNodePreview([
172
244
  { id: nodeId, start: preview.start, end: preview.end },
@@ -178,6 +250,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
178
250
  preview.end,
179
251
  ),
180
252
  ])
253
+ useLiveTransforms.getState().clear(nodeId)
254
+ for (const linkedFence of linkedOriginalsRef.current) {
255
+ useLiveTransforms.getState().clear(linkedFence.id)
256
+ }
181
257
  useScene.temporal.getState().pause()
182
258
 
183
259
  sfxEmitter.emit('sfx:item-place')
@@ -187,10 +263,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
187
263
  }
188
264
 
189
265
  const onCancel = () => {
190
- applyNodePreview([
191
- { id: nodeId, start: originalStart, end: originalEnd },
192
- ...linkedOriginalsRef.current,
193
- ])
266
+ clearPreviewState()
194
267
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
195
268
  useScene.temporal.getState().resume()
196
269
  markToolCancelConsumed()
@@ -203,17 +276,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
203
276
 
204
277
  return () => {
205
278
  if (!wasCommitted) {
206
- applyNodePreview([
207
- { id: nodeId, start: originalStart, end: originalEnd },
208
- ...linkedOriginalsRef.current,
209
- ])
279
+ clearPreviewState()
280
+ } else {
281
+ useLiveTransforms.getState().clear(nodeId)
282
+ for (const linkedFence of linkedOriginalsRef.current) {
283
+ useLiveTransforms.getState().clear(linkedFence.id)
284
+ }
210
285
  }
211
286
  useScene.temporal.getState().resume()
212
287
  emitter.off('grid:move', onGridMove)
213
288
  emitter.off('grid:click', onGridClick)
214
289
  emitter.off('tool:cancel', onCancel)
215
290
  }
216
- }, [exitMoveMode])
291
+ }, [exitMoveMode, node])
217
292
 
218
293
  return (
219
294
  <group>
@@ -1,21 +1,31 @@
1
1
  import type {
2
2
  BuildingNode,
3
+ CeilingNode,
4
+ ColumnNode,
3
5
  DoorNode,
4
6
  FenceNode,
5
7
  ItemNode,
6
8
  RoofNode,
7
9
  RoofSegmentNode,
10
+ SlabNode,
11
+ SpawnNode,
8
12
  StairNode,
9
13
  StairSegmentNode,
14
+ WallNode,
10
15
  WindowNode,
11
16
  } from '@pascal-app/core'
12
17
  import { Vector3 } from 'three'
13
18
  import { sfxEmitter } from '../../../lib/sfx-bus'
14
19
  import useEditor from '../../../store/use-editor'
15
20
  import { MoveBuildingContent } from '../building/move-building-tool'
21
+ import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
22
+ import { MoveColumnTool } from '../column/move-column-tool'
16
23
  import { MoveDoorTool } from '../door/move-door-tool'
17
24
  import { MoveFenceTool } from '../fence/move-fence-tool'
18
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'
19
29
  import { MoveWindowTool } from '../window/move-window-tool'
20
30
  import type { PlacementState } from './placement-types'
21
31
  import { useDraftNode } from './use-draft-node'
@@ -80,7 +90,9 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
80
90
  return <>{cursor}</>
81
91
  }
82
92
 
83
- export const MoveTool: React.FC = () => {
93
+ export const MoveTool: React.FC<{
94
+ onSpawnMoved?: (nodeId: SpawnNode['id']) => void
95
+ }> = ({ onSpawnMoved }) => {
84
96
  const movingNode = useEditor((state) => state.movingNode)
85
97
 
86
98
  if (!movingNode) return null
@@ -89,8 +101,14 @@ export const MoveTool: React.FC = () => {
89
101
  if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
90
102
  if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
91
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} />
92
108
  if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
93
109
  return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
110
+ if (movingNode.type === 'spawn')
111
+ return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
94
112
  if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
95
113
  return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
96
114
  return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -1,22 +1,59 @@
1
- import { isObject } from '@pascal-app/core'
1
+ import { type AssetInput, isObject } from '@pascal-app/core'
2
+ import useEditor from '../../../store/use-editor'
3
+
4
+ function getGridSnapStep(): number {
5
+ return useEditor.getState().gridSnapStep
6
+ }
7
+
8
+ function positiveModulo(value: number, divisor: number): number {
9
+ return ((value % divisor) + divisor) % divisor
10
+ }
2
11
 
3
12
  /**
4
13
  * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
5
14
  * For items with dimensions like 2.5, the center would be at 1.25 from the edge,
6
15
  * which doesn't align with 0.5 grid. This adds an offset so edges align instead.
7
16
  */
8
- export function snapToGrid(position: number, dimension: number): number {
17
+ export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
9
18
  const halfDim = dimension / 2
10
- const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
11
- const offset = needsOffset ? 0.25 : 0
12
- return Math.round((position - offset) * 2) / 2 + offset
19
+ const offset = positiveModulo(halfDim, step)
20
+ return Math.round((position - offset) / step) * step + offset
13
21
  }
14
22
 
15
23
  /**
16
24
  * Snap a value to 0.5 increments (used for wall-local positions).
17
25
  */
18
- export function snapToHalf(value: number): number {
19
- return Math.round(value * 2) / 2
26
+ export function snapToHalf(value: number, step = getGridSnapStep()): number {
27
+ return Math.round(value / step) * step
28
+ }
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)]
20
57
  }
21
58
 
22
59
  /**
@@ -9,11 +9,17 @@ import type {
9
9
  WallEvent,
10
10
  WallNode,
11
11
  } from '@pascal-app/core'
12
- import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
13
- import { Vector3 } from 'three'
12
+ import {
13
+ getScaledDimensions,
14
+ isLowProfileItemSurface,
15
+ sceneRegistry,
16
+ useScene,
17
+ } from '@pascal-app/core'
18
+ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
14
19
  import {
15
20
  calculateCursorRotation,
16
21
  calculateItemRotation,
22
+ getGridAlignedDimensions,
17
23
  getSideFromNormal,
18
24
  isValidWallSideFace,
19
25
  snapToGrid,
@@ -30,6 +36,46 @@ import type {
30
36
  } from './placement-types'
31
37
 
32
38
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
39
+ const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
40
+
41
+ function getWorldNormalY(event: ItemEvent): number | null {
42
+ if (!event.normal) return null
43
+
44
+ const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
45
+ normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
46
+ return normal.y
47
+ }
48
+
49
+ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
50
+ const normalY = getWorldNormalY(event)
51
+ return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
52
+ }
53
+
54
+ function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
55
+ if (isLowProfileItemSurface(surfaceItem)) return null
56
+ if (!isUpwardItemSurfaceHit(event)) return null
57
+
58
+ if (surfaceItem.asset.surface) {
59
+ return surfaceItem.asset.surface.height * surfaceItem.scale[1]
60
+ }
61
+
62
+ if (!Number.isFinite(localPos.y)) return null
63
+ return localPos.y
64
+ }
65
+
66
+ function isDescendantOfItem(
67
+ candidate: ItemNode,
68
+ ancestor: ItemNode,
69
+ nodes: Record<string, AnyNode>,
70
+ ): boolean {
71
+ let parentId = candidate.parentId
72
+ while (parentId) {
73
+ if (parentId === ancestor.id) return true
74
+ const parent = nodes[parentId as AnyNodeId]
75
+ parentId = parent?.parentId ?? null
76
+ }
77
+ return false
78
+ }
33
79
 
34
80
  // ============================================================================
35
81
  // FLOOR STRATEGY
@@ -43,9 +89,10 @@ export const floorStrategy = {
43
89
  move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
44
90
  if (ctx.state.surface !== 'floor') return null
45
91
 
46
- const dims = ctx.draftItem
92
+ const rawDims = ctx.draftItem
47
93
  ? getScaledDimensions(ctx.draftItem)
48
94
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
95
+ const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
49
96
  const [dimX, , dimZ] = dims
50
97
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
51
98
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
@@ -80,8 +127,8 @@ export const floorStrategy = {
80
127
  const valid = validators.canPlaceOnFloor(
81
128
  ctx.levelId,
82
129
  pos,
83
- getScaledDimensions(ctx.draftItem),
84
- [0, 0, 0],
130
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
131
+ ctx.draftItem.rotation,
85
132
  [ctx.draftItem.id],
86
133
  ).valid
87
134
 
@@ -133,14 +180,15 @@ export const wallStrategy = {
133
180
  const z = snapToHalf(event.localPosition[2])
134
181
 
135
182
  // Get auto-adjusted Y position from validator
183
+ const rawDims = ctx.draftItem
184
+ ? getScaledDimensions(ctx.draftItem)
185
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
136
186
  const validation = validators.canPlaceOnWall(
137
187
  ctx.levelId,
138
188
  event.node.id,
139
189
  x,
140
190
  y,
141
- ctx.draftItem
142
- ? getScaledDimensions(ctx.draftItem)
143
- : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
191
+ getGridAlignedDimensions(rawDims, attachTo),
144
192
  attachTo,
145
193
  side,
146
194
  [],
@@ -195,7 +243,7 @@ export const wallStrategy = {
195
243
  event.node.id,
196
244
  snappedX,
197
245
  snappedY,
198
- getScaledDimensions(ctx.draftItem),
246
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
199
247
  ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
200
248
  side,
201
249
  [ctx.draftItem.id],
@@ -239,7 +287,7 @@ export const wallStrategy = {
239
287
  ctx.state.wallId as WallNode['id'],
240
288
  ctx.gridPosition.x,
241
289
  ctx.gridPosition.y,
242
- getScaledDimensions(ctx.draftItem),
290
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
243
291
  ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
244
292
  ctx.draftItem.side,
245
293
  [ctx.draftItem.id],
@@ -301,16 +349,20 @@ export const ceilingStrategy = {
301
349
  const ceilingLevelId = resolveLevelId(event.node, nodes)
302
350
  if (ctx.levelId !== ceilingLevelId) return null
303
351
 
304
- const dims = ctx.draftItem
352
+ const rawDims = ctx.draftItem
305
353
  ? getScaledDimensions(ctx.draftItem)
306
354
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
355
+ const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
307
356
  const [dimX, , dimZ] = dims
308
- const itemHeight = dims[1]
357
+ const itemHeight = rawDims[1]
309
358
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
310
359
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
311
360
 
312
- const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
313
- const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
361
+ // Ceiling items are stored in ceiling-local coordinates, so snapping must
362
+ // use the ceiling hit's local position rather than world position.
363
+ const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
364
+ const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
365
+ const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
314
366
 
315
367
  return {
316
368
  stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -320,7 +372,7 @@ export const ceilingStrategy = {
320
372
  },
321
373
  cursorRotationY: 0,
322
374
  gridPosition: [x, -itemHeight, z],
323
- cursorPosition: [x, event.position[1] - itemHeight, z],
375
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
324
376
  stopPropagation: true,
325
377
  }
326
378
  },
@@ -332,18 +384,20 @@ export const ceilingStrategy = {
332
384
  if (ctx.state.surface !== 'ceiling') return null
333
385
  if (!ctx.draftItem) return null
334
386
 
335
- const dims = getScaledDimensions(ctx.draftItem)
387
+ const rawDims = getScaledDimensions(ctx.draftItem)
388
+ const dims = getGridAlignedDimensions(rawDims, ctx.draftItem.asset.attachTo)
336
389
  const [dimX, , dimZ] = dims
337
- const itemHeight = dims[1]
390
+ const itemHeight = rawDims[1]
338
391
  const rotY = ctx.draftItem.rotation?.[1] ?? 0
339
392
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
340
393
 
341
- const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
342
- const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
394
+ const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
395
+ const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
396
+ const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
343
397
 
344
398
  return {
345
399
  gridPosition: [x, -itemHeight, z],
346
- cursorPosition: [x, event.position[1] - itemHeight, z],
400
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
347
401
  cursorRotationY: 0,
348
402
  nodeUpdate: null,
349
403
  stopPropagation: true,
@@ -371,7 +425,7 @@ export const ceilingStrategy = {
371
425
  const valid = validators.canPlaceOnCeiling(
372
426
  ctx.state.ceilingId as CeilingNode['id'],
373
427
  pos,
374
- getScaledDimensions(ctx.draftItem),
428
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
375
429
  ctx.draftItem.rotation,
376
430
  [ctx.draftItem.id],
377
431
  ).valid
@@ -425,8 +479,11 @@ export const itemSurfaceStrategy = {
425
479
  const surfaceItem = event.node as ItemNode
426
480
  // Don't surface-place on the draft itself
427
481
  if (surfaceItem.id === ctx.draftItem?.id) return null
428
- // Surface item must declare a surface
429
- if (!surfaceItem.asset.surface) return null
482
+ if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
483
+ return null
484
+ }
485
+ const nodes = useScene.getState().nodes
486
+ if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
430
487
 
431
488
  // Size check: our footprint must fit on surface item's footprint
432
489
  const ourDims = ctx.draftItem
@@ -440,17 +497,32 @@ export const itemSurfaceStrategy = {
440
497
 
441
498
  const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
442
499
  const localPos = surfaceMesh.worldToLocal(worldPos)
500
+ const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
501
+ if (surfaceHeight === null) return null
443
502
 
444
503
  const x = snapToGrid(localPos.x, ourDims[0])
445
504
  const z = snapToGrid(localPos.z, ourDims[2])
446
- const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
505
+ const y = surfaceHeight
447
506
 
448
507
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
449
508
 
450
509
  return {
451
510
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
452
- nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
453
- cursorRotationY: 0,
511
+ nodeUpdate: {
512
+ position: [x, y, z],
513
+ parentId: surfaceItem.id,
514
+ rotation: [
515
+ (ctx.draftItem?.rotation ?? [0, 0, 0])[0],
516
+ (() => {
517
+ const surfaceQuat = new Quaternion()
518
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
519
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
520
+ return ctx.currentCursorRotationY - surfaceWorldY
521
+ })(),
522
+ (ctx.draftItem?.rotation ?? [0, 0, 0])[2],
523
+ ] as [number, number, number],
524
+ },
525
+ cursorRotationY: ctx.currentCursorRotationY,
454
526
  gridPosition: [x, y, z],
455
527
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
456
528
  stopPropagation: true,
@@ -463,10 +535,11 @@ export const itemSurfaceStrategy = {
463
535
  move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
464
536
  if (ctx.state.surface !== 'item-surface') return null
465
537
  if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
538
+ if (event.node.id !== ctx.state.surfaceItemId) return null
466
539
 
467
540
  const nodes = useScene.getState().nodes
468
541
  const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
469
- if (!surfaceItem?.asset.surface) return null
542
+ if (!surfaceItem) return null
470
543
 
471
544
  const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
472
545
  if (!surfaceMesh) return null
@@ -474,17 +547,19 @@ export const itemSurfaceStrategy = {
474
547
  const ourDims = getScaledDimensions(ctx.draftItem)
475
548
  const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
476
549
  const localPos = surfaceMesh.worldToLocal(worldPos)
550
+ const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
551
+ if (surfaceHeight === null) return null
477
552
 
478
553
  const x = snapToGrid(localPos.x, ourDims[0])
479
554
  const z = snapToGrid(localPos.z, ourDims[2])
480
- const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
555
+ const y = surfaceHeight
481
556
 
482
557
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
483
558
 
484
559
  return {
485
560
  gridPosition: [x, y, z],
486
561
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
487
- cursorRotationY: 0,
562
+ cursorRotationY: ctx.currentCursorRotationY,
488
563
  nodeUpdate: { position: [x, y, z] },
489
564
  stopPropagation: true,
490
565
  dirtyNodeId: null,
@@ -497,6 +572,7 @@ export const itemSurfaceStrategy = {
497
572
  click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
498
573
  if (ctx.state.surface !== 'item-surface') return null
499
574
  if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
575
+ if (_event.node.id !== ctx.state.surfaceItemId) return null
500
576
 
501
577
  return {
502
578
  nodeUpdate: {
@@ -528,12 +604,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
528
604
 
529
605
  const attachTo = ctx.draftItem.asset.attachTo
530
606
 
607
+ const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
608
+
531
609
  if (attachTo === 'ceiling') {
532
610
  if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
533
611
  return validators.canPlaceOnCeiling(
534
612
  ctx.state.ceilingId as CeilingNode['id'],
535
613
  [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
536
- getScaledDimensions(ctx.draftItem),
614
+ alignedDims,
537
615
  ctx.draftItem.rotation,
538
616
  [ctx.draftItem.id],
539
617
  ).valid
@@ -546,7 +624,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
546
624
  ctx.state.wallId as WallNode['id'],
547
625
  ctx.gridPosition.x,
548
626
  ctx.gridPosition.y,
549
- getScaledDimensions(ctx.draftItem),
627
+ alignedDims,
550
628
  attachTo,
551
629
  ctx.draftItem.side,
552
630
  [ctx.draftItem.id],
@@ -557,8 +635,8 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
557
635
  return validators.canPlaceOnFloor(
558
636
  ctx.levelId,
559
637
  [ctx.gridPosition.x, 0, ctx.gridPosition.z],
560
- getScaledDimensions(ctx.draftItem),
561
- [0, 0, 0],
638
+ alignedDims,
639
+ ctx.draftItem.rotation,
562
640
  [ctx.draftItem.id],
563
641
  ).valid
564
642
  }
@@ -38,6 +38,13 @@ export interface PlacementContext {
38
38
  draftItem: ItemNode | null
39
39
  gridPosition: Vector3
40
40
  state: PlacementState
41
+ /**
42
+ * Current world Y rotation of the placement cursor — the user's intended
43
+ * orientation, preserved across surface transitions. Strategies that
44
+ * re-parent the draft (e.g. floor → item-surface) read this to compute the
45
+ * matching parent-local rotation so the world orientation doesn't jump.
46
+ */
47
+ currentCursorRotationY: number
41
48
  }
42
49
 
43
50
  // ============================================================================