@pascal-app/editor 0.4.0 → 0.6.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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +377 -50
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +125 -10
|
@@ -47,12 +47,16 @@ export const floorStrategy = {
|
|
|
47
47
|
? getScaledDimensions(ctx.draftItem)
|
|
48
48
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
49
49
|
const [dimX, , dimZ] = dims
|
|
50
|
-
const
|
|
51
|
-
const
|
|
50
|
+
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
|
51
|
+
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
52
|
+
// event.localPosition is building-local; the coordinator cursor group is inside the
|
|
53
|
+
// building-local ToolManager group, so local coords are correct for both data and visuals.
|
|
54
|
+
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
|
55
|
+
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
|
52
56
|
|
|
53
57
|
return {
|
|
54
58
|
gridPosition: [x, 0, z],
|
|
55
|
-
cursorPosition: [x, event.
|
|
59
|
+
cursorPosition: [x, event.localPosition[1], z],
|
|
56
60
|
cursorRotationY: 0,
|
|
57
61
|
nodeUpdate: { position: [x, 0, z] },
|
|
58
62
|
stopPropagation: false,
|
|
@@ -77,7 +81,7 @@ export const floorStrategy = {
|
|
|
77
81
|
ctx.levelId,
|
|
78
82
|
pos,
|
|
79
83
|
getScaledDimensions(ctx.draftItem),
|
|
80
|
-
|
|
84
|
+
ctx.draftItem.rotation,
|
|
81
85
|
[ctx.draftItem.id],
|
|
82
86
|
).valid
|
|
83
87
|
|
|
@@ -302,9 +306,11 @@ export const ceilingStrategy = {
|
|
|
302
306
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
303
307
|
const [dimX, , dimZ] = dims
|
|
304
308
|
const itemHeight = dims[1]
|
|
309
|
+
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
|
310
|
+
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
305
311
|
|
|
306
|
-
const x = snapToGrid(event.position[0], dimX)
|
|
307
|
-
const z = snapToGrid(event.position[2], dimZ)
|
|
312
|
+
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
|
313
|
+
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
|
308
314
|
|
|
309
315
|
return {
|
|
310
316
|
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
|
@@ -329,9 +335,11 @@ export const ceilingStrategy = {
|
|
|
329
335
|
const dims = getScaledDimensions(ctx.draftItem)
|
|
330
336
|
const [dimX, , dimZ] = dims
|
|
331
337
|
const itemHeight = dims[1]
|
|
338
|
+
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
|
339
|
+
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
332
340
|
|
|
333
|
-
const x = snapToGrid(event.position[0], dimX)
|
|
334
|
-
const z = snapToGrid(event.position[2], dimZ)
|
|
341
|
+
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
|
342
|
+
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
|
335
343
|
|
|
336
344
|
return {
|
|
337
345
|
gridPosition: [x, -itemHeight, z],
|
|
@@ -550,7 +558,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
550
558
|
ctx.levelId,
|
|
551
559
|
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
|
552
560
|
getScaledDimensions(ctx.draftItem),
|
|
553
|
-
|
|
561
|
+
ctx.draftItem.rotation,
|
|
554
562
|
[ctx.draftItem.id],
|
|
555
563
|
).valid
|
|
556
564
|
}
|
|
@@ -33,6 +33,7 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
|
33
33
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
34
34
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
35
35
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
36
|
+
import { snapToGrid } from './placement-math'
|
|
36
37
|
import {
|
|
37
38
|
ceilingStrategy,
|
|
38
39
|
checkCanPlace,
|
|
@@ -68,7 +69,7 @@ const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
|
|
|
68
69
|
basePlaneMaterial.opacityNode = radialOpacity
|
|
69
70
|
|
|
70
71
|
export interface PlacementCoordinatorConfig {
|
|
71
|
-
asset: AssetInput
|
|
72
|
+
asset: AssetInput | null
|
|
72
73
|
draftNode: DraftNodeHandle
|
|
73
74
|
initDraft: (gridPosition: Vector3) => void
|
|
74
75
|
onCommitted: () => boolean
|
|
@@ -83,6 +84,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
83
84
|
const edgesRef = useRef<LineSegments>(null!)
|
|
84
85
|
const basePlaneRef = useRef<Mesh>(null!)
|
|
85
86
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
|
87
|
+
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
|
86
88
|
const placementState = useRef<PlacementState>(
|
|
87
89
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
|
88
90
|
)
|
|
@@ -96,6 +98,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
96
98
|
const { asset, draftNode } = config
|
|
97
99
|
|
|
98
100
|
useEffect(() => {
|
|
101
|
+
if (!asset) return
|
|
99
102
|
useScene.temporal.getState().pause()
|
|
100
103
|
|
|
101
104
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
@@ -135,11 +138,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
135
138
|
return placeable
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
// Tool visuals are rendered inside the building-local ToolManager group, so all cursor
|
|
142
|
+
// positions must be in building-local space. Wall/ceiling/item-surface strategies return
|
|
143
|
+
// world-space cursor positions (from their event.position); convert them here.
|
|
144
|
+
const worldToBuildingLocal = (x: number, y: number, z: number): Vector3 => {
|
|
145
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
146
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
147
|
+
return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
const applyTransition = (result: TransitionResult) => {
|
|
139
151
|
Object.assign(placementState.current, result.stateUpdate)
|
|
140
152
|
gridPosition.current.set(...result.gridPosition)
|
|
141
153
|
|
|
142
|
-
|
|
154
|
+
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
155
|
+
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
143
156
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
144
157
|
|
|
145
158
|
const draft = draftNode.current
|
|
@@ -152,7 +165,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
152
165
|
|
|
153
166
|
const ensureDraft = (result: TransitionResult) => {
|
|
154
167
|
gridPosition.current.set(...result.gridPosition)
|
|
155
|
-
|
|
168
|
+
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
169
|
+
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
156
170
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
157
171
|
|
|
158
172
|
draftNode.create(
|
|
@@ -181,7 +195,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
181
195
|
if (draftNode.current) {
|
|
182
196
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
183
197
|
if (mesh) {
|
|
184
|
-
|
|
198
|
+
const worldPos = new Vector3()
|
|
199
|
+
mesh.getWorldPosition(worldPos)
|
|
200
|
+
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
|
201
|
+
cursorGroupRef.current.position.copy(localPos)
|
|
185
202
|
// Extract world Y rotation (handles wall-parented items correctly)
|
|
186
203
|
const q = new Quaternion()
|
|
187
204
|
mesh.getWorldQuaternion(q)
|
|
@@ -199,6 +216,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
199
216
|
let previousGridPos: [number, number, number] | null = null
|
|
200
217
|
|
|
201
218
|
const onGridMove = (event: GridEvent) => {
|
|
219
|
+
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
|
220
|
+
if (draftNode.current === null && asset.attachTo === undefined) {
|
|
221
|
+
configRef.current.initDraft(gridPosition.current)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
|
202
225
|
const result = floorStrategy.move(getContext(), event)
|
|
203
226
|
if (!result) return
|
|
204
227
|
|
|
@@ -334,7 +357,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
334
357
|
}
|
|
335
358
|
|
|
336
359
|
gridPosition.current.set(...result.gridPosition)
|
|
337
|
-
|
|
360
|
+
const wc = worldToBuildingLocal(...result.cursorPosition)
|
|
361
|
+
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
|
|
338
362
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
339
363
|
|
|
340
364
|
const draft = draftNode.current
|
|
@@ -480,13 +504,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
480
504
|
return
|
|
481
505
|
}
|
|
482
506
|
|
|
507
|
+
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
483
508
|
const result = itemSurfaceStrategy.move(ctx, event)
|
|
484
509
|
if (!result) return
|
|
485
510
|
|
|
486
511
|
event.stopPropagation()
|
|
487
512
|
|
|
488
513
|
gridPosition.current.set(...result.gridPosition)
|
|
489
|
-
|
|
514
|
+
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
515
|
+
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
490
516
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
491
517
|
|
|
492
518
|
const draft = draftNode.current
|
|
@@ -511,14 +537,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
511
537
|
|
|
512
538
|
event.stopPropagation()
|
|
513
539
|
|
|
514
|
-
// Transition back to floor using
|
|
515
|
-
const wx = Math.round(event.
|
|
516
|
-
const wz = Math.round(event.
|
|
540
|
+
// Transition back to floor using building-local position
|
|
541
|
+
const wx = Math.round(event.localPosition[0] * 2) / 2
|
|
542
|
+
const wz = Math.round(event.localPosition[2] * 2) / 2
|
|
517
543
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
518
544
|
|
|
519
545
|
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
|
520
546
|
gridPosition.current.set(wx, 0, wz)
|
|
521
|
-
cursorGroupRef.current.position.
|
|
547
|
+
cursorGroupRef.current.position.x = wx
|
|
548
|
+
cursorGroupRef.current.position.z = wz
|
|
522
549
|
|
|
523
550
|
const draft = draftNode.current
|
|
524
551
|
if (draft) {
|
|
@@ -587,6 +614,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
587
614
|
return
|
|
588
615
|
}
|
|
589
616
|
|
|
617
|
+
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
590
618
|
const result = ceilingStrategy.move(getContext(), event)
|
|
591
619
|
if (!result) return
|
|
592
620
|
|
|
@@ -603,7 +631,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
603
631
|
}
|
|
604
632
|
|
|
605
633
|
gridPosition.current.set(...result.gridPosition)
|
|
606
|
-
|
|
634
|
+
const cc = worldToBuildingLocal(...result.cursorPosition)
|
|
635
|
+
cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
|
|
607
636
|
|
|
608
637
|
revalidate()
|
|
609
638
|
|
|
@@ -683,12 +712,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
683
712
|
return
|
|
684
713
|
}
|
|
685
714
|
|
|
715
|
+
// Don't intercept keys when focus is inside a text input
|
|
716
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
|
|
686
720
|
const draft = draftNode.current
|
|
687
721
|
if (!draft) return
|
|
688
722
|
|
|
689
723
|
let rotationDelta = 0
|
|
690
|
-
if (event.key === 'r' || event.key === 'R')
|
|
691
|
-
|
|
724
|
+
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
|
725
|
+
rotationDelta = ROTATION_STEP
|
|
726
|
+
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
|
|
727
|
+
rotationDelta = -ROTATION_STEP
|
|
692
728
|
|
|
693
729
|
if (rotationDelta !== 0) {
|
|
694
730
|
event.preventDefault()
|
|
@@ -702,11 +738,55 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
702
738
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
703
739
|
if (mesh) mesh.rotation.y = newRotationY
|
|
704
740
|
|
|
705
|
-
//
|
|
741
|
+
// Re-snap position immediately with updated rotation (dimX/dimZ may swap at 90°)
|
|
742
|
+
const surface = placementState.current.surface
|
|
743
|
+
if (surface === 'floor' || surface === 'ceiling') {
|
|
744
|
+
const dims = getScaledDimensions(draft)
|
|
745
|
+
const [dimX, , dimZ] = dims
|
|
746
|
+
const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
|
|
747
|
+
const x = snapToGrid(lastRawPos.current.x, swapDims ? dimZ : dimX)
|
|
748
|
+
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
|
|
749
|
+
gridPosition.current.set(x, gridPosition.current.y, z)
|
|
750
|
+
draft.position = [x, gridPosition.current.y, z]
|
|
751
|
+
cursorGroupRef.current.position.x = x
|
|
752
|
+
cursorGroupRef.current.position.z = z
|
|
753
|
+
if (mesh) {
|
|
754
|
+
mesh.position.x = x
|
|
755
|
+
mesh.position.z = z
|
|
756
|
+
}
|
|
757
|
+
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
|
|
758
|
+
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
|
759
|
+
if (surfaceMesh) {
|
|
760
|
+
const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
|
|
761
|
+
const dims = getScaledDimensions(draft)
|
|
762
|
+
const [dimX, , dimZ] = dims
|
|
763
|
+
const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
|
|
764
|
+
const x = snapToGrid(localPos.x, swapDims ? dimZ : dimX)
|
|
765
|
+
const z = snapToGrid(localPos.z, swapDims ? dimX : dimZ)
|
|
766
|
+
const y = gridPosition.current.y
|
|
767
|
+
gridPosition.current.set(x, y, z)
|
|
768
|
+
draft.position = [x, y, z]
|
|
769
|
+
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
770
|
+
const localSnapped = worldToBuildingLocal(
|
|
771
|
+
worldSnapped.x,
|
|
772
|
+
worldSnapped.y,
|
|
773
|
+
worldSnapped.z,
|
|
774
|
+
)
|
|
775
|
+
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
|
776
|
+
if (mesh) mesh.position.set(x, y, z)
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Update live transform for 2D floorplan with post-snap position
|
|
706
781
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
|
707
782
|
if (currentLive) {
|
|
708
783
|
useLiveTransforms.getState().set(draft.id, {
|
|
709
784
|
...currentLive,
|
|
785
|
+
position: [
|
|
786
|
+
cursorGroupRef.current.position.x,
|
|
787
|
+
cursorGroupRef.current.position.y,
|
|
788
|
+
cursorGroupRef.current.position.z,
|
|
789
|
+
] as [number, number, number],
|
|
710
790
|
rotation: newRotationY,
|
|
711
791
|
})
|
|
712
792
|
}
|
|
@@ -752,6 +832,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
752
832
|
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
|
753
833
|
edgesRef.current.geometry = edgesGeometry
|
|
754
834
|
|
|
835
|
+
// ---- Undo protection ----
|
|
836
|
+
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
|
837
|
+
// include the draft (created while temporal was paused). Re-insert it so the mesh
|
|
838
|
+
// doesn't disappear mid-placement.
|
|
839
|
+
// We defer via queueMicrotask to avoid nested setState during the undo callback.
|
|
840
|
+
// Temporal is already paused during placement, so createNode won't enter the undo stack.
|
|
841
|
+
let tearingDown = false
|
|
842
|
+
const unsubDraftWatch = useScene.subscribe((state) => {
|
|
843
|
+
if (tearingDown) return
|
|
844
|
+
const draft = draftNode.current
|
|
845
|
+
if (draft === null) return
|
|
846
|
+
if (draft.id in state.nodes) return
|
|
847
|
+
|
|
848
|
+
queueMicrotask(() => {
|
|
849
|
+
if (tearingDown) return
|
|
850
|
+
const draft = draftNode.current
|
|
851
|
+
if (draft === null) return
|
|
852
|
+
if (draft.id in useScene.getState().nodes) return
|
|
853
|
+
// Temporal is paused during placement, createNode won't be tracked
|
|
854
|
+
useScene.getState().createNode(draft, draft.parentId as AnyNodeId)
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
|
|
755
858
|
// ---- Subscribe ----
|
|
756
859
|
|
|
757
860
|
emitter.on('grid:move', onGridMove)
|
|
@@ -770,6 +873,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
770
873
|
emitter.on('ceiling:leave', onCeilingLeave)
|
|
771
874
|
|
|
772
875
|
return () => {
|
|
876
|
+
tearingDown = true
|
|
877
|
+
unsubDraftWatch()
|
|
773
878
|
// Clear live transform for any remaining draft
|
|
774
879
|
if (draftNode.current) {
|
|
775
880
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
@@ -801,6 +906,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
801
906
|
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
|
802
907
|
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
|
803
908
|
useEffect(() => {
|
|
909
|
+
if (!asset) return
|
|
804
910
|
const draft = draftNode.current
|
|
805
911
|
if (!(draft && viewerLevelId) || asset.attachTo) return
|
|
806
912
|
if (draft.parentId === viewerLevelId) return
|
|
@@ -809,6 +915,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
809
915
|
}, [viewerLevelId, draftNode, asset])
|
|
810
916
|
|
|
811
917
|
useFrame((_, delta) => {
|
|
918
|
+
if (!asset) return
|
|
812
919
|
if (!draftNode.current) return
|
|
813
920
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
814
921
|
if (!mesh) return
|
|
@@ -850,9 +957,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
850
957
|
const initialDraft = draftNode.current
|
|
851
958
|
const dims = initialDraft
|
|
852
959
|
? getScaledDimensions(initialDraft)
|
|
853
|
-
: (config.asset
|
|
960
|
+
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
854
961
|
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
|
855
|
-
const wallSideZOffset = config.asset
|
|
962
|
+
const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
|
856
963
|
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
|
857
964
|
|
|
858
965
|
// Base plane geometry (colored rectangle on the ground)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
emitter,
|
|
4
|
+
type FenceNode,
|
|
4
5
|
type GridEvent,
|
|
6
|
+
type LevelNode,
|
|
5
7
|
type RoofNode,
|
|
6
8
|
type RoofSegmentNode,
|
|
7
9
|
type StairNode,
|
|
@@ -9,13 +11,16 @@ import {
|
|
|
9
11
|
sceneRegistry,
|
|
10
12
|
useLiveTransforms,
|
|
11
13
|
useScene,
|
|
14
|
+
type WallNode,
|
|
12
15
|
} from '@pascal-app/core'
|
|
13
16
|
import { useViewer } from '@pascal-app/viewer'
|
|
14
17
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
15
18
|
import * as THREE from 'three'
|
|
16
19
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
20
|
import useEditor from '../../../store/use-editor'
|
|
21
|
+
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
|
18
22
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
23
|
+
import type { WallPlanPoint } from '../wall/wall-drafting'
|
|
19
24
|
|
|
20
25
|
export const MoveRoofTool: React.FC<{
|
|
21
26
|
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
|
|
@@ -29,9 +34,13 @@ export const MoveRoofTool: React.FC<{
|
|
|
29
34
|
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
|
30
35
|
const obj = sceneRegistry.nodes.get(movingNode.id)
|
|
31
36
|
if (obj) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
const worldPos = obj.getWorldPosition(new THREE.Vector3())
|
|
38
|
+
// Cursor renders inside the building-local ToolManager group, so convert
|
|
39
|
+
// world → building-local to honor any building rotation.
|
|
40
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
41
|
+
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
42
|
+
if (buildingObj) buildingObj.worldToLocal(worldPos)
|
|
43
|
+
return [worldPos.x, worldPos.y, worldPos.z]
|
|
35
44
|
}
|
|
36
45
|
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
|
|
37
46
|
if (
|
|
@@ -114,10 +123,55 @@ export const MoveRoofTool: React.FC<{
|
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
const resolveLevelId = () => {
|
|
127
|
+
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
|
|
128
|
+
return movingNode.parentId ?? null
|
|
129
|
+
}
|
|
120
130
|
|
|
131
|
+
if (
|
|
132
|
+
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
|
133
|
+
movingNode.parentId
|
|
134
|
+
) {
|
|
135
|
+
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
|
136
|
+
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const levelId = resolveLevelId()
|
|
143
|
+
const levelNode =
|
|
144
|
+
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
|
|
145
|
+
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
|
|
146
|
+
: null
|
|
147
|
+
const levelChildren = levelNode?.children ?? []
|
|
148
|
+
const levelWalls = levelChildren
|
|
149
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
150
|
+
.filter((node): node is WallNode => node?.type === 'wall')
|
|
151
|
+
const levelFences = levelChildren
|
|
152
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
153
|
+
.filter((node): node is FenceNode => node?.type === 'fence')
|
|
154
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
155
|
+
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
156
|
+
|
|
157
|
+
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
|
158
|
+
if (buildingObj) {
|
|
159
|
+
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
|
|
160
|
+
return [worldPoint.x, worldPoint.y, worldPoint.z]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [localPoint[0], y, localPoint[1]]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const computeLocal = (
|
|
167
|
+
gridX: number,
|
|
168
|
+
gridZ: number,
|
|
169
|
+
y: number,
|
|
170
|
+
buildingLocalX: number,
|
|
171
|
+
buildingLocalZ: number,
|
|
172
|
+
): [number, number] => {
|
|
173
|
+
// Segments have a transformed parent (stair/roof). Convert world → parent-local
|
|
174
|
+
// via Three.js hierarchy so the segment's stored position stays parent-relative.
|
|
121
175
|
if (
|
|
122
176
|
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
|
123
177
|
movingNode.parentId
|
|
@@ -128,37 +182,42 @@ export const MoveRoofTool: React.FC<{
|
|
|
128
182
|
if (parentObj) {
|
|
129
183
|
const worldVec = new THREE.Vector3(gridX, y, gridZ)
|
|
130
184
|
parentObj.worldToLocal(worldVec)
|
|
131
|
-
|
|
132
|
-
localZ = worldVec.z
|
|
133
|
-
} else {
|
|
134
|
-
const dx = gridX - (parentNode.position[0] as number)
|
|
135
|
-
const dz = gridZ - (parentNode.position[2] as number)
|
|
136
|
-
const angle = -(parentNode.rotation as number)
|
|
137
|
-
localX = dx * Math.cos(angle) - dz * Math.sin(angle)
|
|
138
|
-
localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
|
|
185
|
+
return [worldVec.x, worldVec.z]
|
|
139
186
|
}
|
|
187
|
+
const dx = gridX - (parentNode.position[0] as number)
|
|
188
|
+
const dz = gridZ - (parentNode.position[2] as number)
|
|
189
|
+
const angle = -(parentNode.rotation as number)
|
|
190
|
+
return [
|
|
191
|
+
dx * Math.cos(angle) - dz * Math.sin(angle),
|
|
192
|
+
dx * Math.sin(angle) + dz * Math.cos(angle),
|
|
193
|
+
]
|
|
140
194
|
}
|
|
141
195
|
}
|
|
142
196
|
|
|
143
|
-
|
|
197
|
+
// Stair/roof live directly in the level — their stored position is building-local.
|
|
198
|
+
// event.localPosition is already building-local, so using it handles building rotation.
|
|
199
|
+
return [buildingLocalX, buildingLocalZ]
|
|
144
200
|
}
|
|
145
201
|
|
|
146
202
|
const onGridMove = (event: GridEvent) => {
|
|
147
|
-
const gridX = Math.round(event.position[0] * 2) / 2
|
|
148
|
-
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
149
203
|
const y = event.position[1]
|
|
150
204
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
205
|
+
const snappedLocal = snapFenceDraftPoint({
|
|
206
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
207
|
+
walls: levelWalls,
|
|
208
|
+
fences: levelFences,
|
|
209
|
+
})
|
|
210
|
+
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
|
211
|
+
|
|
212
|
+
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
|
|
155
213
|
sfxEmitter.emit('sfx:grid-snap')
|
|
156
214
|
}
|
|
157
215
|
|
|
158
216
|
previousGridPosRef.current = [gridX, gridZ]
|
|
159
|
-
|
|
217
|
+
const [lx, lz] = snappedLocal
|
|
218
|
+
setCursorWorldPos([lx, event.localPosition[1], lz])
|
|
160
219
|
|
|
161
|
-
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
|
220
|
+
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
|
162
221
|
|
|
163
222
|
// Directly update the Three.js mesh — no store update during drag
|
|
164
223
|
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
|
@@ -175,11 +234,16 @@ export const MoveRoofTool: React.FC<{
|
|
|
175
234
|
}
|
|
176
235
|
|
|
177
236
|
const onGridClick = (event: GridEvent) => {
|
|
178
|
-
const gridX = Math.round(event.position[0] * 2) / 2
|
|
179
|
-
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
180
237
|
const y = event.position[1]
|
|
238
|
+
const snappedLocal = snapFenceDraftPoint({
|
|
239
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
240
|
+
walls: levelWalls,
|
|
241
|
+
fences: levelFences,
|
|
242
|
+
})
|
|
243
|
+
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
|
244
|
+
const [lx, lz] = snappedLocal
|
|
181
245
|
|
|
182
|
-
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
|
246
|
+
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
|
183
247
|
|
|
184
248
|
wasCommitted = true
|
|
185
249
|
|
|
@@ -173,9 +173,9 @@ export const RoofTool: React.FC = () => {
|
|
|
173
173
|
const onGridMove = (event: GridEvent) => {
|
|
174
174
|
if (!cursorRef.current) return
|
|
175
175
|
|
|
176
|
-
const gridX = Math.round(event.
|
|
177
|
-
const gridZ = Math.round(event.
|
|
178
|
-
const y = event.
|
|
176
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
177
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
178
|
+
const y = event.localPosition[1]
|
|
179
179
|
|
|
180
180
|
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
|
|
181
181
|
const gridY = y + GRID_OFFSET
|
|
@@ -206,9 +206,9 @@ export const RoofTool: React.FC = () => {
|
|
|
206
206
|
const onGridClick = (event: GridEvent) => {
|
|
207
207
|
if (!currentLevelId) return
|
|
208
208
|
|
|
209
|
-
const gridX = Math.round(event.
|
|
210
|
-
const gridZ = Math.round(event.
|
|
211
|
-
const y = event.
|
|
209
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
210
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
211
|
+
const y = event.localPosition[1]
|
|
212
212
|
|
|
213
213
|
if (corner1Ref.current) {
|
|
214
214
|
const roofId = commitRoofPlacement(
|
|
@@ -197,7 +197,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|
|
197
197
|
const node = nodes[childId as AnyNodeId]
|
|
198
198
|
if (!node) continue
|
|
199
199
|
|
|
200
|
-
if (node.type === 'wall') {
|
|
200
|
+
if (node.type === 'wall' || node.type === 'fence') {
|
|
201
201
|
const wall = node as WallNode
|
|
202
202
|
if (
|
|
203
203
|
segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
|
|
@@ -205,7 +205,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|
|
205
205
|
result.push(wall.id)
|
|
206
206
|
}
|
|
207
207
|
// Check wall children (doors/windows)
|
|
208
|
-
for (const itemId of wall.children) {
|
|
208
|
+
for (const itemId of Array.isArray(wall.children) ? wall.children : []) {
|
|
209
209
|
const child = nodes[itemId as AnyNodeId]
|
|
210
210
|
if (!child) continue
|
|
211
211
|
if (
|