@pascal-app/editor 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -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,207 +13,116 @@ 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
- export function ItemCatalog({ category }: { category: CatalogCategory }) {
16
+ export function ItemCatalog({
17
+ category,
18
+ items: itemsOverride,
19
+ activePlacementTag = null,
20
+ activeFunctionalTag = null,
21
+ search = '',
22
+ overrideItems,
23
+ leadingTile,
24
+ emptyState,
25
+ }: {
26
+ category: CatalogCategory
27
+ items?: AssetInput[]
28
+ activePlacementTag?: string | null
29
+ activeFunctionalTag?: string | null
30
+ search?: string
31
+ /** When set, bypasses all filtering and displays these items directly (used for server search results) */
32
+ overrideItems?: AssetInput[]
33
+ /** Rendered as the first grid cell, always visible when there are items. */
34
+ leadingTile?: React.ReactNode
35
+ /** Rendered when there are no items to show. Replaces the empty grid. */
36
+ emptyState?: React.ReactNode
37
+ }) {
19
38
  const selectedItem = useEditor((state) => state.selectedItem)
20
39
  const setSelectedItem = useEditor((state) => state.setSelectedItem)
21
- const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
22
- const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
23
-
24
- const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
25
-
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
+ const setMode = useEditor((state) => state.setMode)
41
+ const setTool = useEditor((state) => state.setTool)
40
42
 
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
43
+ const sourceItems = itemsOverride ?? CATALOG_ITEMS
44
+ // Server-provided results bypass all local filtering; otherwise filter by category/search/tags
45
+ const filteredItems =
46
+ overrideItems ??
47
+ (() => {
48
+ const categoryItems = search
49
+ ? sourceItems
50
+ : sourceItems.filter((item) => item.category === category)
51
+ return categoryItems.filter((item) => {
52
+ const tags = item.tags ?? []
53
+ if (activePlacementTag && !tags.includes(activePlacementTag)) return false
54
+ if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
55
+ if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
56
+ return true
57
+ })
58
+ })()
49
59
 
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
- })
60
+ const categoryItems = filteredItems
56
61
 
57
62
  // Auto-select first item if current selection is not in the filtered list
58
63
  useEffect(() => {
59
- const isCurrentItemInCategory = filteredItems.some((item) => item.src === selectedItem?.src)
60
- if (!isCurrentItemInCategory && filteredItems.length > 0) {
61
- setSelectedItem(filteredItems[0] as AssetInput)
64
+ const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
65
+ if (!isCurrentItemInCategory && categoryItems.length > 0) {
66
+ setSelectedItem(categoryItems[0] as AssetInput)
62
67
  }
63
- }, [filteredItems, selectedItem?.src, setSelectedItem])
68
+ }, [categoryItems, selectedItem?.src, setSelectedItem])
64
69
 
65
- // Get attachment icon based on attachTo type
66
70
  const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
67
- if (attachTo === 'wall' || attachTo === 'wall-side') {
68
- return '/icons/wall.png'
69
- }
70
- if (attachTo === 'ceiling') {
71
- return '/icons/ceiling.png'
72
- }
71
+ if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
72
+ if (attachTo === 'ceiling') return '/icons/ceiling.png'
73
73
  return null
74
74
  }
75
75
 
76
- 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">
84
- <button
85
- 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',
90
- )}
91
- onClick={() => setActivePlacementTag(null)}
92
- type="button"
93
- >
94
- All
95
- </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
- )}
76
+ if (filteredItems.length === 0 && emptyState) {
77
+ return <>{emptyState}</>
78
+ }
172
79
 
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) || ''}
80
+ return (
81
+ <div
82
+ className="grid gap-2"
83
+ style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))' }}
84
+ >
85
+ {leadingTile}
86
+ {filteredItems.map((item, index) => {
87
+ const isSelected = selectedItem?.src === item?.src
88
+ const attachmentIcon = getAttachmentIcon(item?.attachTo)
89
+ return (
90
+ <button
91
+ className={cn(
92
+ 'group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent',
93
+ isSelected && 'bg-sidebar-accent ring-2 ring-primary-foreground',
94
+ )}
95
+ key={index}
96
+ onClick={() => {
97
+ setSelectedItem(item)
98
+ setTool('item')
99
+ setMode('build')
100
+ }}
101
+ type="button"
102
+ >
103
+ <div className="relative aspect-square w-full overflow-hidden rounded-lg">
104
+ <img
105
+ alt={item.name}
106
+ className="h-full w-full object-cover"
107
+ loading="eager"
108
+ src={resolveCdnUrl(item.thumbnail) || ''}
109
+ />
110
+ {attachmentIcon && (
111
+ <div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
112
+ <img
113
+ alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
114
+ className="h-4 w-4"
115
+ src={attachmentIcon}
196
116
  />
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>
117
+ </div>
118
+ )}
119
+ </div>
120
+ <span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
121
+ {item.name}
122
+ </span>
123
+ </button>
124
+ )
125
+ })}
217
126
  </div>
218
127
  )
219
128
  }
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import type { LevelNode } from '@pascal-app/core'
4
+ import { useEffect, useState } from 'react'
5
+ import type { LevelDuplicatePreset } from '../../lib/level-duplication'
6
+ import { cn } from '../../lib/utils'
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>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
73
+ </DialogHeader>
74
+
75
+ <div className="grid gap-2">
76
+ {DUPLICATE_PRESETS.map((option) => (
77
+ <button
78
+ className={cn(
79
+ 'cursor-pointer rounded-xl border px-3 py-3 text-left transition-colors',
80
+ preset === option.id
81
+ ? 'border-primary bg-primary/10 text-foreground'
82
+ : 'border-border bg-background hover:bg-accent/40',
83
+ )}
84
+ key={option.id}
85
+ onClick={() => setPreset(option.id)}
86
+ type="button"
87
+ >
88
+ <div className="font-medium text-sm">{option.label}</div>
89
+ <div className="mt-1 text-muted-foreground text-xs">{option.description}</div>
90
+ </button>
91
+ ))}
92
+ </div>
93
+
94
+ <DialogFooter>
95
+ <button
96
+ className="cursor-pointer rounded-md px-4 py-2 text-muted-foreground text-sm transition-colors hover:bg-accent"
97
+ onClick={() => onOpenChange(false)}
98
+ type="button"
99
+ >
100
+ Cancel
101
+ </button>
102
+ <button
103
+ className="cursor-pointer rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-opacity hover:opacity-90"
104
+ onClick={() => onConfirm(preset)}
105
+ type="button"
106
+ >
107
+ Duplicate
108
+ </button>
109
+ </DialogFooter>
110
+ </DialogContent>
111
+ </Dialog>
112
+ )
113
+ }
@@ -1,13 +1,12 @@
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
5
  import { Edit, Move, Plus, Trash2 } from 'lucide-react'
6
6
  import { useCallback, useEffect } from 'react'
7
7
  import { sfxEmitter } from '../../../lib/sfx-bus'
8
8
  import useEditor from '../../../store/use-editor'
9
9
  import { ActionButton, ActionGroup } from '../controls/action-button'
10
- import { MaterialPicker } from '../controls/material-picker'
11
10
  import { PanelSection } from '../controls/panel-section'
12
11
  import { SliderControl } from '../controls/slider-control'
13
12
  import { PanelWrapper } from './panel-wrapper'
@@ -32,20 +31,6 @@ export function CeilingPanel() {
32
31
  [selectedId, updateNode],
33
32
  )
34
33
 
35
- const handleMaterialChange = useCallback(
36
- (material: MaterialSchema) => {
37
- handleUpdate({ material, materialPreset: undefined })
38
- },
39
- [handleUpdate],
40
- )
41
-
42
- const handleMaterialPresetChange = useCallback(
43
- (materialPreset: string) => {
44
- handleUpdate({ materialPreset, material: undefined })
45
- },
46
- [handleUpdate],
47
- )
48
-
49
34
  const handleClose = useCallback(() => {
50
35
  setSelection({ selectedIds: [] })
51
36
  setEditingHole(null)
@@ -135,9 +120,8 @@ export function CeilingPanel() {
135
120
  const n = polygon.length
136
121
  for (let i = 0; i < n; i++) {
137
122
  const j = (i + 1) % n
138
- const current = polygon[i]
139
- const next = polygon[j]
140
- if (!(current && next)) continue
123
+ const current = polygon[i]!
124
+ const next = polygon[j]!
141
125
  area += current[0] * next[1]
142
126
  area -= next[0] * current[1]
143
127
  }
@@ -257,15 +241,6 @@ export function CeilingPanel() {
257
241
  </div>
258
242
  </PanelSection>
259
243
 
260
- <PanelSection title="Material">
261
- <MaterialPicker
262
- nodeType="ceiling"
263
- onChange={handleMaterialChange}
264
- onSelectMaterialPreset={handleMaterialPresetChange}
265
- selectedMaterialPreset={node.materialPreset}
266
- value={node.material}
267
- />
268
- </PanelSection>
269
244
  <ActionGroup>
270
245
  <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
271
246
  </ActionGroup>