@pascal-app/editor 0.6.0 → 0.7.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 +9 -5
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +20 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +267 -36
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/tool-manager.tsx +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { useViewer } from '@pascal-app/viewer'
|
|
17
17
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
18
18
|
import * as THREE from 'three'
|
|
19
|
+
import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
|
|
19
20
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
20
21
|
import useEditor from '../../../store/use-editor'
|
|
21
22
|
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
|
@@ -100,6 +101,7 @@ export const MoveRoofTool: React.FC<{
|
|
|
100
101
|
// resetting the mesh position (it resets on dirty) and from triggering
|
|
101
102
|
// expensive merged-mesh CSG rebuilds on every frame.
|
|
102
103
|
let wasCommitted = false
|
|
104
|
+
let wasCancelled = false
|
|
103
105
|
|
|
104
106
|
// Track pending rotation — no store updates during drag
|
|
105
107
|
let pendingRotation: number = movingNode.rotation as number
|
|
@@ -251,11 +253,19 @@ export const MoveRoofTool: React.FC<{
|
|
|
251
253
|
// Resume temporal and apply the final state as a single undoable step.
|
|
252
254
|
useScene.temporal.getState().resume()
|
|
253
255
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
256
|
+
if (isNew && movingNode.type === 'roof') {
|
|
257
|
+
clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
|
|
258
|
+
position: [localX, movingNode.position[1], localZ],
|
|
259
|
+
rotation: pendingRotation,
|
|
260
|
+
metadata: committedMeta,
|
|
261
|
+
})
|
|
262
|
+
} else {
|
|
263
|
+
useScene.getState().updateNode(movingNode.id, {
|
|
264
|
+
position: [localX, movingNode.position[1], localZ],
|
|
265
|
+
rotation: pendingRotation,
|
|
266
|
+
metadata: committedMeta,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
259
269
|
|
|
260
270
|
useScene.temporal.getState().pause()
|
|
261
271
|
|
|
@@ -267,6 +277,7 @@ export const MoveRoofTool: React.FC<{
|
|
|
267
277
|
}
|
|
268
278
|
|
|
269
279
|
const onCancel = () => {
|
|
280
|
+
wasCancelled = true
|
|
270
281
|
useLiveTransforms.getState().clear(movingNode.id)
|
|
271
282
|
if (isNew) {
|
|
272
283
|
useScene.getState().deleteNode(movingNode.id)
|
|
@@ -325,16 +336,12 @@ export const MoveRoofTool: React.FC<{
|
|
|
325
336
|
// Clear ephemeral live transform
|
|
326
337
|
useLiveTransforms.getState().clear(movingNode.id)
|
|
327
338
|
|
|
328
|
-
if (!wasCommitted) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
rotation: original.rotation,
|
|
335
|
-
metadata: original.metadata,
|
|
336
|
-
})
|
|
337
|
-
}
|
|
339
|
+
if (!(wasCommitted || wasCancelled || isNew)) {
|
|
340
|
+
useScene.getState().updateNode(movingNode.id, {
|
|
341
|
+
position: original.position,
|
|
342
|
+
rotation: original.rotation,
|
|
343
|
+
metadata: original.metadata,
|
|
344
|
+
})
|
|
338
345
|
}
|
|
339
346
|
useScene.temporal.getState().resume()
|
|
340
347
|
emitter.off('grid:move', onGridMove)
|
|
@@ -10,8 +10,10 @@ const Y_OFFSET = 0.02
|
|
|
10
10
|
|
|
11
11
|
type DragState = {
|
|
12
12
|
isDragging: boolean
|
|
13
|
-
mode: 'vertex' | 'polygon'
|
|
13
|
+
mode: 'vertex' | 'polygon' | 'edge'
|
|
14
14
|
vertexIndex: number | null
|
|
15
|
+
edgeIndex?: number
|
|
16
|
+
edgeNormal?: [number, number]
|
|
15
17
|
initialPosition: [number, number]
|
|
16
18
|
initialPolygon: Array<[number, number]>
|
|
17
19
|
pointerId: number
|
|
@@ -28,6 +30,8 @@ export interface PolygonEditorProps {
|
|
|
28
30
|
surfaceHeight?: number
|
|
29
31
|
/** Whether to show the center handle that moves the entire polygon. */
|
|
30
32
|
allowPolygonMove?: boolean
|
|
33
|
+
/** Whether polygon edges can be dragged along their perpendicular normal. */
|
|
34
|
+
allowEdgeMove?: boolean
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -35,6 +39,17 @@ export interface PolygonEditorProps {
|
|
|
35
39
|
* Used by zone and site boundary editors
|
|
36
40
|
*/
|
|
37
41
|
const MIN_HANDLE_HEIGHT = 0.15
|
|
42
|
+
const EDGE_HANDLE_HEIGHT = 0.06
|
|
43
|
+
const EDGE_HANDLE_THICKNESS = 0.12
|
|
44
|
+
|
|
45
|
+
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
|
|
46
|
+
const dx = end[0] - start[0]
|
|
47
|
+
const dz = end[1] - start[1]
|
|
48
|
+
const length = Math.hypot(dx, dz)
|
|
49
|
+
if (length < 1e-6) return null
|
|
50
|
+
|
|
51
|
+
return [-dz / length, dx / length]
|
|
52
|
+
}
|
|
38
53
|
|
|
39
54
|
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
40
55
|
polygon,
|
|
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
44
59
|
levelId,
|
|
45
60
|
surfaceHeight = 0,
|
|
46
61
|
allowPolygonMove = false,
|
|
62
|
+
allowEdgeMove = false,
|
|
47
63
|
}) => {
|
|
48
64
|
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
|
49
65
|
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
|
@@ -89,6 +105,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
89
105
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
|
90
106
|
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
|
91
107
|
|
|
108
|
+
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
|
|
109
|
+
previewPolygonRef.current = nextPolygon
|
|
110
|
+
setPreviewPolygon(nextPolygon)
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
92
113
|
// Keep ref in sync
|
|
93
114
|
useEffect(() => {
|
|
94
115
|
previewPolygonRef.current = previewPolygon
|
|
@@ -96,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
96
117
|
|
|
97
118
|
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
|
98
119
|
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
|
120
|
+
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
|
|
99
121
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
100
122
|
|
|
101
123
|
const lineRef = useRef<Line>(null!)
|
|
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
106
128
|
if (polygon !== lastPolygonRef.current) {
|
|
107
129
|
lastPolygonRef.current = polygon
|
|
108
130
|
// External change (e.g. undo/redo) — clear any stale preview/drag state
|
|
109
|
-
if (previewPolygon)
|
|
131
|
+
if (previewPolygon) updatePreviewPolygon(null)
|
|
110
132
|
if (dragState) setDragState(null)
|
|
111
133
|
}
|
|
112
134
|
|
|
@@ -134,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
134
156
|
})
|
|
135
157
|
}, [displayPolygon])
|
|
136
158
|
|
|
159
|
+
const edgeHandles = useMemo(() => {
|
|
160
|
+
if (displayPolygon.length < 2) return []
|
|
161
|
+
|
|
162
|
+
return displayPolygon.flatMap(([x1, z1], index) => {
|
|
163
|
+
const nextIndex = (index + 1) % displayPolygon.length
|
|
164
|
+
const [x2, z2] = displayPolygon[nextIndex]!
|
|
165
|
+
const dx = x2 - x1
|
|
166
|
+
const dz = z2 - z1
|
|
167
|
+
const length = Math.hypot(dx, dz)
|
|
168
|
+
if (length < 1e-6) return []
|
|
169
|
+
|
|
170
|
+
return [
|
|
171
|
+
{
|
|
172
|
+
index,
|
|
173
|
+
length,
|
|
174
|
+
midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
|
|
175
|
+
rotationY: -Math.atan2(dz, dx),
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
})
|
|
179
|
+
}, [displayPolygon])
|
|
180
|
+
|
|
137
181
|
// Update vertex position using grid cursor position
|
|
138
182
|
const handleVertexDrag = useCallback(
|
|
139
183
|
(vertexIndex: number, position: [number, number]) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return newPolygon
|
|
145
|
-
})
|
|
184
|
+
const basePolygon = previewPolygonRef.current ?? polygon
|
|
185
|
+
const newPolygon = [...basePolygon]
|
|
186
|
+
newPolygon[vertexIndex] = position
|
|
187
|
+
updatePreviewPolygon(newPolygon)
|
|
146
188
|
},
|
|
147
|
-
[polygon],
|
|
189
|
+
[polygon, updatePreviewPolygon],
|
|
148
190
|
)
|
|
149
191
|
|
|
150
192
|
// Commit polygon changes
|
|
@@ -152,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
152
194
|
if (previewPolygonRef.current) {
|
|
153
195
|
onPolygonChange(previewPolygonRef.current)
|
|
154
196
|
}
|
|
155
|
-
|
|
197
|
+
updatePreviewPolygon(null)
|
|
156
198
|
setDragState(null)
|
|
157
|
-
}, [onPolygonChange])
|
|
199
|
+
}, [onPolygonChange, updatePreviewPolygon])
|
|
158
200
|
|
|
159
201
|
// Handle adding a new vertex at midpoint
|
|
160
202
|
const handleAddVertex = useCallback(
|
|
@@ -166,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
166
208
|
...basePolygon.slice(afterIndex + 1),
|
|
167
209
|
]
|
|
168
210
|
|
|
169
|
-
|
|
170
|
-
return
|
|
211
|
+
updatePreviewPolygon(newPolygon)
|
|
212
|
+
return {
|
|
213
|
+
polygon: newPolygon,
|
|
214
|
+
vertexIndex: afterIndex + 1,
|
|
215
|
+
}
|
|
171
216
|
},
|
|
172
|
-
[polygon, previewPolygon],
|
|
217
|
+
[polygon, previewPolygon, updatePreviewPolygon],
|
|
173
218
|
)
|
|
174
219
|
|
|
175
220
|
// Handle deleting a vertex
|
|
@@ -180,9 +225,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
180
225
|
|
|
181
226
|
const newPolygon = basePolygon.filter((_, i) => i !== index)
|
|
182
227
|
onPolygonChange(newPolygon)
|
|
183
|
-
|
|
228
|
+
updatePreviewPolygon(null)
|
|
184
229
|
},
|
|
185
|
-
[polygon, previewPolygon, onPolygonChange, minVertices],
|
|
230
|
+
[polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
|
|
186
231
|
)
|
|
187
232
|
|
|
188
233
|
// Listen to grid:move events to track cursor position
|
|
@@ -212,9 +257,31 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
212
257
|
} else if (dragState.mode === 'polygon') {
|
|
213
258
|
const deltaX = newPosition[0] - dragState.initialPosition[0]
|
|
214
259
|
const deltaZ = newPosition[1] - dragState.initialPosition[1]
|
|
215
|
-
|
|
260
|
+
updatePreviewPolygon(
|
|
216
261
|
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
|
|
217
262
|
)
|
|
263
|
+
} else if (
|
|
264
|
+
dragState.mode === 'edge' &&
|
|
265
|
+
dragState.edgeIndex !== undefined &&
|
|
266
|
+
dragState.edgeNormal
|
|
267
|
+
) {
|
|
268
|
+
const [normalX, normalZ] = dragState.edgeNormal
|
|
269
|
+
const pointerDeltaX = newPosition[0] - dragState.initialPosition[0]
|
|
270
|
+
const pointerDeltaZ = newPosition[1] - dragState.initialPosition[1]
|
|
271
|
+
const normalDistance = pointerDeltaX * normalX + pointerDeltaZ * normalZ
|
|
272
|
+
const edgeStartIndex = dragState.edgeIndex
|
|
273
|
+
const edgeEndIndex = (edgeStartIndex + 1) % dragState.initialPolygon.length
|
|
274
|
+
const nextPolygon = dragState.initialPolygon.map((point, index) => {
|
|
275
|
+
if (index !== edgeStartIndex && index !== edgeEndIndex) {
|
|
276
|
+
return point
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return [point[0] + normalX * normalDistance, point[1] + normalZ * normalDistance] as [
|
|
280
|
+
number,
|
|
281
|
+
number,
|
|
282
|
+
]
|
|
283
|
+
})
|
|
284
|
+
updatePreviewPolygon(nextPolygon)
|
|
218
285
|
}
|
|
219
286
|
}
|
|
220
287
|
}
|
|
@@ -223,7 +290,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
223
290
|
return () => {
|
|
224
291
|
emitter.off('grid:move', onGridMove)
|
|
225
292
|
}
|
|
226
|
-
}, [dragState, handleVertexDrag])
|
|
293
|
+
}, [dragState, handleVertexDrag, updatePreviewPolygon])
|
|
227
294
|
|
|
228
295
|
// Set up pointer up listener for ending drag
|
|
229
296
|
useEffect(() => {
|
|
@@ -288,6 +355,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
288
355
|
if (displayPolygon.length < minVertices) return null
|
|
289
356
|
|
|
290
357
|
const canDelete = displayPolygon.length > minVertices
|
|
358
|
+
const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
|
359
|
+
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
|
|
291
360
|
|
|
292
361
|
const editorContent = (
|
|
293
362
|
<group>
|
|
@@ -316,7 +385,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
316
385
|
const isHovered = hoveredVertex === index
|
|
317
386
|
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
|
318
387
|
const radius = 0.1
|
|
319
|
-
const height =
|
|
388
|
+
const height = handleHeight
|
|
320
389
|
|
|
321
390
|
return (
|
|
322
391
|
<mesh
|
|
@@ -337,6 +406,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
337
406
|
onPointerDown={(e) => {
|
|
338
407
|
if (e.button !== 0) return
|
|
339
408
|
e.stopPropagation()
|
|
409
|
+
setHoveredEdge(null)
|
|
340
410
|
setDragState({
|
|
341
411
|
isDragging: true,
|
|
342
412
|
mode: 'vertex',
|
|
@@ -375,6 +445,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
375
445
|
onPointerDown={(e) => {
|
|
376
446
|
if (e.button !== 0) return
|
|
377
447
|
e.stopPropagation()
|
|
448
|
+
setHoveredEdge(null)
|
|
378
449
|
setDragState({
|
|
379
450
|
isDragging: true,
|
|
380
451
|
mode: 'polygon',
|
|
@@ -384,23 +455,75 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
384
455
|
pointerId: e.pointerId,
|
|
385
456
|
})
|
|
386
457
|
}}
|
|
387
|
-
position={[
|
|
388
|
-
polygonCenter[0],
|
|
389
|
-
editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
|
|
390
|
-
polygonCenter[1],
|
|
391
|
-
]}
|
|
458
|
+
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
|
|
392
459
|
>
|
|
393
460
|
<sphereGeometry args={[0.09, 20, 20]} />
|
|
394
461
|
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
|
|
395
462
|
</mesh>
|
|
396
463
|
)}
|
|
397
464
|
|
|
465
|
+
{allowEdgeMove &&
|
|
466
|
+
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
|
|
467
|
+
const isHovered = hoveredEdge === index
|
|
468
|
+
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<mesh
|
|
472
|
+
key={`edge-${index}`}
|
|
473
|
+
layers={EDITOR_LAYER}
|
|
474
|
+
onClick={(e) => {
|
|
475
|
+
if (e.button !== 0) return
|
|
476
|
+
e.stopPropagation()
|
|
477
|
+
}}
|
|
478
|
+
onPointerDown={(e) => {
|
|
479
|
+
if (e.button !== 0) return
|
|
480
|
+
e.stopPropagation()
|
|
481
|
+
const start = displayPolygon[index]
|
|
482
|
+
const end = displayPolygon[(index + 1) % displayPolygon.length]
|
|
483
|
+
if (!(start && end)) return
|
|
484
|
+
|
|
485
|
+
const edgeNormal = getEdgeNormal(start, end)
|
|
486
|
+
if (!edgeNormal) return
|
|
487
|
+
|
|
488
|
+
setHoveredEdge(null)
|
|
489
|
+
setDragState({
|
|
490
|
+
isDragging: true,
|
|
491
|
+
mode: 'edge',
|
|
492
|
+
vertexIndex: null,
|
|
493
|
+
edgeIndex: index,
|
|
494
|
+
edgeNormal,
|
|
495
|
+
initialPosition: cursorPosition,
|
|
496
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
497
|
+
pointerId: e.pointerId,
|
|
498
|
+
})
|
|
499
|
+
}}
|
|
500
|
+
onPointerEnter={(e) => {
|
|
501
|
+
e.stopPropagation()
|
|
502
|
+
setHoveredEdge(index)
|
|
503
|
+
}}
|
|
504
|
+
onPointerLeave={(e) => {
|
|
505
|
+
e.stopPropagation()
|
|
506
|
+
setHoveredEdge(null)
|
|
507
|
+
}}
|
|
508
|
+
position={[midpoint[0], edgeHandleY, midpoint[1]]}
|
|
509
|
+
rotation={[0, rotationY, 0]}
|
|
510
|
+
>
|
|
511
|
+
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
|
512
|
+
<meshStandardMaterial
|
|
513
|
+
color={isDragging ? '#22c55e' : '#94a3b8'}
|
|
514
|
+
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
|
515
|
+
transparent
|
|
516
|
+
/>
|
|
517
|
+
</mesh>
|
|
518
|
+
)
|
|
519
|
+
})}
|
|
520
|
+
|
|
398
521
|
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
|
399
522
|
{!dragState &&
|
|
400
523
|
midpoints.map(([x, z], index) => {
|
|
401
524
|
const isHovered = hoveredMidpoint === index
|
|
402
525
|
const radius = 0.06
|
|
403
|
-
const height =
|
|
526
|
+
const height = handleHeight
|
|
404
527
|
|
|
405
528
|
return (
|
|
406
529
|
<mesh
|
|
@@ -413,12 +536,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
413
536
|
onPointerDown={(e) => {
|
|
414
537
|
if (e.button !== 0) return
|
|
415
538
|
e.stopPropagation()
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
539
|
+
const insertedVertex = handleAddVertex(index, [x!, z!])
|
|
540
|
+
if (insertedVertex.vertexIndex >= 0) {
|
|
418
541
|
setDragState({
|
|
419
542
|
isDragging: true,
|
|
420
|
-
|
|
543
|
+
mode: 'vertex',
|
|
544
|
+
vertexIndex: insertedVertex.vertexIndex,
|
|
421
545
|
initialPosition: [x!, z!],
|
|
546
|
+
initialPolygon: insertedVertex.polygon,
|
|
422
547
|
pointerId: e.pointerId,
|
|
423
548
|
})
|
|
424
549
|
setHoveredMidpoint(null)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FenceNode,
|
|
3
|
+
getWallCurveFrameAt,
|
|
4
|
+
getWallCurveLength,
|
|
5
|
+
isCurvedWall,
|
|
6
|
+
type WallNode,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
|
|
9
|
+
export type PlanPoint = [number, number]
|
|
10
|
+
|
|
11
|
+
export type SegmentAngleLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
|
|
12
|
+
|
|
13
|
+
export type SegmentAngleReference = {
|
|
14
|
+
vector: PlanPoint
|
|
15
|
+
orientation: 'directed' | 'axis'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const POINT_MATCH_TOLERANCE = 1e-5
|
|
19
|
+
const SEGMENT_POINT_TOLERANCE = 0.15
|
|
20
|
+
const CURVE_TANGENT_SAMPLE_SPACING = 0.08
|
|
21
|
+
|
|
22
|
+
function distanceSquared(a: PlanPoint, b: PlanPoint) {
|
|
23
|
+
const dx = a[0] - b[0]
|
|
24
|
+
const dz = a[1] - b[1]
|
|
25
|
+
|
|
26
|
+
return dx * dx + dz * dz
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pointsMatch(a: PlanPoint, b: PlanPoint, tolerance = POINT_MATCH_TOLERANCE) {
|
|
30
|
+
return distanceSquared(a, b) <= tolerance * tolerance
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getProjectedPointOnSegment(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
|
|
34
|
+
const [x1, z1] = segment.start
|
|
35
|
+
const [x2, z2] = segment.end
|
|
36
|
+
const dx = x2 - x1
|
|
37
|
+
const dz = z2 - z1
|
|
38
|
+
const lengthSquared = dx * dx + dz * dz
|
|
39
|
+
|
|
40
|
+
if (lengthSquared < 1e-9) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
|
45
|
+
if (t <= 0 || t >= 1) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [x1 + dx * t, z1 + dz * t]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
|
|
53
|
+
const curveLength = getWallCurveLength(segment)
|
|
54
|
+
const sampleCount = Math.max(24, Math.ceil(curveLength / CURVE_TANGENT_SAMPLE_SPACING))
|
|
55
|
+
let best: { distance: number; tangent: PlanPoint } | null = null
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index <= sampleCount; index += 1) {
|
|
58
|
+
const frame = getWallCurveFrameAt(segment, index / sampleCount)
|
|
59
|
+
const candidate: PlanPoint = [frame.point.x, frame.point.y]
|
|
60
|
+
const distance = distanceSquared(point, candidate)
|
|
61
|
+
|
|
62
|
+
if (best && distance >= best.distance) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
best = {
|
|
67
|
+
distance,
|
|
68
|
+
tangent: [frame.tangent.x, frame.tangent.y],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!best || best.distance > SEGMENT_POINT_TOLERANCE * SEGMENT_POINT_TOLERANCE) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return best.tangent
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatAngleRadians(angle: number) {
|
|
80
|
+
return `${Math.round((angle * 180) / Math.PI)}°`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null {
|
|
84
|
+
const firstLength = Math.hypot(first[0], first[1])
|
|
85
|
+
const secondLength = Math.hypot(second[0], second[1])
|
|
86
|
+
|
|
87
|
+
if (firstLength < 1e-6 || secondLength < 1e-6) return null
|
|
88
|
+
|
|
89
|
+
const dot = first[0] * second[0] + first[1] * second[1]
|
|
90
|
+
const cosine = Math.min(1, Math.max(-1, dot / (firstLength * secondLength)))
|
|
91
|
+
|
|
92
|
+
return Math.acos(cosine)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getAngleToSegmentReference(
|
|
96
|
+
vector: PlanPoint,
|
|
97
|
+
reference: SegmentAngleReference,
|
|
98
|
+
): number | null {
|
|
99
|
+
const angle = getAngleBetweenVectors(vector, reference.vector)
|
|
100
|
+
|
|
101
|
+
if (angle === null || reference.orientation === 'directed') {
|
|
102
|
+
return angle
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const reverseAngle = getAngleBetweenVectors(vector, [-reference.vector[0], -reference.vector[1]])
|
|
106
|
+
|
|
107
|
+
if (reverseAngle === null) {
|
|
108
|
+
return angle
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Math.min(angle, reverseAngle)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getSegmentAngleReferenceAtPoint(
|
|
115
|
+
point: PlanPoint,
|
|
116
|
+
segment: SegmentAngleLike,
|
|
117
|
+
): SegmentAngleReference | null {
|
|
118
|
+
if (pointsMatch(point, segment.start)) {
|
|
119
|
+
const frame = getWallCurveFrameAt(segment, 0)
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
vector: [frame.tangent.x, frame.tangent.y],
|
|
123
|
+
orientation: 'directed',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (pointsMatch(point, segment.end)) {
|
|
128
|
+
const frame = getWallCurveFrameAt(segment, 1)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
vector: [-frame.tangent.x, -frame.tangent.y],
|
|
132
|
+
orientation: 'directed',
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isCurvedWall(segment)) {
|
|
137
|
+
const tangent = getCurveTangentAtPoint(point, segment)
|
|
138
|
+
|
|
139
|
+
return tangent
|
|
140
|
+
? {
|
|
141
|
+
vector: tangent,
|
|
142
|
+
orientation: 'axis',
|
|
143
|
+
}
|
|
144
|
+
: null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const projected = getProjectedPointOnSegment(point, segment)
|
|
148
|
+
if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
vector: [segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]],
|
|
154
|
+
orientation: 'axis',
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import '../../../three-types'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
emitter,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
type SpawnNode,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useLiveTransforms,
|
|
9
|
+
useScene,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
12
|
+
import { Vector3 } from 'three'
|
|
13
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
|
+
import useEditor from '../../../store/use-editor'
|
|
15
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
16
|
+
|
|
17
|
+
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
|
18
|
+
const worldVector = new Vector3()
|
|
19
|
+
|
|
20
|
+
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
|
|
21
|
+
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
|
|
22
|
+
if (!levelObject) {
|
|
23
|
+
return [
|
|
24
|
+
roundToHalf(event.localPosition[0]),
|
|
25
|
+
event.localPosition[1],
|
|
26
|
+
roundToHalf(event.localPosition[2]),
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
worldVector.set(event.position[0], event.position[1], event.position[2])
|
|
31
|
+
levelObject.updateWorldMatrix(true, false)
|
|
32
|
+
levelObject.worldToLocal(worldVector)
|
|
33
|
+
|
|
34
|
+
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const MoveSpawnTool: React.FC<{
|
|
38
|
+
node: SpawnNode
|
|
39
|
+
onCommitted?: (nodeId: SpawnNode['id']) => void
|
|
40
|
+
}> = ({ node, onCommitted }) => {
|
|
41
|
+
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
|
42
|
+
|
|
43
|
+
const exitMoveMode = useCallback(() => {
|
|
44
|
+
useEditor.getState().setMovingNode(null)
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
useScene.temporal.getState().pause()
|
|
49
|
+
|
|
50
|
+
let committed = false
|
|
51
|
+
|
|
52
|
+
const onGridMove = (event: GridEvent) => {
|
|
53
|
+
const nextPosition: [number, number, number] = [
|
|
54
|
+
roundToHalf(event.localPosition[0]),
|
|
55
|
+
event.localPosition[1],
|
|
56
|
+
roundToHalf(event.localPosition[2]),
|
|
57
|
+
]
|
|
58
|
+
setPreviewPosition(nextPosition)
|
|
59
|
+
useLiveTransforms.getState().set(node.id, {
|
|
60
|
+
position: [...nextPosition],
|
|
61
|
+
rotation: node.rotation,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const onGridClick = (event: GridEvent) => {
|
|
66
|
+
const nextPosition = getLevelLocalSpawnPosition(node, event)
|
|
67
|
+
|
|
68
|
+
committed = true
|
|
69
|
+
useScene.temporal.getState().resume()
|
|
70
|
+
useScene.getState().updateNode(node.id, { position: nextPosition })
|
|
71
|
+
onCommitted?.(node.id)
|
|
72
|
+
useLiveTransforms.getState().clear(node.id)
|
|
73
|
+
sfxEmitter.emit('sfx:item-place')
|
|
74
|
+
exitMoveMode()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const onCancel = () => {
|
|
78
|
+
useLiveTransforms.getState().clear(node.id)
|
|
79
|
+
useScene.temporal.getState().resume()
|
|
80
|
+
exitMoveMode()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
emitter.on('grid:move', onGridMove)
|
|
84
|
+
emitter.on('grid:click', onGridClick)
|
|
85
|
+
emitter.on('tool:cancel', onCancel)
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
emitter.off('grid:move', onGridMove)
|
|
89
|
+
emitter.off('grid:click', onGridClick)
|
|
90
|
+
emitter.off('tool:cancel', onCancel)
|
|
91
|
+
useLiveTransforms.getState().clear(node.id)
|
|
92
|
+
if (!committed) {
|
|
93
|
+
useScene.temporal.getState().resume()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}, [exitMoveMode, node, onCommitted])
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
|
|
100
|
+
)
|
|
101
|
+
}
|