@pascal-app/editor 0.4.0 → 0.6.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 +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +377 -50
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +125 -10
|
@@ -3,25 +3,81 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
+
getEffectiveWallSurfaceMaterial,
|
|
7
|
+
getClampedWallCurveOffset,
|
|
8
|
+
getMaxWallCurveOffset,
|
|
9
|
+
getWallCurveLength,
|
|
10
|
+
getWallSurfaceMaterialSignature,
|
|
11
|
+
normalizeWallCurveOffset,
|
|
6
12
|
type MaterialSchema,
|
|
7
13
|
useScene,
|
|
14
|
+
type WallSurfaceSide,
|
|
8
15
|
type WallNode,
|
|
9
16
|
} from '@pascal-app/core'
|
|
10
17
|
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
-
import {
|
|
18
|
+
import { Move, Spline } from 'lucide-react'
|
|
19
|
+
import { useCallback, useMemo } from 'react'
|
|
20
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
21
|
+
import useEditor from '../../../store/use-editor'
|
|
22
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
12
23
|
import { MaterialPicker } from '../controls/material-picker'
|
|
13
24
|
import { PanelSection } from '../controls/panel-section'
|
|
14
25
|
import { SliderControl } from '../controls/slider-control'
|
|
15
26
|
import { PanelWrapper } from './panel-wrapper'
|
|
16
27
|
|
|
28
|
+
function buildWallSurfaceMaterialPatch(
|
|
29
|
+
node: WallNode,
|
|
30
|
+
targetSide: WallSurfaceSide | null,
|
|
31
|
+
material: MaterialSchema | undefined,
|
|
32
|
+
materialPreset: string | undefined,
|
|
33
|
+
): Partial<WallNode> {
|
|
34
|
+
const nextSurfaceMaterial = { material, materialPreset }
|
|
35
|
+
const nextInterior =
|
|
36
|
+
targetSide === null || targetSide === 'interior'
|
|
37
|
+
? nextSurfaceMaterial
|
|
38
|
+
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
|
39
|
+
const nextExterior =
|
|
40
|
+
targetSide === null || targetSide === 'exterior'
|
|
41
|
+
? nextSurfaceMaterial
|
|
42
|
+
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
interiorMaterial: nextInterior.material,
|
|
46
|
+
interiorMaterialPreset: nextInterior.materialPreset,
|
|
47
|
+
exteriorMaterial: nextExterior.material,
|
|
48
|
+
exteriorMaterialPreset: nextExterior.materialPreset,
|
|
49
|
+
material: undefined,
|
|
50
|
+
materialPreset: undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
export function WallPanel() {
|
|
18
|
-
const
|
|
55
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
19
56
|
const setSelection = useViewer((s) => s.setSelection)
|
|
20
|
-
const nodes = useScene((s) => s.nodes)
|
|
21
57
|
const updateNode = useScene((s) => s.updateNode)
|
|
58
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
59
|
+
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
|
60
|
+
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
61
|
+
|
|
62
|
+
const node = useScene((s) =>
|
|
63
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
|
64
|
+
)
|
|
22
65
|
|
|
23
|
-
|
|
24
|
-
|
|
66
|
+
// Boolean selector — re-renders only when this specific wall's child
|
|
67
|
+
// composition crosses the "has a door/window/wall-item" threshold.
|
|
68
|
+
const hasWallChildrenBlockingCurve = useScene((s) => {
|
|
69
|
+
if (!node) return false
|
|
70
|
+
return (node.children ?? []).some((childId) => {
|
|
71
|
+
const child = s.nodes[childId as AnyNodeId]
|
|
72
|
+
if (!child) return false
|
|
73
|
+
if (child.type === 'door' || child.type === 'window') return true
|
|
74
|
+
if (child.type === 'item') {
|
|
75
|
+
const attachTo = child.asset?.attachTo
|
|
76
|
+
return attachTo === 'wall' || attachTo === 'wall-side'
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
})
|
|
80
|
+
})
|
|
25
81
|
|
|
26
82
|
const handleUpdate = useCallback(
|
|
27
83
|
(updates: Partial<WallNode>) => {
|
|
@@ -32,6 +88,35 @@ export function WallPanel() {
|
|
|
32
88
|
[selectedId, updateNode],
|
|
33
89
|
)
|
|
34
90
|
|
|
91
|
+
const effectiveInteriorMaterial = useMemo(
|
|
92
|
+
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
|
|
93
|
+
[node],
|
|
94
|
+
)
|
|
95
|
+
const effectiveExteriorMaterial = useMemo(
|
|
96
|
+
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
|
|
97
|
+
[node],
|
|
98
|
+
)
|
|
99
|
+
const surfaceMaterialsMatch = useMemo(
|
|
100
|
+
() =>
|
|
101
|
+
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
|
|
102
|
+
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
|
|
103
|
+
[effectiveExteriorMaterial, effectiveInteriorMaterial],
|
|
104
|
+
)
|
|
105
|
+
const materialTargetSide =
|
|
106
|
+
selectedMaterialTarget &&
|
|
107
|
+
selectedMaterialTarget.nodeId === node?.id &&
|
|
108
|
+
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
|
109
|
+
? selectedMaterialTarget.role
|
|
110
|
+
: null
|
|
111
|
+
const materialPickerValue =
|
|
112
|
+
materialTargetSide === 'interior'
|
|
113
|
+
? effectiveInteriorMaterial
|
|
114
|
+
: materialTargetSide === 'exterior'
|
|
115
|
+
? effectiveExteriorMaterial
|
|
116
|
+
: surfaceMaterialsMatch
|
|
117
|
+
? effectiveInteriorMaterial
|
|
118
|
+
: {}
|
|
119
|
+
|
|
35
120
|
const handleUpdateLength = useCallback(
|
|
36
121
|
(newLength: number) => {
|
|
37
122
|
if (!node || newLength <= 0) return
|
|
@@ -55,25 +140,50 @@ export function WallPanel() {
|
|
|
55
140
|
[node, handleUpdate],
|
|
56
141
|
)
|
|
57
142
|
|
|
58
|
-
const
|
|
143
|
+
const handleMaterialPresetChange = useCallback(
|
|
144
|
+
(materialPreset: string) => {
|
|
145
|
+
if (!node || !materialTargetSide) return
|
|
146
|
+
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
|
|
147
|
+
},
|
|
148
|
+
[handleUpdate, materialTargetSide, node],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const handleCustomMaterialChange = useCallback(
|
|
59
152
|
(material: MaterialSchema) => {
|
|
60
|
-
|
|
153
|
+
if (!node || !materialTargetSide) return
|
|
154
|
+
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
|
|
61
155
|
},
|
|
62
|
-
[handleUpdate],
|
|
156
|
+
[handleUpdate, materialTargetSide, node],
|
|
63
157
|
)
|
|
64
158
|
|
|
65
159
|
const handleClose = useCallback(() => {
|
|
66
160
|
setSelection({ selectedIds: [] })
|
|
67
161
|
}, [setSelection])
|
|
68
162
|
|
|
69
|
-
|
|
163
|
+
const handleMove = useCallback(() => {
|
|
164
|
+
if (!node) return
|
|
165
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
166
|
+
setMovingNode(node)
|
|
167
|
+
setSelection({ selectedIds: [] })
|
|
168
|
+
}, [node, setMovingNode, setSelection])
|
|
169
|
+
|
|
170
|
+
const handleCurve = useCallback(() => {
|
|
171
|
+
if (!node) return
|
|
172
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
173
|
+
setCurvingWall(node)
|
|
174
|
+
setSelection({ selectedIds: [] })
|
|
175
|
+
}, [node, setCurvingWall, setSelection])
|
|
176
|
+
|
|
177
|
+
if (!(node && node.type === 'wall' && selectedId)) return null
|
|
70
178
|
|
|
71
179
|
const dx = node.end[0] - node.start[0]
|
|
72
180
|
const dz = node.end[1] - node.start[1]
|
|
73
|
-
const length =
|
|
181
|
+
const length = getWallCurveLength(node)
|
|
74
182
|
|
|
75
183
|
const height = node.height ?? 2.5
|
|
76
184
|
const thickness = node.thickness ?? 0.1
|
|
185
|
+
const curveOffset = getClampedWallCurveOffset(node)
|
|
186
|
+
const maxCurveOffset = getMaxWallCurveOffset(node)
|
|
77
187
|
|
|
78
188
|
return (
|
|
79
189
|
<PanelWrapper
|
|
@@ -113,10 +223,48 @@ export function WallPanel() {
|
|
|
113
223
|
unit="m"
|
|
114
224
|
value={Math.round(thickness * 1000) / 1000}
|
|
115
225
|
/>
|
|
226
|
+
{!hasWallChildrenBlockingCurve && (
|
|
227
|
+
<SliderControl
|
|
228
|
+
label="Curve"
|
|
229
|
+
max={Math.max(0.01, maxCurveOffset)}
|
|
230
|
+
min={-Math.max(0.01, maxCurveOffset)}
|
|
231
|
+
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
|
|
232
|
+
precision={2}
|
|
233
|
+
step={0.1}
|
|
234
|
+
unit="m"
|
|
235
|
+
value={Math.round(curveOffset * 100) / 100}
|
|
236
|
+
/>
|
|
237
|
+
)}
|
|
116
238
|
</PanelSection>
|
|
117
239
|
|
|
118
240
|
<PanelSection title="Material">
|
|
119
|
-
|
|
241
|
+
{!materialTargetSide ? (
|
|
242
|
+
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
|
243
|
+
Click the wall face you want to edit. Materials now apply to one side at a time.
|
|
244
|
+
</div>
|
|
245
|
+
) : null}
|
|
246
|
+
<MaterialPicker
|
|
247
|
+
disabled={!materialTargetSide}
|
|
248
|
+
hideSideControl
|
|
249
|
+
nodeType="wall"
|
|
250
|
+
onChange={handleCustomMaterialChange}
|
|
251
|
+
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
252
|
+
selectedMaterialPreset={materialPickerValue.materialPreset}
|
|
253
|
+
value={materialPickerValue.material}
|
|
254
|
+
/>
|
|
255
|
+
</PanelSection>
|
|
256
|
+
|
|
257
|
+
<PanelSection title="Actions">
|
|
258
|
+
<ActionGroup>
|
|
259
|
+
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
260
|
+
{!hasWallChildrenBlockingCurve && (
|
|
261
|
+
<ActionButton
|
|
262
|
+
icon={<Spline className="h-3.5 w-3.5" />}
|
|
263
|
+
label="Curve"
|
|
264
|
+
onClick={handleCurve}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</ActionGroup>
|
|
120
268
|
</PanelSection>
|
|
121
269
|
</PanelWrapper>
|
|
122
270
|
)
|
|
@@ -24,19 +24,17 @@ import { PanelWrapper } from './panel-wrapper'
|
|
|
24
24
|
import { PresetsPopover } from './presets/presets-popover'
|
|
25
25
|
|
|
26
26
|
export function WindowPanel() {
|
|
27
|
-
const
|
|
27
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
28
28
|
const setSelection = useViewer((s) => s.setSelection)
|
|
29
|
-
const nodes = useScene((s) => s.nodes)
|
|
30
29
|
const updateNode = useScene((s) => s.updateNode)
|
|
31
30
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
32
31
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
33
32
|
|
|
34
33
|
const adapter = usePresetsAdapter()
|
|
35
34
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
: undefined
|
|
35
|
+
const node = useScene((s) =>
|
|
36
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
|
|
37
|
+
)
|
|
40
38
|
|
|
41
39
|
const handleUpdate = useCallback(
|
|
42
40
|
(updates: Partial<WindowNode>) => {
|
|
@@ -153,7 +151,7 @@ export function WindowPanel() {
|
|
|
153
151
|
[handleUpdate],
|
|
154
152
|
)
|
|
155
153
|
|
|
156
|
-
if (!node
|
|
154
|
+
if (!(node && node.type === 'window' && selectedId)) return null
|
|
157
155
|
|
|
158
156
|
const numCols = node.columnRatios.length
|
|
159
157
|
const numRows = node.rowRatios.length
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
|
1
|
+
import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { Building2, Plus } from 'lucide-react'
|
|
4
|
-
import { useState } from 'react'
|
|
4
|
+
import { memo, useState } from 'react'
|
|
5
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
5
6
|
import {
|
|
6
7
|
Tooltip,
|
|
7
8
|
TooltipContent,
|
|
@@ -11,37 +12,46 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
|
11
12
|
import { TreeNodeActions } from './tree-node-actions'
|
|
12
13
|
|
|
13
14
|
interface BuildingTreeNodeProps {
|
|
14
|
-
|
|
15
|
+
nodeId: AnyNodeId
|
|
15
16
|
depth: number
|
|
16
17
|
isLast?: boolean
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export
|
|
20
|
+
export const BuildingTreeNode = memo(function BuildingTreeNode({
|
|
21
|
+
nodeId,
|
|
22
|
+
depth,
|
|
23
|
+
isLast,
|
|
24
|
+
}: BuildingTreeNodeProps) {
|
|
20
25
|
const [expanded, setExpanded] = useState(true)
|
|
21
26
|
const createNode = useScene((state) => state.createNode)
|
|
22
|
-
const
|
|
23
|
-
const
|
|
27
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
28
|
+
const name = useScene((s) => s.nodes[nodeId]?.name)
|
|
29
|
+
const children = useScene(
|
|
30
|
+
useShallow((s) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []),
|
|
31
|
+
)
|
|
32
|
+
const isSelected = useViewer((state) => state.selection.buildingId === nodeId)
|
|
33
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
24
34
|
const setSelection = useViewer((state) => state.setSelection)
|
|
25
35
|
|
|
26
36
|
const handleClick = () => {
|
|
27
|
-
setSelection({ buildingId:
|
|
37
|
+
setSelection({ buildingId: nodeId })
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
const handleAddLevel = (e: React.MouseEvent) => {
|
|
31
41
|
e.stopPropagation()
|
|
32
42
|
const newLevel = LevelNode.parse({
|
|
33
|
-
level:
|
|
43
|
+
level: children.length,
|
|
34
44
|
children: [],
|
|
35
|
-
parentId:
|
|
45
|
+
parentId: nodeId,
|
|
36
46
|
})
|
|
37
|
-
createNode(newLevel,
|
|
47
|
+
createNode(newLevel, nodeId)
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
return (
|
|
41
51
|
<TreeNodeWrapper
|
|
42
52
|
actions={
|
|
43
53
|
<div className="flex items-center gap-0.5">
|
|
44
|
-
<TreeNodeActions
|
|
54
|
+
<TreeNodeActions nodeId={nodeId} />
|
|
45
55
|
<Tooltip>
|
|
46
56
|
<TooltipTrigger asChild>
|
|
47
57
|
<button
|
|
@@ -57,24 +67,25 @@ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps)
|
|
|
57
67
|
}
|
|
58
68
|
depth={depth}
|
|
59
69
|
expanded={expanded}
|
|
60
|
-
hasChildren={
|
|
70
|
+
hasChildren={children.length > 0}
|
|
61
71
|
icon={<Building2 className="h-3.5 w-3.5" />}
|
|
62
72
|
isHovered={isHovered}
|
|
63
73
|
isLast={isLast}
|
|
64
74
|
isSelected={isSelected}
|
|
65
|
-
|
|
75
|
+
isVisible={isVisible}
|
|
76
|
+
label={name || 'Building'}
|
|
66
77
|
onClick={handleClick}
|
|
67
|
-
onDoubleClick={() => focusTreeNode(
|
|
78
|
+
onDoubleClick={() => focusTreeNode(nodeId)}
|
|
68
79
|
onToggle={() => setExpanded(!expanded)}
|
|
69
80
|
>
|
|
70
|
-
{
|
|
81
|
+
{children.map((childId, index) => (
|
|
71
82
|
<TreeNode
|
|
72
83
|
depth={depth + 1}
|
|
73
|
-
isLast={index ===
|
|
84
|
+
isLast={index === children.length - 1}
|
|
74
85
|
key={childId}
|
|
75
86
|
nodeId={childId}
|
|
76
87
|
/>
|
|
77
88
|
))}
|
|
78
89
|
</TreeNodeWrapper>
|
|
79
90
|
)
|
|
80
|
-
}
|
|
91
|
+
})
|
|
@@ -1,111 +1,123 @@
|
|
|
1
1
|
import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import Image from 'next/image'
|
|
4
|
-
import { useEffect, useState } from 'react'
|
|
4
|
+
import { memo, useCallback, useEffect, useState } from 'react'
|
|
5
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
5
6
|
import useEditor from './../../../../../store/use-editor'
|
|
6
7
|
import { InlineRenameInput } from './inline-rename-input'
|
|
7
8
|
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
8
9
|
import { TreeNodeActions } from './tree-node-actions'
|
|
9
10
|
|
|
10
11
|
interface CeilingTreeNodeProps {
|
|
11
|
-
|
|
12
|
+
nodeId: AnyNodeId
|
|
12
13
|
depth: number
|
|
13
14
|
isLast?: boolean
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export
|
|
17
|
+
export const CeilingTreeNode = memo(function CeilingTreeNode({
|
|
18
|
+
nodeId,
|
|
19
|
+
depth,
|
|
20
|
+
isLast,
|
|
21
|
+
}: CeilingTreeNodeProps) {
|
|
17
22
|
const [expanded, setExpanded] = useState(false)
|
|
18
23
|
const [isEditing, setIsEditing] = useState(false)
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
24
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
25
|
+
const children = useScene(
|
|
26
|
+
useShallow((s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.children ?? []),
|
|
27
|
+
)
|
|
28
|
+
const polygon = useScene(
|
|
29
|
+
(s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.polygon ?? [],
|
|
30
|
+
)
|
|
31
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
32
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
22
33
|
const setSelection = useViewer((state) => state.setSelection)
|
|
23
34
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
24
35
|
|
|
36
|
+
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
|
25
37
|
useEffect(() => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
return useViewer.subscribe((state) => {
|
|
39
|
+
const { selectedIds } = state.selection
|
|
40
|
+
if (selectedIds.length === 0) return
|
|
41
|
+
const nodes = useScene.getState().nodes
|
|
42
|
+
for (const id of selectedIds) {
|
|
43
|
+
let current = nodes[id as AnyNodeId]
|
|
44
|
+
while (current?.parentId) {
|
|
45
|
+
if (current.parentId === nodeId) {
|
|
46
|
+
setExpanded(true)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
current = nodes[current.parentId as AnyNodeId]
|
|
35
50
|
}
|
|
36
|
-
current = nodes[current.parentId as AnyNodeId]
|
|
37
51
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (isDescendant) {
|
|
41
|
-
setExpanded(true)
|
|
42
|
-
}
|
|
43
|
-
}, [selectedIds, node.id])
|
|
44
|
-
|
|
45
|
-
const handleClick = (e: React.MouseEvent) => {
|
|
46
|
-
e.stopPropagation()
|
|
47
|
-
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
|
48
|
-
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
49
|
-
useEditor.getState().setPhase('structure')
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const handleDoubleClick = () => {
|
|
54
|
-
focusTreeNode(node.id)
|
|
55
|
-
}
|
|
52
|
+
})
|
|
53
|
+
}, [nodeId])
|
|
56
54
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
const handleClick = useCallback(
|
|
56
|
+
(e: React.MouseEvent) => {
|
|
57
|
+
e.stopPropagation()
|
|
58
|
+
const handled = handleTreeSelection(
|
|
59
|
+
e,
|
|
60
|
+
nodeId,
|
|
61
|
+
useViewer.getState().selection.selectedIds,
|
|
62
|
+
setSelection,
|
|
63
|
+
)
|
|
64
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
65
|
+
useEditor.getState().setPhase('structure')
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
[nodeId, setSelection],
|
|
69
|
+
)
|
|
60
70
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
|
|
72
|
+
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
|
73
|
+
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
|
74
|
+
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
|
|
75
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
76
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
|
78
|
+
const area = calculatePolygonArea(polygon).toFixed(1)
|
|
67
79
|
const defaultName = `Ceiling (${area}m²)`
|
|
68
80
|
|
|
69
81
|
return (
|
|
70
82
|
<TreeNodeWrapper
|
|
71
|
-
actions={<TreeNodeActions
|
|
83
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
72
84
|
depth={depth}
|
|
73
85
|
expanded={expanded}
|
|
74
|
-
hasChildren={
|
|
86
|
+
hasChildren={children.length > 0}
|
|
75
87
|
icon={
|
|
76
88
|
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
|
|
77
89
|
}
|
|
78
90
|
isHovered={isHovered}
|
|
79
91
|
isLast={isLast}
|
|
80
92
|
isSelected={isSelected}
|
|
81
|
-
isVisible={
|
|
93
|
+
isVisible={isVisible}
|
|
82
94
|
label={
|
|
83
95
|
<InlineRenameInput
|
|
84
96
|
defaultName={defaultName}
|
|
85
97
|
isEditing={isEditing}
|
|
86
|
-
|
|
87
|
-
onStartEditing={
|
|
88
|
-
onStopEditing={
|
|
98
|
+
nodeId={nodeId as AnyNodeId}
|
|
99
|
+
onStartEditing={handleStartEditing}
|
|
100
|
+
onStopEditing={handleStopEditing}
|
|
89
101
|
/>
|
|
90
102
|
}
|
|
91
|
-
nodeId={
|
|
103
|
+
nodeId={nodeId}
|
|
92
104
|
onClick={handleClick}
|
|
93
105
|
onDoubleClick={handleDoubleClick}
|
|
94
106
|
onMouseEnter={handleMouseEnter}
|
|
95
107
|
onMouseLeave={handleMouseLeave}
|
|
96
|
-
onToggle={
|
|
108
|
+
onToggle={handleToggle}
|
|
97
109
|
>
|
|
98
|
-
{
|
|
110
|
+
{children.map((childId, index) => (
|
|
99
111
|
<TreeNode
|
|
100
112
|
depth={depth + 1}
|
|
101
|
-
isLast={index ===
|
|
113
|
+
isLast={index === children.length - 1}
|
|
102
114
|
key={childId}
|
|
103
115
|
nodeId={childId}
|
|
104
116
|
/>
|
|
105
117
|
))}
|
|
106
118
|
</TreeNodeWrapper>
|
|
107
119
|
)
|
|
108
|
-
}
|
|
120
|
+
})
|
|
109
121
|
|
|
110
122
|
/**
|
|
111
123
|
* Calculate the area of a polygon using the shoelace formula
|
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
5
|
import Image from 'next/image'
|
|
6
|
-
import { useState } from 'react'
|
|
6
|
+
import { memo, useCallback, useState } from 'react'
|
|
7
7
|
import useEditor from './../../../../../store/use-editor'
|
|
8
8
|
import { InlineRenameInput } from './inline-rename-input'
|
|
9
9
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
10
10
|
import { TreeNodeActions } from './tree-node-actions'
|
|
11
11
|
|
|
12
12
|
interface DoorTreeNodeProps {
|
|
13
|
-
|
|
13
|
+
nodeId: AnyNodeId
|
|
14
14
|
depth: number
|
|
15
15
|
isLast?: boolean
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export
|
|
18
|
+
export const DoorTreeNode = memo(function DoorTreeNode({
|
|
19
|
+
nodeId,
|
|
20
|
+
depth,
|
|
21
|
+
isLast,
|
|
22
|
+
}: DoorTreeNodeProps) {
|
|
19
23
|
const [isEditing, setIsEditing] = useState(false)
|
|
20
|
-
const
|
|
21
|
-
const isSelected = selectedIds.includes(
|
|
22
|
-
const isHovered = useViewer((state) => state.hoveredId ===
|
|
24
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
25
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
26
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
23
27
|
const setSelection = useViewer((state) => state.setSelection)
|
|
24
28
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
25
29
|
|
|
26
|
-
const
|
|
30
|
+
const handleClick = useCallback(
|
|
31
|
+
(e: React.MouseEvent) => {
|
|
32
|
+
e.stopPropagation()
|
|
33
|
+
const handled = handleTreeSelection(
|
|
34
|
+
e,
|
|
35
|
+
nodeId,
|
|
36
|
+
useViewer.getState().selection.selectedIds,
|
|
37
|
+
setSelection,
|
|
38
|
+
)
|
|
39
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
40
|
+
useEditor.getState().setPhase('structure')
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
[nodeId, setSelection],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
47
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
27
48
|
|
|
28
49
|
return (
|
|
29
50
|
<TreeNodeWrapper
|
|
30
|
-
actions={<TreeNodeActions
|
|
51
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
31
52
|
depth={depth}
|
|
32
53
|
expanded={false}
|
|
33
54
|
hasChildren={false}
|
|
@@ -37,28 +58,22 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
|
|
|
37
58
|
isHovered={isHovered}
|
|
38
59
|
isLast={isLast}
|
|
39
60
|
isSelected={isSelected}
|
|
40
|
-
isVisible={
|
|
61
|
+
isVisible={isVisible}
|
|
41
62
|
label={
|
|
42
63
|
<InlineRenameInput
|
|
43
|
-
defaultName=
|
|
64
|
+
defaultName="Door"
|
|
44
65
|
isEditing={isEditing}
|
|
45
|
-
|
|
46
|
-
onStartEditing={
|
|
47
|
-
onStopEditing={
|
|
66
|
+
nodeId={nodeId as AnyNodeId}
|
|
67
|
+
onStartEditing={handleStartEditing}
|
|
68
|
+
onStopEditing={handleStopEditing}
|
|
48
69
|
/>
|
|
49
70
|
}
|
|
50
|
-
nodeId={
|
|
51
|
-
onClick={
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
55
|
-
useEditor.getState().setPhase('structure')
|
|
56
|
-
}
|
|
57
|
-
}}
|
|
58
|
-
onDoubleClick={() => focusTreeNode(node.id)}
|
|
59
|
-
onMouseEnter={() => setHoveredId(node.id)}
|
|
71
|
+
nodeId={nodeId}
|
|
72
|
+
onClick={handleClick}
|
|
73
|
+
onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
|
|
74
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
60
75
|
onMouseLeave={() => setHoveredId(null)}
|
|
61
76
|
onToggle={() => {}}
|
|
62
77
|
/>
|
|
63
78
|
)
|
|
64
|
-
}
|
|
79
|
+
})
|