@pascal-app/editor 0.8.0 → 0.9.1

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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,263 @@
1
+ 'use client'
2
+
3
+ import type { AssetInput } from '@pascal-app/core'
4
+ import { Root as TooltipRoot } from '@radix-ui/react-tooltip'
5
+ import NextImage from 'next/image'
6
+ import { useMemo, useState } from 'react'
7
+ import { triggerSFX } from '../../../../../lib/sfx-bus'
8
+ import { cn } from '../../../../../lib/utils'
9
+ import { ItemCatalog } from '../../../item-catalog/item-catalog'
10
+ import {
11
+ TooltipContent,
12
+ TooltipProvider,
13
+ TooltipTrigger,
14
+ } from '../../../../../components/ui/primitives/tooltip'
15
+
16
+ /** A function-axis taxonomy node, assembled into a tree by the embedder. */
17
+ export type FunctionTreeNode = {
18
+ slug: string
19
+ name: string
20
+ iconUrl?: string | null
21
+ children: FunctionTreeNode[]
22
+ }
23
+
24
+ const SOURCE_CHIPS: Array<{ id: NonNullable<AssetInput['source']>; label: string }> = [
25
+ { id: 'library', label: 'Library' },
26
+ { id: 'community', label: 'Community' },
27
+ { id: 'mine', label: 'Mine' },
28
+ ]
29
+
30
+ /** Every slug at or below `node`, so a non-leaf selection matches descendants. */
31
+ function descendantSlugs(node: FunctionTreeNode): Set<string> {
32
+ const out = new Set<string>()
33
+ const walk = (n: FunctionTreeNode) => {
34
+ out.add(n.slug)
35
+ for (const child of n.children) walk(child)
36
+ }
37
+ walk(node)
38
+ return out
39
+ }
40
+
41
+ function itemFunctionSlugs(item: AssetInput): string[] {
42
+ if (item.functionTags && item.functionTags.length > 0) return item.functionTags
43
+ return item.category ? [item.category] : []
44
+ }
45
+
46
+ /**
47
+ * DB-driven hierarchical Items browse. Roots render as the category tab bar;
48
+ * a selected root with children exposes those children as a secondary chip
49
+ * row. Selecting any node shows items tagged with that node or any descendant.
50
+ * Library / Community / Mine narrows by source on top of the tree selection.
51
+ */
52
+ export function FunctionTreePanel({
53
+ functionTree,
54
+ items,
55
+ onSearchChange,
56
+ searchResults,
57
+ leadingTile,
58
+ emptyState,
59
+ }: {
60
+ functionTree: FunctionTreeNode[]
61
+ items?: AssetInput[]
62
+ onSearchChange?: (query: string) => void
63
+ searchResults?: AssetInput[] | null
64
+ leadingTile?: React.ReactNode
65
+ emptyState?: React.ReactNode
66
+ }) {
67
+ const [activeRootSlug, setActiveRootSlug] = useState<string | null>(
68
+ functionTree[0]?.slug ?? null,
69
+ )
70
+ const [activeChildSlug, setActiveChildSlug] = useState<string | null>(null)
71
+ const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
72
+ const [search, setSearch] = useState('')
73
+
74
+ const isServerSearch = onSearchChange !== undefined
75
+ const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
76
+
77
+ const activeRoot = functionTree.find((n) => n.slug === activeRootSlug) ?? functionTree[0]
78
+ const activeNode =
79
+ (activeChildSlug && activeRoot?.children.find((c) => c.slug === activeChildSlug)) || activeRoot
80
+
81
+ const matchesSource = (item: AssetInput) => {
82
+ if (!activeSource) return true
83
+ const itemSource = item.source ?? 'library'
84
+ if (activeSource === 'mine') return itemSource === 'mine'
85
+ if (activeSource === 'library') return itemSource === 'library'
86
+ if (activeSource === 'community') {
87
+ if (itemSource === 'community') return true
88
+ if (itemSource === 'mine') return !item.isDraft
89
+ return false
90
+ }
91
+ return true
92
+ }
93
+
94
+ const treeItems = useMemo(() => {
95
+ const base = items ?? []
96
+ if (!activeNode) return base.filter(matchesSource)
97
+ const slugs = descendantSlugs(activeNode)
98
+ return base.filter(
99
+ (item) => matchesSource(item) && itemFunctionSlugs(item).some((s) => slugs.has(s)),
100
+ )
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ }, [items, activeNode, activeSource])
103
+
104
+ const searchItems = useMemo(() => {
105
+ if (!(isServerSearch && search && searchResults)) return null
106
+ return activeSource ? searchResults.filter(matchesSource) : searchResults
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [isServerSearch, search, searchResults, activeSource])
109
+
110
+ function selectRoot(slug: string) {
111
+ setActiveRootSlug(slug)
112
+ setActiveChildSlug(null)
113
+ setSearch('')
114
+ onSearchChange?.('')
115
+ }
116
+
117
+ return (
118
+ <div className="flex h-full flex-col">
119
+ {/* Root nodes as a category grid — icon when available, otherwise a
120
+ two-letter abbreviation, with the full name in a hover tooltip.
121
+ Mirrors the Build tab's tile grid so the two panels read the same. */}
122
+ <TooltipProvider delayDuration={0} disableHoverableContent>
123
+ <div className="grid shrink-0 grid-cols-5 gap-1.5 border-border/70 border-b p-2">
124
+ {functionTree.map((root) => {
125
+ const isActive = activeRoot?.slug === root.slug
126
+ return (
127
+ <TooltipRoot key={root.slug}>
128
+ <TooltipTrigger asChild>
129
+ <button
130
+ className={cn(
131
+ 'relative flex aspect-square items-center justify-center rounded-xl transition-all duration-200',
132
+ isActive
133
+ ? 'bg-primary/10 ring-1 ring-primary/50'
134
+ : 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
135
+ )}
136
+ onClick={() => {
137
+ triggerSFX('sfx:menu-click')
138
+ selectRoot(root.slug)
139
+ }}
140
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
141
+ type="button"
142
+ >
143
+ {root.iconUrl ? (
144
+ <NextImage
145
+ alt={root.name}
146
+ className="size-7 object-contain"
147
+ height={28}
148
+ src={root.iconUrl}
149
+ width={28}
150
+ />
151
+ ) : (
152
+ <span className="font-semibold text-muted-foreground text-xs uppercase">
153
+ {root.name.slice(0, 2)}
154
+ </span>
155
+ )}
156
+ </button>
157
+ </TooltipTrigger>
158
+ <TooltipContent className="icon-grid-tooltip pointer-events-none" side="top">
159
+ {root.name}
160
+ </TooltipContent>
161
+ </TooltipRoot>
162
+ )
163
+ })}
164
+ </div>
165
+ </TooltipProvider>
166
+
167
+ {/* Search + source filter */}
168
+ <div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
169
+ <div className="flex items-center gap-1.5">
170
+ <input
171
+ className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
172
+ onChange={(e) => {
173
+ setSearch(e.target.value)
174
+ onSearchChange?.(e.target.value)
175
+ }}
176
+ placeholder="Search..."
177
+ type="text"
178
+ value={search}
179
+ />
180
+ <div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
181
+ {SOURCE_CHIPS.map((chip) => {
182
+ const isActive = activeSource === chip.id
183
+ return (
184
+ <button
185
+ className={cn(
186
+ 'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
187
+ isActive
188
+ ? 'bg-background text-foreground shadow-sm'
189
+ : 'text-muted-foreground hover:text-foreground',
190
+ )}
191
+ key={chip.id}
192
+ onClick={() => setActiveSource(isActive ? null : chip.id)}
193
+ type="button"
194
+ >
195
+ {chip.label}
196
+ </button>
197
+ )
198
+ })}
199
+ </div>
200
+ </div>
201
+
202
+ {/* Child nodes of the active root as a secondary chip row */}
203
+ {!search && activeRoot && activeRoot.children.length > 0 && (
204
+ <div className="flex flex-wrap gap-1">
205
+ <button
206
+ className={cn(
207
+ 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
208
+ activeChildSlug === null
209
+ ? 'bg-violet-500 text-white'
210
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
211
+ )}
212
+ onClick={() => setActiveChildSlug(null)}
213
+ type="button"
214
+ >
215
+ All
216
+ </button>
217
+ {activeRoot.children.map((child) => {
218
+ const isActive = activeChildSlug === child.slug
219
+ return (
220
+ <button
221
+ className={cn(
222
+ 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs capitalize transition-colors',
223
+ isActive
224
+ ? 'bg-violet-500 text-white'
225
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
226
+ )}
227
+ key={child.slug}
228
+ onClick={() => setActiveChildSlug(isActive ? null : child.slug)}
229
+ type="button"
230
+ >
231
+ {child.name}
232
+ </button>
233
+ )
234
+ })}
235
+ </div>
236
+ )}
237
+ </div>
238
+
239
+ {/* Item grid */}
240
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
241
+ {isSearchPending ? (
242
+ <div className="flex h-full items-center justify-center">
243
+ <div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
244
+ </div>
245
+ ) : isServerSearch && search && searchResults?.length === 0 ? (
246
+ (emptyState ?? (
247
+ <div className="flex h-full items-center justify-center text-muted-foreground text-xs">
248
+ No results for &ldquo;{search}&rdquo;
249
+ </div>
250
+ ))
251
+ ) : (
252
+ <ItemCatalog
253
+ category={'furnish' as never}
254
+ emptyState={emptyState}
255
+ key={activeNode?.slug ?? 'all'}
256
+ leadingTile={leadingTile}
257
+ overrideItems={isServerSearch && search ? (searchItems ?? undefined) : treeItems}
258
+ />
259
+ )}
260
+ </div>
261
+ </div>
262
+ )
263
+ }
@@ -3,12 +3,14 @@
3
3
  import type { AssetInput } from '@pascal-app/core'
4
4
  import NextImage from 'next/image'
5
5
  import { useEffect, useState } from 'react'
6
+ import { triggerSFX } from '../../../../../lib/sfx-bus'
6
7
  import { cn } from '../../../../../lib/utils'
7
8
  import type { CatalogCategory } from '../../../../../store/use-editor'
8
9
  import useEditor from '../../../../../store/use-editor'
9
10
  import { furnishTools } from '../../../action-menu/furnish-tools'
10
11
  import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
11
12
  import { ItemCatalog } from '../../../item-catalog/item-catalog'
13
+ import { type FunctionTreeNode, FunctionTreePanel } from './function-tree-panel'
12
14
 
13
15
  const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
14
16
 
@@ -18,6 +20,9 @@ export function ItemsPanel({
18
20
  searchResults,
19
21
  leadingTile,
20
22
  emptyState,
23
+ functionTree,
24
+ showSourceFilter = true,
25
+ showTagFilters = true,
21
26
  }: {
22
27
  items?: AssetInput[]
23
28
  /** Called when the search query changes (community edition uses this for server-side search) */
@@ -34,6 +39,64 @@ export function ItemsPanel({
34
39
  * or no search results). Replaces the default "No results" message.
35
40
  */
36
41
  emptyState?: React.ReactNode
42
+ /**
43
+ * DB-driven function taxonomy. When provided, the panel renders the
44
+ * hierarchical tree browse instead of the legacy hardcoded category tabs.
45
+ */
46
+ functionTree?: FunctionTreeNode[]
47
+ /**
48
+ * Library/Community/Mine source chips. The open-source editor has no
49
+ * uploaded items (only the built-in catalog), so it hides these.
50
+ */
51
+ showSourceFilter?: boolean
52
+ /**
53
+ * Placement/functional tag filter chips under the search row. The
54
+ * open-source editor hides these to keep the panel to plain categories.
55
+ */
56
+ showTagFilters?: boolean
57
+ }) {
58
+ // When the embedder supplies a function taxonomy, the hierarchical browse
59
+ // replaces the legacy `furnishTools` category tabs entirely.
60
+ if (functionTree && functionTree.length > 0) {
61
+ return (
62
+ <FunctionTreePanel
63
+ emptyState={emptyState}
64
+ functionTree={functionTree}
65
+ items={items}
66
+ leadingTile={leadingTile}
67
+ onSearchChange={onSearchChange}
68
+ searchResults={searchResults}
69
+ />
70
+ )
71
+ }
72
+
73
+ return <LegacyItemsPanel
74
+ emptyState={emptyState}
75
+ items={items}
76
+ leadingTile={leadingTile}
77
+ onSearchChange={onSearchChange}
78
+ searchResults={searchResults}
79
+ showSourceFilter={showSourceFilter}
80
+ showTagFilters={showTagFilters}
81
+ />
82
+ }
83
+
84
+ function LegacyItemsPanel({
85
+ items,
86
+ onSearchChange,
87
+ searchResults,
88
+ leadingTile,
89
+ emptyState,
90
+ showSourceFilter = true,
91
+ showTagFilters = true,
92
+ }: {
93
+ items?: AssetInput[]
94
+ onSearchChange?: (query: string) => void
95
+ searchResults?: AssetInput[] | null
96
+ leadingTile?: React.ReactNode
97
+ emptyState?: React.ReactNode
98
+ showSourceFilter?: boolean
99
+ showTagFilters?: boolean
37
100
  }) {
38
101
  const mode = useEditor((s) => s.mode)
39
102
  const catalogCategory = useEditor((s) => s.catalogCategory)
@@ -45,8 +108,11 @@ export function ItemsPanel({
45
108
  const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
46
109
  // Library / Community / Mine. Default to Library so first-time users see
47
110
  // the curated catalog rather than every uploaded item; clicking the chip
48
- // again clears the filter (`null` = show everything).
49
- const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
111
+ // again clears the filter (`null` = show everything). With the chips hidden
112
+ // there is nothing to filter by, so start unfiltered.
113
+ const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>(
114
+ showSourceFilter ? 'library' : null,
115
+ )
50
116
  const [search, setSearch] = useState('')
51
117
  const isServerSearch = onSearchChange !== undefined
52
118
  // True when server search is active but results haven't come back yet
@@ -108,7 +174,7 @@ export function ItemsPanel({
108
174
  const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
109
175
  const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
110
176
  const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
111
- const hasFilters = allTags.length > 1
177
+ const hasFilters = showTagFilters && allTags.length > 1
112
178
 
113
179
  const placementCount = (tag: string | null) =>
114
180
  categoryItems.filter((item) => {
@@ -129,7 +195,7 @@ export function ItemsPanel({
129
195
  return (
130
196
  <div className="flex h-full flex-col">
131
197
  {/* Category tabs */}
132
- <div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
198
+ <div className="flex shrink-0 flex-wrap gap-1 border-border/70 border-b p-2">
133
199
  {furnishTools.map((cat) => {
134
200
  const isActive = activeCategory.catalogCategory === cat.catalogCategory
135
201
  return (
@@ -141,7 +207,11 @@ export function ItemsPanel({
141
207
  : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
142
208
  )}
143
209
  key={cat.catalogCategory}
144
- onClick={() => selectCategory(cat.catalogCategory)}
210
+ onClick={() => {
211
+ triggerSFX('sfx:menu-click')
212
+ selectCategory(cat.catalogCategory)
213
+ }}
214
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
145
215
  type="button"
146
216
  >
147
217
  <NextImage
@@ -161,9 +231,13 @@ export function ItemsPanel({
161
231
  <div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
162
232
  <div className="flex items-center gap-1.5">
163
233
  {/* Search and source filter take 50/50 of the row. `min-w-0` on
164
- both sides lets each half shrink to fit when the panel narrows. */}
234
+ both sides lets each half shrink to fit when the panel narrows.
235
+ With the source chips hidden, search spans the full row. */}
165
236
  <input
166
- className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
237
+ className={cn(
238
+ 'min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none',
239
+ showSourceFilter ? 'w-1/2' : 'w-full',
240
+ )}
167
241
  onChange={(e) => {
168
242
  setSearch(e.target.value)
169
243
  onSearchChange?.(e.target.value)
@@ -172,7 +246,7 @@ export function ItemsPanel({
172
246
  type="text"
173
247
  value={search}
174
248
  />
175
- {sourceChips.length > 0 && (
249
+ {showSourceFilter && sourceChips.length > 0 && (
176
250
  <div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
177
251
  {sourceChips.map((chip) => {
178
252
  const isActive = activeSource === chip.id
@@ -1,4 +1,4 @@
1
- import { emitter, useScene } from '@pascal-app/core'
1
+ import { emitter, useScene, validateBuildJson } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { TreeView, VisualJson } from '@visual-json/react'
4
4
  import { Camera, Download, Save, Trash2, Upload } from 'lucide-react'
@@ -21,6 +21,7 @@ import { Switch } from './../../../../../components/ui/primitives/switch'
21
21
  import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
22
22
  import { AudioSettingsDialog } from './audio-settings-dialog'
23
23
  import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
24
+ import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
24
25
 
25
26
  type SceneNode = Record<string, unknown> & {
26
27
  id?: unknown
@@ -183,8 +184,10 @@ export function SettingsPanel({
183
184
  const resetSelection = useViewer((state) => state.resetSelection)
184
185
  const exportScene = useViewer((state) => state.exportScene)
185
186
  const showGrid = useViewer((state) => state.showGrid)
187
+ const shadows = useViewer((state) => state.shadows)
186
188
  const setPhase = useEditor((state) => state.setPhase)
187
189
  const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
190
+ const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
188
191
  const sceneGraphValue = useMemo(
189
192
  () => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
190
193
  [nodes, rootNodeIds],
@@ -221,16 +224,37 @@ export function SettingsPanel({
221
224
 
222
225
  const reader = new FileReader()
223
226
  reader.onload = (event) => {
227
+ const text = event.target?.result as string
228
+ let parsed: unknown
224
229
  try {
225
- const data = JSON.parse(event.target?.result as string)
226
- if (data.nodes && data.rootNodeIds) {
227
- setScene(data.nodes, data.rootNodeIds)
228
- resetSelection()
229
- setPhase('site')
230
- }
231
- } catch (err) {
232
- console.error('Failed to load build:', err)
230
+ parsed = JSON.parse(text)
231
+ } catch {
232
+ setPendingImport({
233
+ fileName: file.name,
234
+ fileSizeBytes: file.size,
235
+ result: {
236
+ ok: false,
237
+ parsed: null,
238
+ stats: { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 },
239
+ errors: [
240
+ {
241
+ severity: 'error',
242
+ code: 'invalid_json',
243
+ message: 'File could not be parsed as JSON.',
244
+ },
245
+ ],
246
+ warnings: [],
247
+ schemaIssues: [],
248
+ schemaIssueCount: 0,
249
+ },
250
+ })
251
+ return
233
252
  }
253
+ setPendingImport({
254
+ fileName: file.name,
255
+ fileSizeBytes: file.size,
256
+ result: validateBuildJson(parsed),
257
+ })
234
258
  }
235
259
  reader.readAsText(file)
236
260
 
@@ -238,6 +262,16 @@ export function SettingsPanel({
238
262
  e.target.value = ''
239
263
  }
240
264
 
265
+ const handleConfirmImport = (parsed: { nodes: Record<string, unknown>; rootNodeIds: string[] }) => {
266
+ setScene(
267
+ parsed.nodes as Parameters<typeof setScene>[0],
268
+ parsed.rootNodeIds as Parameters<typeof setScene>[1],
269
+ )
270
+ resetSelection()
271
+ setPhase('site')
272
+ setPendingImport(null)
273
+ }
274
+
241
275
  const handleResetToDefault = () => {
242
276
  clearScene()
243
277
  resetSelection()
@@ -307,6 +341,16 @@ export function SettingsPanel({
307
341
  onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
308
342
  />
309
343
  </div>
344
+ <div className="flex items-center justify-between">
345
+ <div>
346
+ <div className="font-medium text-sm">Shadows</div>
347
+ <div className="text-muted-foreground text-xs">Cast shadows from lights</div>
348
+ </div>
349
+ <Switch
350
+ checked={shadows}
351
+ onCheckedChange={(checked) => useViewer.getState().setShadows(checked)}
352
+ />
353
+ </div>
310
354
  </div>
311
355
  )}
312
356
 
@@ -380,6 +424,12 @@ export function SettingsPanel({
380
424
  ref={fileInputRef}
381
425
  type="file"
382
426
  />
427
+
428
+ <LoadBuildDialog
429
+ onCancel={() => setPendingImport(null)}
430
+ onConfirm={handleConfirmImport}
431
+ pending={pendingImport}
432
+ />
383
433
  </div>
384
434
 
385
435
  {/* Audio Section */}
@@ -37,7 +37,6 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
37
37
  { keys: ['1'], action: 'Switch to Site phase' },
38
38
  { keys: ['2'], action: 'Switch to Structure phase' },
39
39
  { keys: ['3'], action: 'Switch to Furnish phase' },
40
- { keys: ['S'], action: 'Switch to Structure layer' },
41
40
  { keys: ['F'], action: 'Switch to Furnish layer' },
42
41
  { keys: ['Z'], action: 'Switch to Zones layer' },
43
42
  {
@@ -56,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
56
55
  shortcuts: [
57
56
  { keys: ['V'], action: 'Switch to Select mode' },
58
57
  { keys: ['B'], action: 'Switch to Build mode' },
58
+ { keys: ['X'], action: 'Switch to Delete mode' },
59
59
  {
60
60
  keys: ['Esc'],
61
61
  action: 'Cancel the active tool and return to Select mode',
@@ -100,6 +100,11 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
100
100
  {
101
101
  title: 'Camera',
102
102
  shortcuts: [
103
+ {
104
+ keys: ['W', 'A', 'S', 'D'],
105
+ action: 'Pan camera',
106
+ note: 'Moves in screen space, similar to dragging the camera view.',
107
+ },
103
108
  {
104
109
  keys: ['Middle click'],
105
110
  action: 'Pan camera',