@pascal-app/editor 0.5.1 → 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 +12 -7
- 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 +29 -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 +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- 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 +377 -58
- 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/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- 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 +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- 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 +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -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/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- 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 +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- 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 +1121 -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 +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- 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 +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- 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 +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -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/history.ts +20 -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/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
|
|
2
|
+
import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core/schema'
|
|
3
|
+
|
|
4
|
+
export type LevelDuplicatePreset =
|
|
5
|
+
| 'everything'
|
|
6
|
+
| 'structure'
|
|
7
|
+
| 'structure-materials'
|
|
8
|
+
| 'structure-furniture'
|
|
9
|
+
|
|
10
|
+
const NON_DUPLICABLE_NODE_TYPES = new Set<AnyNode['type']>(['scan', 'guide', 'spawn'])
|
|
11
|
+
const STRUCTURAL_NODE_TYPES = new Set<AnyNode['type']>([
|
|
12
|
+
'level',
|
|
13
|
+
'wall',
|
|
14
|
+
'fence',
|
|
15
|
+
'zone',
|
|
16
|
+
'slab',
|
|
17
|
+
'ceiling',
|
|
18
|
+
'roof',
|
|
19
|
+
'roof-segment',
|
|
20
|
+
'stair',
|
|
21
|
+
'stair-segment',
|
|
22
|
+
'window',
|
|
23
|
+
'door',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
|
|
27
|
+
if (NON_DUPLICABLE_NODE_TYPES.has(node.type)) return false
|
|
28
|
+
if (preset === 'everything') return true
|
|
29
|
+
if (preset === 'structure-furniture') return true
|
|
30
|
+
if (preset === 'structure' || preset === 'structure-materials') {
|
|
31
|
+
return STRUCTURAL_NODE_TYPES.has(node.type)
|
|
32
|
+
}
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripMaterials(node: AnyNode): AnyNode {
|
|
37
|
+
const next = { ...node } as Record<string, unknown>
|
|
38
|
+
|
|
39
|
+
switch (node.type) {
|
|
40
|
+
case 'wall':
|
|
41
|
+
delete next.material
|
|
42
|
+
delete next.materialPreset
|
|
43
|
+
delete next.interiorMaterial
|
|
44
|
+
delete next.interiorMaterialPreset
|
|
45
|
+
delete next.exteriorMaterial
|
|
46
|
+
delete next.exteriorMaterialPreset
|
|
47
|
+
break
|
|
48
|
+
case 'slab':
|
|
49
|
+
case 'ceiling':
|
|
50
|
+
case 'fence':
|
|
51
|
+
case 'roof-segment':
|
|
52
|
+
case 'stair-segment':
|
|
53
|
+
case 'window':
|
|
54
|
+
case 'door':
|
|
55
|
+
delete next.material
|
|
56
|
+
delete next.materialPreset
|
|
57
|
+
break
|
|
58
|
+
case 'roof':
|
|
59
|
+
delete next.material
|
|
60
|
+
delete next.materialPreset
|
|
61
|
+
delete next.topMaterial
|
|
62
|
+
delete next.topMaterialPreset
|
|
63
|
+
delete next.edgeMaterial
|
|
64
|
+
delete next.edgeMaterialPreset
|
|
65
|
+
delete next.wallMaterial
|
|
66
|
+
delete next.wallMaterialPreset
|
|
67
|
+
break
|
|
68
|
+
case 'stair':
|
|
69
|
+
delete next.material
|
|
70
|
+
delete next.materialPreset
|
|
71
|
+
delete next.railingMaterial
|
|
72
|
+
delete next.railingMaterialPreset
|
|
73
|
+
delete next.treadMaterial
|
|
74
|
+
delete next.treadMaterialPreset
|
|
75
|
+
delete next.sideMaterial
|
|
76
|
+
delete next.sideMaterialPreset
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return next as AnyNode
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findLevelBuildingId(nodes: Record<AnyNodeId, AnyNode>, levelId: AnyNodeId) {
|
|
84
|
+
for (const node of Object.values(nodes)) {
|
|
85
|
+
if (node.type !== 'building' || !('children' in node) || !Array.isArray(node.children)) {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ((node.children as AnyNodeId[]).includes(levelId)) {
|
|
90
|
+
return node.id as AnyNodeId
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildLevelDuplicateCreateOps({
|
|
98
|
+
nodes,
|
|
99
|
+
level,
|
|
100
|
+
levels,
|
|
101
|
+
preset,
|
|
102
|
+
}: {
|
|
103
|
+
nodes: Record<AnyNodeId, AnyNode>
|
|
104
|
+
level: LevelNode
|
|
105
|
+
levels: LevelNode[]
|
|
106
|
+
preset: LevelDuplicatePreset
|
|
107
|
+
}) {
|
|
108
|
+
const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
|
|
109
|
+
const parentBuildingId =
|
|
110
|
+
(level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id)
|
|
111
|
+
const nextLevelNumber = level.level + 1
|
|
112
|
+
const shiftedLevels = levels
|
|
113
|
+
.filter((entry) => entry.id !== level.id && entry.level >= nextLevelNumber)
|
|
114
|
+
.map((entry) => ({
|
|
115
|
+
id: entry.id,
|
|
116
|
+
level: entry.level + 1,
|
|
117
|
+
}))
|
|
118
|
+
|
|
119
|
+
const filteredNodes = clonedNodes
|
|
120
|
+
.filter((node) => shouldKeepNode(node, preset))
|
|
121
|
+
.map((node) => (preset === 'structure' ? stripMaterials(node) : node))
|
|
122
|
+
|
|
123
|
+
const keptIds = new Set(filteredNodes.map((node) => node.id))
|
|
124
|
+
|
|
125
|
+
const cleanedNodes = filteredNodes.map((node) => {
|
|
126
|
+
if (!('children' in node) || !Array.isArray(node.children)) {
|
|
127
|
+
return node
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
...node,
|
|
132
|
+
children: node.children.filter((childId) => keptIds.has(childId as AnyNodeId)),
|
|
133
|
+
} as AnyNode
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
createOps: cleanedNodes.map((node) => ({
|
|
138
|
+
node:
|
|
139
|
+
node.id === newLevelId
|
|
140
|
+
? ({
|
|
141
|
+
...node,
|
|
142
|
+
level: nextLevelNumber,
|
|
143
|
+
} as AnyNode)
|
|
144
|
+
: node,
|
|
145
|
+
parentId:
|
|
146
|
+
node.id === newLevelId
|
|
147
|
+
? parentBuildingId
|
|
148
|
+
: ((node.parentId as AnyNodeId | null) ?? undefined),
|
|
149
|
+
})),
|
|
150
|
+
newLevelId,
|
|
151
|
+
shiftedLevels,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
GuideNode,
|
|
4
|
+
type GuideNode as GuideNodeType,
|
|
5
|
+
saveAsset,
|
|
6
|
+
} from '@pascal-app/core'
|
|
7
|
+
|
|
8
|
+
export function getGuideImageName(filename: string) {
|
|
9
|
+
const trimmed = filename.trim()
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return 'Guide image'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const dotIndex = trimmed.lastIndexOf('.')
|
|
15
|
+
return dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function createLocalGuideImage({
|
|
19
|
+
createNode,
|
|
20
|
+
file,
|
|
21
|
+
levelId,
|
|
22
|
+
position = [0, 0, 0],
|
|
23
|
+
}: {
|
|
24
|
+
createNode: (node: GuideNodeType, parentId: AnyNodeId) => void
|
|
25
|
+
file: File
|
|
26
|
+
levelId: string
|
|
27
|
+
position?: [number, number, number]
|
|
28
|
+
}) {
|
|
29
|
+
const assetUrl = await saveAsset(file)
|
|
30
|
+
const guide = GuideNode.parse({
|
|
31
|
+
name: getGuideImageName(file.name),
|
|
32
|
+
url: assetUrl,
|
|
33
|
+
position,
|
|
34
|
+
rotation: [0, 0, 0],
|
|
35
|
+
scale: 1,
|
|
36
|
+
opacity: 50,
|
|
37
|
+
scaleReference: null,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
createNode(guide, levelId as AnyNodeId)
|
|
41
|
+
return guide
|
|
42
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
type ColumnNode,
|
|
6
|
+
type FenceNode,
|
|
7
|
+
getCatalogMaterialById,
|
|
8
|
+
getEffectiveRoofSurfaceMaterial,
|
|
9
|
+
getEffectiveStairSurfaceMaterial,
|
|
10
|
+
getEffectiveWallSurfaceMaterial,
|
|
11
|
+
getLibraryMaterialIdFromRef,
|
|
12
|
+
type MaterialSchema,
|
|
13
|
+
type MaterialTarget,
|
|
14
|
+
type RoofNode,
|
|
15
|
+
type RoofSurfaceMaterialRole,
|
|
16
|
+
type SlabNode,
|
|
17
|
+
type StairNode,
|
|
18
|
+
type StairSurfaceMaterialRole,
|
|
19
|
+
type WallNode,
|
|
20
|
+
type WallSurfaceSide,
|
|
21
|
+
} from '@pascal-app/core'
|
|
22
|
+
|
|
23
|
+
export type PaintableMaterialTarget = Extract<
|
|
24
|
+
MaterialTarget,
|
|
25
|
+
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
|
|
26
|
+
>
|
|
27
|
+
|
|
28
|
+
export type SingleSurfaceMaterialRole = 'surface'
|
|
29
|
+
|
|
30
|
+
export type ActivePaintMaterial = {
|
|
31
|
+
material?: MaterialSchema
|
|
32
|
+
materialPreset?: string
|
|
33
|
+
sourceTarget: PaintableMaterialTarget
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hasActivePaintMaterial(
|
|
37
|
+
material: ActivePaintMaterial | null | undefined,
|
|
38
|
+
): material is ActivePaintMaterial {
|
|
39
|
+
return Boolean(
|
|
40
|
+
material && (material.material !== undefined || material.materialPreset !== undefined),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) {
|
|
45
|
+
const catalogId =
|
|
46
|
+
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
|
|
47
|
+
|
|
48
|
+
return getCatalogMaterialById(catalogId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) {
|
|
52
|
+
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildWallSurfaceMaterialPatch(
|
|
56
|
+
node: WallNode,
|
|
57
|
+
targetSide: WallSurfaceSide,
|
|
58
|
+
material: MaterialSchema | undefined,
|
|
59
|
+
materialPreset: string | undefined,
|
|
60
|
+
): Partial<WallNode> {
|
|
61
|
+
const nextSurfaceMaterial = { material, materialPreset }
|
|
62
|
+
const nextInterior =
|
|
63
|
+
targetSide === 'interior'
|
|
64
|
+
? nextSurfaceMaterial
|
|
65
|
+
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
|
66
|
+
const nextExterior =
|
|
67
|
+
targetSide === 'exterior'
|
|
68
|
+
? nextSurfaceMaterial
|
|
69
|
+
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
interiorMaterial: nextInterior.material,
|
|
73
|
+
interiorMaterialPreset: nextInterior.materialPreset,
|
|
74
|
+
exteriorMaterial: nextExterior.material,
|
|
75
|
+
exteriorMaterialPreset: nextExterior.materialPreset,
|
|
76
|
+
material: undefined,
|
|
77
|
+
materialPreset: undefined,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildRoofSurfaceMaterialPatch(
|
|
82
|
+
node: RoofNode,
|
|
83
|
+
targetRole: RoofSurfaceMaterialRole,
|
|
84
|
+
material: MaterialSchema | undefined,
|
|
85
|
+
materialPreset: string | undefined,
|
|
86
|
+
): Partial<RoofNode> {
|
|
87
|
+
const nextSurfaceMaterial = { material, materialPreset }
|
|
88
|
+
const nextTop =
|
|
89
|
+
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
|
|
90
|
+
const nextEdge =
|
|
91
|
+
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
|
|
92
|
+
const nextWall =
|
|
93
|
+
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
topMaterial: nextTop.material,
|
|
97
|
+
topMaterialPreset: nextTop.materialPreset,
|
|
98
|
+
edgeMaterial: nextEdge.material,
|
|
99
|
+
edgeMaterialPreset: nextEdge.materialPreset,
|
|
100
|
+
wallMaterial: nextWall.material,
|
|
101
|
+
wallMaterialPreset: nextWall.materialPreset,
|
|
102
|
+
material: undefined,
|
|
103
|
+
materialPreset: undefined,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildStairSurfaceMaterialPatch(
|
|
108
|
+
node: StairNode,
|
|
109
|
+
targetRole: StairSurfaceMaterialRole,
|
|
110
|
+
material: MaterialSchema | undefined,
|
|
111
|
+
materialPreset: string | undefined,
|
|
112
|
+
): Partial<StairNode> {
|
|
113
|
+
const nextSurfaceMaterial = { material, materialPreset }
|
|
114
|
+
const nextRailing =
|
|
115
|
+
targetRole === 'railing'
|
|
116
|
+
? nextSurfaceMaterial
|
|
117
|
+
: getEffectiveStairSurfaceMaterial(node, 'railing')
|
|
118
|
+
const nextTread =
|
|
119
|
+
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
|
|
120
|
+
const nextSide =
|
|
121
|
+
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
railingMaterial: nextRailing.material,
|
|
125
|
+
railingMaterialPreset: nextRailing.materialPreset,
|
|
126
|
+
treadMaterial: nextTread.material,
|
|
127
|
+
treadMaterialPreset: nextTread.materialPreset,
|
|
128
|
+
sideMaterial: nextSide.material,
|
|
129
|
+
sideMaterialPreset: nextSide.materialPreset,
|
|
130
|
+
material: undefined,
|
|
131
|
+
materialPreset: undefined,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildSingleSurfaceMaterialPatch<
|
|
136
|
+
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
|
|
137
|
+
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
|
138
|
+
return {
|
|
139
|
+
material,
|
|
140
|
+
materialPreset,
|
|
141
|
+
} as Partial<TNode>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function resolveActivePaintMaterialFromSelection(params: {
|
|
145
|
+
nodes: Record<string, any>
|
|
146
|
+
selectedId: string | null
|
|
147
|
+
selectedMaterialTarget: {
|
|
148
|
+
nodeId: string
|
|
149
|
+
role:
|
|
150
|
+
| WallSurfaceSide
|
|
151
|
+
| StairSurfaceMaterialRole
|
|
152
|
+
| RoofSurfaceMaterialRole
|
|
153
|
+
| SingleSurfaceMaterialRole
|
|
154
|
+
} | null
|
|
155
|
+
}): ActivePaintMaterial | null {
|
|
156
|
+
const { nodes, selectedId, selectedMaterialTarget } = params
|
|
157
|
+
if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
|
|
158
|
+
return null
|
|
159
|
+
|
|
160
|
+
const selectedNode = nodes[selectedId]
|
|
161
|
+
if (!selectedNode) return null
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
selectedNode.type === 'wall' &&
|
|
165
|
+
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
|
166
|
+
) {
|
|
167
|
+
const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
|
168
|
+
return hasActivePaintMaterial({
|
|
169
|
+
material: surface.material,
|
|
170
|
+
materialPreset: surface.materialPreset,
|
|
171
|
+
sourceTarget: 'wall',
|
|
172
|
+
})
|
|
173
|
+
? {
|
|
174
|
+
material: surface.material,
|
|
175
|
+
materialPreset: surface.materialPreset,
|
|
176
|
+
sourceTarget: 'wall',
|
|
177
|
+
}
|
|
178
|
+
: null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
selectedNode.type === 'roof' &&
|
|
183
|
+
(selectedMaterialTarget.role === 'top' ||
|
|
184
|
+
selectedMaterialTarget.role === 'edge' ||
|
|
185
|
+
selectedMaterialTarget.role === 'wall')
|
|
186
|
+
) {
|
|
187
|
+
const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
|
188
|
+
return hasActivePaintMaterial({
|
|
189
|
+
material: surface.material,
|
|
190
|
+
materialPreset: surface.materialPreset,
|
|
191
|
+
sourceTarget: 'roof',
|
|
192
|
+
})
|
|
193
|
+
? {
|
|
194
|
+
material: surface.material,
|
|
195
|
+
materialPreset: surface.materialPreset,
|
|
196
|
+
sourceTarget: 'roof',
|
|
197
|
+
}
|
|
198
|
+
: null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
selectedNode.type === 'stair' &&
|
|
203
|
+
(selectedMaterialTarget.role === 'railing' ||
|
|
204
|
+
selectedMaterialTarget.role === 'tread' ||
|
|
205
|
+
selectedMaterialTarget.role === 'side')
|
|
206
|
+
) {
|
|
207
|
+
const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
|
208
|
+
return hasActivePaintMaterial({
|
|
209
|
+
material: surface.material,
|
|
210
|
+
materialPreset: surface.materialPreset,
|
|
211
|
+
sourceTarget: 'stair',
|
|
212
|
+
})
|
|
213
|
+
? {
|
|
214
|
+
material: surface.material,
|
|
215
|
+
materialPreset: surface.materialPreset,
|
|
216
|
+
sourceTarget: 'stair',
|
|
217
|
+
}
|
|
218
|
+
: null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
(selectedNode.type === 'fence' ||
|
|
223
|
+
selectedNode.type === 'column' ||
|
|
224
|
+
selectedNode.type === 'slab' ||
|
|
225
|
+
selectedNode.type === 'ceiling') &&
|
|
226
|
+
selectedMaterialTarget.role === 'surface'
|
|
227
|
+
) {
|
|
228
|
+
const target = selectedNode.type
|
|
229
|
+
return hasActivePaintMaterial({
|
|
230
|
+
material: selectedNode.material,
|
|
231
|
+
materialPreset: selectedNode.materialPreset,
|
|
232
|
+
sourceTarget: target,
|
|
233
|
+
})
|
|
234
|
+
? {
|
|
235
|
+
material: selectedNode.material,
|
|
236
|
+
materialPreset: selectedNode.materialPreset,
|
|
237
|
+
sourceTarget: target,
|
|
238
|
+
}
|
|
239
|
+
: null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function resolvePaintTargetFromSelection(params: {
|
|
246
|
+
nodes: Record<string, any>
|
|
247
|
+
selectedId: string | null
|
|
248
|
+
}): PaintableMaterialTarget | null {
|
|
249
|
+
const { nodes, selectedId } = params
|
|
250
|
+
if (!selectedId) return null
|
|
251
|
+
|
|
252
|
+
const selectedNode = nodes[selectedId]
|
|
253
|
+
if (!selectedNode) return null
|
|
254
|
+
|
|
255
|
+
if (selectedNode.type === 'wall') {
|
|
256
|
+
return 'wall'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') {
|
|
260
|
+
return 'roof'
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') {
|
|
264
|
+
return 'stair'
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (selectedNode.type === 'fence') {
|
|
268
|
+
return 'fence'
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (selectedNode.type === 'column') {
|
|
272
|
+
return 'column'
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (selectedNode.type === 'slab') {
|
|
276
|
+
return 'slab'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (selectedNode.type === 'ceiling') {
|
|
280
|
+
return 'ceiling'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
generateId,
|
|
6
|
+
type RoofNode,
|
|
7
|
+
RoofNode as RoofNodeSchema,
|
|
8
|
+
type RoofSegmentNode,
|
|
9
|
+
RoofSegmentNode as RoofSegmentNodeSchema,
|
|
10
|
+
sceneRegistry,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import useEditor from '../store/use-editor'
|
|
15
|
+
|
|
16
|
+
type DuplicateRoofMode = 'select' | 'move'
|
|
17
|
+
|
|
18
|
+
type DuplicateRoofOptions = {
|
|
19
|
+
mode?: DuplicateRoofMode
|
|
20
|
+
offset?: [number, number, number]
|
|
21
|
+
parentId?: AnyNodeId
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type DuplicateRoofResult = {
|
|
25
|
+
roof: RoofNode
|
|
26
|
+
segmentIds: RoofSegmentNode['id'][]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MOVE_REGISTRY_RETRY_LIMIT = 12
|
|
30
|
+
|
|
31
|
+
function stripDuplicateFlags(metadata: unknown) {
|
|
32
|
+
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
|
33
|
+
return metadata
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const nextMeta = { ...(metadata as Record<string, unknown>) }
|
|
37
|
+
delete nextMeta.isNew
|
|
38
|
+
delete nextMeta.isTransient
|
|
39
|
+
return nextMeta
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildDuplicateMetadata(metadata: unknown) {
|
|
43
|
+
const cleaned = stripDuplicateFlags(metadata)
|
|
44
|
+
if (typeof cleaned !== 'object' || cleaned === null || Array.isArray(cleaned)) {
|
|
45
|
+
return { isNew: true }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...cleaned,
|
|
50
|
+
isNew: true,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function moveRoofWhenRegistered(roofId: RoofNode['id'], attempt = 0) {
|
|
55
|
+
const latestRoof = useScene.getState().nodes[roofId as AnyNodeId]
|
|
56
|
+
if (latestRoof?.type !== 'roof') {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (sceneRegistry.nodes.has(roofId)) {
|
|
61
|
+
useEditor.getState().setMovingNode(latestRoof)
|
|
62
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (attempt >= MOVE_REGISTRY_RETRY_LIMIT) {
|
|
67
|
+
console.warn(`Duplicated roof "${roofId}" did not register before move mode started`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
requestAnimationFrame(() => moveRoofWhenRegistered(roofId, attempt + 1))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function duplicateRoofSubtree(
|
|
75
|
+
sourceRoofId: AnyNodeId,
|
|
76
|
+
options: DuplicateRoofOptions = {},
|
|
77
|
+
): DuplicateRoofResult {
|
|
78
|
+
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
|
|
79
|
+
|
|
80
|
+
const scene = useScene.getState()
|
|
81
|
+
const sourceRoof = scene.nodes[sourceRoofId]
|
|
82
|
+
|
|
83
|
+
if (!sourceRoof || sourceRoof.type !== 'roof') {
|
|
84
|
+
throw new Error(`Node "${sourceRoofId}" is not a roof`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parentId = explicitParentId ?? (sourceRoof.parentId as AnyNodeId | null)
|
|
88
|
+
if (!parentId) {
|
|
89
|
+
throw new Error(`Roof "${sourceRoofId}" is missing a parent level`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const roofClone = RoofNodeSchema.parse({
|
|
93
|
+
...structuredClone(sourceRoof),
|
|
94
|
+
id: generateId('roof'),
|
|
95
|
+
parentId,
|
|
96
|
+
children: [],
|
|
97
|
+
position: [
|
|
98
|
+
sourceRoof.position[0] + offset[0],
|
|
99
|
+
sourceRoof.position[1] + offset[1],
|
|
100
|
+
sourceRoof.position[2] + offset[2],
|
|
101
|
+
] as RoofNode['position'],
|
|
102
|
+
metadata: buildDuplicateMetadata(sourceRoof.metadata),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const segmentClones: RoofSegmentNode[] = []
|
|
106
|
+
for (const childId of sourceRoof.children ?? []) {
|
|
107
|
+
const childNode = scene.nodes[childId as AnyNodeId]
|
|
108
|
+
if (!childNode || childNode.type !== 'roof-segment') {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const childClone = RoofSegmentNodeSchema.parse({
|
|
113
|
+
...structuredClone(childNode),
|
|
114
|
+
id: generateId('rseg'),
|
|
115
|
+
parentId: roofClone.id,
|
|
116
|
+
metadata: buildDuplicateMetadata(childNode.metadata),
|
|
117
|
+
})
|
|
118
|
+
segmentClones.push(childClone)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
scene.createNodes([
|
|
122
|
+
{ node: roofClone, parentId },
|
|
123
|
+
...segmentClones.map((segment) => ({ node: segment, parentId: roofClone.id as AnyNodeId })),
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
const nextScene = useScene.getState()
|
|
127
|
+
const createdRoof = nextScene.nodes[roofClone.id as AnyNodeId]
|
|
128
|
+
if (!createdRoof || createdRoof.type !== 'roof') {
|
|
129
|
+
throw new Error(`Duplicated roof "${roofClone.id}" was not created`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const createdParent = nextScene.nodes[parentId]
|
|
133
|
+
const parentChildIds =
|
|
134
|
+
createdParent && 'children' in createdParent && Array.isArray(createdParent.children)
|
|
135
|
+
? (createdParent.children as AnyNodeId[])
|
|
136
|
+
: null
|
|
137
|
+
if (!createdParent || !parentChildIds?.includes(createdRoof.id as AnyNodeId)) {
|
|
138
|
+
throw new Error(`Duplicated roof "${createdRoof.id}" was not linked to parent "${parentId}"`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const segmentIds = segmentClones.map((segment) => segment.id)
|
|
142
|
+
const createdChildIds = (createdRoof.children ?? []) as AnyNodeId[]
|
|
143
|
+
const missingSegmentId = segmentIds.find(
|
|
144
|
+
(segmentId) => !createdChildIds.includes(segmentId as AnyNodeId),
|
|
145
|
+
)
|
|
146
|
+
if (missingSegmentId) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Duplicated roof "${createdRoof.id}" is missing cloned segment "${missingSegmentId}"`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const invalidSegment = segmentIds.find((segmentId) => {
|
|
153
|
+
const segment = nextScene.nodes[segmentId as AnyNodeId]
|
|
154
|
+
return !segment || segment.type !== 'roof-segment' || segment.parentId !== createdRoof.id
|
|
155
|
+
})
|
|
156
|
+
if (invalidSegment) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Duplicated roof segment "${invalidSegment}" was not linked to roof "${createdRoof.id}"`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const setSelection = useViewer.getState().setSelection
|
|
163
|
+
if (mode === 'select') {
|
|
164
|
+
setSelection({ selectedIds: [createdRoof.id] })
|
|
165
|
+
} else {
|
|
166
|
+
setSelection({ selectedIds: [createdRoof.id] })
|
|
167
|
+
requestAnimationFrame(() => moveRoofWhenRegistered(createdRoof.id))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
roof: createdRoof,
|
|
172
|
+
segmentIds,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function clearRoofDuplicateMetadata(
|
|
177
|
+
roofId: AnyNodeId,
|
|
178
|
+
updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata'>> = {},
|
|
179
|
+
) {
|
|
180
|
+
const scene = useScene.getState()
|
|
181
|
+
const roofNode = scene.nodes[roofId]
|
|
182
|
+
if (!roofNode || roofNode.type !== 'roof') {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const nodeUpdates: { id: AnyNodeId; data: Record<string, unknown> }[] = [
|
|
187
|
+
{
|
|
188
|
+
id: roofId,
|
|
189
|
+
data: {
|
|
190
|
+
...updates,
|
|
191
|
+
metadata:
|
|
192
|
+
updates.metadata !== undefined
|
|
193
|
+
? stripDuplicateFlags(updates.metadata)
|
|
194
|
+
: stripDuplicateFlags(roofNode.metadata),
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
for (const childId of roofNode.children ?? []) {
|
|
200
|
+
const childNode = scene.nodes[childId as AnyNodeId]
|
|
201
|
+
if (!childNode || childNode.type !== 'roof-segment') {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
nodeUpdates.push({
|
|
206
|
+
id: childNode.id as AnyNodeId,
|
|
207
|
+
data: {
|
|
208
|
+
metadata: stripDuplicateFlags(childNode.metadata),
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
scene.updateNodes(nodeUpdates as { id: AnyNodeId; data: Partial<RoofNode | RoofSegmentNode> }[])
|
|
214
|
+
}
|