@pascal-app/editor 0.6.0 → 0.8.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 +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- 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 +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -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/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -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/ceiling/move-ceiling-tool.tsx +9 -2
- 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/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- 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 +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- 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 +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- 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 +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- 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 +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -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/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -1
- 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 +70 -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/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -395
|
@@ -7,19 +7,129 @@ import {
|
|
|
7
7
|
type WallNode,
|
|
8
8
|
} from '@pascal-app/core'
|
|
9
9
|
import { useViewer } from '@pascal-app/viewer'
|
|
10
|
-
import {
|
|
10
|
+
import { Html } from '@react-three/drei'
|
|
11
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
12
|
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
12
13
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
13
14
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
14
15
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
15
16
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
17
|
+
import {
|
|
18
|
+
formatAngleRadians,
|
|
19
|
+
getAngleToSegmentReference,
|
|
20
|
+
getSegmentAngleReferenceAtPoint,
|
|
21
|
+
} from '../shared/segment-angle'
|
|
16
22
|
import {
|
|
17
23
|
createFenceOnCurrentLevel,
|
|
18
|
-
snapFenceDraftPoint,
|
|
19
24
|
type FencePlanPoint,
|
|
25
|
+
snapFenceDraftPoint,
|
|
20
26
|
} from './fence-drafting'
|
|
21
27
|
|
|
22
28
|
const FENCE_PREVIEW_HEIGHT = 1.8
|
|
29
|
+
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
|
|
30
|
+
const DRAFT_ANGLE_LABEL_Y = 0.28
|
|
31
|
+
|
|
32
|
+
type DraftAngleLabel = {
|
|
33
|
+
id: string
|
|
34
|
+
label: string
|
|
35
|
+
position: [number, number, number]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type DraftMeasurementState = {
|
|
39
|
+
lengthLabel: string
|
|
40
|
+
lengthPosition: [number, number, number]
|
|
41
|
+
angleLabels: DraftAngleLabel[]
|
|
42
|
+
} | null
|
|
43
|
+
|
|
44
|
+
type SegmentLike = {
|
|
45
|
+
id: string
|
|
46
|
+
start: FencePlanPoint
|
|
47
|
+
end: FencePlanPoint
|
|
48
|
+
curveOffset?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
52
|
+
if (unit === 'imperial') {
|
|
53
|
+
const feet = value * 3.280_84
|
|
54
|
+
const wholeFeet = Math.floor(feet)
|
|
55
|
+
const inches = Math.round((feet - wholeFeet) * 12)
|
|
56
|
+
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
57
|
+
return `${wholeFeet}'${inches}"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getDraftAngleLabels(
|
|
64
|
+
start: FencePlanPoint,
|
|
65
|
+
end: FencePlanPoint,
|
|
66
|
+
segments: SegmentLike[],
|
|
67
|
+
): DraftAngleLabel[] {
|
|
68
|
+
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
|
|
69
|
+
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
|
|
70
|
+
const endpoints = [
|
|
71
|
+
{ id: 'start', point: start, draftVector: draftFromStart },
|
|
72
|
+
{ id: 'end', point: end, draftVector: draftFromEnd },
|
|
73
|
+
]
|
|
74
|
+
const labels: DraftAngleLabel[] = []
|
|
75
|
+
|
|
76
|
+
for (const endpoint of endpoints) {
|
|
77
|
+
const connectedSegment = segments.find((segment) =>
|
|
78
|
+
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
|
|
79
|
+
)
|
|
80
|
+
if (!connectedSegment) continue
|
|
81
|
+
|
|
82
|
+
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
|
|
83
|
+
if (!connectedReference) continue
|
|
84
|
+
|
|
85
|
+
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
|
|
86
|
+
if (angle === null) continue
|
|
87
|
+
|
|
88
|
+
labels.push({
|
|
89
|
+
id: endpoint.id,
|
|
90
|
+
label: formatAngleRadians(angle),
|
|
91
|
+
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return labels
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getDraftMeasurementState(
|
|
99
|
+
start: FencePlanPoint,
|
|
100
|
+
end: FencePlanPoint,
|
|
101
|
+
segments: SegmentLike[],
|
|
102
|
+
unit: 'metric' | 'imperial',
|
|
103
|
+
): DraftMeasurementState {
|
|
104
|
+
const dx = end[0] - start[0]
|
|
105
|
+
const dz = end[1] - start[1]
|
|
106
|
+
const length = Math.hypot(dx, dz)
|
|
107
|
+
|
|
108
|
+
if (length < 0.01) return null
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
lengthLabel: formatMeasurement(length, unit),
|
|
112
|
+
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
|
113
|
+
angleLabels: getDraftAngleLabels(start, end, segments),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
|
|
118
|
+
return [
|
|
119
|
+
...walls.map((wall) => ({
|
|
120
|
+
id: wall.id,
|
|
121
|
+
start: wall.start,
|
|
122
|
+
end: wall.end,
|
|
123
|
+
curveOffset: wall.curveOffset,
|
|
124
|
+
})),
|
|
125
|
+
...fences.map((fence) => ({
|
|
126
|
+
id: fence.id,
|
|
127
|
+
start: fence.start,
|
|
128
|
+
end: fence.end,
|
|
129
|
+
curveOffset: fence.curveOffset,
|
|
130
|
+
})),
|
|
131
|
+
]
|
|
132
|
+
}
|
|
23
133
|
|
|
24
134
|
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
|
25
135
|
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
|
@@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } =
|
|
|
70
180
|
}
|
|
71
181
|
|
|
72
182
|
export const FenceTool: React.FC = () => {
|
|
183
|
+
const unit = useViewer((state) => state.unit)
|
|
73
184
|
const cursorRef = useRef<Group>(null)
|
|
74
185
|
const previewRef = useRef<Mesh>(null!)
|
|
75
186
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
|
76
187
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
|
77
188
|
const buildingState = useRef(0)
|
|
78
189
|
const shiftPressed = useRef(false)
|
|
190
|
+
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
|
79
191
|
|
|
80
192
|
useEffect(() => {
|
|
81
193
|
let previousFenceEnd: [number, number] | null = null
|
|
@@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => {
|
|
|
107
219
|
previousFenceEnd = currentFenceEnd
|
|
108
220
|
|
|
109
221
|
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
|
|
222
|
+
setDraftMeasurement(
|
|
223
|
+
getDraftMeasurementState(
|
|
224
|
+
[startingPoint.current.x, startingPoint.current.z],
|
|
225
|
+
snappedLocal,
|
|
226
|
+
getReferenceSegments(walls, fences),
|
|
227
|
+
unit,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
110
230
|
} else {
|
|
111
231
|
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
|
112
232
|
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
|
233
|
+
setDraftMeasurement(null)
|
|
113
234
|
}
|
|
114
235
|
}
|
|
115
236
|
|
|
@@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => {
|
|
|
123
244
|
endingPoint.current.copy(startingPoint.current)
|
|
124
245
|
buildingState.current = 1
|
|
125
246
|
previewRef.current.visible = true
|
|
247
|
+
setDraftMeasurement(null)
|
|
126
248
|
} else {
|
|
127
249
|
const snappedEnd = snapFenceDraftPoint({
|
|
128
250
|
point: localClick,
|
|
@@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => {
|
|
|
137
259
|
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
|
138
260
|
previewRef.current.visible = false
|
|
139
261
|
buildingState.current = 0
|
|
262
|
+
setDraftMeasurement(null)
|
|
140
263
|
}
|
|
141
264
|
}
|
|
142
265
|
|
|
@@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => {
|
|
|
153
276
|
markToolCancelConsumed()
|
|
154
277
|
buildingState.current = 0
|
|
155
278
|
previewRef.current.visible = false
|
|
279
|
+
setDraftMeasurement(null)
|
|
156
280
|
}
|
|
157
281
|
}
|
|
158
282
|
|
|
@@ -169,11 +293,11 @@ export const FenceTool: React.FC = () => {
|
|
|
169
293
|
window.removeEventListener('keydown', onKeyDown)
|
|
170
294
|
window.removeEventListener('keyup', onKeyUp)
|
|
171
295
|
}
|
|
172
|
-
}, [])
|
|
296
|
+
}, [unit])
|
|
173
297
|
|
|
174
298
|
return (
|
|
175
299
|
<group>
|
|
176
|
-
<CursorSphere
|
|
300
|
+
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
|
|
177
301
|
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
|
178
302
|
<shapeGeometry />
|
|
179
303
|
<meshBasicMaterial
|
|
@@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => {
|
|
|
185
309
|
transparent
|
|
186
310
|
/>
|
|
187
311
|
</mesh>
|
|
312
|
+
|
|
313
|
+
{draftMeasurement && (
|
|
314
|
+
<>
|
|
315
|
+
<DraftMeasurementLabel
|
|
316
|
+
label={draftMeasurement.lengthLabel}
|
|
317
|
+
position={draftMeasurement.lengthPosition}
|
|
318
|
+
/>
|
|
319
|
+
{draftMeasurement.angleLabels.map((angleLabel) => (
|
|
320
|
+
<DraftMeasurementLabel
|
|
321
|
+
key={angleLabel.id}
|
|
322
|
+
label={angleLabel.label}
|
|
323
|
+
position={angleLabel.position}
|
|
324
|
+
/>
|
|
325
|
+
))}
|
|
326
|
+
</>
|
|
327
|
+
)}
|
|
188
328
|
</group>
|
|
189
329
|
)
|
|
190
330
|
}
|
|
331
|
+
|
|
332
|
+
function DraftMeasurementLabel({
|
|
333
|
+
label,
|
|
334
|
+
position,
|
|
335
|
+
}: {
|
|
336
|
+
label: string
|
|
337
|
+
position: [number, number, number]
|
|
338
|
+
}) {
|
|
339
|
+
return (
|
|
340
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
341
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
|
342
|
+
{label}
|
|
343
|
+
</div>
|
|
344
|
+
</Html>
|
|
345
|
+
)
|
|
346
|
+
}
|
|
@@ -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: {
|
|
@@ -50,10 +131,12 @@ function getLinkedFenceSnapshots(args: {
|
|
|
50
131
|
}
|
|
51
132
|
|
|
52
133
|
if (
|
|
53
|
-
!
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
134
|
+
!(
|
|
135
|
+
samePoint(node.start, originalStart) ||
|
|
136
|
+
samePoint(node.start, originalEnd) ||
|
|
137
|
+
samePoint(node.end, originalStart) ||
|
|
138
|
+
samePoint(node.end, originalEnd)
|
|
139
|
+
)
|
|
57
140
|
) {
|
|
58
141
|
continue
|
|
59
142
|
}
|
|
@@ -62,6 +145,7 @@ function getLinkedFenceSnapshots(args: {
|
|
|
62
145
|
id: node.id,
|
|
63
146
|
start: [...node.start] as FencePlanPoint,
|
|
64
147
|
end: [...node.end] as FencePlanPoint,
|
|
148
|
+
curveOffset: node.curveOffset,
|
|
65
149
|
})
|
|
66
150
|
}
|
|
67
151
|
|
|
@@ -77,6 +161,7 @@ function getLinkedFenceUpdates(
|
|
|
77
161
|
) {
|
|
78
162
|
return linkedFences.map((fence) => ({
|
|
79
163
|
id: fence.id,
|
|
164
|
+
curveOffset: fence.curveOffset,
|
|
80
165
|
start: samePoint(fence.start, originalStart)
|
|
81
166
|
? nextStart
|
|
82
167
|
: samePoint(fence.start, originalEnd)
|
|
@@ -112,6 +197,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
112
197
|
}),
|
|
113
198
|
)
|
|
114
199
|
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
|
|
200
|
+
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
|
115
201
|
|
|
116
202
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
117
203
|
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
|
|
@@ -158,27 +244,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
158
244
|
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
|
|
159
245
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
|
160
246
|
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
|
247
|
+
const linkedUpdates = detachLinkedFences
|
|
248
|
+
? []
|
|
249
|
+
: getLinkedFenceUpdates(
|
|
250
|
+
linkedOriginalsRef.current,
|
|
251
|
+
originalStart,
|
|
252
|
+
originalEnd,
|
|
253
|
+
nextStart,
|
|
254
|
+
nextEnd,
|
|
255
|
+
)
|
|
161
256
|
previewRef.current = { start: nextStart, end: nextEnd }
|
|
162
257
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
|
163
|
-
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
nextStart,
|
|
172
|
-
nextEnd,
|
|
173
|
-
)),
|
|
174
|
-
])
|
|
258
|
+
setAngleLabel(
|
|
259
|
+
getEndpointAngleLabel({
|
|
260
|
+
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
|
|
261
|
+
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
|
|
262
|
+
nodeId,
|
|
263
|
+
}),
|
|
264
|
+
)
|
|
265
|
+
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
|
175
266
|
}
|
|
176
267
|
|
|
177
|
-
const restoreOriginal = () => {
|
|
268
|
+
const restoreOriginal = (clearAngleLabel = true) => {
|
|
178
269
|
applyNodePreview([
|
|
179
270
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
180
271
|
...linkedOriginalsRef.current,
|
|
181
272
|
])
|
|
273
|
+
if (clearAngleLabel) {
|
|
274
|
+
setAngleLabel(null)
|
|
275
|
+
}
|
|
182
276
|
}
|
|
183
277
|
|
|
184
278
|
const onGridMove = (event: GridEvent) => {
|
|
@@ -211,8 +305,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
211
305
|
}
|
|
212
306
|
|
|
213
307
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
214
|
-
const hasChanged =
|
|
215
|
-
|
|
308
|
+
const hasChanged = !(
|
|
309
|
+
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
|
310
|
+
)
|
|
216
311
|
|
|
217
312
|
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
|
218
313
|
wasCommitted = true
|
|
@@ -240,6 +335,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
240
335
|
}
|
|
241
336
|
|
|
242
337
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
338
|
+
setAngleLabel(null)
|
|
243
339
|
exitMoveMode()
|
|
244
340
|
event.nativeEvent?.stopPropagation?.()
|
|
245
341
|
}
|
|
@@ -248,6 +344,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
248
344
|
restoreOriginal()
|
|
249
345
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
250
346
|
resumeSceneHistory(useScene)
|
|
347
|
+
setAngleLabel(null)
|
|
251
348
|
markToolCancelConsumed()
|
|
252
349
|
exitMoveMode()
|
|
253
350
|
}
|
|
@@ -290,7 +387,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
290
387
|
|
|
291
388
|
return () => {
|
|
292
389
|
if (!wasCommitted) {
|
|
293
|
-
restoreOriginal()
|
|
390
|
+
restoreOriginal(false)
|
|
294
391
|
}
|
|
295
392
|
resumeSceneHistory(useScene)
|
|
296
393
|
emitter.off('grid:move', onGridMove)
|
|
@@ -312,7 +409,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
312
409
|
>
|
|
313
410
|
<div className="translate-y-10">
|
|
314
411
|
<div
|
|
315
|
-
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px]
|
|
412
|
+
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
|
|
316
413
|
altPressed
|
|
317
414
|
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
|
318
415
|
: 'border-border/70 bg-background/90 text-foreground/80'
|
|
@@ -322,6 +419,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
322
419
|
</div>
|
|
323
420
|
</div>
|
|
324
421
|
</Html>
|
|
422
|
+
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
|
325
423
|
</group>
|
|
326
424
|
)
|
|
327
425
|
}
|
|
426
|
+
|
|
427
|
+
function EndpointAngleLabel({
|
|
428
|
+
label,
|
|
429
|
+
position,
|
|
430
|
+
}: {
|
|
431
|
+
label: string
|
|
432
|
+
position: [number, number, number]
|
|
433
|
+
}) {
|
|
434
|
+
return (
|
|
435
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
436
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
|
437
|
+
{label}
|
|
438
|
+
</div>
|
|
439
|
+
</Html>
|
|
440
|
+
)
|
|
441
|
+
}
|