@pascal-app/editor 0.4.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 +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
emitter,
|
|
4
|
+
type GridEvent,
|
|
5
|
+
type RoofNode,
|
|
6
|
+
type RoofSegmentNode,
|
|
7
|
+
type StairNode,
|
|
8
|
+
type StairSegmentNode,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useLiveTransforms,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
15
|
+
import * as THREE from 'three'
|
|
16
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
|
+
import useEditor from '../../../store/use-editor'
|
|
18
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
19
|
+
|
|
20
|
+
export const MoveRoofTool: React.FC<{
|
|
21
|
+
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
|
|
22
|
+
}> = ({ node: movingNode }) => {
|
|
23
|
+
const exitMoveMode = useCallback(() => {
|
|
24
|
+
useEditor.getState().setMovingNode(null)
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
28
|
+
|
|
29
|
+
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
|
30
|
+
const obj = sceneRegistry.nodes.get(movingNode.id)
|
|
31
|
+
if (obj) {
|
|
32
|
+
const pos = new THREE.Vector3()
|
|
33
|
+
obj.getWorldPosition(pos)
|
|
34
|
+
return [pos.x, pos.y, pos.z]
|
|
35
|
+
}
|
|
36
|
+
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
|
|
37
|
+
if (
|
|
38
|
+
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
|
39
|
+
movingNode.parentId
|
|
40
|
+
) {
|
|
41
|
+
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
|
42
|
+
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
|
43
|
+
const parentAngle = parentNode.rotation as number
|
|
44
|
+
const px = parentNode.position[0] as number
|
|
45
|
+
const py = parentNode.position[1] as number
|
|
46
|
+
const pz = parentNode.position[2] as number
|
|
47
|
+
const lx = movingNode.position[0]
|
|
48
|
+
const ly = movingNode.position[1]
|
|
49
|
+
const lz = movingNode.position[2]
|
|
50
|
+
|
|
51
|
+
const wx = lx * Math.cos(parentAngle) - lz * Math.sin(parentAngle) + px
|
|
52
|
+
const wz = lx * Math.sin(parentAngle) + lz * Math.cos(parentAngle) + pz
|
|
53
|
+
return [wx, py + ly, wz]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [movingNode.position[0], movingNode.position[1], movingNode.position[2]]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
useScene.temporal.getState().pause()
|
|
61
|
+
|
|
62
|
+
const meta =
|
|
63
|
+
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
|
|
64
|
+
? (movingNode.metadata as Record<string, unknown>)
|
|
65
|
+
: {}
|
|
66
|
+
const isNew = !!meta.isNew
|
|
67
|
+
const committedMeta: RoofNode['metadata'] = (() => {
|
|
68
|
+
if (
|
|
69
|
+
typeof movingNode.metadata !== 'object' ||
|
|
70
|
+
movingNode.metadata === null ||
|
|
71
|
+
Array.isArray(movingNode.metadata)
|
|
72
|
+
) {
|
|
73
|
+
return movingNode.metadata
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const nextMeta = { ...movingNode.metadata } as Record<string, unknown>
|
|
77
|
+
delete nextMeta.isNew
|
|
78
|
+
delete nextMeta.isTransient
|
|
79
|
+
return nextMeta as RoofNode['metadata']
|
|
80
|
+
})()
|
|
81
|
+
|
|
82
|
+
const original = {
|
|
83
|
+
position: [...movingNode.position] as [number, number, number],
|
|
84
|
+
rotation: movingNode.rotation,
|
|
85
|
+
parentId: movingNode.parentId,
|
|
86
|
+
metadata: movingNode.metadata,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Track whether the move was committed so cleanup knows whether to revert.
|
|
90
|
+
// We avoid setting isTransient on the store to prevent RoofSystem from
|
|
91
|
+
// resetting the mesh position (it resets on dirty) and from triggering
|
|
92
|
+
// expensive merged-mesh CSG rebuilds on every frame.
|
|
93
|
+
let wasCommitted = false
|
|
94
|
+
|
|
95
|
+
// Track pending rotation — no store updates during drag
|
|
96
|
+
let pendingRotation: number = movingNode.rotation as number
|
|
97
|
+
|
|
98
|
+
// For roof-segment moves: the selection was cleared before entering move mode,
|
|
99
|
+
// so isSelected=false on the parent roof, hiding individual segment meshes and
|
|
100
|
+
// showing only the merged mesh. We directly flip Three.js visibility so the
|
|
101
|
+
// user sees the individual segment tracking the cursor.
|
|
102
|
+
let segmentWrapperGroup: THREE.Object3D | null = null
|
|
103
|
+
let mergedRoofMesh: THREE.Object3D | null = null
|
|
104
|
+
if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
|
|
105
|
+
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
|
|
106
|
+
if (segmentMesh?.parent) {
|
|
107
|
+
// segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
|
|
108
|
+
// segmentMesh.parent.parent = the registered roof/stair group
|
|
109
|
+
segmentWrapperGroup = segmentMesh.parent
|
|
110
|
+
const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
|
|
111
|
+
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
|
|
112
|
+
segmentWrapperGroup.visible = true
|
|
113
|
+
if (mergedRoofMesh) mergedRoofMesh.visible = false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => {
|
|
118
|
+
let localX = gridX
|
|
119
|
+
let localZ = gridZ
|
|
120
|
+
|
|
121
|
+
if (
|
|
122
|
+
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
|
123
|
+
movingNode.parentId
|
|
124
|
+
) {
|
|
125
|
+
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
|
126
|
+
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
|
127
|
+
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
|
|
128
|
+
if (parentObj) {
|
|
129
|
+
const worldVec = new THREE.Vector3(gridX, y, gridZ)
|
|
130
|
+
parentObj.worldToLocal(worldVec)
|
|
131
|
+
localX = worldVec.x
|
|
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)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [localX, localZ]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
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
|
+
const y = event.position[1]
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
previousGridPosRef.current &&
|
|
153
|
+
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
|
154
|
+
) {
|
|
155
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
previousGridPosRef.current = [gridX, gridZ]
|
|
159
|
+
setCursorWorldPos([gridX, y, gridZ])
|
|
160
|
+
|
|
161
|
+
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
|
162
|
+
|
|
163
|
+
// Directly update the Three.js mesh — no store update during drag
|
|
164
|
+
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
|
165
|
+
if (mesh) {
|
|
166
|
+
mesh.position.x = localX
|
|
167
|
+
mesh.position.z = localZ
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Publish world-space position so the 2D floorplan can track the drag
|
|
171
|
+
useLiveTransforms.getState().set(movingNode.id, {
|
|
172
|
+
position: [gridX, y, gridZ],
|
|
173
|
+
rotation: pendingRotation,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
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
|
+
const y = event.position[1]
|
|
181
|
+
|
|
182
|
+
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
|
183
|
+
|
|
184
|
+
wasCommitted = true
|
|
185
|
+
|
|
186
|
+
// The store still holds the original values (we didn't update during drag).
|
|
187
|
+
// Resume temporal and apply the final state as a single undoable step.
|
|
188
|
+
useScene.temporal.getState().resume()
|
|
189
|
+
|
|
190
|
+
useScene.getState().updateNode(movingNode.id, {
|
|
191
|
+
position: [localX, movingNode.position[1], localZ],
|
|
192
|
+
rotation: pendingRotation,
|
|
193
|
+
metadata: committedMeta,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
useScene.temporal.getState().pause()
|
|
197
|
+
|
|
198
|
+
sfxEmitter.emit('sfx:item-place')
|
|
199
|
+
useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
|
|
200
|
+
useLiveTransforms.getState().clear(movingNode.id)
|
|
201
|
+
exitMoveMode()
|
|
202
|
+
event.nativeEvent?.stopPropagation?.()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const onCancel = () => {
|
|
206
|
+
useLiveTransforms.getState().clear(movingNode.id)
|
|
207
|
+
if (isNew) {
|
|
208
|
+
useScene.getState().deleteNode(movingNode.id)
|
|
209
|
+
} else {
|
|
210
|
+
useScene.getState().updateNode(movingNode.id, {
|
|
211
|
+
position: original.position,
|
|
212
|
+
rotation: original.rotation,
|
|
213
|
+
metadata: original.metadata,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
useScene.temporal.getState().resume()
|
|
217
|
+
exitMoveMode()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
221
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const ROTATION_STEP = Math.PI / 4
|
|
226
|
+
let rotationDelta = 0
|
|
227
|
+
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
|
228
|
+
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
|
229
|
+
|
|
230
|
+
if (rotationDelta !== 0) {
|
|
231
|
+
event.preventDefault()
|
|
232
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
233
|
+
|
|
234
|
+
pendingRotation += rotationDelta
|
|
235
|
+
|
|
236
|
+
// Directly update the Three.js mesh — no store update during drag
|
|
237
|
+
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
|
238
|
+
if (mesh) mesh.rotation.y = pendingRotation
|
|
239
|
+
|
|
240
|
+
// Update live transform rotation for 2D floorplan
|
|
241
|
+
const currentLive = useLiveTransforms.getState().get(movingNode.id)
|
|
242
|
+
if (currentLive) {
|
|
243
|
+
useLiveTransforms.getState().set(movingNode.id, {
|
|
244
|
+
...currentLive,
|
|
245
|
+
rotation: pendingRotation,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
emitter.on('grid:move', onGridMove)
|
|
252
|
+
emitter.on('grid:click', onGridClick)
|
|
253
|
+
emitter.on('tool:cancel', onCancel)
|
|
254
|
+
window.addEventListener('keydown', onKeyDown)
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
// Restore segment wrapper visibility (React will re-sync on next render)
|
|
258
|
+
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
|
|
259
|
+
if (mergedRoofMesh) mergedRoofMesh.visible = true
|
|
260
|
+
|
|
261
|
+
// Clear ephemeral live transform
|
|
262
|
+
useLiveTransforms.getState().clear(movingNode.id)
|
|
263
|
+
|
|
264
|
+
if (!wasCommitted) {
|
|
265
|
+
if (isNew) {
|
|
266
|
+
useScene.getState().deleteNode(movingNode.id)
|
|
267
|
+
} else {
|
|
268
|
+
useScene.getState().updateNode(movingNode.id, {
|
|
269
|
+
position: original.position,
|
|
270
|
+
rotation: original.rotation,
|
|
271
|
+
metadata: original.metadata,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
useScene.temporal.getState().resume()
|
|
276
|
+
emitter.off('grid:move', onGridMove)
|
|
277
|
+
emitter.off('grid:click', onGridClick)
|
|
278
|
+
emitter.off('tool:cancel', onCancel)
|
|
279
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
280
|
+
}
|
|
281
|
+
}, [movingNode, exitMoveMode])
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<group>
|
|
285
|
+
<CursorSphere position={cursorWorldPos} showTooltip={false} />
|
|
286
|
+
</group>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
emitter,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
type LevelNode,
|
|
7
|
+
RoofNode,
|
|
8
|
+
RoofSegmentNode,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useScene,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
13
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import * as THREE from 'three'
|
|
15
|
+
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
|
|
16
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
17
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
18
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
19
|
+
import useEditor from '../../../store/use-editor'
|
|
20
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_WALL_HEIGHT = 0.5
|
|
23
|
+
const DEFAULT_ROOF_HEIGHT = 2.5
|
|
24
|
+
const GRID_OFFSET = 0.02
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a roof group with one default gable segment
|
|
28
|
+
*/
|
|
29
|
+
const commitRoofPlacement = (
|
|
30
|
+
levelId: LevelNode['id'],
|
|
31
|
+
corner1: [number, number, number],
|
|
32
|
+
corner2: [number, number, number],
|
|
33
|
+
selectedIds: string[],
|
|
34
|
+
): AnyNode['id'] => {
|
|
35
|
+
const { createNode, createNodes, nodes } = useScene.getState()
|
|
36
|
+
|
|
37
|
+
const centerX = (corner1[0] + corner2[0]) / 2
|
|
38
|
+
const centerZ = (corner1[2] + corner2[2]) / 2
|
|
39
|
+
|
|
40
|
+
const width = Math.max(Math.abs(corner2[0] - corner1[0]), 1)
|
|
41
|
+
const depth = Math.max(Math.abs(corner2[2] - corner1[2]), 1)
|
|
42
|
+
|
|
43
|
+
// Determine if there is an active roof node we should add to
|
|
44
|
+
let targetRoofId: RoofNode['id'] | null = null
|
|
45
|
+
const selectedId = selectedIds[0]
|
|
46
|
+
if (selectedIds.length === 1 && selectedId) {
|
|
47
|
+
const selectedNode = nodes[selectedId as AnyNodeId]
|
|
48
|
+
if (selectedNode?.type === 'roof') {
|
|
49
|
+
targetRoofId = selectedNode.id
|
|
50
|
+
} else if (selectedNode?.type === 'roof-segment' && selectedNode.parentId) {
|
|
51
|
+
targetRoofId = selectedNode.parentId as RoofNode['id']
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (targetRoofId) {
|
|
56
|
+
const targetRoof = nodes[targetRoofId] as RoofNode
|
|
57
|
+
let localX = centerX
|
|
58
|
+
let localZ = centerZ
|
|
59
|
+
|
|
60
|
+
// Convert world coordinates to the local space of the parent roof
|
|
61
|
+
const targetObj = sceneRegistry.nodes.get(targetRoofId)
|
|
62
|
+
if (targetObj) {
|
|
63
|
+
const worldVec = new THREE.Vector3(centerX, 0, centerZ)
|
|
64
|
+
targetObj.worldToLocal(worldVec)
|
|
65
|
+
localX = worldVec.x
|
|
66
|
+
localZ = worldVec.z
|
|
67
|
+
} else {
|
|
68
|
+
// Math fallback if mesh isn't ready
|
|
69
|
+
const dx = centerX - targetRoof.position[0]
|
|
70
|
+
const dz = centerZ - targetRoof.position[2]
|
|
71
|
+
const angle = -targetRoof.rotation
|
|
72
|
+
localX = dx * Math.cos(angle) - dz * Math.sin(angle)
|
|
73
|
+
localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const segment = RoofSegmentNode.parse({
|
|
77
|
+
width,
|
|
78
|
+
depth,
|
|
79
|
+
wallHeight: DEFAULT_WALL_HEIGHT,
|
|
80
|
+
roofHeight: DEFAULT_ROOF_HEIGHT,
|
|
81
|
+
roofType: 'gable',
|
|
82
|
+
position: [localX, 0, localZ],
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
createNode(segment, targetRoofId as AnyNode['id'])
|
|
86
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
87
|
+
return segment.id // Returns segment ID so it can be selected immediately
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Count existing roofs for naming
|
|
91
|
+
const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
|
|
92
|
+
const name = `Roof ${roofCount + 1}`
|
|
93
|
+
|
|
94
|
+
// Create the segment first (centered in its new parent)
|
|
95
|
+
const segment = RoofSegmentNode.parse({
|
|
96
|
+
width,
|
|
97
|
+
depth,
|
|
98
|
+
wallHeight: DEFAULT_WALL_HEIGHT,
|
|
99
|
+
roofHeight: DEFAULT_ROOF_HEIGHT,
|
|
100
|
+
roofType: 'gable',
|
|
101
|
+
position: [0, 0, 0],
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Create the roof container
|
|
105
|
+
const roof = RoofNode.parse({
|
|
106
|
+
name,
|
|
107
|
+
position: [centerX, 0, centerZ],
|
|
108
|
+
children: [segment.id],
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Create roof first (so segment can be parented to it), then segment
|
|
112
|
+
createNodes([
|
|
113
|
+
{ node: roof, parentId: levelId },
|
|
114
|
+
{ node: segment, parentId: roof.id },
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
118
|
+
return roof.id
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type PreviewState = {
|
|
122
|
+
corner1: [number, number, number] | null
|
|
123
|
+
cursorPosition: [number, number, number]
|
|
124
|
+
levelY: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const RoofTool: React.FC = () => {
|
|
128
|
+
const cursorRef = useRef<Group>(null)
|
|
129
|
+
const outlineRef = useRef<Line>(null!)
|
|
130
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
131
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
132
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
133
|
+
const setTool = useEditor((state) => state.setTool)
|
|
134
|
+
const setMode = useEditor((state) => state.setMode)
|
|
135
|
+
|
|
136
|
+
const selectedIdsRef = useRef(selectedIds)
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
selectedIdsRef.current = selectedIds
|
|
139
|
+
}, [selectedIds])
|
|
140
|
+
|
|
141
|
+
const corner1Ref = useRef<[number, number, number] | null>(null)
|
|
142
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
143
|
+
const [preview, setPreview] = useState<PreviewState>({
|
|
144
|
+
corner1: null,
|
|
145
|
+
cursorPosition: [0, 0, 0],
|
|
146
|
+
levelY: 0,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!currentLevelId) return
|
|
151
|
+
|
|
152
|
+
outlineRef.current.geometry = new BufferGeometry()
|
|
153
|
+
|
|
154
|
+
const updateOutline = (
|
|
155
|
+
corner1: [number, number, number],
|
|
156
|
+
corner2: [number, number, number],
|
|
157
|
+
) => {
|
|
158
|
+
const gridY = corner1[1] + GRID_OFFSET
|
|
159
|
+
|
|
160
|
+
const groundPoints = [
|
|
161
|
+
new Vector3(corner1[0], gridY, corner1[2]),
|
|
162
|
+
new Vector3(corner2[0], gridY, corner1[2]),
|
|
163
|
+
new Vector3(corner2[0], gridY, corner2[2]),
|
|
164
|
+
new Vector3(corner1[0], gridY, corner2[2]),
|
|
165
|
+
new Vector3(corner1[0], gridY, corner1[2]),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
outlineRef.current.geometry.dispose()
|
|
169
|
+
outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints)
|
|
170
|
+
outlineRef.current.visible = true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const onGridMove = (event: GridEvent) => {
|
|
174
|
+
if (!cursorRef.current) return
|
|
175
|
+
|
|
176
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
177
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
178
|
+
const y = event.position[1]
|
|
179
|
+
|
|
180
|
+
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
|
|
181
|
+
const gridY = y + GRID_OFFSET
|
|
182
|
+
|
|
183
|
+
cursorRef.current.position.set(gridX, gridY, gridZ)
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
corner1Ref.current &&
|
|
187
|
+
previousGridPosRef.current &&
|
|
188
|
+
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
|
189
|
+
) {
|
|
190
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
previousGridPosRef.current = [gridX, gridZ]
|
|
194
|
+
|
|
195
|
+
setPreview({
|
|
196
|
+
corner1: corner1Ref.current,
|
|
197
|
+
cursorPosition,
|
|
198
|
+
levelY: y,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if (corner1Ref.current) {
|
|
202
|
+
updateOutline(corner1Ref.current, cursorPosition)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const onGridClick = (event: GridEvent) => {
|
|
207
|
+
if (!currentLevelId) return
|
|
208
|
+
|
|
209
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
210
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
211
|
+
const y = event.position[1]
|
|
212
|
+
|
|
213
|
+
if (corner1Ref.current) {
|
|
214
|
+
const roofId = commitRoofPlacement(
|
|
215
|
+
currentLevelId,
|
|
216
|
+
corner1Ref.current,
|
|
217
|
+
[gridX, y, gridZ],
|
|
218
|
+
selectedIdsRef.current,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
setSelection({ selectedIds: [roofId as AnyNode['id']] })
|
|
222
|
+
|
|
223
|
+
corner1Ref.current = null
|
|
224
|
+
outlineRef.current.visible = false
|
|
225
|
+
} else {
|
|
226
|
+
corner1Ref.current = [gridX, y, gridZ]
|
|
227
|
+
setPreview((prev) => ({
|
|
228
|
+
...prev,
|
|
229
|
+
corner1: corner1Ref.current,
|
|
230
|
+
}))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const onCancel = () => {
|
|
235
|
+
if (corner1Ref.current) {
|
|
236
|
+
markToolCancelConsumed()
|
|
237
|
+
corner1Ref.current = null
|
|
238
|
+
outlineRef.current.visible = false
|
|
239
|
+
setPreview((prev) => ({ ...prev, corner1: null }))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
emitter.on('grid:move', onGridMove)
|
|
244
|
+
emitter.on('grid:click', onGridClick)
|
|
245
|
+
emitter.on('tool:cancel', onCancel)
|
|
246
|
+
|
|
247
|
+
return () => {
|
|
248
|
+
emitter.off('grid:move', onGridMove)
|
|
249
|
+
emitter.off('grid:click', onGridClick)
|
|
250
|
+
emitter.off('tool:cancel', onCancel)
|
|
251
|
+
|
|
252
|
+
corner1Ref.current = null
|
|
253
|
+
}
|
|
254
|
+
}, [currentLevelId, setSelection])
|
|
255
|
+
|
|
256
|
+
const { corner1, cursorPosition, levelY } = preview
|
|
257
|
+
|
|
258
|
+
const previewDimensions = useMemo(() => {
|
|
259
|
+
if (!corner1) return null
|
|
260
|
+
const length = Math.abs(cursorPosition[0] - corner1[0])
|
|
261
|
+
const width = Math.abs(cursorPosition[2] - corner1[2])
|
|
262
|
+
const centerX = (corner1[0] + cursorPosition[0]) / 2
|
|
263
|
+
const centerZ = (corner1[2] + cursorPosition[2]) / 2
|
|
264
|
+
return { length, width, centerX, centerZ }
|
|
265
|
+
}, [corner1, cursorPosition])
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<group>
|
|
269
|
+
<CursorSphere ref={cursorRef} />
|
|
270
|
+
|
|
271
|
+
{/* @ts-ignore */}
|
|
272
|
+
<line
|
|
273
|
+
frustumCulled={false}
|
|
274
|
+
layers={EDITOR_LAYER}
|
|
275
|
+
// @ts-expect-error
|
|
276
|
+
ref={outlineRef}
|
|
277
|
+
renderOrder={1}
|
|
278
|
+
visible={false}
|
|
279
|
+
>
|
|
280
|
+
<bufferGeometry />
|
|
281
|
+
<lineBasicNodeMaterial
|
|
282
|
+
color="#818cf8"
|
|
283
|
+
depthTest={false}
|
|
284
|
+
depthWrite={false}
|
|
285
|
+
linewidth={2}
|
|
286
|
+
opacity={0.3}
|
|
287
|
+
transparent
|
|
288
|
+
/>
|
|
289
|
+
</line>
|
|
290
|
+
|
|
291
|
+
{corner1 && (
|
|
292
|
+
<CursorSphere
|
|
293
|
+
color="#818cf8"
|
|
294
|
+
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
|
|
295
|
+
showTooltip={false}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
|
|
300
|
+
<mesh
|
|
301
|
+
layers={EDITOR_LAYER}
|
|
302
|
+
position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
|
|
303
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
304
|
+
>
|
|
305
|
+
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
|
|
306
|
+
<meshBasicMaterial
|
|
307
|
+
color="#818cf8"
|
|
308
|
+
depthTest={false}
|
|
309
|
+
depthWrite={false}
|
|
310
|
+
opacity={0.1}
|
|
311
|
+
side={DoubleSide}
|
|
312
|
+
transparent
|
|
313
|
+
/>
|
|
314
|
+
</mesh>
|
|
315
|
+
)}
|
|
316
|
+
</group>
|
|
317
|
+
)
|
|
318
|
+
}
|