@pascal-app/editor 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -1,32 +1,56 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type AnyNode,
|
|
6
|
+
type AnyNodeId,
|
|
7
|
+
type FenceNode,
|
|
8
|
+
getClampedWallCurveOffset,
|
|
9
|
+
getMaxWallCurveOffset,
|
|
10
|
+
getWallCurveLength,
|
|
11
|
+
type MaterialSchema,
|
|
12
|
+
normalizeWallCurveOffset,
|
|
13
|
+
useScene,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
|
|
4
16
|
import { useViewer } from '@pascal-app/viewer'
|
|
17
|
+
import { Move, Spline } from 'lucide-react'
|
|
5
18
|
import { useCallback } from 'react'
|
|
19
|
+
|
|
20
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
21
|
+
import useEditor from '../../../store/use-editor'
|
|
22
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
23
|
+
import { MaterialPicker } from '../controls/material-picker'
|
|
6
24
|
import { PanelSection } from '../controls/panel-section'
|
|
7
25
|
import { SegmentedControl } from '../controls/segmented-control'
|
|
8
26
|
import { SliderControl } from '../controls/slider-control'
|
|
9
27
|
import { PanelWrapper } from './panel-wrapper'
|
|
10
28
|
|
|
11
|
-
|
|
29
|
+
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
|
|
30
|
+
type FenceBaseStyleValue = 'grounded' | 'floating'
|
|
31
|
+
|
|
32
|
+
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
|
|
12
33
|
{ label: 'Slat', value: 'slat' },
|
|
13
34
|
{ label: 'Rail', value: 'rail' },
|
|
14
35
|
{ label: 'Privacy', value: 'privacy' },
|
|
15
36
|
]
|
|
16
37
|
|
|
17
|
-
const FENCE_BASE_STYLE_OPTIONS: { label: string; value:
|
|
38
|
+
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
|
|
18
39
|
{ label: 'Grounded', value: 'grounded' },
|
|
19
40
|
{ label: 'Floating', value: 'floating' },
|
|
20
41
|
]
|
|
21
42
|
|
|
22
43
|
export function FencePanel() {
|
|
23
|
-
const
|
|
44
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
45
|
+
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
|
24
46
|
const setSelection = useViewer((s) => s.setSelection)
|
|
25
|
-
const nodes = useScene((s) => s.nodes)
|
|
26
47
|
const updateNode = useScene((s) => s.updateNode)
|
|
48
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
49
|
+
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
|
27
50
|
|
|
28
|
-
const
|
|
29
|
-
|
|
51
|
+
const node = useScene((s) =>
|
|
52
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
|
|
53
|
+
)
|
|
30
54
|
|
|
31
55
|
const handleUpdate = useCallback(
|
|
32
56
|
(updates: Partial<FenceNode>) => {
|
|
@@ -62,14 +86,23 @@ export function FencePanel() {
|
|
|
62
86
|
setSelection({ selectedIds: [] })
|
|
63
87
|
}, [setSelection])
|
|
64
88
|
|
|
65
|
-
if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
|
|
66
89
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
|
94
|
+
|
|
95
|
+
const length = getWallCurveLength(node)
|
|
96
|
+
const curveOffset = getClampedWallCurveOffset(node)
|
|
97
|
+
const maxCurveOffset = getMaxWallCurveOffset(node)
|
|
70
98
|
|
|
71
99
|
return (
|
|
72
|
-
<PanelWrapper
|
|
100
|
+
<PanelWrapper
|
|
101
|
+
icon="/icons/build.png"
|
|
102
|
+
onClose={handleClose}
|
|
103
|
+
title={node.name || 'Fence'}
|
|
104
|
+
width={300}
|
|
105
|
+
>
|
|
73
106
|
<PanelSection title="Style">
|
|
74
107
|
<SegmentedControl
|
|
75
108
|
onChange={(value) => handleUpdate({ style: value })}
|
|
@@ -95,6 +128,16 @@ export function FencePanel() {
|
|
|
95
128
|
unit="m"
|
|
96
129
|
value={length}
|
|
97
130
|
/>
|
|
131
|
+
<SliderControl
|
|
132
|
+
label="Curve"
|
|
133
|
+
max={Math.max(0.01, maxCurveOffset)}
|
|
134
|
+
min={-Math.max(0.01, maxCurveOffset)}
|
|
135
|
+
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
|
|
136
|
+
precision={2}
|
|
137
|
+
step={0.1}
|
|
138
|
+
unit="m"
|
|
139
|
+
value={Math.round(curveOffset * 100) / 100}
|
|
140
|
+
/>
|
|
98
141
|
<SliderControl
|
|
99
142
|
label="Height"
|
|
100
143
|
max={4}
|
|
@@ -14,15 +14,15 @@ import { CollectionsPopover } from './collections/collections-popover'
|
|
|
14
14
|
import { PanelWrapper } from './panel-wrapper'
|
|
15
15
|
|
|
16
16
|
export function ItemPanel() {
|
|
17
|
-
const
|
|
17
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
18
18
|
const setSelection = useViewer((s) => s.setSelection)
|
|
19
|
-
const nodes = useScene((s) => s.nodes)
|
|
20
19
|
const updateNode = useScene((s) => s.updateNode)
|
|
21
20
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
22
21
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
23
22
|
|
|
24
|
-
const
|
|
25
|
-
|
|
23
|
+
const node = useScene((s) =>
|
|
24
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined,
|
|
25
|
+
)
|
|
26
26
|
|
|
27
27
|
const [uniformScale, setUniformScale] = useState(true)
|
|
28
28
|
|
|
@@ -75,7 +75,7 @@ export function ItemPanel() {
|
|
|
75
75
|
setSelection({ selectedIds: [] })
|
|
76
76
|
}, [selectedId, deleteNode, setSelection])
|
|
77
77
|
|
|
78
|
-
if (!node
|
|
78
|
+
if (!(node && node.type === 'item' && selectedId)) return null
|
|
79
79
|
|
|
80
80
|
return (
|
|
81
81
|
<PanelWrapper
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { X } from 'lucide-react'
|
|
4
|
+
import { AnimatePresence, motion } from 'motion/react'
|
|
5
|
+
import Image from 'next/image'
|
|
6
|
+
import { type ReactNode, useEffect, useState } from 'react'
|
|
7
|
+
import { createPortal } from 'react-dom'
|
|
8
|
+
import useEditor from '../../../store/use-editor'
|
|
9
|
+
|
|
10
|
+
interface MobilePanelSheetProps {
|
|
11
|
+
open: boolean
|
|
12
|
+
onClose: () => void
|
|
13
|
+
icon?: string
|
|
14
|
+
title: string
|
|
15
|
+
children: ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HEIGHT_VH = 50
|
|
19
|
+
const DRAG_CLOSE_THRESHOLD_PX = 120
|
|
20
|
+
|
|
21
|
+
export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) {
|
|
22
|
+
const [mounted, setMounted] = useState(false)
|
|
23
|
+
const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setMounted(true)
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
// Publish the sheet's pixel height to the shared store so the mobile layout
|
|
30
|
+
// can shrink the viewer container and preview edits live. 0 means closed.
|
|
31
|
+
// Tracks visualViewport so the value follows the on-screen keyboard on iOS.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!open) {
|
|
34
|
+
setMobilePanelSheetHeight(0)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const compute = () => {
|
|
38
|
+
const vh = window.visualViewport?.height ?? window.innerHeight
|
|
39
|
+
setMobilePanelSheetHeight(Math.round((vh * HEIGHT_VH) / 100))
|
|
40
|
+
}
|
|
41
|
+
compute()
|
|
42
|
+
const vv = window.visualViewport
|
|
43
|
+
vv?.addEventListener('resize', compute)
|
|
44
|
+
window.addEventListener('resize', compute)
|
|
45
|
+
return () => {
|
|
46
|
+
vv?.removeEventListener('resize', compute)
|
|
47
|
+
window.removeEventListener('resize', compute)
|
|
48
|
+
setMobilePanelSheetHeight(0)
|
|
49
|
+
}
|
|
50
|
+
}, [open, setMobilePanelSheetHeight])
|
|
51
|
+
|
|
52
|
+
if (!mounted) return null
|
|
53
|
+
|
|
54
|
+
return createPortal(
|
|
55
|
+
<AnimatePresence>
|
|
56
|
+
{open && (
|
|
57
|
+
<motion.div
|
|
58
|
+
animate={{ y: 0 }}
|
|
59
|
+
className="dark fixed right-0 bottom-0 left-0 z-[60] flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgba(0,0,0,0.24)]"
|
|
60
|
+
drag="y"
|
|
61
|
+
dragConstraints={{ top: 0, bottom: 0 }}
|
|
62
|
+
dragElastic={{ top: 0, bottom: 0.4 }}
|
|
63
|
+
exit={{ y: '100%' }}
|
|
64
|
+
initial={{ y: '100%' }}
|
|
65
|
+
onDragEnd={(_, info) => {
|
|
66
|
+
if (info.offset.y > DRAG_CLOSE_THRESHOLD_PX) onClose()
|
|
67
|
+
}}
|
|
68
|
+
style={{ height: `${HEIGHT_VH}dvh` }}
|
|
69
|
+
transition={{ type: 'spring', stiffness: 320, damping: 32, mass: 0.8 }}
|
|
70
|
+
>
|
|
71
|
+
<div className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing">
|
|
72
|
+
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="flex shrink-0 items-center justify-between border-border/50 border-b px-3 pt-1 pb-3">
|
|
76
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
77
|
+
{icon && (
|
|
78
|
+
<Image
|
|
79
|
+
alt=""
|
|
80
|
+
className="shrink-0 object-contain"
|
|
81
|
+
height={18}
|
|
82
|
+
src={icon}
|
|
83
|
+
width={18}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
|
|
87
|
+
{title}
|
|
88
|
+
</h2>
|
|
89
|
+
</div>
|
|
90
|
+
<button
|
|
91
|
+
aria-label="Close"
|
|
92
|
+
className="flex h-8 w-8 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
|
93
|
+
onClick={onClose}
|
|
94
|
+
type="button"
|
|
95
|
+
>
|
|
96
|
+
<X className="h-4 w-4" />
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
101
|
+
{children}
|
|
102
|
+
</div>
|
|
103
|
+
</motion.div>
|
|
104
|
+
)}
|
|
105
|
+
</AnimatePresence>,
|
|
106
|
+
document.body,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
4
|
+
import { Copy, Move, SlidersHorizontal, Trash2 } from 'lucide-react'
|
|
5
|
+
import Image from 'next/image'
|
|
6
|
+
import type { MouseEventHandler } from 'react'
|
|
7
|
+
import { cn } from '../../../lib/utils'
|
|
8
|
+
import { getNodeDisplay } from './node-display'
|
|
9
|
+
|
|
10
|
+
interface MobileSelectionBarProps {
|
|
11
|
+
node: AnyNode
|
|
12
|
+
onMove: () => void
|
|
13
|
+
onDuplicate: () => void
|
|
14
|
+
onDelete: () => void
|
|
15
|
+
onEdit: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ACTION_BTN =
|
|
19
|
+
'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/8 hover:text-foreground'
|
|
20
|
+
|
|
21
|
+
export function MobileSelectionBar({
|
|
22
|
+
node,
|
|
23
|
+
onMove,
|
|
24
|
+
onDuplicate,
|
|
25
|
+
onDelete,
|
|
26
|
+
onEdit,
|
|
27
|
+
}: MobileSelectionBarProps) {
|
|
28
|
+
const { icon, label } = getNodeDisplay(node)
|
|
29
|
+
|
|
30
|
+
const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
|
|
34
|
+
<button
|
|
35
|
+
aria-label={`Edit ${label}`}
|
|
36
|
+
className={cn(
|
|
37
|
+
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
|
|
38
|
+
)}
|
|
39
|
+
onClick={onEdit}
|
|
40
|
+
type="button"
|
|
41
|
+
>
|
|
42
|
+
<Image
|
|
43
|
+
alt=""
|
|
44
|
+
className="shrink-0 rounded object-contain"
|
|
45
|
+
height={20}
|
|
46
|
+
src={icon}
|
|
47
|
+
width={20}
|
|
48
|
+
/>
|
|
49
|
+
<span className="truncate font-medium text-foreground text-sm">{label}</span>
|
|
50
|
+
</button>
|
|
51
|
+
|
|
52
|
+
<div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
|
|
53
|
+
<button
|
|
54
|
+
aria-label="Move"
|
|
55
|
+
className={ACTION_BTN}
|
|
56
|
+
onClick={(e) => {
|
|
57
|
+
stop(e)
|
|
58
|
+
onMove()
|
|
59
|
+
}}
|
|
60
|
+
type="button"
|
|
61
|
+
>
|
|
62
|
+
<Move className="h-4 w-4" />
|
|
63
|
+
</button>
|
|
64
|
+
<button
|
|
65
|
+
aria-label="Duplicate"
|
|
66
|
+
className={ACTION_BTN}
|
|
67
|
+
onClick={(e) => {
|
|
68
|
+
stop(e)
|
|
69
|
+
onDuplicate()
|
|
70
|
+
}}
|
|
71
|
+
type="button"
|
|
72
|
+
>
|
|
73
|
+
<Copy className="h-4 w-4" />
|
|
74
|
+
</button>
|
|
75
|
+
<button
|
|
76
|
+
aria-label="Delete"
|
|
77
|
+
className={cn(ACTION_BTN, 'hover:bg-red-500/15 hover:text-red-400')}
|
|
78
|
+
onClick={(e) => {
|
|
79
|
+
stop(e)
|
|
80
|
+
onDelete()
|
|
81
|
+
}}
|
|
82
|
+
type="button"
|
|
83
|
+
>
|
|
84
|
+
<Trash2 className="h-4 w-4" />
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
aria-label="Edit properties"
|
|
88
|
+
className={ACTION_BTN}
|
|
89
|
+
onClick={(e) => {
|
|
90
|
+
stop(e)
|
|
91
|
+
onEdit()
|
|
92
|
+
}}
|
|
93
|
+
type="button"
|
|
94
|
+
>
|
|
95
|
+
<SlidersHorizontal className="h-4 w-4" />
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type NodeDisplay = {
|
|
4
|
+
icon: string
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
|
9
|
+
item: { icon: '/icons/furniture.png', label: 'Item' },
|
|
10
|
+
wall: { icon: '/icons/wall.png', label: 'Wall' },
|
|
11
|
+
door: { icon: '/icons/door.png', label: 'Door' },
|
|
12
|
+
window: { icon: '/icons/window.png', label: 'Window' },
|
|
13
|
+
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
|
14
|
+
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
|
15
|
+
column: { icon: '/icons/column.png', label: 'Column' },
|
|
16
|
+
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
|
17
|
+
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
|
18
|
+
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
|
19
|
+
stair: { icon: '/icons/stair.png', label: 'Stair' },
|
|
20
|
+
'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' },
|
|
21
|
+
scan: { icon: '/icons/mesh.png', label: '3D Scan' },
|
|
22
|
+
guide: { icon: '/icons/floorplan.png', label: 'Guide image' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
|
|
26
|
+
if (!node) return { icon: '/icons/select.png', label: 'Selection' }
|
|
27
|
+
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type }
|
|
28
|
+
// Item nodes carry an asset with its own thumbnail/name
|
|
29
|
+
if (node.type === 'item') {
|
|
30
|
+
return {
|
|
31
|
+
icon: node.asset?.thumbnail || fallback.icon,
|
|
32
|
+
label: node.name || node.asset?.name || fallback.label,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
icon: fallback.icon,
|
|
37
|
+
label: node.name || fallback.label,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import useEditor from '../../../store/use-editor'
|
|
4
|
+
import { SliderControl } from '../controls/slider-control'
|
|
5
|
+
import { Input } from '../primitives/input'
|
|
6
|
+
import { PanelSection } from '../controls/panel-section'
|
|
7
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
8
|
+
|
|
9
|
+
function buildDefaultCustomMaterial() {
|
|
10
|
+
return {
|
|
11
|
+
preset: 'custom' as const,
|
|
12
|
+
properties: {
|
|
13
|
+
color: '#ffffff',
|
|
14
|
+
roughness: 0.5,
|
|
15
|
+
metalness: 0,
|
|
16
|
+
opacity: 1,
|
|
17
|
+
transparent: false,
|
|
18
|
+
side: 'front' as const,
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function PaintPanel() {
|
|
24
|
+
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
|
25
|
+
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
26
|
+
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
|
27
|
+
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
|
|
28
|
+
|
|
29
|
+
const customMaterial =
|
|
30
|
+
activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
|
|
31
|
+
? activePaintMaterial.material
|
|
32
|
+
: null
|
|
33
|
+
|
|
34
|
+
if (!customMaterial) return null
|
|
35
|
+
|
|
36
|
+
const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
|
|
37
|
+
|
|
38
|
+
const updateCustomMaterial = (
|
|
39
|
+
updates: Partial<typeof currentProps>,
|
|
40
|
+
nextTransparent = currentProps.transparent,
|
|
41
|
+
) => {
|
|
42
|
+
setActivePaintMaterial({
|
|
43
|
+
material: {
|
|
44
|
+
preset: 'custom',
|
|
45
|
+
properties: {
|
|
46
|
+
...currentProps,
|
|
47
|
+
...updates,
|
|
48
|
+
transparent: nextTransparent,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<PanelWrapper
|
|
57
|
+
onClose={() => setPaintPanelOpen(false)}
|
|
58
|
+
title="Material"
|
|
59
|
+
width={320}
|
|
60
|
+
>
|
|
61
|
+
<PanelSection title="Custom Material">
|
|
62
|
+
<div className="space-y-3">
|
|
63
|
+
<div className="space-y-2">
|
|
64
|
+
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
|
65
|
+
Color
|
|
66
|
+
</label>
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
<input
|
|
69
|
+
className="h-10 w-14 cursor-pointer rounded-md border border-input bg-transparent"
|
|
70
|
+
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
|
|
71
|
+
type="color"
|
|
72
|
+
value={currentProps.color}
|
|
73
|
+
/>
|
|
74
|
+
<Input
|
|
75
|
+
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
|
|
76
|
+
value={currentProps.color}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="space-y-1">
|
|
82
|
+
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
|
83
|
+
Surface
|
|
84
|
+
</label>
|
|
85
|
+
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
|
|
86
|
+
<SliderControl
|
|
87
|
+
label="Roughness"
|
|
88
|
+
max={1}
|
|
89
|
+
min={0}
|
|
90
|
+
onChange={(roughness) => updateCustomMaterial({ roughness })}
|
|
91
|
+
precision={2}
|
|
92
|
+
step={0.01}
|
|
93
|
+
value={currentProps.roughness}
|
|
94
|
+
/>
|
|
95
|
+
<SliderControl
|
|
96
|
+
label="Metalness"
|
|
97
|
+
max={1}
|
|
98
|
+
min={0}
|
|
99
|
+
onChange={(metalness) => updateCustomMaterial({ metalness })}
|
|
100
|
+
precision={2}
|
|
101
|
+
step={0.01}
|
|
102
|
+
value={currentProps.metalness}
|
|
103
|
+
/>
|
|
104
|
+
<SliderControl
|
|
105
|
+
label="Opacity"
|
|
106
|
+
max={1}
|
|
107
|
+
min={0}
|
|
108
|
+
onChange={(opacity) =>
|
|
109
|
+
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
|
|
110
|
+
}
|
|
111
|
+
precision={2}
|
|
112
|
+
step={0.01}
|
|
113
|
+
value={currentProps.opacity}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="space-y-2">
|
|
119
|
+
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
|
120
|
+
Side
|
|
121
|
+
</label>
|
|
122
|
+
<select
|
|
123
|
+
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
|
|
124
|
+
onChange={(e) =>
|
|
125
|
+
updateCustomMaterial({ side: e.target.value as 'front' | 'back' | 'double' })
|
|
126
|
+
}
|
|
127
|
+
value={currentProps.side}
|
|
128
|
+
>
|
|
129
|
+
<option value="front">Front</option>
|
|
130
|
+
<option value="back">Back</option>
|
|
131
|
+
<option value="double">Double</option>
|
|
132
|
+
</select>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</PanelSection>
|
|
136
|
+
</PanelWrapper>
|
|
137
|
+
)
|
|
138
|
+
}
|