@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.
Files changed (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. 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
+ }