@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
@@ -3,7 +3,7 @@
3
3
  import type { AssetInput } from '@pascal-app/core'
4
4
  import { resolveCdnUrl } from '@pascal-app/viewer'
5
5
  import Image from 'next/image'
6
- import { useEffect, useState } from 'react'
6
+ import { useEffect } from 'react'
7
7
  import {
8
8
  Tooltip,
9
9
  TooltipContent,
@@ -13,54 +13,19 @@ import { cn } from './../../../lib/utils'
13
13
  import useEditor, { type CatalogCategory } from './../../../store/use-editor'
14
14
  import { CATALOG_ITEMS } from './catalog-items'
15
15
 
16
- const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
17
-
18
16
  export function ItemCatalog({ category }: { category: CatalogCategory }) {
19
17
  const selectedItem = useEditor((state) => state.selectedItem)
20
18
  const setSelectedItem = useEditor((state) => state.setSelectedItem)
21
- const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
22
- const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
23
19
 
24
20
  const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
25
21
 
26
- // Collect tags available in this category
27
- const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
28
- const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
29
- const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
30
- const hasFilters = allTags.length > 1
31
-
32
- // Count items for a placement tag given the current functional filter
33
- const placementCount = (tag: string | null) =>
34
- categoryItems.filter((item) => {
35
- const tags = item.tags ?? []
36
- if (tag !== null && !tags.includes(tag)) return false
37
- if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
38
- return true
39
- }).length
40
-
41
- // Count items for a functional tag given the current placement filter
42
- const functionalCount = (tag: string) =>
43
- categoryItems.filter((item) => {
44
- const tags = item.tags ?? []
45
- if (!tags.includes(tag)) return false
46
- if (activePlacementTag && !tags.includes(activePlacementTag)) return false
47
- return true
48
- }).length
49
-
50
- const filteredItems = categoryItems.filter((item) => {
51
- const tags = item.tags ?? []
52
- if (activePlacementTag && !tags.includes(activePlacementTag)) return false
53
- if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
54
- return true
55
- })
56
-
57
- // Auto-select first item if current selection is not in the filtered list
22
+ // Auto-select first item if current selection is not in this category
58
23
  useEffect(() => {
59
- const isCurrentItemInCategory = filteredItems.some((item) => item.src === selectedItem?.src)
60
- if (!isCurrentItemInCategory && filteredItems.length > 0) {
61
- setSelectedItem(filteredItems[0] as AssetInput)
24
+ const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
25
+ if (!isCurrentItemInCategory && categoryItems.length > 0) {
26
+ setSelectedItem(categoryItems[0] as AssetInput)
62
27
  }
63
- }, [filteredItems, selectedItem?.src, setSelectedItem])
28
+ }, [categoryItems, selectedItem?.src, setSelectedItem])
64
29
 
65
30
  // Get attachment icon based on attachTo type
66
31
  const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
@@ -74,146 +39,48 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
74
39
  }
75
40
 
76
41
  return (
77
- <div className="flex flex-col gap-2">
78
- {/* Filter chips */}
79
- {hasFilters && (
80
- <div className="flex flex-col gap-1.5">
81
- {/* Placement row */}
82
- {placementTags.length > 0 && (
83
- <div className="flex flex-wrap gap-1">
42
+ <div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
43
+ {categoryItems.map((item, index) => {
44
+ const isSelected = selectedItem?.src === item?.src
45
+ const attachmentIcon = getAttachmentIcon(item?.attachTo)
46
+ return (
47
+ <Tooltip key={index}>
48
+ <TooltipTrigger asChild>
84
49
  <button
85
50
  className={cn(
86
- 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
87
- activePlacementTag === null
88
- ? 'bg-blue-500 text-white'
89
- : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
51
+ 'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',
52
+ isSelected && 'ring-2 ring-primary-foreground',
90
53
  )}
91
- onClick={() => setActivePlacementTag(null)}
54
+ onClick={() => setSelectedItem(item)}
92
55
  type="button"
93
56
  >
94
- All
57
+ <Image
58
+ alt={item.name}
59
+ className="rounded-lg object-cover"
60
+ fill
61
+ loading="eager"
62
+ sizes="56px"
63
+ src={resolveCdnUrl(item.thumbnail) || ''}
64
+ />
65
+ {attachmentIcon && (
66
+ <div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
67
+ <Image
68
+ alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
69
+ className="h-4 w-4"
70
+ height={16}
71
+ src={attachmentIcon}
72
+ width={16}
73
+ />
74
+ </div>
75
+ )}
95
76
  </button>
96
- {placementTags.map((tag) => {
97
- const count = placementCount(tag)
98
- const isActive = activePlacementTag === tag
99
- const isEmpty = count === 0 && !isActive
100
- return (
101
- <button
102
- className={cn(
103
- 'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
104
- isActive
105
- ? 'bg-blue-500 text-white'
106
- : isEmpty
107
- ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
108
- : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
109
- )}
110
- disabled={isEmpty}
111
- key={tag}
112
- onClick={() => setActivePlacementTag(isActive ? null : tag)}
113
- type="button"
114
- >
115
- {tag}
116
- <span
117
- className={cn(
118
- 'text-[10px]',
119
- isActive ? 'text-blue-200' : isEmpty ? 'text-zinc-600' : 'text-blue-500/70',
120
- )}
121
- >
122
- {count}
123
- </span>
124
- </button>
125
- )
126
- })}
127
- </div>
128
- )}
129
-
130
- {/* Functional row */}
131
- {functionalTags.length > 0 && (
132
- <div className="flex flex-wrap gap-1">
133
- {functionalTags.map((tag) => {
134
- const count = functionalCount(tag)
135
- const isActive = activeFunctionalTag === tag
136
- const isEmpty = count === 0 && !isActive
137
- return (
138
- <button
139
- className={cn(
140
- 'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
141
- isActive
142
- ? 'bg-violet-500 text-white'
143
- : isEmpty
144
- ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
145
- : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
146
- )}
147
- disabled={isEmpty}
148
- key={tag}
149
- onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
150
- type="button"
151
- >
152
- {tag}
153
- <span
154
- className={cn(
155
- 'text-[10px]',
156
- isActive
157
- ? 'text-violet-200'
158
- : isEmpty
159
- ? 'text-zinc-600'
160
- : 'text-zinc-500/70',
161
- )}
162
- >
163
- {count}
164
- </span>
165
- </button>
166
- )
167
- })}
168
- </div>
169
- )}
170
- </div>
171
- )}
172
-
173
- {/* Items */}
174
- <div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
175
- {filteredItems.map((item, index) => {
176
- const isSelected = selectedItem?.src === item?.src
177
- const attachmentIcon = getAttachmentIcon(item?.attachTo)
178
- return (
179
- <Tooltip key={index}>
180
- <TooltipTrigger asChild>
181
- <button
182
- className={cn(
183
- 'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',
184
- isSelected && 'ring-2 ring-primary-foreground',
185
- )}
186
- onClick={() => setSelectedItem(item)}
187
- type="button"
188
- >
189
- <Image
190
- alt={item.name}
191
- className="rounded-lg object-cover"
192
- fill
193
- loading="eager"
194
- sizes="56px"
195
- src={resolveCdnUrl(item.thumbnail) || ''}
196
- />
197
- {attachmentIcon && (
198
- <div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
199
- <Image
200
- alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
201
- className="h-4 w-4"
202
- height={16}
203
- src={attachmentIcon}
204
- width={16}
205
- />
206
- </div>
207
- )}
208
- </button>
209
- </TooltipTrigger>
210
- <TooltipContent className="text-xs" side="top">
211
- {item.name}
212
- </TooltipContent>
213
- </Tooltip>
214
- )
215
- })}
216
- </div>
77
+ </TooltipTrigger>
78
+ <TooltipContent className="text-xs" side="top">
79
+ {item.name}
80
+ </TooltipContent>
81
+ </Tooltip>
82
+ )
83
+ })}
217
84
  </div>
218
85
  )
219
86
  }
@@ -0,0 +1,115 @@
1
+ 'use client'
2
+
3
+ import type { LevelNode } from '@pascal-app/core'
4
+ import { useEffect, useState } from 'react'
5
+ import { cn } from '../../lib/utils'
6
+ import type { LevelDuplicatePreset } from '../../lib/level-duplication'
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from './primitives/dialog'
15
+
16
+ const DUPLICATE_PRESETS: Array<{
17
+ id: LevelDuplicatePreset
18
+ label: string
19
+ description: string
20
+ }> = [
21
+ {
22
+ id: 'everything',
23
+ label: 'Everything',
24
+ description: 'Structure, materials, furniture, and references.',
25
+ },
26
+ {
27
+ id: 'structure',
28
+ label: 'Structure only',
29
+ description: 'Walls, slabs, roofs, stairs, windows, and doors without finishes.',
30
+ },
31
+ {
32
+ id: 'structure-materials',
33
+ label: 'Structure + materials',
34
+ description: 'Structure with the current material and finish assignments.',
35
+ },
36
+ {
37
+ id: 'structure-furniture',
38
+ label: 'Structure + furniture',
39
+ description: 'Structure, finishes, and placed items, without guide references.',
40
+ },
41
+ ]
42
+
43
+ function getLevelLabel(level: LevelNode | null) {
44
+ if (!level) return 'this level'
45
+ return level.name || `Level ${level.level}`
46
+ }
47
+
48
+ export function LevelDuplicateDialog({
49
+ open,
50
+ level,
51
+ onConfirm,
52
+ onOpenChange,
53
+ }: {
54
+ open: boolean
55
+ level: LevelNode | null
56
+ onConfirm: (preset: LevelDuplicatePreset) => void
57
+ onOpenChange: (open: boolean) => void
58
+ }) {
59
+ const [preset, setPreset] = useState<LevelDuplicatePreset>('everything')
60
+
61
+ useEffect(() => {
62
+ if (open) {
63
+ setPreset('everything')
64
+ }
65
+ }, [open])
66
+
67
+ return (
68
+ <Dialog onOpenChange={onOpenChange} open={open}>
69
+ <DialogContent className="sm:max-w-md" showCloseButton={false}>
70
+ <DialogHeader>
71
+ <DialogTitle>Duplicate Level</DialogTitle>
72
+ <DialogDescription>
73
+ Choose what to copy from {getLevelLabel(level)}.
74
+ </DialogDescription>
75
+ </DialogHeader>
76
+
77
+ <div className="grid gap-2">
78
+ {DUPLICATE_PRESETS.map((option) => (
79
+ <button
80
+ className={cn(
81
+ 'cursor-pointer rounded-xl border px-3 py-3 text-left transition-colors',
82
+ preset === option.id
83
+ ? 'border-primary bg-primary/10 text-foreground'
84
+ : 'border-border bg-background hover:bg-accent/40',
85
+ )}
86
+ key={option.id}
87
+ onClick={() => setPreset(option.id)}
88
+ type="button"
89
+ >
90
+ <div className="font-medium text-sm">{option.label}</div>
91
+ <div className="mt-1 text-muted-foreground text-xs">{option.description}</div>
92
+ </button>
93
+ ))}
94
+ </div>
95
+
96
+ <DialogFooter>
97
+ <button
98
+ className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
99
+ onClick={() => onOpenChange(false)}
100
+ type="button"
101
+ >
102
+ Cancel
103
+ </button>
104
+ <button
105
+ className="cursor-pointer rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-opacity hover:opacity-90"
106
+ onClick={() => onConfirm(preset)}
107
+ type="button"
108
+ >
109
+ Duplicate
110
+ </button>
111
+ </DialogFooter>
112
+ </DialogContent>
113
+ </Dialog>
114
+ )
115
+ }
@@ -1,28 +1,27 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
3
+ import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Edit, Plus, Trash2 } from 'lucide-react'
5
+ import { Edit, Move, Plus, Trash2 } from 'lucide-react'
6
6
  import { useCallback, useEffect } from 'react'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
7
8
  import useEditor from '../../../store/use-editor'
8
- import { ActionButton } from '../controls/action-button'
9
- import { MaterialPicker } from '../controls/material-picker'
9
+ import { ActionButton, ActionGroup } from '../controls/action-button'
10
10
  import { PanelSection } from '../controls/panel-section'
11
11
  import { SliderControl } from '../controls/slider-control'
12
12
  import { PanelWrapper } from './panel-wrapper'
13
13
 
14
14
  export function CeilingPanel() {
15
- const selectedIds = useViewer((s) => s.selection.selectedIds)
15
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
16
16
  const setSelection = useViewer((s) => s.setSelection)
17
- const nodes = useScene((s) => s.nodes)
18
17
  const updateNode = useScene((s) => s.updateNode)
19
18
  const editingHole = useEditor((s) => s.editingHole)
20
19
  const setEditingHole = useEditor((s) => s.setEditingHole)
20
+ const setMovingNode = useEditor((s) => s.setMovingNode)
21
21
 
22
- const selectedId = selectedIds[0]
23
- const node = selectedId
24
- ? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
25
- : undefined
22
+ const node = useScene((s) =>
23
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
24
+ )
26
25
 
27
26
  const handleUpdate = useCallback(
28
27
  (updates: Partial<CeilingNode>) => {
@@ -32,13 +31,6 @@ export function CeilingPanel() {
32
31
  [selectedId, updateNode],
33
32
  )
34
33
 
35
- const handleMaterialChange = useCallback(
36
- (material: MaterialSchema) => {
37
- handleUpdate({ material })
38
- },
39
- [handleUpdate],
40
- )
41
-
42
34
  const handleClose = useCallback(() => {
43
35
  setSelection({ selectedIds: [] })
44
36
  setEditingHole(null)
@@ -77,7 +69,13 @@ export function CeilingPanel() {
77
69
  [cx - holeSize, cz + holeSize],
78
70
  ]
79
71
  const currentHoles = node?.holes || []
80
- handleUpdate({ holes: [...currentHoles, newHole] })
72
+ const currentMetadata = currentHoles.map(
73
+ (_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
74
+ )
75
+ handleUpdate({
76
+ holes: [...currentHoles, newHole],
77
+ holeMetadata: [...currentMetadata, { source: 'manual' }],
78
+ })
81
79
  setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
82
80
  }, [node, selectedId, handleUpdate, setEditingHole])
83
81
 
@@ -93,16 +91,28 @@ export function CeilingPanel() {
93
91
  (index: number) => {
94
92
  if (!selectedId) return
95
93
  const currentHoles = node?.holes || []
94
+ if (node?.holeMetadata?.[index]?.source === 'stair') return
96
95
  const newHoles = currentHoles.filter((_, i) => i !== index)
97
- handleUpdate({ holes: newHoles })
96
+ const currentMetadata = currentHoles.map(
97
+ (_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
98
+ )
99
+ const newMetadata = currentMetadata.filter((_, i) => i !== index)
100
+ handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
98
101
  if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
99
102
  setEditingHole(null)
100
103
  }
101
104
  },
102
- [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
105
+ [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
103
106
  )
104
107
 
105
- if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
108
+ const handleMove = useCallback(() => {
109
+ if (!node) return
110
+ sfxEmitter.emit('sfx:item-pick')
111
+ setMovingNode(node)
112
+ setSelection({ selectedIds: [] })
113
+ }, [node, setMovingNode, setSelection])
114
+
115
+ if (!(node && node.type === 'ceiling' && selectedId)) return null
106
116
 
107
117
  const calculateArea = (polygon: Array<[number, number]>): number => {
108
118
  if (polygon.length < 3) return 0
@@ -110,8 +120,11 @@ export function CeilingPanel() {
110
120
  const n = polygon.length
111
121
  for (let i = 0; i < n; i++) {
112
122
  const j = (i + 1) % n
113
- area += polygon[i]?.[0] * polygon[j]?.[1]
114
- area -= polygon[j]?.[0] * polygon[i]?.[1]
123
+ const current = polygon[i]
124
+ const next = polygon[j]
125
+ if (!(current && next)) continue
126
+ area += current[0] * next[1]
127
+ area -= next[0] * current[1]
115
128
  }
116
129
  return Math.abs(area) / 2
117
130
  }
@@ -158,6 +171,8 @@ export function CeilingPanel() {
158
171
  const holeArea = calculateArea(hole)
159
172
  const isEditing =
160
173
  editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
174
+ const source = node.holeMetadata?.[index]?.source ?? 'manual'
175
+ const isAutoHole = source === 'stair'
161
176
  return (
162
177
  <div
163
178
  className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -174,7 +189,8 @@ export function CeilingPanel() {
174
189
  Hole {index + 1} {isEditing && '(Editing)'}
175
190
  </p>
176
191
  <p className="text-[10px] text-muted-foreground">
177
- {holeArea.toFixed(2)} m² · {hole.length} pts
192
+ {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
193
+ {isAutoHole ? 'Auto stair cutout' : 'Manual'}
178
194
  </p>
179
195
  </div>
180
196
  <div className="flex items-center gap-1">
@@ -184,6 +200,10 @@ export function CeilingPanel() {
184
200
  label="Done"
185
201
  onClick={() => setEditingHole(null)}
186
202
  />
203
+ ) : isAutoHole ? (
204
+ <div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
205
+ Auto
206
+ </div>
187
207
  ) : (
188
208
  <>
189
209
  <button
@@ -222,9 +242,9 @@ export function CeilingPanel() {
222
242
  </div>
223
243
  </PanelSection>
224
244
 
225
- <PanelSection title="Material">
226
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
227
- </PanelSection>
245
+ <ActionGroup>
246
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
247
+ </ActionGroup>
228
248
  </PanelWrapper>
229
249
  )
230
250
  }