@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,303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
emitter,
|
|
4
|
+
sceneRegistry,
|
|
5
|
+
spatialGridManager,
|
|
6
|
+
useScene,
|
|
7
|
+
type WallEvent,
|
|
8
|
+
WindowNode,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { useEffect, useRef } from 'react'
|
|
12
|
+
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
|
13
|
+
import { LineBasicNodeMaterial } from 'three/webgpu'
|
|
14
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
15
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
16
|
+
import {
|
|
17
|
+
calculateCursorRotation,
|
|
18
|
+
calculateItemRotation,
|
|
19
|
+
getSideFromNormal,
|
|
20
|
+
isValidWallSideFace,
|
|
21
|
+
snapToHalf,
|
|
22
|
+
} from '../item/placement-math'
|
|
23
|
+
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
|
24
|
+
|
|
25
|
+
// Shared edge material — reuse across renders, just toggle color
|
|
26
|
+
const edgeMaterial = new LineBasicNodeMaterial({
|
|
27
|
+
color: 0xef_44_44, // red-500 default (invalid)
|
|
28
|
+
linewidth: 3,
|
|
29
|
+
depthTest: false,
|
|
30
|
+
depthWrite: false,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Window tool — places WindowNodes on walls only.
|
|
35
|
+
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
|
|
36
|
+
*/
|
|
37
|
+
export const WindowTool: React.FC = () => {
|
|
38
|
+
const draftRef = useRef<WindowNode | null>(null)
|
|
39
|
+
const cursorGroupRef = useRef<Group>(null!)
|
|
40
|
+
const edgesRef = useRef<LineSegments>(null!)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
useScene.temporal.getState().pause()
|
|
44
|
+
|
|
45
|
+
const getLevelId = () => useViewer.getState().selection.levelId
|
|
46
|
+
const getLevelYOffset = () => {
|
|
47
|
+
const id = getLevelId()
|
|
48
|
+
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
|
49
|
+
}
|
|
50
|
+
const getSlabElevation = (wallEvent: WallEvent) =>
|
|
51
|
+
spatialGridManager.getSlabElevationForWall(
|
|
52
|
+
wallEvent.node.parentId ?? '',
|
|
53
|
+
wallEvent.node.start,
|
|
54
|
+
wallEvent.node.end,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const markWallDirty = (wallId: string) => {
|
|
58
|
+
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const destroyDraft = () => {
|
|
62
|
+
if (!draftRef.current) return
|
|
63
|
+
const wallId = draftRef.current.parentId
|
|
64
|
+
useScene.getState().deleteNode(draftRef.current.id)
|
|
65
|
+
draftRef.current = null
|
|
66
|
+
// Rebuild wall so it removes the cutout from the deleted draft
|
|
67
|
+
if (wallId) markWallDirty(wallId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const hideCursor = () => {
|
|
71
|
+
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const updateCursor = (
|
|
75
|
+
worldPosition: [number, number, number],
|
|
76
|
+
cursorRotationY: number,
|
|
77
|
+
valid: boolean,
|
|
78
|
+
) => {
|
|
79
|
+
const group = cursorGroupRef.current
|
|
80
|
+
if (!group) return
|
|
81
|
+
group.visible = true
|
|
82
|
+
group.position.set(...worldPosition)
|
|
83
|
+
group.rotation.y = cursorRotationY
|
|
84
|
+
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const onWallEnter = (event: WallEvent) => {
|
|
88
|
+
if (!isValidWallSideFace(event.normal)) return
|
|
89
|
+
const levelId = getLevelId()
|
|
90
|
+
if (!levelId) return
|
|
91
|
+
// Only interact with walls on the current level
|
|
92
|
+
if (event.node.parentId !== levelId) return
|
|
93
|
+
|
|
94
|
+
destroyDraft()
|
|
95
|
+
|
|
96
|
+
const side = getSideFromNormal(event.normal)
|
|
97
|
+
const itemRotation = calculateItemRotation(event.normal)
|
|
98
|
+
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
99
|
+
|
|
100
|
+
const localX = snapToHalf(event.localPosition[0])
|
|
101
|
+
const localY = snapToHalf(event.localPosition[1])
|
|
102
|
+
|
|
103
|
+
const width = 1.5
|
|
104
|
+
const height = 1.5
|
|
105
|
+
|
|
106
|
+
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
|
107
|
+
|
|
108
|
+
const node = WindowNode.parse({
|
|
109
|
+
position: [clampedX, clampedY, 0],
|
|
110
|
+
rotation: [0, itemRotation, 0],
|
|
111
|
+
side,
|
|
112
|
+
wallId: event.node.id,
|
|
113
|
+
parentId: event.node.id,
|
|
114
|
+
metadata: { isTransient: true },
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
118
|
+
draftRef.current = node
|
|
119
|
+
|
|
120
|
+
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
|
121
|
+
|
|
122
|
+
updateCursor(
|
|
123
|
+
wallLocalToWorld(
|
|
124
|
+
event.node,
|
|
125
|
+
clampedX,
|
|
126
|
+
clampedY,
|
|
127
|
+
getLevelYOffset(),
|
|
128
|
+
getSlabElevation(event),
|
|
129
|
+
),
|
|
130
|
+
cursorRotation,
|
|
131
|
+
valid,
|
|
132
|
+
)
|
|
133
|
+
event.stopPropagation()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const onWallMove = (event: WallEvent) => {
|
|
137
|
+
if (!isValidWallSideFace(event.normal)) return
|
|
138
|
+
// Only interact with walls on the current level
|
|
139
|
+
if (event.node.parentId !== getLevelId()) return
|
|
140
|
+
|
|
141
|
+
const side = getSideFromNormal(event.normal)
|
|
142
|
+
const itemRotation = calculateItemRotation(event.normal)
|
|
143
|
+
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
144
|
+
|
|
145
|
+
const localX = snapToHalf(event.localPosition[0])
|
|
146
|
+
const localY = snapToHalf(event.localPosition[1])
|
|
147
|
+
|
|
148
|
+
const width = draftRef.current?.width ?? 1.5
|
|
149
|
+
const height = draftRef.current?.height ?? 1.5
|
|
150
|
+
|
|
151
|
+
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
|
152
|
+
|
|
153
|
+
if (draftRef.current) {
|
|
154
|
+
useScene.getState().updateNode(draftRef.current.id, {
|
|
155
|
+
position: [clampedX, clampedY, 0],
|
|
156
|
+
rotation: [0, itemRotation, 0],
|
|
157
|
+
side,
|
|
158
|
+
parentId: event.node.id,
|
|
159
|
+
wallId: event.node.id,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const valid = !hasWallChildOverlap(
|
|
164
|
+
event.node.id,
|
|
165
|
+
clampedX,
|
|
166
|
+
clampedY,
|
|
167
|
+
width,
|
|
168
|
+
height,
|
|
169
|
+
draftRef.current?.id,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
updateCursor(
|
|
173
|
+
wallLocalToWorld(
|
|
174
|
+
event.node,
|
|
175
|
+
clampedX,
|
|
176
|
+
clampedY,
|
|
177
|
+
getLevelYOffset(),
|
|
178
|
+
getSlabElevation(event),
|
|
179
|
+
),
|
|
180
|
+
cursorRotation,
|
|
181
|
+
valid,
|
|
182
|
+
)
|
|
183
|
+
event.stopPropagation()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const onWallClick = (event: WallEvent) => {
|
|
187
|
+
if (!draftRef.current) return
|
|
188
|
+
if (!isValidWallSideFace(event.normal)) return
|
|
189
|
+
// Only interact with walls on the current level
|
|
190
|
+
if (event.node.parentId !== getLevelId()) return
|
|
191
|
+
|
|
192
|
+
const side = getSideFromNormal(event.normal)
|
|
193
|
+
const itemRotation = calculateItemRotation(event.normal)
|
|
194
|
+
|
|
195
|
+
const localX = snapToHalf(event.localPosition[0])
|
|
196
|
+
const localY = snapToHalf(event.localPosition[1])
|
|
197
|
+
const { clampedX, clampedY } = clampToWall(
|
|
198
|
+
event.node,
|
|
199
|
+
localX,
|
|
200
|
+
localY,
|
|
201
|
+
draftRef.current.width,
|
|
202
|
+
draftRef.current.height,
|
|
203
|
+
)
|
|
204
|
+
const valid = !hasWallChildOverlap(
|
|
205
|
+
event.node.id,
|
|
206
|
+
clampedX,
|
|
207
|
+
clampedY,
|
|
208
|
+
draftRef.current.width,
|
|
209
|
+
draftRef.current.height,
|
|
210
|
+
draftRef.current.id,
|
|
211
|
+
)
|
|
212
|
+
if (!valid) return
|
|
213
|
+
|
|
214
|
+
const draft = draftRef.current
|
|
215
|
+
draftRef.current = null
|
|
216
|
+
|
|
217
|
+
// Delete transient draft (paused, invisible to undo)
|
|
218
|
+
useScene.getState().deleteNode(draft.id)
|
|
219
|
+
|
|
220
|
+
// Resume → create permanent node (single undoable action)
|
|
221
|
+
useScene.temporal.getState().resume()
|
|
222
|
+
|
|
223
|
+
const levelId = getLevelId()
|
|
224
|
+
const state = useScene.getState()
|
|
225
|
+
const windowCount = Object.values(state.nodes).filter((n) => {
|
|
226
|
+
if (n.type !== 'window') return false
|
|
227
|
+
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
|
228
|
+
return wall?.parentId === levelId
|
|
229
|
+
}).length
|
|
230
|
+
const name = `Window ${windowCount + 1}`
|
|
231
|
+
|
|
232
|
+
const node = WindowNode.parse({
|
|
233
|
+
name,
|
|
234
|
+
position: [clampedX, clampedY, 0],
|
|
235
|
+
rotation: [0, itemRotation, 0],
|
|
236
|
+
side,
|
|
237
|
+
wallId: event.node.id,
|
|
238
|
+
parentId: event.node.id,
|
|
239
|
+
width: draft.width,
|
|
240
|
+
height: draft.height,
|
|
241
|
+
frameThickness: draft.frameThickness,
|
|
242
|
+
frameDepth: draft.frameDepth,
|
|
243
|
+
columnRatios: draft.columnRatios,
|
|
244
|
+
rowRatios: draft.rowRatios,
|
|
245
|
+
columnDividerThickness: draft.columnDividerThickness,
|
|
246
|
+
rowDividerThickness: draft.rowDividerThickness,
|
|
247
|
+
sill: draft.sill,
|
|
248
|
+
sillDepth: draft.sillDepth,
|
|
249
|
+
sillThickness: draft.sillThickness,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
253
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
254
|
+
useScene.temporal.getState().pause()
|
|
255
|
+
sfxEmitter.emit('sfx:item-place')
|
|
256
|
+
|
|
257
|
+
event.stopPropagation()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const onWallLeave = () => {
|
|
261
|
+
destroyDraft()
|
|
262
|
+
hideCursor()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const onCancel = () => {
|
|
266
|
+
destroyDraft()
|
|
267
|
+
hideCursor()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
emitter.on('wall:enter', onWallEnter)
|
|
271
|
+
emitter.on('wall:move', onWallMove)
|
|
272
|
+
emitter.on('wall:click', onWallClick)
|
|
273
|
+
emitter.on('wall:leave', onWallLeave)
|
|
274
|
+
emitter.on('tool:cancel', onCancel)
|
|
275
|
+
|
|
276
|
+
return () => {
|
|
277
|
+
destroyDraft()
|
|
278
|
+
hideCursor()
|
|
279
|
+
useScene.temporal.getState().resume()
|
|
280
|
+
emitter.off('wall:enter', onWallEnter)
|
|
281
|
+
emitter.off('wall:move', onWallMove)
|
|
282
|
+
emitter.off('wall:click', onWallClick)
|
|
283
|
+
emitter.off('wall:leave', onWallLeave)
|
|
284
|
+
emitter.off('tool:cancel', onCancel)
|
|
285
|
+
}
|
|
286
|
+
}, [])
|
|
287
|
+
|
|
288
|
+
// Cursor geometry: window outline rectangle (width × height × frameDepth)
|
|
289
|
+
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
|
|
290
|
+
const edgesGeo = new EdgesGeometry(boxGeo)
|
|
291
|
+
boxGeo.dispose()
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<group ref={cursorGroupRef} visible={false}>
|
|
295
|
+
<lineSegments
|
|
296
|
+
geometry={edgesGeo}
|
|
297
|
+
layers={EDITOR_LAYER}
|
|
298
|
+
material={edgeMaterial}
|
|
299
|
+
ref={edgesRef}
|
|
300
|
+
/>
|
|
301
|
+
</group>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core'
|
|
2
|
+
import { useCallback } from 'react'
|
|
3
|
+
import { PolygonEditor } from '../shared/polygon-editor'
|
|
4
|
+
|
|
5
|
+
interface ZoneBoundaryEditorProps {
|
|
6
|
+
zoneId: ZoneNode['id']
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Zone boundary editor - allows editing zone polygon vertices for a specific zone
|
|
11
|
+
* Uses the generic PolygonEditor component
|
|
12
|
+
*/
|
|
13
|
+
export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }) => {
|
|
14
|
+
const zoneNode = useScene((state) => state.nodes[zoneId])
|
|
15
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
16
|
+
|
|
17
|
+
const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
|
|
18
|
+
|
|
19
|
+
const handlePolygonChange = useCallback(
|
|
20
|
+
(newPolygon: Array<[number, number]>) => {
|
|
21
|
+
updateNode(zoneId, { polygon: newPolygon })
|
|
22
|
+
},
|
|
23
|
+
[zoneId, updateNode],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if (!zone?.polygon || zone.polygon.length < 3) return null
|
|
27
|
+
|
|
28
|
+
const zoneColor = zone.color || '#3b82f6'
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<PolygonEditor
|
|
32
|
+
color={zoneColor}
|
|
33
|
+
levelId={resolveLevelId(zone, useScene.getState().nodes)}
|
|
34
|
+
minVertices={3}
|
|
35
|
+
onPolygonChange={handlePolygonChange}
|
|
36
|
+
polygon={zone.polygon}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
|
5
|
+
import { EDITOR_LAYER } from './../../../lib/constants'
|
|
6
|
+
import useEditor from './../../../store/use-editor'
|
|
7
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
8
|
+
|
|
9
|
+
const Y_OFFSET = 0.02
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
|
13
|
+
*/
|
|
14
|
+
const calculateSnapPoint = (
|
|
15
|
+
lastPoint: [number, number],
|
|
16
|
+
currentPoint: [number, number],
|
|
17
|
+
): [number, number] => {
|
|
18
|
+
const [x1, y1] = lastPoint
|
|
19
|
+
const [x, y] = currentPoint
|
|
20
|
+
|
|
21
|
+
const dx = x - x1
|
|
22
|
+
const dy = y - y1
|
|
23
|
+
const absDx = Math.abs(dx)
|
|
24
|
+
const absDy = Math.abs(dy)
|
|
25
|
+
|
|
26
|
+
// Calculate distances to horizontal, vertical, and diagonal lines
|
|
27
|
+
const horizontalDist = absDy
|
|
28
|
+
const verticalDist = absDx
|
|
29
|
+
const diagonalDist = Math.abs(absDx - absDy)
|
|
30
|
+
|
|
31
|
+
// Find the minimum distance to determine which axis to snap to
|
|
32
|
+
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
|
33
|
+
|
|
34
|
+
if (minDist === diagonalDist) {
|
|
35
|
+
// Snap to 45° diagonal
|
|
36
|
+
const diagonalLength = Math.min(absDx, absDy)
|
|
37
|
+
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
|
38
|
+
}
|
|
39
|
+
if (minDist === horizontalDist) {
|
|
40
|
+
// Snap to horizontal
|
|
41
|
+
return [x, y1]
|
|
42
|
+
}
|
|
43
|
+
// Snap to vertical
|
|
44
|
+
return [x1, y]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a zone with the given polygon points
|
|
49
|
+
*/
|
|
50
|
+
const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
|
|
51
|
+
const { createNode, nodes } = useScene.getState()
|
|
52
|
+
|
|
53
|
+
// Count existing zones for naming and color cycling
|
|
54
|
+
const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
|
|
55
|
+
const name = `Zone ${zoneCount + 1}`
|
|
56
|
+
|
|
57
|
+
// Default to blue, cycle through palette for subsequent zones
|
|
58
|
+
const color = '#3b82f6'
|
|
59
|
+
|
|
60
|
+
const zone = ZoneNode.parse({
|
|
61
|
+
name,
|
|
62
|
+
polygon: points,
|
|
63
|
+
color,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
createNode(zone, levelId)
|
|
67
|
+
|
|
68
|
+
// Select the newly created zone
|
|
69
|
+
useViewer.getState().setSelection({ zoneId: zone.id })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type PreviewState = {
|
|
73
|
+
points: Array<[number, number]>
|
|
74
|
+
cursorPoint: [number, number] | null
|
|
75
|
+
levelY: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Helper to validate point values (no NaN or Infinity)
|
|
79
|
+
const isValidPoint = (pt: [number, number] | null | undefined): pt is [number, number] => {
|
|
80
|
+
if (!pt) return false
|
|
81
|
+
return Number.isFinite(pt[0]) && Number.isFinite(pt[1])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const ZoneTool: React.FC = () => {
|
|
85
|
+
const cursorRef = useRef<Group>(null)
|
|
86
|
+
const mainLineRef = useRef<Line>(null!)
|
|
87
|
+
const closingLineRef = useRef<Line>(null!)
|
|
88
|
+
const pointsRef = useRef<Array<[number, number]>>([])
|
|
89
|
+
const levelYRef = useRef(0) // Track current level Y position
|
|
90
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
91
|
+
const setTool = useEditor((state) => state.setTool)
|
|
92
|
+
|
|
93
|
+
// Preview state for reactive rendering (for shape and point markers)
|
|
94
|
+
const [preview, setPreview] = useState<PreviewState>({
|
|
95
|
+
points: [],
|
|
96
|
+
cursorPoint: null,
|
|
97
|
+
levelY: 0,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!currentLevelId) return
|
|
102
|
+
|
|
103
|
+
let cursorPosition: [number, number] = [0, 0]
|
|
104
|
+
|
|
105
|
+
// Initialize line geometries
|
|
106
|
+
mainLineRef.current.geometry = new BufferGeometry()
|
|
107
|
+
closingLineRef.current.geometry = new BufferGeometry()
|
|
108
|
+
|
|
109
|
+
const updateLines = () => {
|
|
110
|
+
const points = pointsRef.current
|
|
111
|
+
const y = levelYRef.current + Y_OFFSET
|
|
112
|
+
|
|
113
|
+
if (points.length === 0) {
|
|
114
|
+
mainLineRef.current.visible = false
|
|
115
|
+
closingLineRef.current.visible = false
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Build main line points
|
|
120
|
+
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
|
|
121
|
+
|
|
122
|
+
// Add cursor point
|
|
123
|
+
const lastPoint = points[points.length - 1]
|
|
124
|
+
if (lastPoint) {
|
|
125
|
+
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
|
126
|
+
if (isValidPoint(snapped)) {
|
|
127
|
+
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Update main line geometry
|
|
132
|
+
if (linePoints.length >= 2) {
|
|
133
|
+
mainLineRef.current.geometry.dispose()
|
|
134
|
+
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
|
135
|
+
mainLineRef.current.visible = true
|
|
136
|
+
} else {
|
|
137
|
+
mainLineRef.current.visible = false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Update closing line (from cursor back to first point)
|
|
141
|
+
const firstPoint = points[0]
|
|
142
|
+
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
|
143
|
+
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
|
144
|
+
if (isValidPoint(snapped)) {
|
|
145
|
+
const closingPoints = [
|
|
146
|
+
new Vector3(snapped[0], y, snapped[1]),
|
|
147
|
+
new Vector3(firstPoint[0], y, firstPoint[1]),
|
|
148
|
+
]
|
|
149
|
+
closingLineRef.current.geometry.dispose()
|
|
150
|
+
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
|
151
|
+
closingLineRef.current.visible = true
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
closingLineRef.current.visible = false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const updatePreview = () => {
|
|
159
|
+
const points = pointsRef.current
|
|
160
|
+
const lastPoint = points[points.length - 1]
|
|
161
|
+
|
|
162
|
+
let cursorPt: [number, number] | null = null
|
|
163
|
+
if (lastPoint) {
|
|
164
|
+
cursorPt = calculateSnapPoint(lastPoint, cursorPosition)
|
|
165
|
+
} else if (points.length === 0) {
|
|
166
|
+
cursorPt = cursorPosition
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
|
|
170
|
+
updateLines()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const onGridMove = (event: GridEvent) => {
|
|
174
|
+
if (!cursorRef.current) return
|
|
175
|
+
|
|
176
|
+
// Snap to 0.5 grid
|
|
177
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
178
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
179
|
+
cursorPosition = [gridX, gridZ]
|
|
180
|
+
levelYRef.current = event.position[1]
|
|
181
|
+
|
|
182
|
+
// If we have points, snap to axis from last point
|
|
183
|
+
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
|
184
|
+
if (lastPoint) {
|
|
185
|
+
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
|
186
|
+
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])
|
|
187
|
+
} else {
|
|
188
|
+
cursorRef.current.position.set(gridX, event.position[1], gridZ)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
updatePreview()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const onGridClick = (event: GridEvent) => {
|
|
195
|
+
if (!currentLevelId) return
|
|
196
|
+
|
|
197
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
198
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
199
|
+
let clickPoint: [number, number] = [gridX, gridZ]
|
|
200
|
+
|
|
201
|
+
// Snap to axis from last point
|
|
202
|
+
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
|
203
|
+
if (lastPoint) {
|
|
204
|
+
clickPoint = calculateSnapPoint(lastPoint, clickPoint)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check if clicking on the first point to close the shape
|
|
208
|
+
const firstPoint = pointsRef.current[0]
|
|
209
|
+
if (
|
|
210
|
+
pointsRef.current.length >= 3 &&
|
|
211
|
+
firstPoint &&
|
|
212
|
+
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
|
213
|
+
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
|
214
|
+
) {
|
|
215
|
+
// Create the zone
|
|
216
|
+
commitZoneDrawing(currentLevelId, pointsRef.current)
|
|
217
|
+
|
|
218
|
+
// Reset state
|
|
219
|
+
pointsRef.current = []
|
|
220
|
+
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
|
|
221
|
+
mainLineRef.current.visible = false
|
|
222
|
+
closingLineRef.current.visible = false
|
|
223
|
+
} else {
|
|
224
|
+
// Add point to polygon
|
|
225
|
+
pointsRef.current = [...pointsRef.current, clickPoint]
|
|
226
|
+
updatePreview()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const onGridDoubleClick = (_event: GridEvent) => {
|
|
231
|
+
if (!currentLevelId) return
|
|
232
|
+
|
|
233
|
+
// Need at least 3 points to form a polygon
|
|
234
|
+
if (pointsRef.current.length >= 3) {
|
|
235
|
+
commitZoneDrawing(currentLevelId, pointsRef.current)
|
|
236
|
+
|
|
237
|
+
// Reset state
|
|
238
|
+
pointsRef.current = []
|
|
239
|
+
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
|
|
240
|
+
mainLineRef.current.visible = false
|
|
241
|
+
closingLineRef.current.visible = false
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Subscribe to events
|
|
246
|
+
emitter.on('grid:move', onGridMove)
|
|
247
|
+
emitter.on('grid:click', onGridClick)
|
|
248
|
+
emitter.on('grid:double-click', onGridDoubleClick)
|
|
249
|
+
|
|
250
|
+
return () => {
|
|
251
|
+
emitter.off('grid:move', onGridMove)
|
|
252
|
+
emitter.off('grid:click', onGridClick)
|
|
253
|
+
emitter.off('grid:double-click', onGridDoubleClick)
|
|
254
|
+
|
|
255
|
+
// Reset state on unmount
|
|
256
|
+
pointsRef.current = []
|
|
257
|
+
}
|
|
258
|
+
}, [currentLevelId])
|
|
259
|
+
|
|
260
|
+
const { points, cursorPoint, levelY } = preview
|
|
261
|
+
|
|
262
|
+
// Create preview shape when we have 3+ points
|
|
263
|
+
const previewShape = useMemo(() => {
|
|
264
|
+
if (points.length < 3) return null
|
|
265
|
+
|
|
266
|
+
const allPoints = [...points]
|
|
267
|
+
if (isValidPoint(cursorPoint)) {
|
|
268
|
+
allPoints.push(cursorPoint)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
|
272
|
+
// - Shape X -> World X
|
|
273
|
+
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
|
274
|
+
const firstPt = allPoints[0]
|
|
275
|
+
if (!isValidPoint(firstPt)) return null
|
|
276
|
+
|
|
277
|
+
const shape = new Shape()
|
|
278
|
+
shape.moveTo(firstPt[0], -firstPt[1])
|
|
279
|
+
|
|
280
|
+
for (let i = 1; i < allPoints.length; i++) {
|
|
281
|
+
const pt = allPoints[i]
|
|
282
|
+
if (isValidPoint(pt)) {
|
|
283
|
+
shape.lineTo(pt[0], -pt[1])
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
shape.closePath()
|
|
287
|
+
|
|
288
|
+
return shape
|
|
289
|
+
}, [points, cursorPoint])
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<group>
|
|
293
|
+
{/* Cursor */}
|
|
294
|
+
<CursorSphere ref={cursorRef} />
|
|
295
|
+
|
|
296
|
+
{/* Preview fill */}
|
|
297
|
+
{previewShape && (
|
|
298
|
+
<mesh
|
|
299
|
+
frustumCulled={false}
|
|
300
|
+
layers={EDITOR_LAYER}
|
|
301
|
+
position={[0, levelY + Y_OFFSET, 0]}
|
|
302
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
303
|
+
>
|
|
304
|
+
<shapeGeometry args={[previewShape]} />
|
|
305
|
+
<meshBasicMaterial
|
|
306
|
+
color="#818cf8"
|
|
307
|
+
depthTest={false}
|
|
308
|
+
opacity={0.15}
|
|
309
|
+
side={DoubleSide}
|
|
310
|
+
transparent
|
|
311
|
+
/>
|
|
312
|
+
</mesh>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
{/* Main line - uses native line element with TSL-compatible material */}
|
|
316
|
+
{/* @ts-ignore */}
|
|
317
|
+
<line
|
|
318
|
+
frustumCulled={false}
|
|
319
|
+
layers={EDITOR_LAYER}
|
|
320
|
+
// @ts-expect-error
|
|
321
|
+
ref={mainLineRef}
|
|
322
|
+
renderOrder={1}
|
|
323
|
+
visible={false}
|
|
324
|
+
>
|
|
325
|
+
<bufferGeometry />
|
|
326
|
+
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
|
327
|
+
</line>
|
|
328
|
+
|
|
329
|
+
{/* Closing line - uses native line element with TSL-compatible material */}
|
|
330
|
+
{/* @ts-ignore */}
|
|
331
|
+
<line
|
|
332
|
+
frustumCulled={false}
|
|
333
|
+
layers={EDITOR_LAYER}
|
|
334
|
+
// @ts-expect-error
|
|
335
|
+
ref={closingLineRef}
|
|
336
|
+
renderOrder={1}
|
|
337
|
+
visible={false}
|
|
338
|
+
>
|
|
339
|
+
<bufferGeometry />
|
|
340
|
+
<lineBasicNodeMaterial
|
|
341
|
+
color="#818cf8"
|
|
342
|
+
depthTest={false}
|
|
343
|
+
depthWrite={false}
|
|
344
|
+
linewidth={2}
|
|
345
|
+
opacity={0.5}
|
|
346
|
+
transparent
|
|
347
|
+
/>
|
|
348
|
+
</line>
|
|
349
|
+
|
|
350
|
+
{/* Point markers */}
|
|
351
|
+
{points.map(([x, z], index) =>
|
|
352
|
+
isValidPoint([x, z]) ? (
|
|
353
|
+
<CursorSphere
|
|
354
|
+
color="#818cf8"
|
|
355
|
+
height={0}
|
|
356
|
+
key={index}
|
|
357
|
+
position={[x, levelY + Y_OFFSET + 0.01, z]}
|
|
358
|
+
showTooltip={false}
|
|
359
|
+
/>
|
|
360
|
+
) : null,
|
|
361
|
+
)}
|
|
362
|
+
</group>
|
|
363
|
+
)
|
|
364
|
+
}
|