@pascal-app/editor 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/components/editor/floating-action-menu.tsx +101 -29
- package/src/components/editor/floating-building-action-menu.tsx +69 -0
- package/src/components/editor/floorplan-panel.tsx +31 -13
- package/src/components/editor/index.tsx +219 -167
- package/src/components/editor/node-action-menu.tsx +26 -10
- package/src/components/editor/selection-manager.tsx +38 -2
- package/src/components/editor/thumbnail-generator.tsx +245 -64
- 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/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +19 -7
- package/src/components/tools/door/move-door-tool.tsx +17 -8
- package/src/components/tools/fence/fence-drafting.ts +125 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-tool.tsx +223 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +7 -0
- package/src/components/tools/item/placement-strategies.ts +15 -7
- package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
- package/src/components/tools/roof/move-roof-tool.tsx +5 -2
- 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 +2 -2
- 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 +29 -6
- package/src/components/tools/tool-manager.tsx +42 -14
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +17 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +19 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/panels/fence-panel.tsx +184 -0
- package/src/components/ui/panels/panel-manager.tsx +3 -0
- package/src/components/ui/panels/stair-panel.tsx +206 -33
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
- 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 +59 -52
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
- package/src/components/viewer-overlay.tsx +1 -0
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +10 -2
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +4 -0
- package/src/store/use-editor.tsx +7 -0
|
@@ -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)
|
|
@@ -216,6 +233,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
216
233
|
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
|
217
234
|
cursorGroupRef.current.position.x = result.cursorPosition[0]
|
|
218
235
|
cursorGroupRef.current.position.z = result.cursorPosition[2]
|
|
236
|
+
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
|
219
237
|
|
|
220
238
|
const draft = draftNode.current
|
|
221
239
|
if (draft) draft.position = result.gridPosition
|
|
@@ -334,7 +352,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
334
352
|
}
|
|
335
353
|
|
|
336
354
|
gridPosition.current.set(...result.gridPosition)
|
|
337
|
-
|
|
355
|
+
const wc = worldToBuildingLocal(...result.cursorPosition)
|
|
356
|
+
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
|
|
338
357
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
339
358
|
|
|
340
359
|
const draft = draftNode.current
|
|
@@ -486,7 +505,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
486
505
|
event.stopPropagation()
|
|
487
506
|
|
|
488
507
|
gridPosition.current.set(...result.gridPosition)
|
|
489
|
-
|
|
508
|
+
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
509
|
+
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
510
|
+
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
490
511
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
491
512
|
|
|
492
513
|
const draft = draftNode.current
|
|
@@ -511,14 +532,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
511
532
|
|
|
512
533
|
event.stopPropagation()
|
|
513
534
|
|
|
514
|
-
// Transition back to floor using
|
|
515
|
-
const wx = Math.round(event.
|
|
516
|
-
const wz = Math.round(event.
|
|
535
|
+
// Transition back to floor using building-local position
|
|
536
|
+
const wx = Math.round(event.localPosition[0] * 2) / 2
|
|
537
|
+
const wz = Math.round(event.localPosition[2] * 2) / 2
|
|
517
538
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
518
539
|
|
|
519
540
|
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
|
520
541
|
gridPosition.current.set(wx, 0, wz)
|
|
521
|
-
cursorGroupRef.current.position.
|
|
542
|
+
cursorGroupRef.current.position.x = wx
|
|
543
|
+
cursorGroupRef.current.position.z = wz
|
|
522
544
|
|
|
523
545
|
const draft = draftNode.current
|
|
524
546
|
if (draft) {
|
|
@@ -603,7 +625,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
603
625
|
}
|
|
604
626
|
|
|
605
627
|
gridPosition.current.set(...result.gridPosition)
|
|
606
|
-
|
|
628
|
+
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
629
|
+
const cc = worldToBuildingLocal(...result.cursorPosition)
|
|
630
|
+
cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
|
|
607
631
|
|
|
608
632
|
revalidate()
|
|
609
633
|
|
|
@@ -677,6 +701,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
677
701
|
|
|
678
702
|
const ROTATION_STEP = Math.PI / 2
|
|
679
703
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
704
|
+
// Don't intercept keys when focus is inside a text input
|
|
705
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
|
|
680
709
|
if (event.key === 'Shift') {
|
|
681
710
|
shiftFreeRef.current = true
|
|
682
711
|
revalidate()
|
|
@@ -702,11 +731,55 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
702
731
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
703
732
|
if (mesh) mesh.rotation.y = newRotationY
|
|
704
733
|
|
|
705
|
-
//
|
|
734
|
+
// Re-snap position immediately with updated rotation (dimX/dimZ may swap at 90°)
|
|
735
|
+
const surface = placementState.current.surface
|
|
736
|
+
if (surface === 'floor' || surface === 'ceiling') {
|
|
737
|
+
const dims = getScaledDimensions(draft)
|
|
738
|
+
const [dimX, , dimZ] = dims
|
|
739
|
+
const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
|
|
740
|
+
const x = snapToGrid(lastRawPos.current.x, swapDims ? dimZ : dimX)
|
|
741
|
+
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
|
|
742
|
+
gridPosition.current.set(x, gridPosition.current.y, z)
|
|
743
|
+
draft.position = [x, gridPosition.current.y, z]
|
|
744
|
+
cursorGroupRef.current.position.x = x
|
|
745
|
+
cursorGroupRef.current.position.z = z
|
|
746
|
+
if (mesh) {
|
|
747
|
+
mesh.position.x = x
|
|
748
|
+
mesh.position.z = z
|
|
749
|
+
}
|
|
750
|
+
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
|
|
751
|
+
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
|
752
|
+
if (surfaceMesh) {
|
|
753
|
+
const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
|
|
754
|
+
const dims = getScaledDimensions(draft)
|
|
755
|
+
const [dimX, , dimZ] = dims
|
|
756
|
+
const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
|
|
757
|
+
const x = snapToGrid(localPos.x, swapDims ? dimZ : dimX)
|
|
758
|
+
const z = snapToGrid(localPos.z, swapDims ? dimX : dimZ)
|
|
759
|
+
const y = gridPosition.current.y
|
|
760
|
+
gridPosition.current.set(x, y, z)
|
|
761
|
+
draft.position = [x, y, z]
|
|
762
|
+
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
763
|
+
const localSnapped = worldToBuildingLocal(
|
|
764
|
+
worldSnapped.x,
|
|
765
|
+
worldSnapped.y,
|
|
766
|
+
worldSnapped.z,
|
|
767
|
+
)
|
|
768
|
+
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
|
769
|
+
if (mesh) mesh.position.set(x, y, z)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Update live transform for 2D floorplan with post-snap position
|
|
706
774
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
|
707
775
|
if (currentLive) {
|
|
708
776
|
useLiveTransforms.getState().set(draft.id, {
|
|
709
777
|
...currentLive,
|
|
778
|
+
position: [
|
|
779
|
+
cursorGroupRef.current.position.x,
|
|
780
|
+
cursorGroupRef.current.position.y,
|
|
781
|
+
cursorGroupRef.current.position.z,
|
|
782
|
+
] as [number, number, number],
|
|
710
783
|
rotation: newRotationY,
|
|
711
784
|
})
|
|
712
785
|
}
|
|
@@ -801,6 +874,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
801
874
|
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
|
802
875
|
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
|
803
876
|
useEffect(() => {
|
|
877
|
+
if (!asset) return
|
|
804
878
|
const draft = draftNode.current
|
|
805
879
|
if (!(draft && viewerLevelId) || asset.attachTo) return
|
|
806
880
|
if (draft.parentId === viewerLevelId) return
|
|
@@ -809,6 +883,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
809
883
|
}, [viewerLevelId, draftNode, asset])
|
|
810
884
|
|
|
811
885
|
useFrame((_, delta) => {
|
|
886
|
+
if (!asset) return
|
|
812
887
|
if (!draftNode.current) return
|
|
813
888
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
814
889
|
if (!mesh) return
|
|
@@ -850,9 +925,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
850
925
|
const initialDraft = draftNode.current
|
|
851
926
|
const dims = initialDraft
|
|
852
927
|
? getScaledDimensions(initialDraft)
|
|
853
|
-
: (config.asset
|
|
928
|
+
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
854
929
|
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
|
855
|
-
const wallSideZOffset = config.asset
|
|
930
|
+
const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
|
856
931
|
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
|
857
932
|
|
|
858
933
|
// Base plane geometry (colored rectangle on the ground)
|
|
@@ -156,7 +156,10 @@ export const MoveRoofTool: React.FC<{
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
previousGridPosRef.current = [gridX, gridZ]
|
|
159
|
-
|
|
159
|
+
// Cursor is inside the building-local ToolManager group — use local position
|
|
160
|
+
const lx = Math.round(event.localPosition[0] * 2) / 2
|
|
161
|
+
const lz = Math.round(event.localPosition[2] * 2) / 2
|
|
162
|
+
setCursorWorldPos([lx, event.localPosition[1], lz])
|
|
160
163
|
|
|
161
164
|
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
|
162
165
|
|
|
@@ -175,7 +178,7 @@ export const MoveRoofTool: React.FC<{
|
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
const onGridClick = (event: GridEvent) => {
|
|
178
|
-
const gridX = Math.round(event.position[0] * 2) / 2
|
|
181
|
+
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
|
|
179
182
|
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
180
183
|
const y = event.position[1]
|
|
181
184
|
|
|
@@ -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 (
|
|
@@ -139,8 +139,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
139
139
|
// Listen to grid:move events to track cursor position
|
|
140
140
|
useEffect(() => {
|
|
141
141
|
const onGridMove = (event: GridEvent) => {
|
|
142
|
-
const gridX = Math.round(event.
|
|
143
|
-
const gridZ = Math.round(event.
|
|
142
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
143
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
144
144
|
const newPosition: [number, number] = [gridX, gridZ]
|
|
145
145
|
|
|
146
146
|
// Play snap sound when cursor moves to a new grid cell during drag
|
|
@@ -86,12 +86,12 @@ export const SlabTool: React.FC = () => {
|
|
|
86
86
|
const onGridMove = (event: GridEvent) => {
|
|
87
87
|
if (!cursorRef.current) return
|
|
88
88
|
|
|
89
|
-
const gridX = Math.round(event.
|
|
90
|
-
const gridZ = Math.round(event.
|
|
89
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
90
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
91
91
|
const gridPosition: [number, number] = [gridX, gridZ]
|
|
92
92
|
|
|
93
93
|
setCursorPosition(gridPosition)
|
|
94
|
-
setLevelY(event.
|
|
94
|
+
setLevelY(event.localPosition[1])
|
|
95
95
|
|
|
96
96
|
// Calculate snapped display position (bypass snap when Shift is held)
|
|
97
97
|
const lastPoint = points[points.length - 1]
|
|
@@ -112,7 +112,7 @@ export const SlabTool: React.FC = () => {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
previousSnappedPointRef.current = displayPoint
|
|
115
|
-
cursorRef.current.position.set(displayPoint[0], event.
|
|
115
|
+
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
const onGridClick = (_event: GridEvent) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const DEFAULT_STAIR_TYPE = 'straight' as const
|
|
1
2
|
export const DEFAULT_STAIR_WIDTH = 1.0
|
|
2
3
|
export const DEFAULT_STAIR_LENGTH = 3.0
|
|
3
4
|
export const DEFAULT_STAIR_HEIGHT = 2.5
|
|
@@ -5,3 +6,12 @@ export const DEFAULT_STAIR_STEP_COUNT = 10
|
|
|
5
6
|
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
|
|
6
7
|
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
|
7
8
|
export const DEFAULT_STAIR_THICKNESS = 0.25
|
|
9
|
+
export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
|
|
10
|
+
export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
|
|
11
|
+
export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
|
|
12
|
+
export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const
|
|
13
|
+
export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9
|
|
14
|
+
export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true
|
|
15
|
+
export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true
|
|
16
|
+
export const DEFAULT_STAIR_RAILING_MODE = 'right' as const
|
|
17
|
+
export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92
|
|
@@ -13,12 +13,21 @@ import * as THREE from 'three'
|
|
|
13
13
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
14
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
15
15
|
import {
|
|
16
|
+
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
|
17
|
+
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
|
18
|
+
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
|
|
19
|
+
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
|
20
|
+
DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
|
|
21
|
+
DEFAULT_SPIRAL_TOP_LANDING_MODE,
|
|
16
22
|
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
|
17
23
|
DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
18
24
|
DEFAULT_STAIR_HEIGHT,
|
|
19
25
|
DEFAULT_STAIR_LENGTH,
|
|
26
|
+
DEFAULT_STAIR_RAILING_HEIGHT,
|
|
27
|
+
DEFAULT_STAIR_RAILING_MODE,
|
|
20
28
|
DEFAULT_STAIR_STEP_COUNT,
|
|
21
29
|
DEFAULT_STAIR_THICKNESS,
|
|
30
|
+
DEFAULT_STAIR_TYPE,
|
|
22
31
|
DEFAULT_STAIR_WIDTH,
|
|
23
32
|
} from './stair-defaults'
|
|
24
33
|
|
|
@@ -88,6 +97,20 @@ function commitStairPlacement(
|
|
|
88
97
|
name,
|
|
89
98
|
position,
|
|
90
99
|
rotation,
|
|
100
|
+
stairType: DEFAULT_STAIR_TYPE,
|
|
101
|
+
width: DEFAULT_STAIR_WIDTH,
|
|
102
|
+
totalRise: DEFAULT_STAIR_HEIGHT,
|
|
103
|
+
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
|
104
|
+
thickness: DEFAULT_STAIR_THICKNESS,
|
|
105
|
+
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
106
|
+
innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
|
107
|
+
sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
|
108
|
+
topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE,
|
|
109
|
+
topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
|
|
110
|
+
showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
|
|
111
|
+
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
|
112
|
+
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
|
|
113
|
+
railingMode: DEFAULT_STAIR_RAILING_MODE,
|
|
91
114
|
children: [segment.id],
|
|
92
115
|
})
|
|
93
116
|
|
|
@@ -116,9 +139,9 @@ export const StairTool: React.FC = () => {
|
|
|
116
139
|
if (previewRef.current) previewRef.current.rotation.y = 0
|
|
117
140
|
|
|
118
141
|
const onGridMove = (event: GridEvent) => {
|
|
119
|
-
const gridX = Math.round(event.
|
|
120
|
-
const gridZ = Math.round(event.
|
|
121
|
-
const y = event.
|
|
142
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
143
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
144
|
+
const y = event.localPosition[1]
|
|
122
145
|
|
|
123
146
|
if (cursorRef.current) {
|
|
124
147
|
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
|
|
@@ -141,9 +164,9 @@ export const StairTool: React.FC = () => {
|
|
|
141
164
|
const onGridClick = (event: GridEvent) => {
|
|
142
165
|
if (!currentLevelId) return
|
|
143
166
|
|
|
144
|
-
const gridX = Math.round(event.
|
|
145
|
-
const gridZ = Math.round(event.
|
|
146
|
-
const y = event.
|
|
167
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
168
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
169
|
+
const y = event.localPosition[1]
|
|
147
170
|
|
|
148
171
|
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
|
|
149
172
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
type BuildingNode,
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
type SlabNode,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
2
8
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
9
|
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
|
4
10
|
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
|
5
11
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
|
6
12
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
|
7
13
|
import { DoorTool } from './door/door-tool'
|
|
14
|
+
import { FenceTool } from './fence/fence-tool'
|
|
8
15
|
import { ItemTool } from './item/item-tool'
|
|
9
16
|
import { MoveTool } from './item/move-tool'
|
|
10
17
|
import { RoofTool } from './roof/roof-tool'
|
|
@@ -24,6 +31,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|
|
24
31
|
},
|
|
25
32
|
structure: {
|
|
26
33
|
wall: WallTool,
|
|
34
|
+
fence: FenceTool,
|
|
27
35
|
slab: SlabTool,
|
|
28
36
|
ceiling: CeilingTool,
|
|
29
37
|
roof: RoofTool,
|
|
@@ -45,9 +53,18 @@ export const ToolManager: React.FC = () => {
|
|
|
45
53
|
const movingNode = useEditor((state) => state.movingNode)
|
|
46
54
|
const editingHole = useEditor((state) => state.editingHole)
|
|
47
55
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
56
|
+
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
48
57
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
49
58
|
const nodes = useScene((state) => state.nodes)
|
|
50
59
|
|
|
60
|
+
// Building transform for the local group — all building-relative tools live inside this group
|
|
61
|
+
// so their cursor positions and committed data are naturally in building-local space.
|
|
62
|
+
const building = buildingId
|
|
63
|
+
? (nodes[buildingId as AnyNodeId] as BuildingNode | undefined)
|
|
64
|
+
: undefined
|
|
65
|
+
const buildingPosition = building?.position ?? [0, 0, 0]
|
|
66
|
+
const buildingRotation = building?.rotation ?? [0, 0, 0]
|
|
67
|
+
|
|
51
68
|
// Check if a slab is selected
|
|
52
69
|
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
|
|
53
70
|
| SlabNode['id']
|
|
@@ -102,19 +119,30 @@ export const ToolManager: React.FC = () => {
|
|
|
102
119
|
return (
|
|
103
120
|
<>
|
|
104
121
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
105
|
-
{
|
|
106
|
-
{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
|
123
|
+
{movingNode?.type === 'building' && <MoveTool />}
|
|
124
|
+
|
|
125
|
+
{/* Building-local group: all other tools are relative to the selected building.
|
|
126
|
+
Cursor visuals set positions in building-local space; this group applies the
|
|
127
|
+
building's world transform so they render at the correct world position. */}
|
|
128
|
+
<group
|
|
129
|
+
position={buildingPosition as [number, number, number]}
|
|
130
|
+
rotation={buildingRotation as [number, number, number]}
|
|
131
|
+
>
|
|
132
|
+
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
|
133
|
+
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
|
134
|
+
{showSlabHoleEditor && selectedSlabId && editingHole && (
|
|
135
|
+
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
|
|
136
|
+
)}
|
|
137
|
+
{showCeilingBoundaryEditor && selectedCeilingId && (
|
|
138
|
+
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
|
139
|
+
)}
|
|
140
|
+
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
|
|
141
|
+
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
|
142
|
+
)}
|
|
143
|
+
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
|
144
|
+
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
|
145
|
+
</group>
|
|
118
146
|
</>
|
|
119
147
|
)
|
|
120
148
|
}
|
|
@@ -78,31 +78,28 @@ export const WallTool: React.FC = () => {
|
|
|
78
78
|
let gridPosition: WallPlanPoint = [0, 0]
|
|
79
79
|
let previousWallEnd: [number, number] | null = null
|
|
80
80
|
|
|
81
|
+
// All positions are building-local: this tool is inside the ToolManager building group,
|
|
82
|
+
// so local coords are used for both data and visual positioning.
|
|
81
83
|
const onGridMove = (event: GridEvent) => {
|
|
82
84
|
if (!(cursorRef.current && wallPreviewRef.current)) return
|
|
83
85
|
|
|
84
86
|
const walls = getCurrentLevelWalls()
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
walls,
|
|
89
|
-
})
|
|
87
|
+
// event.localPosition is building-local — consistent with stored wall start/end
|
|
88
|
+
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
89
|
+
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
|
|
90
90
|
|
|
91
91
|
if (buildingState.current === 1) {
|
|
92
|
-
const
|
|
93
|
-
point:
|
|
92
|
+
const snappedLocal = snapWallDraftPoint({
|
|
93
|
+
point: localPoint,
|
|
94
94
|
walls,
|
|
95
95
|
start: [startingPoint.current.x, startingPoint.current.z],
|
|
96
96
|
angleSnap: !shiftPressed.current,
|
|
97
97
|
})
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Position the cursor at the end of the wall being drawn
|
|
102
|
-
cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
|
|
98
|
+
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
|
99
|
+
cursorRef.current.position.copy(endingPoint.current)
|
|
103
100
|
|
|
104
101
|
// Play snap sound only when the actual wall end position changes
|
|
105
|
-
const currentWallEnd: [number, number] = [
|
|
102
|
+
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
|
106
103
|
if (
|
|
107
104
|
previousWallEnd &&
|
|
108
105
|
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
|
@@ -111,43 +108,36 @@ export const WallTool: React.FC = () => {
|
|
|
111
108
|
}
|
|
112
109
|
previousWallEnd = currentWallEnd
|
|
113
110
|
|
|
114
|
-
// Update wall preview geometry
|
|
115
111
|
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
|
116
112
|
} else {
|
|
117
113
|
// Not drawing a wall yet, show the snapped anchor point.
|
|
118
|
-
cursorRef.current.position.set(gridPosition[0], event.
|
|
114
|
+
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
|
|
119
115
|
}
|
|
120
116
|
}
|
|
121
117
|
|
|
122
118
|
const onGridClick = (event: GridEvent) => {
|
|
123
119
|
const walls = getCurrentLevelWalls()
|
|
124
|
-
const
|
|
120
|
+
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
125
121
|
|
|
126
122
|
if (buildingState.current === 0) {
|
|
127
|
-
const snappedStart = snapWallDraftPoint({
|
|
128
|
-
point: clickPoint,
|
|
129
|
-
walls,
|
|
130
|
-
})
|
|
123
|
+
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
|
|
131
124
|
gridPosition = snappedStart
|
|
132
|
-
startingPoint.current.set(snappedStart[0], event.
|
|
125
|
+
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
|
133
126
|
endingPoint.current.copy(startingPoint.current)
|
|
134
127
|
buildingState.current = 1
|
|
135
128
|
wallPreviewRef.current.visible = true
|
|
136
129
|
} else if (buildingState.current === 1) {
|
|
137
130
|
const snappedEnd = snapWallDraftPoint({
|
|
138
|
-
point:
|
|
131
|
+
point: localClick,
|
|
139
132
|
walls,
|
|
140
133
|
start: [startingPoint.current.x, startingPoint.current.z],
|
|
141
134
|
angleSnap: !shiftPressed.current,
|
|
142
135
|
})
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const dz = endingPoint.current.z - startingPoint.current.z
|
|
136
|
+
const dx = snappedEnd[0] - startingPoint.current.x
|
|
137
|
+
const dz = snappedEnd[1] - startingPoint.current.z
|
|
146
138
|
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
[endingPoint.current.x, endingPoint.current.z],
|
|
150
|
-
)
|
|
139
|
+
// Both start and end are building-local ✓
|
|
140
|
+
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
|
151
141
|
wallPreviewRef.current.visible = false
|
|
152
142
|
buildingState.current = 0
|
|
153
143
|
}
|
|
@@ -185,17 +185,26 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
185
185
|
movingWindowNode.height,
|
|
186
186
|
)
|
|
187
187
|
|
|
188
|
-
useScene.getState().updateNode(movingWindowNode.id, {
|
|
189
|
-
position: [clampedX, clampedY, 0],
|
|
190
|
-
rotation: [0, itemRotation, 0],
|
|
191
|
-
side,
|
|
192
|
-
parentId: event.node.id,
|
|
193
|
-
wallId: event.node.id,
|
|
194
|
-
})
|
|
195
|
-
|
|
196
188
|
if (currentWallId !== event.node.id) {
|
|
189
|
+
// Wall changed mid-move: must updateNode to reparent
|
|
190
|
+
useScene.getState().updateNode(movingWindowNode.id, {
|
|
191
|
+
position: [clampedX, clampedY, 0],
|
|
192
|
+
rotation: [0, itemRotation, 0],
|
|
193
|
+
side,
|
|
194
|
+
parentId: event.node.id,
|
|
195
|
+
wallId: event.node.id,
|
|
196
|
+
})
|
|
197
197
|
markWallDirty(currentWallId)
|
|
198
198
|
currentWallId = event.node.id
|
|
199
|
+
} else {
|
|
200
|
+
// Same wall: update Three.js mesh directly to avoid store churn
|
|
201
|
+
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
|
202
|
+
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
|
203
|
+
if (windowMesh) {
|
|
204
|
+
windowMesh.position.set(clampedX, clampedY, 0)
|
|
205
|
+
windowMesh.rotation.set(0, itemRotation, 0)
|
|
206
|
+
windowMesh.updateMatrixWorld(true)
|
|
207
|
+
}
|
|
199
208
|
}
|
|
200
209
|
markWallDirty(event.node.id)
|
|
201
210
|
|
|
@@ -77,7 +77,7 @@ export function hasWallChildOverlap(
|
|
|
77
77
|
const newLeft = clampedX - halfW
|
|
78
78
|
const newRight = clampedX + halfW
|
|
79
79
|
|
|
80
|
-
for (const childId of wallNode.children) {
|
|
80
|
+
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
|
81
81
|
if (childId === ignoreId) continue
|
|
82
82
|
const child = nodes[childId as AnyNodeId]
|
|
83
83
|
if (!child) continue
|