@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
|
@@ -2,32 +2,113 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNodeId,
|
|
5
|
-
type FenceNode,
|
|
6
|
-
type WallNode,
|
|
7
5
|
emitter,
|
|
6
|
+
type FenceNode,
|
|
8
7
|
type GridEvent,
|
|
9
8
|
pauseSceneHistory,
|
|
10
9
|
resumeSceneHistory,
|
|
11
10
|
useScene,
|
|
11
|
+
type WallNode,
|
|
12
12
|
} from '@pascal-app/core'
|
|
13
|
-
import { Html } from '@react-three/drei'
|
|
14
13
|
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { Html } from '@react-three/drei'
|
|
15
15
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
16
16
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
17
17
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
18
18
|
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
|
|
19
19
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
formatAngleRadians,
|
|
22
|
+
getAngleToSegmentReference,
|
|
23
|
+
getSegmentAngleReferenceAtPoint,
|
|
24
|
+
} from '../shared/segment-angle'
|
|
21
25
|
import { isWallLongEnough } from '../wall/wall-drafting'
|
|
26
|
+
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
|
|
22
27
|
|
|
23
28
|
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
|
|
24
29
|
return a[0] === b[0] && a[1] === b[1]
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
type SegmentLike = {
|
|
33
|
+
id: string
|
|
34
|
+
start: FencePlanPoint
|
|
35
|
+
end: FencePlanPoint
|
|
36
|
+
curveOffset?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type AngleLabelState = {
|
|
40
|
+
label: string
|
|
41
|
+
position: [number, number, number]
|
|
42
|
+
} | null
|
|
43
|
+
|
|
44
|
+
function getEndpointAngleLabel(args: {
|
|
45
|
+
preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
|
|
46
|
+
segments: SegmentLike[]
|
|
47
|
+
nodeId: FenceNode['id']
|
|
48
|
+
}): AngleLabelState {
|
|
49
|
+
const { preview, segments, nodeId } = args
|
|
50
|
+
const endpoints = [
|
|
51
|
+
{
|
|
52
|
+
point: preview.start,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
point: preview.end,
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
const targetSegment: SegmentLike = {
|
|
59
|
+
id: nodeId,
|
|
60
|
+
start: preview.start,
|
|
61
|
+
end: preview.end,
|
|
62
|
+
curveOffset: preview.curveOffset,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const endpoint of endpoints) {
|
|
66
|
+
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
|
|
67
|
+
if (!targetReference) continue
|
|
68
|
+
|
|
69
|
+
const connectedSegment = segments.find(
|
|
70
|
+
(segment) =>
|
|
71
|
+
segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
|
|
72
|
+
)
|
|
73
|
+
if (!connectedSegment) continue
|
|
74
|
+
|
|
75
|
+
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
|
|
76
|
+
if (!connectedReference) continue
|
|
77
|
+
|
|
78
|
+
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
|
|
79
|
+
if (angle === null) continue
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
label: formatAngleRadians(angle),
|
|
83
|
+
position: [endpoint.point[0], 0.34, endpoint.point[1]],
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
|
|
91
|
+
return [
|
|
92
|
+
...walls.map((wall) => ({
|
|
93
|
+
id: wall.id,
|
|
94
|
+
start: wall.start,
|
|
95
|
+
end: wall.end,
|
|
96
|
+
curveOffset: wall.curveOffset,
|
|
97
|
+
})),
|
|
98
|
+
...fences.map((fence) => ({
|
|
99
|
+
id: fence.id,
|
|
100
|
+
start: fence.start,
|
|
101
|
+
end: fence.end,
|
|
102
|
+
curveOffset: fence.curveOffset,
|
|
103
|
+
})),
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
|
|
27
107
|
type LinkedFenceSnapshot = {
|
|
28
108
|
id: FenceNode['id']
|
|
29
109
|
start: FencePlanPoint
|
|
30
110
|
end: FencePlanPoint
|
|
111
|
+
curveOffset?: number
|
|
31
112
|
}
|
|
32
113
|
|
|
33
114
|
function getLinkedFenceSnapshots(args: {
|
|
@@ -62,6 +143,7 @@ function getLinkedFenceSnapshots(args: {
|
|
|
62
143
|
id: node.id,
|
|
63
144
|
start: [...node.start] as FencePlanPoint,
|
|
64
145
|
end: [...node.end] as FencePlanPoint,
|
|
146
|
+
curveOffset: node.curveOffset,
|
|
65
147
|
})
|
|
66
148
|
}
|
|
67
149
|
|
|
@@ -77,6 +159,7 @@ function getLinkedFenceUpdates(
|
|
|
77
159
|
) {
|
|
78
160
|
return linkedFences.map((fence) => ({
|
|
79
161
|
id: fence.id,
|
|
162
|
+
curveOffset: fence.curveOffset,
|
|
80
163
|
start: samePoint(fence.start, originalStart)
|
|
81
164
|
? nextStart
|
|
82
165
|
: samePoint(fence.start, originalEnd)
|
|
@@ -112,6 +195,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
112
195
|
}),
|
|
113
196
|
)
|
|
114
197
|
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
|
|
198
|
+
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
|
115
199
|
|
|
116
200
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
117
201
|
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
|
|
@@ -158,27 +242,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
158
242
|
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
|
|
159
243
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
|
160
244
|
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
|
245
|
+
const linkedUpdates = detachLinkedFences
|
|
246
|
+
? []
|
|
247
|
+
: getLinkedFenceUpdates(
|
|
248
|
+
linkedOriginalsRef.current,
|
|
249
|
+
originalStart,
|
|
250
|
+
originalEnd,
|
|
251
|
+
nextStart,
|
|
252
|
+
nextEnd,
|
|
253
|
+
)
|
|
161
254
|
previewRef.current = { start: nextStart, end: nextEnd }
|
|
162
255
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
|
163
|
-
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
nextStart,
|
|
172
|
-
nextEnd,
|
|
173
|
-
)),
|
|
174
|
-
])
|
|
256
|
+
setAngleLabel(
|
|
257
|
+
getEndpointAngleLabel({
|
|
258
|
+
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
|
|
259
|
+
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
|
|
260
|
+
nodeId,
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
|
175
264
|
}
|
|
176
265
|
|
|
177
|
-
const restoreOriginal = () => {
|
|
266
|
+
const restoreOriginal = (clearAngleLabel = true) => {
|
|
178
267
|
applyNodePreview([
|
|
179
268
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
180
269
|
...linkedOriginalsRef.current,
|
|
181
270
|
])
|
|
271
|
+
if (clearAngleLabel) {
|
|
272
|
+
setAngleLabel(null)
|
|
273
|
+
}
|
|
182
274
|
}
|
|
183
275
|
|
|
184
276
|
const onGridMove = (event: GridEvent) => {
|
|
@@ -240,6 +332,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
240
332
|
}
|
|
241
333
|
|
|
242
334
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
335
|
+
setAngleLabel(null)
|
|
243
336
|
exitMoveMode()
|
|
244
337
|
event.nativeEvent?.stopPropagation?.()
|
|
245
338
|
}
|
|
@@ -248,6 +341,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
248
341
|
restoreOriginal()
|
|
249
342
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
250
343
|
resumeSceneHistory(useScene)
|
|
344
|
+
setAngleLabel(null)
|
|
251
345
|
markToolCancelConsumed()
|
|
252
346
|
exitMoveMode()
|
|
253
347
|
}
|
|
@@ -290,7 +384,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
290
384
|
|
|
291
385
|
return () => {
|
|
292
386
|
if (!wasCommitted) {
|
|
293
|
-
restoreOriginal()
|
|
387
|
+
restoreOriginal(false)
|
|
294
388
|
}
|
|
295
389
|
resumeSceneHistory(useScene)
|
|
296
390
|
emitter.off('grid:move', onGridMove)
|
|
@@ -322,6 +416,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
322
416
|
</div>
|
|
323
417
|
</div>
|
|
324
418
|
</Html>
|
|
419
|
+
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
|
325
420
|
</group>
|
|
326
421
|
)
|
|
327
422
|
}
|
|
423
|
+
|
|
424
|
+
function EndpointAngleLabel({
|
|
425
|
+
label,
|
|
426
|
+
position,
|
|
427
|
+
}: {
|
|
428
|
+
label: string
|
|
429
|
+
position: [number, number, number]
|
|
430
|
+
}) {
|
|
431
|
+
return (
|
|
432
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
433
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
|
|
434
|
+
{label}
|
|
435
|
+
</div>
|
|
436
|
+
</Html>
|
|
437
|
+
)
|
|
438
|
+
}
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
emitter,
|
|
6
|
+
type FenceNode,
|
|
7
|
+
type GridEvent,
|
|
8
|
+
type LevelNode,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useLiveTransforms,
|
|
11
|
+
useScene,
|
|
12
|
+
type WallNode,
|
|
13
|
+
} from '@pascal-app/core'
|
|
4
14
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
15
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
16
|
+
import type * as THREE from 'three'
|
|
6
17
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
7
18
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
19
|
import useEditor from '../../../store/use-editor'
|
|
20
|
+
import { snapFenceDraftPoint } from './fence-drafting'
|
|
9
21
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
22
|
|
|
11
|
-
function snap(value: number) {
|
|
12
|
-
return Math.round(value * 2) / 2
|
|
13
|
-
}
|
|
14
|
-
|
|
15
23
|
function samePoint(a: [number, number], b: [number, number]) {
|
|
16
24
|
return a[0] === b[0] && a[1] === b[1]
|
|
17
25
|
}
|
|
@@ -24,10 +32,11 @@ type LinkedFenceSnapshot = {
|
|
|
24
32
|
|
|
25
33
|
function getLinkedFenceSnapshots(args: {
|
|
26
34
|
fenceId: FenceNode['id']
|
|
35
|
+
fenceParentId: string | null
|
|
27
36
|
originalStart: [number, number]
|
|
28
37
|
originalEnd: [number, number]
|
|
29
38
|
}) {
|
|
30
|
-
const { fenceId, originalStart, originalEnd } = args
|
|
39
|
+
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
|
31
40
|
const { nodes } = useScene.getState()
|
|
32
41
|
const snapshots: LinkedFenceSnapshot[] = []
|
|
33
42
|
|
|
@@ -36,6 +45,10 @@ function getLinkedFenceSnapshots(args: {
|
|
|
36
45
|
continue
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
if ((node.parentId ?? null) !== fenceParentId) {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
if (
|
|
40
53
|
!samePoint(node.start, originalStart) &&
|
|
41
54
|
!samePoint(node.start, originalEnd) &&
|
|
@@ -78,12 +91,14 @@ function getLinkedFenceUpdates(
|
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
94
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
81
95
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
82
96
|
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
|
83
97
|
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
|
84
98
|
const linkedOriginalsRef = useRef(
|
|
85
99
|
getLinkedFenceSnapshots({
|
|
86
100
|
fenceId: node.id,
|
|
101
|
+
fenceParentId: node.parentId ?? null,
|
|
87
102
|
originalStart: node.start,
|
|
88
103
|
originalEnd: node.end,
|
|
89
104
|
}),
|
|
@@ -106,10 +121,49 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
106
121
|
const nodeId = nodeIdRef.current
|
|
107
122
|
const originalStart = originalStartRef.current
|
|
108
123
|
const originalEnd = originalEndRef.current
|
|
124
|
+
const levelNode =
|
|
125
|
+
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
|
126
|
+
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
|
127
|
+
: null
|
|
128
|
+
const levelChildren = levelNode?.children ?? []
|
|
129
|
+
const levelWalls = levelChildren
|
|
130
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
131
|
+
.filter((child): child is WallNode => child?.type === 'wall')
|
|
132
|
+
const levelFences = levelChildren
|
|
133
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
134
|
+
.filter((child): child is FenceNode => child?.type === 'fence')
|
|
109
135
|
|
|
110
136
|
useScene.temporal.getState().pause()
|
|
111
137
|
let wasCommitted = false
|
|
112
138
|
|
|
139
|
+
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
|
|
140
|
+
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
|
141
|
+
if (!mesh) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
mesh.position.set(deltaX, 0, deltaZ)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
|
|
149
|
+
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
|
|
150
|
+
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
|
|
151
|
+
useLiveTransforms.getState().set(fence.id, {
|
|
152
|
+
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
|
|
153
|
+
rotation: 0,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const clearPreviewState = () => {
|
|
158
|
+
setMeshOffset(nodeId, 0, 0)
|
|
159
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
160
|
+
|
|
161
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
162
|
+
setMeshOffset(linkedFence.id, 0, 0)
|
|
163
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
113
167
|
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
|
|
114
168
|
useScene.getState().updateNodes(
|
|
115
169
|
updates.map((entry) => ({
|
|
@@ -127,21 +181,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
127
181
|
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
|
128
182
|
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
|
129
183
|
setCursorLocalPos([centerX, 0, centerZ])
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
184
|
+
const deltaX = nextStart[0] - originalStart[0]
|
|
185
|
+
const deltaZ = nextStart[1] - originalStart[1]
|
|
186
|
+
setMeshOffset(nodeId, deltaX, deltaZ)
|
|
187
|
+
setFenceLiveTransform(node, deltaX, deltaZ)
|
|
188
|
+
|
|
189
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
190
|
+
setMeshOffset(linkedFence.id, deltaX, deltaZ)
|
|
191
|
+
setFenceLiveTransform(
|
|
192
|
+
{
|
|
193
|
+
...node,
|
|
194
|
+
id: linkedFence.id,
|
|
195
|
+
start: linkedFence.start,
|
|
196
|
+
end: linkedFence.end,
|
|
197
|
+
},
|
|
198
|
+
deltaX,
|
|
199
|
+
deltaZ,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
140
202
|
}
|
|
141
203
|
|
|
142
204
|
const onGridMove = (event: GridEvent) => {
|
|
143
|
-
const localX =
|
|
144
|
-
|
|
205
|
+
const [localX, localZ] = snapFenceDraftPoint({
|
|
206
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
207
|
+
walls: levelWalls,
|
|
208
|
+
fences: levelFences,
|
|
209
|
+
ignoreFenceIds: [nodeId],
|
|
210
|
+
})
|
|
145
211
|
|
|
146
212
|
if (
|
|
147
213
|
previousGridPosRef.current &&
|
|
@@ -164,17 +230,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
164
230
|
}
|
|
165
231
|
|
|
166
232
|
const onGridClick = (event: GridEvent) => {
|
|
233
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
234
|
+
event.nativeEvent?.stopPropagation?.()
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
167
238
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
168
239
|
|
|
169
240
|
wasCommitted = true
|
|
170
241
|
|
|
171
|
-
// Restore original baseline while paused so the next resume+update
|
|
172
|
-
// registers as a single tracked change (undo reverts to original).
|
|
173
|
-
applyNodePreview([
|
|
174
|
-
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
175
|
-
...linkedOriginalsRef.current,
|
|
176
|
-
])
|
|
177
|
-
|
|
178
242
|
useScene.temporal.getState().resume()
|
|
179
243
|
applyNodePreview([
|
|
180
244
|
{ id: nodeId, start: preview.start, end: preview.end },
|
|
@@ -186,6 +250,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
186
250
|
preview.end,
|
|
187
251
|
),
|
|
188
252
|
])
|
|
253
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
254
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
255
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
256
|
+
}
|
|
189
257
|
useScene.temporal.getState().pause()
|
|
190
258
|
|
|
191
259
|
sfxEmitter.emit('sfx:item-place')
|
|
@@ -195,10 +263,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
195
263
|
}
|
|
196
264
|
|
|
197
265
|
const onCancel = () => {
|
|
198
|
-
|
|
199
|
-
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
200
|
-
...linkedOriginalsRef.current,
|
|
201
|
-
])
|
|
266
|
+
clearPreviewState()
|
|
202
267
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
203
268
|
useScene.temporal.getState().resume()
|
|
204
269
|
markToolCancelConsumed()
|
|
@@ -211,17 +276,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
211
276
|
|
|
212
277
|
return () => {
|
|
213
278
|
if (!wasCommitted) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
279
|
+
clearPreviewState()
|
|
280
|
+
} else {
|
|
281
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
282
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
283
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
284
|
+
}
|
|
218
285
|
}
|
|
219
286
|
useScene.temporal.getState().resume()
|
|
220
287
|
emitter.off('grid:move', onGridMove)
|
|
221
288
|
emitter.off('grid:click', onGridClick)
|
|
222
289
|
emitter.off('tool:cancel', onCancel)
|
|
223
290
|
}
|
|
224
|
-
}, [exitMoveMode])
|
|
291
|
+
}, [exitMoveMode, node])
|
|
225
292
|
|
|
226
293
|
return (
|
|
227
294
|
<group>
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BuildingNode,
|
|
3
3
|
CeilingNode,
|
|
4
|
+
ColumnNode,
|
|
4
5
|
DoorNode,
|
|
5
6
|
FenceNode,
|
|
6
7
|
ItemNode,
|
|
7
8
|
RoofNode,
|
|
8
9
|
RoofSegmentNode,
|
|
9
10
|
SlabNode,
|
|
11
|
+
SpawnNode,
|
|
10
12
|
StairNode,
|
|
11
13
|
StairSegmentNode,
|
|
12
14
|
WallNode,
|
|
@@ -17,10 +19,12 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
|
17
19
|
import useEditor from '../../../store/use-editor'
|
|
18
20
|
import { MoveBuildingContent } from '../building/move-building-tool'
|
|
19
21
|
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
|
22
|
+
import { MoveColumnTool } from '../column/move-column-tool'
|
|
20
23
|
import { MoveDoorTool } from '../door/move-door-tool'
|
|
21
24
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
|
22
25
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
|
23
26
|
import { MoveSlabTool } from '../slab/move-slab-tool'
|
|
27
|
+
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
|
24
28
|
import { MoveWallTool } from '../wall/move-wall-tool'
|
|
25
29
|
import { MoveWindowTool } from '../window/move-window-tool'
|
|
26
30
|
import type { PlacementState } from './placement-types'
|
|
@@ -86,7 +90,9 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
|
|
86
90
|
return <>{cursor}</>
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
export const MoveTool: React.FC
|
|
93
|
+
export const MoveTool: React.FC<{
|
|
94
|
+
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
|
95
|
+
}> = ({ onSpawnMoved }) => {
|
|
90
96
|
const movingNode = useEditor((state) => state.movingNode)
|
|
91
97
|
|
|
92
98
|
if (!movingNode) return null
|
|
@@ -96,10 +102,13 @@ export const MoveTool: React.FC = () => {
|
|
|
96
102
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
97
103
|
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
98
104
|
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
|
105
|
+
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
|
99
106
|
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
|
100
107
|
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
|
101
108
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
102
109
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
110
|
+
if (movingNode.type === 'spawn')
|
|
111
|
+
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
|
|
103
112
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
|
104
113
|
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
|
105
114
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isObject } from '@pascal-app/core'
|
|
1
|
+
import { type AssetInput, isObject } from '@pascal-app/core'
|
|
2
2
|
import useEditor from '../../../store/use-editor'
|
|
3
3
|
|
|
4
4
|
function getGridSnapStep(): number {
|
|
@@ -27,6 +27,35 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
|
|
27
27
|
return Math.round(value / step) * step
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Round a value up to the next multiple of `step`, with a minimum of `step`.
|
|
32
|
+
*/
|
|
33
|
+
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
|
|
34
|
+
return Math.max(step, Math.ceil(value / step) * step)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Expand an item's scaled dimensions up to the active grid step on the axes
|
|
39
|
+
* the placement grid covers. Used for the placement wireframe, snap math, and
|
|
40
|
+
* collision against the draft so a small item visually reserves a full grid
|
|
41
|
+
* cell.
|
|
42
|
+
*
|
|
43
|
+
* - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact.
|
|
44
|
+
* - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact
|
|
45
|
+
* so wall-thickness offsets aren't disturbed.
|
|
46
|
+
*/
|
|
47
|
+
export function getGridAlignedDimensions(
|
|
48
|
+
scaledDims: [number, number, number],
|
|
49
|
+
attachTo: AssetInput['attachTo'] | null | undefined,
|
|
50
|
+
step = getGridSnapStep(),
|
|
51
|
+
): [number, number, number] {
|
|
52
|
+
const [w, h, d] = scaledDims
|
|
53
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
|
54
|
+
return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d]
|
|
55
|
+
}
|
|
56
|
+
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
/**
|
|
31
60
|
* Calculate cursor rotation in WORLD space from wall normal and orientation.
|
|
32
61
|
*/
|