@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -1,16 +1,24 @@
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'
9
20
  import { CursorSphere } from '../shared/cursor-sphere'
10
-
11
- function snap(value: number) {
12
- return Math.round(value * 2) / 2
13
- }
21
+ import { snapFenceDraftPoint } from './fence-drafting'
14
22
 
15
23
  function samePoint(a: [number, number], b: [number, number]) {
16
24
  return a[0] === b[0] && a[1] === b[1]
@@ -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,11 +45,17 @@ function getLinkedFenceSnapshots(args: {
36
45
  continue
37
46
  }
38
47
 
48
+ if ((node.parentId ?? null) !== fenceParentId) {
49
+ continue
50
+ }
51
+
39
52
  if (
40
- !samePoint(node.start, originalStart) &&
41
- !samePoint(node.start, originalEnd) &&
42
- !samePoint(node.end, originalStart) &&
43
- !samePoint(node.end, originalEnd)
53
+ !(
54
+ samePoint(node.start, originalStart) ||
55
+ samePoint(node.start, originalEnd) ||
56
+ samePoint(node.end, originalStart) ||
57
+ samePoint(node.end, originalEnd)
58
+ )
44
59
  ) {
45
60
  continue
46
61
  }
@@ -78,12 +93,14 @@ function getLinkedFenceUpdates(
78
93
  }
79
94
 
80
95
  export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
96
+ const activatedAtRef = useRef<number>(Date.now())
81
97
  const previousGridPosRef = useRef<[number, number] | null>(null)
82
98
  const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
83
99
  const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
84
100
  const linkedOriginalsRef = useRef(
85
101
  getLinkedFenceSnapshots({
86
102
  fenceId: node.id,
103
+ fenceParentId: node.parentId ?? null,
87
104
  originalStart: node.start,
88
105
  originalEnd: node.end,
89
106
  }),
@@ -106,11 +123,52 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
106
123
  const nodeId = nodeIdRef.current
107
124
  const originalStart = originalStartRef.current
108
125
  const originalEnd = originalEndRef.current
126
+ const levelNode =
127
+ node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
128
+ ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
129
+ : null
130
+ const levelChildren = levelNode?.children ?? []
131
+ const levelWalls = levelChildren
132
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
133
+ .filter((child): child is WallNode => child?.type === 'wall')
134
+ const levelFences = levelChildren
135
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
136
+ .filter((child): child is FenceNode => child?.type === 'fence')
109
137
 
110
138
  useScene.temporal.getState().pause()
111
139
  let wasCommitted = false
112
140
 
113
- const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
141
+ const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
142
+ const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
143
+ if (!mesh) {
144
+ return
145
+ }
146
+
147
+ mesh.position.set(deltaX, 0, deltaZ)
148
+ }
149
+
150
+ const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
151
+ const originalCenterX = (fence.start[0] + fence.end[0]) / 2
152
+ const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
153
+ useLiveTransforms.getState().set(fence.id, {
154
+ position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
155
+ rotation: 0,
156
+ })
157
+ }
158
+
159
+ const clearPreviewState = () => {
160
+ setMeshOffset(nodeId, 0, 0)
161
+ useLiveTransforms.getState().clear(nodeId)
162
+
163
+ for (const linkedFence of linkedOriginalsRef.current) {
164
+ setMeshOffset(linkedFence.id, 0, 0)
165
+ useLiveTransforms.getState().clear(linkedFence.id)
166
+ }
167
+ }
168
+
169
+ const applyNodePreview = (
170
+ updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
171
+ ) => {
114
172
  useScene.getState().updateNodes(
115
173
  updates.map((entry) => ({
116
174
  id: entry.id as AnyNodeId,
@@ -127,21 +185,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
127
185
  const centerX = (nextStart[0] + nextEnd[0]) / 2
128
186
  const centerZ = (nextStart[1] + nextEnd[1]) / 2
129
187
  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
- ])
188
+ const deltaX = nextStart[0] - originalStart[0]
189
+ const deltaZ = nextStart[1] - originalStart[1]
190
+ setMeshOffset(nodeId, deltaX, deltaZ)
191
+ setFenceLiveTransform(node, deltaX, deltaZ)
192
+
193
+ for (const linkedFence of linkedOriginalsRef.current) {
194
+ setMeshOffset(linkedFence.id, deltaX, deltaZ)
195
+ setFenceLiveTransform(
196
+ {
197
+ ...node,
198
+ id: linkedFence.id,
199
+ start: linkedFence.start,
200
+ end: linkedFence.end,
201
+ },
202
+ deltaX,
203
+ deltaZ,
204
+ )
205
+ }
140
206
  }
141
207
 
142
208
  const onGridMove = (event: GridEvent) => {
143
- const localX = snap(event.localPosition[0])
144
- const localZ = snap(event.localPosition[2])
209
+ const [localX, localZ] = snapFenceDraftPoint({
210
+ point: [event.localPosition[0], event.localPosition[2]],
211
+ walls: levelWalls,
212
+ fences: levelFences,
213
+ ignoreFenceIds: [nodeId],
214
+ })
145
215
 
146
216
  if (
147
217
  previousGridPosRef.current &&
@@ -164,17 +234,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
164
234
  }
165
235
 
166
236
  const onGridClick = (event: GridEvent) => {
237
+ if (Date.now() - activatedAtRef.current < 150) {
238
+ event.nativeEvent?.stopPropagation?.()
239
+ return
240
+ }
241
+
167
242
  const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
168
243
 
169
244
  wasCommitted = true
170
245
 
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
246
  useScene.temporal.getState().resume()
179
247
  applyNodePreview([
180
248
  { id: nodeId, start: preview.start, end: preview.end },
@@ -186,6 +254,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
186
254
  preview.end,
187
255
  ),
188
256
  ])
257
+ useLiveTransforms.getState().clear(nodeId)
258
+ for (const linkedFence of linkedOriginalsRef.current) {
259
+ useLiveTransforms.getState().clear(linkedFence.id)
260
+ }
189
261
  useScene.temporal.getState().pause()
190
262
 
191
263
  sfxEmitter.emit('sfx:item-place')
@@ -195,10 +267,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
195
267
  }
196
268
 
197
269
  const onCancel = () => {
198
- applyNodePreview([
199
- { id: nodeId, start: originalStart, end: originalEnd },
200
- ...linkedOriginalsRef.current,
201
- ])
270
+ clearPreviewState()
202
271
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
203
272
  useScene.temporal.getState().resume()
204
273
  markToolCancelConsumed()
@@ -210,18 +279,20 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
210
279
  emitter.on('tool:cancel', onCancel)
211
280
 
212
281
  return () => {
213
- if (!wasCommitted) {
214
- applyNodePreview([
215
- { id: nodeId, start: originalStart, end: originalEnd },
216
- ...linkedOriginalsRef.current,
217
- ])
282
+ if (wasCommitted) {
283
+ useLiveTransforms.getState().clear(nodeId)
284
+ for (const linkedFence of linkedOriginalsRef.current) {
285
+ useLiveTransforms.getState().clear(linkedFence.id)
286
+ }
287
+ } else {
288
+ clearPreviewState()
218
289
  }
219
290
  useScene.temporal.getState().resume()
220
291
  emitter.off('grid:move', onGridMove)
221
292
  emitter.off('grid:click', onGridClick)
222
293
  emitter.off('tool:cancel', onCancel)
223
294
  }
224
- }, [exitMoveMode])
295
+ }, [exitMoveMode, node])
225
296
 
226
297
  return (
227
298
  <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'
@@ -94,12 +98,14 @@ export const MoveTool: React.FC = () => {
94
98
  return <MoveBuildingContent node={movingNode as BuildingNode} />
95
99
  if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
96
100
  if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
97
- if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
98
101
  if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
102
+ if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
99
103
  if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
100
104
  if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
105
+ if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
101
106
  if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
102
107
  return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
108
+ if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
103
109
  if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
104
110
  return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
105
111
  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 {
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
10
10
  }
11
11
 
12
12
  /**
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.
13
+ * Snaps a position to the active grid step, aligning item edges to grid lines.
16
14
  */
17
15
  export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
18
16
  const halfDim = dimension / 2
@@ -21,12 +19,41 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
21
19
  }
22
20
 
23
21
  /**
24
- * Snap a value to 0.5 increments (used for wall-local positions).
22
+ * Snap a value to the active grid step (used for wall-local positions).
25
23
  */
26
24
  export function snapToHalf(value: number, step = getGridSnapStep()): number {
27
25
  return Math.round(value / step) * step
28
26
  }
29
27
 
28
+ /**
29
+ * Round a value up to the next multiple of `step`, with a minimum of `step`.
30
+ */
31
+ export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
32
+ return Math.max(step, Math.ceil(value / step) * step)
33
+ }
34
+
35
+ /**
36
+ * Expand an item's scaled dimensions up to the active grid step on the axes
37
+ * the placement grid covers. Used for the placement wireframe, snap math, and
38
+ * collision against the draft so a small item visually reserves a full grid
39
+ * cell.
40
+ *
41
+ * - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact.
42
+ * - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact
43
+ * so wall-thickness offsets aren't disturbed.
44
+ */
45
+ export function getGridAlignedDimensions(
46
+ scaledDims: [number, number, number],
47
+ attachTo: AssetInput['attachTo'] | null | undefined,
48
+ step = getGridSnapStep(),
49
+ ): [number, number, number] {
50
+ const [w, h, d] = scaledDims
51
+ if (attachTo === 'wall' || attachTo === 'wall-side') {
52
+ return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d]
53
+ }
54
+ return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
55
+ }
56
+
30
57
  /**
31
58
  * Calculate cursor rotation in WORLD space from wall normal and orientation.
32
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,7 +127,7 @@ export const floorStrategy = {
80
127
  const valid = validators.canPlaceOnFloor(
81
128
  ctx.levelId,
82
129
  pos,
83
- getScaledDimensions(ctx.draftItem),
130
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
84
131
  ctx.draftItem.rotation,
85
132
  [ctx.draftItem.id],
86
133
  ).valid
@@ -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,33 @@ 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
 
509
+ // Counter-rotate so the draft's world Y rotation stays continuous when
510
+ // the user drags onto a rotated surface item. The cursor wireframe
511
+ // already shows the user's intended world rotation; we just need to
512
+ // store the right local value relative to the new parent.
513
+ const surfaceQuat = new Quaternion()
514
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
515
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
516
+ const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
517
+ const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
518
+
450
519
  return {
451
520
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
452
- nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
453
- cursorRotationY: 0,
521
+ nodeUpdate: {
522
+ position: [x, y, z],
523
+ parentId: surfaceItem.id,
524
+ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
525
+ },
526
+ cursorRotationY: ctx.currentCursorRotationY,
454
527
  gridPosition: [x, y, z],
455
528
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
456
529
  stopPropagation: true,
@@ -463,10 +536,11 @@ export const itemSurfaceStrategy = {
463
536
  move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
464
537
  if (ctx.state.surface !== 'item-surface') return null
465
538
  if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
539
+ if (event.node.id !== ctx.state.surfaceItemId) return null
466
540
 
467
541
  const nodes = useScene.getState().nodes
468
542
  const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
469
- if (!surfaceItem?.asset.surface) return null
543
+ if (!surfaceItem) return null
470
544
 
471
545
  const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
472
546
  if (!surfaceMesh) return null
@@ -474,17 +548,19 @@ export const itemSurfaceStrategy = {
474
548
  const ourDims = getScaledDimensions(ctx.draftItem)
475
549
  const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
476
550
  const localPos = surfaceMesh.worldToLocal(worldPos)
551
+ const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
552
+ if (surfaceHeight === null) return null
477
553
 
478
554
  const x = snapToGrid(localPos.x, ourDims[0])
479
555
  const z = snapToGrid(localPos.z, ourDims[2])
480
- const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
556
+ const y = surfaceHeight
481
557
 
482
558
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
483
559
 
484
560
  return {
485
561
  gridPosition: [x, y, z],
486
562
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
487
- cursorRotationY: 0,
563
+ cursorRotationY: ctx.currentCursorRotationY,
488
564
  nodeUpdate: { position: [x, y, z] },
489
565
  stopPropagation: true,
490
566
  dirtyNodeId: null,
@@ -497,6 +573,7 @@ export const itemSurfaceStrategy = {
497
573
  click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
498
574
  if (ctx.state.surface !== 'item-surface') return null
499
575
  if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
576
+ if (_event.node.id !== ctx.state.surfaceItemId) return null
500
577
 
501
578
  return {
502
579
  nodeUpdate: {
@@ -528,12 +605,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
528
605
 
529
606
  const attachTo = ctx.draftItem.asset.attachTo
530
607
 
608
+ const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
609
+
531
610
  if (attachTo === 'ceiling') {
532
611
  if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
533
612
  return validators.canPlaceOnCeiling(
534
613
  ctx.state.ceilingId as CeilingNode['id'],
535
614
  [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
536
- getScaledDimensions(ctx.draftItem),
615
+ alignedDims,
537
616
  ctx.draftItem.rotation,
538
617
  [ctx.draftItem.id],
539
618
  ).valid
@@ -546,7 +625,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
546
625
  ctx.state.wallId as WallNode['id'],
547
626
  ctx.gridPosition.x,
548
627
  ctx.gridPosition.y,
549
- getScaledDimensions(ctx.draftItem),
628
+ alignedDims,
550
629
  attachTo,
551
630
  ctx.draftItem.side,
552
631
  [ctx.draftItem.id],
@@ -557,7 +636,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
557
636
  return validators.canPlaceOnFloor(
558
637
  ctx.levelId,
559
638
  [ctx.gridPosition.x, 0, ctx.gridPosition.z],
560
- getScaledDimensions(ctx.draftItem),
639
+ alignedDims,
561
640
  ctx.draftItem.rotation,
562
641
  [ctx.draftItem.id],
563
642
  ).valid
@@ -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
  // ============================================================================