@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.
- package/package.json +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5829 -12005
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +178 -149
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +433 -156
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -538
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +10 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +320 -37
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- 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 “{search}”
|
|
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
|
-
|
|
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
|
|
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={() =>
|
|
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=
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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',
|