@pascal-app/editor 0.7.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.
- package/package.json +6 -6
- package/src/components/editor/custom-camera-controls.tsx +2 -1
- package/src/components/editor/editor-layout-v2.tsx +4 -3
- package/src/components/editor/first-person/build-collider-world.ts +5 -7
- package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
- package/src/components/editor/first-person-controls.tsx +11 -11
- package/src/components/editor/floating-action-menu.tsx +0 -0
- package/src/components/editor/floorplan-panel.tsx +44 -37
- package/src/components/editor/index.tsx +68 -53
- package/src/components/editor/selection-manager.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +18 -61
- package/src/components/editor/use-floorplan-background-placement.ts +3 -3
- package/src/components/editor/wall-measurement-label.tsx +0 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-tool.tsx +2 -2
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
- package/src/components/tools/fence/move-fence-tool.tsx +13 -9
- package/src/components/tools/item/move-tool.tsx +3 -6
- package/src/components/tools/item/placement-math.ts +2 -4
- package/src/components/tools/item/placement-strategies.ts +11 -10
- package/src/components/tools/item/use-draft-node.ts +0 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
- package/src/components/tools/roof/move-roof-tool.tsx +7 -2
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/segment-angle.ts +1 -1
- package/src/components/tools/tool-manager.tsx +12 -12
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +0 -0
- package/src/components/tools/wall/wall-tool.tsx +3 -3
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +7 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +35 -86
- package/src/components/ui/action-menu/view-toggles.tsx +19 -31
- package/src/components/ui/command-palette/editor-commands.tsx +6 -4
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +8 -5
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
- package/src/components/ui/level-duplicate-dialog.tsx +3 -5
- package/src/components/ui/panels/ceiling-panel.tsx +2 -3
- package/src/components/ui/panels/column-panel.tsx +62 -18
- package/src/components/ui/panels/door-panel.tsx +272 -265
- package/src/components/ui/panels/fence-panel.tsx +0 -5
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +3 -32
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/panels/roof-panel.tsx +52 -2
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
- package/src/components/ui/panels/slab-panel.tsx +0 -0
- package/src/components/ui/panels/spawn-panel.tsx +10 -4
- package/src/components/ui/panels/stair-panel.tsx +66 -14
- package/src/components/ui/panels/wall-panel.tsx +97 -1
- package/src/components/ui/panels/window-panel.tsx +13 -5
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +10 -0
- package/src/index.tsx +8 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +1 -1
- package/src/lib/material-paint.ts +1 -1
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +2 -2
- package/src/store/use-editor.tsx +27 -59
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -436
|
@@ -4,28 +4,60 @@ import {
|
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
getClampedWallCurveOffset,
|
|
7
|
+
getEffectiveWallSurfaceMaterial,
|
|
7
8
|
getMaxWallCurveOffset,
|
|
8
9
|
getWallCurveLength,
|
|
10
|
+
getWallSurfaceMaterialSignature,
|
|
11
|
+
type MaterialSchema,
|
|
9
12
|
normalizeWallCurveOffset,
|
|
10
13
|
useScene,
|
|
11
14
|
type WallNode,
|
|
15
|
+
type WallSurfaceSide,
|
|
12
16
|
} from '@pascal-app/core'
|
|
13
17
|
import { useViewer } from '@pascal-app/viewer'
|
|
14
18
|
import { Move, Spline } from 'lucide-react'
|
|
15
|
-
import { useCallback } from 'react'
|
|
19
|
+
import { useCallback, useMemo } from 'react'
|
|
16
20
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
21
|
import useEditor from '../../../store/use-editor'
|
|
18
22
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
23
|
+
import { MaterialPicker } from '../controls/material-picker'
|
|
19
24
|
import { PanelSection } from '../controls/panel-section'
|
|
20
25
|
import { SliderControl } from '../controls/slider-control'
|
|
21
26
|
import { PanelWrapper } from './panel-wrapper'
|
|
22
27
|
|
|
28
|
+
function buildWallSurfaceMaterialPatch(
|
|
29
|
+
node: WallNode,
|
|
30
|
+
targetSide: WallSurfaceSide | null,
|
|
31
|
+
material: MaterialSchema | undefined,
|
|
32
|
+
materialPreset: string | undefined,
|
|
33
|
+
): Partial<WallNode> {
|
|
34
|
+
const nextSurfaceMaterial = { material, materialPreset }
|
|
35
|
+
const nextInterior =
|
|
36
|
+
targetSide === null || targetSide === 'interior'
|
|
37
|
+
? nextSurfaceMaterial
|
|
38
|
+
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
|
39
|
+
const nextExterior =
|
|
40
|
+
targetSide === null || targetSide === 'exterior'
|
|
41
|
+
? nextSurfaceMaterial
|
|
42
|
+
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
interiorMaterial: nextInterior.material,
|
|
46
|
+
interiorMaterialPreset: nextInterior.materialPreset,
|
|
47
|
+
exteriorMaterial: nextExterior.material,
|
|
48
|
+
exteriorMaterialPreset: nextExterior.materialPreset,
|
|
49
|
+
material: undefined,
|
|
50
|
+
materialPreset: undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
export function WallPanel() {
|
|
24
55
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
25
56
|
const setSelection = useViewer((s) => s.setSelection)
|
|
26
57
|
const updateNode = useScene((s) => s.updateNode)
|
|
27
58
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
28
59
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
|
60
|
+
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
29
61
|
|
|
30
62
|
const node = useScene((s) =>
|
|
31
63
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
|
@@ -56,6 +88,35 @@ export function WallPanel() {
|
|
|
56
88
|
[selectedId, updateNode],
|
|
57
89
|
)
|
|
58
90
|
|
|
91
|
+
const effectiveInteriorMaterial = useMemo(
|
|
92
|
+
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
|
|
93
|
+
[node],
|
|
94
|
+
)
|
|
95
|
+
const effectiveExteriorMaterial = useMemo(
|
|
96
|
+
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
|
|
97
|
+
[node],
|
|
98
|
+
)
|
|
99
|
+
const surfaceMaterialsMatch = useMemo(
|
|
100
|
+
() =>
|
|
101
|
+
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
|
|
102
|
+
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
|
|
103
|
+
[effectiveExteriorMaterial, effectiveInteriorMaterial],
|
|
104
|
+
)
|
|
105
|
+
const materialTargetSide =
|
|
106
|
+
selectedMaterialTarget &&
|
|
107
|
+
selectedMaterialTarget.nodeId === node?.id &&
|
|
108
|
+
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
|
109
|
+
? selectedMaterialTarget.role
|
|
110
|
+
: null
|
|
111
|
+
const materialPickerValue =
|
|
112
|
+
materialTargetSide === 'interior'
|
|
113
|
+
? effectiveInteriorMaterial
|
|
114
|
+
: materialTargetSide === 'exterior'
|
|
115
|
+
? effectiveExteriorMaterial
|
|
116
|
+
: surfaceMaterialsMatch
|
|
117
|
+
? effectiveInteriorMaterial
|
|
118
|
+
: {}
|
|
119
|
+
|
|
59
120
|
const handleUpdateLength = useCallback(
|
|
60
121
|
(newLength: number) => {
|
|
61
122
|
if (!node || newLength <= 0) return
|
|
@@ -79,6 +140,24 @@ export function WallPanel() {
|
|
|
79
140
|
[node, handleUpdate],
|
|
80
141
|
)
|
|
81
142
|
|
|
143
|
+
const handleMaterialPresetChange = useCallback(
|
|
144
|
+
(materialPreset: string) => {
|
|
145
|
+
if (!(node && materialTargetSide)) return
|
|
146
|
+
handleUpdate(
|
|
147
|
+
buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
|
|
148
|
+
)
|
|
149
|
+
},
|
|
150
|
+
[handleUpdate, materialTargetSide, node],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const handleCustomMaterialChange = useCallback(
|
|
154
|
+
(material: MaterialSchema) => {
|
|
155
|
+
if (!(node && materialTargetSide)) return
|
|
156
|
+
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
|
|
157
|
+
},
|
|
158
|
+
[handleUpdate, materialTargetSide, node],
|
|
159
|
+
)
|
|
160
|
+
|
|
82
161
|
const handleClose = useCallback(() => {
|
|
83
162
|
setSelection({ selectedIds: [] })
|
|
84
163
|
}, [setSelection])
|
|
@@ -160,6 +239,23 @@ export function WallPanel() {
|
|
|
160
239
|
)}
|
|
161
240
|
</PanelSection>
|
|
162
241
|
|
|
242
|
+
<PanelSection title="Material">
|
|
243
|
+
{materialTargetSide ? null : (
|
|
244
|
+
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
|
245
|
+
Click the wall face you want to edit. Materials now apply to one side at a time.
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
<MaterialPicker
|
|
249
|
+
disabled={!materialTargetSide}
|
|
250
|
+
hideSideControl
|
|
251
|
+
nodeType="wall"
|
|
252
|
+
onChange={handleCustomMaterialChange}
|
|
253
|
+
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
254
|
+
selectedMaterialPreset={materialPickerValue.materialPreset}
|
|
255
|
+
value={materialPickerValue.material}
|
|
256
|
+
/>
|
|
257
|
+
</PanelSection>
|
|
258
|
+
|
|
163
259
|
<PanelSection title="Actions">
|
|
164
260
|
<ActionGroup>
|
|
165
261
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
@@ -12,8 +12,8 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
12
12
|
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
|
13
13
|
import { useCallback, useRef } from 'react'
|
|
14
14
|
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
|
15
|
-
import { cn } from '../../../lib/utils'
|
|
16
15
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
16
|
+
import { cn } from '../../../lib/utils'
|
|
17
17
|
import useEditor from '../../../store/use-editor'
|
|
18
18
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
19
19
|
import { MetricControl } from '../controls/metric-control'
|
|
@@ -129,7 +129,11 @@ export function WindowPanel() {
|
|
|
129
129
|
if (liveNode?.type !== 'window') return
|
|
130
130
|
|
|
131
131
|
if (
|
|
132
|
-
!(
|
|
132
|
+
!(
|
|
133
|
+
previewRef.current &&
|
|
134
|
+
previewRef.current.id === selectedId &&
|
|
135
|
+
previewRef.current.key === key
|
|
136
|
+
)
|
|
133
137
|
) {
|
|
134
138
|
previewRef.current = {
|
|
135
139
|
id: selectedId as AnyNodeId,
|
|
@@ -299,7 +303,8 @@ export function WindowPanel() {
|
|
|
299
303
|
const normRows = node.rowRatios.map((r) => r / rowSum)
|
|
300
304
|
const isOpening = node.openingKind === 'opening'
|
|
301
305
|
const openingShape = node.openingShape ?? 'rectangle'
|
|
302
|
-
const windowShape =
|
|
306
|
+
const windowShape =
|
|
307
|
+
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
|
|
303
308
|
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
|
304
309
|
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
|
305
310
|
const cornerRadius = node.cornerRadius ?? 0.15
|
|
@@ -340,7 +345,10 @@ export function WindowPanel() {
|
|
|
340
345
|
nextUpdates.openingCornerRadii = nextRadii
|
|
341
346
|
}
|
|
342
347
|
} else {
|
|
343
|
-
const nextRadius = Math.min(
|
|
348
|
+
const nextRadius = Math.min(
|
|
349
|
+
Math.max(cornerRadius, 0),
|
|
350
|
+
getMaxSharedWindowRadius(nextWidth, nextHeight),
|
|
351
|
+
)
|
|
344
352
|
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
|
|
345
353
|
nextUpdates.cornerRadius = nextRadius
|
|
346
354
|
}
|
|
@@ -597,7 +605,7 @@ export function WindowPanel() {
|
|
|
597
605
|
/>
|
|
598
606
|
</PanelSection>
|
|
599
607
|
|
|
600
|
-
{!isOpening
|
|
608
|
+
{!(isOpening || rectangleOnlyWindowTypes.has(node.windowType)) && (
|
|
601
609
|
<PanelSection title="Corner Shape">
|
|
602
610
|
<SegmentedControl
|
|
603
611
|
onChange={(value) =>
|
|
@@ -144,7 +144,7 @@ export function NumberInput({
|
|
|
144
144
|
}}
|
|
145
145
|
/>
|
|
146
146
|
<div
|
|
147
|
-
className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-
|
|
147
|
+
className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-elevation-0 transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
|
|
148
148
|
>
|
|
149
149
|
<div
|
|
150
150
|
className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AssetInput } from '@pascal-app/core'
|
|
4
|
+
import NextImage from 'next/image'
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import { cn } from '../../../../../lib/utils'
|
|
7
|
+
import type { CatalogCategory } from '../../../../../store/use-editor'
|
|
8
|
+
import useEditor from '../../../../../store/use-editor'
|
|
9
|
+
import { furnishTools } from '../../../action-menu/furnish-tools'
|
|
10
|
+
import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
|
|
11
|
+
import { ItemCatalog } from '../../../item-catalog/item-catalog'
|
|
12
|
+
|
|
13
|
+
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
|
14
|
+
|
|
15
|
+
export function ItemsPanel({
|
|
16
|
+
items,
|
|
17
|
+
onSearchChange,
|
|
18
|
+
searchResults,
|
|
19
|
+
leadingTile,
|
|
20
|
+
emptyState,
|
|
21
|
+
}: {
|
|
22
|
+
items?: AssetInput[]
|
|
23
|
+
/** Called when the search query changes (community edition uses this for server-side search) */
|
|
24
|
+
onSearchChange?: (query: string) => void
|
|
25
|
+
/** When non-null and search is active, these results bypass local filtering (server search results) */
|
|
26
|
+
searchResults?: AssetInput[] | null
|
|
27
|
+
/**
|
|
28
|
+
* Optional node rendered as the first grid cell, always visible. Used by the
|
|
29
|
+
* community edition to inject a "+ Generate with AI" tile.
|
|
30
|
+
*/
|
|
31
|
+
leadingTile?: React.ReactNode
|
|
32
|
+
/**
|
|
33
|
+
* Optional node rendered when the grid has no items to show (empty category
|
|
34
|
+
* or no search results). Replaces the default "No results" message.
|
|
35
|
+
*/
|
|
36
|
+
emptyState?: React.ReactNode
|
|
37
|
+
}) {
|
|
38
|
+
const mode = useEditor((s) => s.mode)
|
|
39
|
+
const catalogCategory = useEditor((s) => s.catalogCategory)
|
|
40
|
+
const setMode = useEditor((s) => s.setMode)
|
|
41
|
+
const setTool = useEditor((s) => s.setTool)
|
|
42
|
+
const setCatalogCategory = useEditor((s) => s.setCatalogCategory)
|
|
43
|
+
|
|
44
|
+
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
|
|
45
|
+
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
|
46
|
+
// Library / Community / Mine. Default to Library so first-time users see
|
|
47
|
+
// 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')
|
|
50
|
+
const [search, setSearch] = useState('')
|
|
51
|
+
const isServerSearch = onSearchChange !== undefined
|
|
52
|
+
// True when server search is active but results haven't come back yet
|
|
53
|
+
const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
|
|
54
|
+
|
|
55
|
+
// Auto-select the first category when the panel mounts without one
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!(catalogCategory && furnishTools.some((c) => c.catalogCategory === catalogCategory))) {
|
|
58
|
+
setCatalogCategory(furnishTools[0]!.catalogCategory)
|
|
59
|
+
}
|
|
60
|
+
}, [catalogCategory, setCatalogCategory])
|
|
61
|
+
|
|
62
|
+
const activeCategory =
|
|
63
|
+
furnishTools.find((c) => c.catalogCategory === catalogCategory) ?? furnishTools[0]!
|
|
64
|
+
|
|
65
|
+
function selectCategory(categoryId: CatalogCategory) {
|
|
66
|
+
setCatalogCategory(categoryId)
|
|
67
|
+
setTool('item')
|
|
68
|
+
setActivePlacementTag(null)
|
|
69
|
+
setActiveFunctionalTag(null)
|
|
70
|
+
setSearch('')
|
|
71
|
+
if (mode !== 'build') setMode('build')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Compute tags for the current category (for filter chips)
|
|
75
|
+
const baseItems = items ?? CATALOG_ITEMS
|
|
76
|
+
// Apply the Library/Community/Mine filter before any category/tag work.
|
|
77
|
+
// Items that don't carry a source field (e.g. seeded built-in catalog
|
|
78
|
+
// entries from `CATALOG_ITEMS`) fall under "library".
|
|
79
|
+
//
|
|
80
|
+
// Community is broader than just other users' uploads: my own *published*
|
|
81
|
+
// items show up there too so I can preview my catalog the way other users
|
|
82
|
+
// see it. My drafts only appear under Mine.
|
|
83
|
+
const matchesSource = (item: AssetInput) => {
|
|
84
|
+
if (!activeSource) return true
|
|
85
|
+
const itemSource = item.source ?? 'library'
|
|
86
|
+
if (activeSource === 'mine') return itemSource === 'mine'
|
|
87
|
+
if (activeSource === 'library') return itemSource === 'library'
|
|
88
|
+
if (activeSource === 'community') {
|
|
89
|
+
if (itemSource === 'community') return true
|
|
90
|
+
if (itemSource === 'mine') return !item.isDraft
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
const sourceItems = baseItems.filter(matchesSource)
|
|
96
|
+
const categoryItems = sourceItems.filter(
|
|
97
|
+
(item) => item.category === activeCategory.catalogCategory,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// The three source chips are always shown so users can discover the
|
|
101
|
+
// filter even before they own any items. Selecting "Mine" with no
|
|
102
|
+
// matching items falls through to the empty/no-results state.
|
|
103
|
+
const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
|
|
104
|
+
{ id: 'library', label: 'Library' },
|
|
105
|
+
{ id: 'community', label: 'Community' },
|
|
106
|
+
{ id: 'mine', label: 'Mine' },
|
|
107
|
+
]
|
|
108
|
+
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
|
109
|
+
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
|
110
|
+
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
|
|
111
|
+
const hasFilters = allTags.length > 1
|
|
112
|
+
|
|
113
|
+
const placementCount = (tag: string | null) =>
|
|
114
|
+
categoryItems.filter((item) => {
|
|
115
|
+
const tags = item.tags ?? []
|
|
116
|
+
if (tag !== null && !tags.includes(tag)) return false
|
|
117
|
+
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
118
|
+
return true
|
|
119
|
+
}).length
|
|
120
|
+
|
|
121
|
+
const functionalCount = (tag: string) =>
|
|
122
|
+
categoryItems.filter((item) => {
|
|
123
|
+
const tags = item.tags ?? []
|
|
124
|
+
if (!tags.includes(tag)) return false
|
|
125
|
+
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
126
|
+
return true
|
|
127
|
+
}).length
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="flex h-full flex-col">
|
|
131
|
+
{/* Category tabs */}
|
|
132
|
+
<div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
|
|
133
|
+
{furnishTools.map((cat) => {
|
|
134
|
+
const isActive = activeCategory.catalogCategory === cat.catalogCategory
|
|
135
|
+
return (
|
|
136
|
+
<button
|
|
137
|
+
className={cn(
|
|
138
|
+
'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
|
|
139
|
+
isActive
|
|
140
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
141
|
+
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
|
|
142
|
+
)}
|
|
143
|
+
key={cat.catalogCategory}
|
|
144
|
+
onClick={() => selectCategory(cat.catalogCategory)}
|
|
145
|
+
type="button"
|
|
146
|
+
>
|
|
147
|
+
<NextImage
|
|
148
|
+
alt={cat.label}
|
|
149
|
+
className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
|
|
150
|
+
height={28}
|
|
151
|
+
src={cat.iconSrc}
|
|
152
|
+
width={28}
|
|
153
|
+
/>
|
|
154
|
+
<span className="font-medium text-[10px] leading-none">{cat.label}</span>
|
|
155
|
+
</button>
|
|
156
|
+
)
|
|
157
|
+
})}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Search + filters (non-scrollable) */}
|
|
161
|
+
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
|
|
162
|
+
<div className="flex items-center gap-1.5">
|
|
163
|
+
{/* 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. */}
|
|
165
|
+
<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"
|
|
167
|
+
onChange={(e) => {
|
|
168
|
+
setSearch(e.target.value)
|
|
169
|
+
onSearchChange?.(e.target.value)
|
|
170
|
+
}}
|
|
171
|
+
placeholder="Search..."
|
|
172
|
+
type="text"
|
|
173
|
+
value={search}
|
|
174
|
+
/>
|
|
175
|
+
{sourceChips.length > 0 && (
|
|
176
|
+
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
|
|
177
|
+
{sourceChips.map((chip) => {
|
|
178
|
+
const isActive = activeSource === chip.id
|
|
179
|
+
return (
|
|
180
|
+
<button
|
|
181
|
+
className={cn(
|
|
182
|
+
'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
|
|
183
|
+
isActive
|
|
184
|
+
? 'bg-background text-foreground shadow-sm'
|
|
185
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
186
|
+
)}
|
|
187
|
+
key={chip.id}
|
|
188
|
+
onClick={() => setActiveSource(isActive ? null : chip.id)}
|
|
189
|
+
type="button"
|
|
190
|
+
>
|
|
191
|
+
{chip.label}
|
|
192
|
+
</button>
|
|
193
|
+
)
|
|
194
|
+
})}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{hasFilters && !search && !isServerSearch && (
|
|
200
|
+
<div className="flex flex-col gap-1.5">
|
|
201
|
+
{placementTags.length > 0 && (
|
|
202
|
+
<div className="flex flex-wrap gap-1">
|
|
203
|
+
<button
|
|
204
|
+
className={cn(
|
|
205
|
+
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
|
|
206
|
+
activePlacementTag === null
|
|
207
|
+
? 'bg-blue-500 text-white'
|
|
208
|
+
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
209
|
+
)}
|
|
210
|
+
onClick={() => setActivePlacementTag(null)}
|
|
211
|
+
type="button"
|
|
212
|
+
>
|
|
213
|
+
All
|
|
214
|
+
</button>
|
|
215
|
+
{placementTags.map((tag) => {
|
|
216
|
+
const count = placementCount(tag)
|
|
217
|
+
const isActive = activePlacementTag === tag
|
|
218
|
+
const isEmpty = count === 0 && !isActive
|
|
219
|
+
return (
|
|
220
|
+
<button
|
|
221
|
+
className={cn(
|
|
222
|
+
'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',
|
|
223
|
+
isActive
|
|
224
|
+
? 'bg-blue-500 text-white'
|
|
225
|
+
: isEmpty
|
|
226
|
+
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
227
|
+
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
228
|
+
)}
|
|
229
|
+
disabled={isEmpty}
|
|
230
|
+
key={tag}
|
|
231
|
+
onClick={() => setActivePlacementTag(isActive ? null : tag)}
|
|
232
|
+
type="button"
|
|
233
|
+
>
|
|
234
|
+
{tag}
|
|
235
|
+
<span
|
|
236
|
+
className={cn(
|
|
237
|
+
'text-[10px]',
|
|
238
|
+
isActive
|
|
239
|
+
? 'text-blue-200'
|
|
240
|
+
: isEmpty
|
|
241
|
+
? 'text-zinc-600'
|
|
242
|
+
: 'text-blue-500/70',
|
|
243
|
+
)}
|
|
244
|
+
>
|
|
245
|
+
{count}
|
|
246
|
+
</span>
|
|
247
|
+
</button>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{functionalTags.length > 0 && (
|
|
254
|
+
<div className="flex flex-wrap gap-1">
|
|
255
|
+
{functionalTags.map((tag) => {
|
|
256
|
+
const count = functionalCount(tag)
|
|
257
|
+
const isActive = activeFunctionalTag === tag
|
|
258
|
+
const isEmpty = count === 0 && !isActive
|
|
259
|
+
return (
|
|
260
|
+
<button
|
|
261
|
+
className={cn(
|
|
262
|
+
'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',
|
|
263
|
+
isActive
|
|
264
|
+
? 'bg-violet-500 text-white'
|
|
265
|
+
: isEmpty
|
|
266
|
+
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
267
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
|
268
|
+
)}
|
|
269
|
+
disabled={isEmpty}
|
|
270
|
+
key={tag}
|
|
271
|
+
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
|
|
272
|
+
type="button"
|
|
273
|
+
>
|
|
274
|
+
{tag}
|
|
275
|
+
<span
|
|
276
|
+
className={cn(
|
|
277
|
+
'text-[10px]',
|
|
278
|
+
isActive
|
|
279
|
+
? 'text-violet-200'
|
|
280
|
+
: isEmpty
|
|
281
|
+
? 'text-zinc-600'
|
|
282
|
+
: 'text-zinc-500/70',
|
|
283
|
+
)}
|
|
284
|
+
>
|
|
285
|
+
{count}
|
|
286
|
+
</span>
|
|
287
|
+
</button>
|
|
288
|
+
)
|
|
289
|
+
})}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
{/* Item grid */}
|
|
297
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
298
|
+
{isSearchPending ? (
|
|
299
|
+
<div className="flex h-full items-center justify-center">
|
|
300
|
+
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
|
|
301
|
+
</div>
|
|
302
|
+
) : isServerSearch && search && searchResults?.length === 0 ? (
|
|
303
|
+
(emptyState ?? (
|
|
304
|
+
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
|
|
305
|
+
No results for “{search}”
|
|
306
|
+
</div>
|
|
307
|
+
))
|
|
308
|
+
) : (
|
|
309
|
+
<ItemCatalog
|
|
310
|
+
activeFunctionalTag={isServerSearch ? null : activeFunctionalTag}
|
|
311
|
+
activePlacementTag={isServerSearch ? null : activePlacementTag}
|
|
312
|
+
category={activeCategory.catalogCategory}
|
|
313
|
+
emptyState={emptyState}
|
|
314
|
+
items={activeSource && items ? items.filter(matchesSource) : items}
|
|
315
|
+
key={activeCategory.catalogCategory}
|
|
316
|
+
leadingTile={leadingTile}
|
|
317
|
+
overrideItems={
|
|
318
|
+
isServerSearch && search
|
|
319
|
+
? activeSource && searchResults
|
|
320
|
+
? searchResults.filter(matchesSource)
|
|
321
|
+
: (searchResults ?? undefined)
|
|
322
|
+
: undefined
|
|
323
|
+
}
|
|
324
|
+
search={isServerSearch ? '' : search}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
)
|
|
330
|
+
}
|
|
File without changes
|
|
File without changes
|
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
type BuildingNode,
|
|
5
5
|
emitter,
|
|
6
|
-
GuideNode,
|
|
6
|
+
type GuideNode,
|
|
7
7
|
LevelNode,
|
|
8
|
-
ScanNode,
|
|
8
|
+
type ScanNode,
|
|
9
9
|
type SiteNode,
|
|
10
10
|
useScene,
|
|
11
11
|
type ZoneNode,
|
|
@@ -32,14 +32,12 @@ import {
|
|
|
32
32
|
PopoverContent,
|
|
33
33
|
PopoverTrigger,
|
|
34
34
|
} from './../../../../../components/ui/primitives/popover'
|
|
35
|
-
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
|
36
|
-
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
|
37
|
-
|
|
38
35
|
import {
|
|
39
36
|
buildLevelDuplicateCreateOps,
|
|
40
37
|
type LevelDuplicatePreset,
|
|
41
38
|
} from './../../../../../lib/level-duplication'
|
|
42
|
-
|
|
39
|
+
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
|
40
|
+
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
|
43
41
|
import { cn } from './../../../../../lib/utils'
|
|
44
42
|
import useEditor from './../../../../../store/use-editor'
|
|
45
43
|
import { useUploadStore } from '../../../../../store/use-upload'
|
|
File without changes
|
|
@@ -50,13 +50,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
|
|
|
50
50
|
expanded={false}
|
|
51
51
|
hasChildren={false}
|
|
52
52
|
icon={
|
|
53
|
-
<Image
|
|
54
|
-
alt=""
|
|
55
|
-
className="object-contain"
|
|
56
|
-
height={14}
|
|
57
|
-
src="/icons/site.png"
|
|
58
|
-
width={14}
|
|
59
|
-
/>
|
|
53
|
+
<Image alt="" className="object-contain" height={14} src="/icons/site.png" width={14} />
|
|
60
54
|
}
|
|
61
55
|
isHovered={isHovered}
|
|
62
56
|
isLast={isLast}
|
|
@@ -82,7 +82,9 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|
|
82
82
|
|
|
83
83
|
switch (nodeType) {
|
|
84
84
|
case 'building':
|
|
85
|
-
return
|
|
85
|
+
return (
|
|
86
|
+
<BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
|
87
|
+
)
|
|
86
88
|
case 'ceiling':
|
|
87
89
|
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
88
90
|
case 'column':
|
|
@@ -44,7 +44,9 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|
|
44
44
|
depth={depth}
|
|
45
45
|
expanded={false}
|
|
46
46
|
hasChildren={false}
|
|
47
|
-
icon={
|
|
47
|
+
icon={
|
|
48
|
+
<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />
|
|
49
|
+
}
|
|
48
50
|
isHovered={isHovered}
|
|
49
51
|
isLast={isLast}
|
|
50
52
|
isSelected={isSelected}
|
|
@@ -41,7 +41,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|
|
41
41
|
className={cn(
|
|
42
42
|
'group/row mx-1 mb-0.5 flex h-8 cursor-pointer select-none items-center rounded-lg border px-2 text-sm transition-all duration-200',
|
|
43
43
|
isSelected
|
|
44
|
-
? 'border-neutral-200/60 bg-white text-foreground shadow-
|
|
44
|
+
? 'border-neutral-200/60 bg-white text-foreground shadow-elevation-0 ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
|
|
45
45
|
: 'border-transparent text-muted-foreground hover:border-neutral-200/50 hover:bg-white/40 hover:text-foreground dark:hover:border-border/40 dark:hover:bg-accent/30',
|
|
46
46
|
)}
|
|
47
47
|
onClick={handleClick}
|
|
@@ -17,7 +17,7 @@ const sliderVariants = cva(
|
|
|
17
17
|
[&_[data-slot=slider-track]]:border
|
|
18
18
|
[&_[data-slot=slider-track]]:border-neutral-300
|
|
19
19
|
[&_[data-slot=slider-track]]:bg-white/50
|
|
20
|
-
[&_[data-slot=slider-track]]:shadow-
|
|
20
|
+
[&_[data-slot=slider-track]]:shadow-elevation-0
|
|
21
21
|
[&_[data-slot=slider-track]]:ring-1
|
|
22
22
|
[&_[data-slot=slider-track]]:ring-white
|
|
23
23
|
[&_[data-slot=slider-track]]:ring-inset
|
|
File without changes
|
|
File without changes
|