feat: editor layout redesign v2 + 3D box select (#207)
Major layout redesign introducing v2 editor layout: **New Features** - V2 two-column layout (resizable sidebar + viewer panel with toolbar slots) - 3D box select tool with marquee selection (561 lines pure Three.js geometry) - View mode system (3D/2D/Split) replacing old floorplan toggle - Floating level selector on viewer panel - Viewer toolbar: level mode, wall mode, units, theme, camera, walkthrough, preview - Horizontal tab bar for sidebar panels **Layout Changes** - Fixed 5-button control bar: Select, Box Select, Site Edit, Build, Delete - Simplified view toggles (scans/guides only) - Always-mounted viewers (display:none) to preserve WebGL context - First-person walkthrough forces 3D view, restores on exit - Site edit as permanent control (phase='site' is sole signal) **Technical** - layoutVersion prop: 'v1' (default) or 'v2' - New exports: FloatingLevelSelector, SettingsPanel, SidebarTab, ViewMode, ViewerToolbarLeft/Right - isCollapsed/setIsCollapsed added to sidebar store - isFirstPersonMode restored for walkthrough support 19 files changed, +3230/-1898 Built and verified: bun run build passes (all tasks, 0 errors)
This commit is contained in:
@@ -1,11 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Editor } from '@pascal-app/editor'
|
import {
|
||||||
|
Editor,
|
||||||
|
type SidebarTab,
|
||||||
|
ViewerToolbarLeft,
|
||||||
|
ViewerToolbarRight,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
|
||||||
|
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
|
||||||
|
{
|
||||||
|
id: 'site',
|
||||||
|
label: 'Scene',
|
||||||
|
component: () => null, // Built-in SitePanel handles this
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen">
|
<div className="h-screen w-screen">
|
||||||
<Editor projectId="local-editor" />
|
<Editor
|
||||||
|
layoutVersion="v2"
|
||||||
|
projectId="local-editor"
|
||||||
|
sidebarTabs={SIDEBAR_TABS}
|
||||||
|
viewerToolbarLeft={<ViewerToolbarLeft />}
|
||||||
|
viewerToolbarRight={<ViewerToolbarRight />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@pascal-app/core",
|
"name": "@pascal-app/core",
|
||||||
"version": "0.3.1",
|
"version": "0.3.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
},
|
},
|
||||||
"packages/viewer": {
|
"packages/viewer": {
|
||||||
"name": "@pascal-app/viewer",
|
"name": "@pascal-app/viewer",
|
||||||
"version": "0.3.1",
|
"version": "0.3.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"polygon-clipping": "^0.15.7",
|
"polygon-clipping": "^0.15.7",
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type ReactNode, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import useEditor from '../../store/use-editor'
|
||||||
|
import { useSidebarStore } from '../ui/primitives/sidebar'
|
||||||
|
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
|
||||||
|
|
||||||
|
const SIDEBAR_MIN_WIDTH = 300
|
||||||
|
const SIDEBAR_MAX_WIDTH = 800
|
||||||
|
const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
||||||
|
|
||||||
|
// ── Left column: resizable panel with tab bar ────────────────────────────────
|
||||||
|
|
||||||
|
function LeftColumn({
|
||||||
|
tabs,
|
||||||
|
renderTabContent,
|
||||||
|
}: {
|
||||||
|
tabs: SidebarTab[]
|
||||||
|
renderTabContent: (tabId: string) => ReactNode
|
||||||
|
}) {
|
||||||
|
const width = useSidebarStore((s) => s.width)
|
||||||
|
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||||
|
const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
|
||||||
|
const setWidth = useSidebarStore((s) => s.setWidth)
|
||||||
|
const isDragging = useSidebarStore((s) => s.isDragging)
|
||||||
|
const setIsDragging = useSidebarStore((s) => s.setIsDragging)
|
||||||
|
const activePanel = useEditor((s) => s.activeSidebarPanel)
|
||||||
|
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
|
||||||
|
|
||||||
|
const isResizing = useRef(false)
|
||||||
|
const isExpanding = useRef(false)
|
||||||
|
|
||||||
|
// Ensure active panel is a valid tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabs.length > 0 && !tabs.some((t) => t.id === activePanel)) {
|
||||||
|
setActivePanel(tabs[0]!.id)
|
||||||
|
}
|
||||||
|
}, [tabs, activePanel, setActivePanel])
|
||||||
|
|
||||||
|
const handleResizerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isResizing.current = true
|
||||||
|
setIsDragging(true)
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
},
|
||||||
|
[setIsDragging],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleGrabDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isExpanding.current = true
|
||||||
|
setIsDragging(true)
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
},
|
||||||
|
[setIsDragging],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
|
if (isResizing.current) {
|
||||||
|
const newWidth = e.clientX
|
||||||
|
if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
|
||||||
|
setIsCollapsed(true)
|
||||||
|
} else {
|
||||||
|
setIsCollapsed(false)
|
||||||
|
setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
|
||||||
|
}
|
||||||
|
} else if (isExpanding.current && e.clientX > 60) {
|
||||||
|
setIsCollapsed(false)
|
||||||
|
setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
isResizing.current = false
|
||||||
|
isExpanding.current = false
|
||||||
|
setIsDragging(false)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', handlePointerUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
}
|
||||||
|
}, [setWidth, setIsCollapsed, setIsDragging])
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-full w-2 flex-shrink-0 cursor-col-resize transition-colors hover:bg-primary/20"
|
||||||
|
onPointerDown={handleGrabDown}
|
||||||
|
title="Expand sidebar"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative z-10 flex h-full flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
transition: isDragging ? 'none' : 'width 150ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">{renderTabContent(activePanel)}</div>
|
||||||
|
|
||||||
|
{/* Resize handle + hit area */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center"
|
||||||
|
onPointerDown={handleResizerDown}
|
||||||
|
>
|
||||||
|
<div className="h-8 w-1 rounded-full bg-neutral-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Right column: viewer area with toolbar ───────────────────────────────────
|
||||||
|
|
||||||
|
function RightColumn({
|
||||||
|
toolbarLeft,
|
||||||
|
toolbarRight,
|
||||||
|
children,
|
||||||
|
overlays,
|
||||||
|
}: {
|
||||||
|
toolbarLeft?: ReactNode
|
||||||
|
toolbarRight?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
overlays?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
clipPath: 'inset(0 0 0 0 round 16px 0 0 0)',
|
||||||
|
boxShadow: '-4px -2px 16px rgba(0, 0, 0, 0.08), -1px 0 4px rgba(0, 0, 0, 0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Viewer toolbar */}
|
||||||
|
{(toolbarLeft || toolbarRight) && (
|
||||||
|
<div className="pointer-events-none absolute top-3 right-3 left-3 z-20 flex items-center justify-between gap-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">{toolbarLeft}</div>
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">{toolbarRight}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Canvas area */}
|
||||||
|
<div className="relative flex-1 overflow-hidden">{children}</div>
|
||||||
|
{/* Overlays scoped to the viewer column */}
|
||||||
|
{overlays && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-30"
|
||||||
|
style={{ transform: 'translateZ(0)' }}
|
||||||
|
>
|
||||||
|
{overlays}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main v2 layout ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface EditorLayoutV2Props {
|
||||||
|
navbarSlot?: ReactNode
|
||||||
|
sidebarTabs?: SidebarTab[]
|
||||||
|
renderTabContent: (tabId: string) => ReactNode
|
||||||
|
viewerToolbarLeft?: ReactNode
|
||||||
|
viewerToolbarRight?: ReactNode
|
||||||
|
viewerContent: ReactNode
|
||||||
|
overlays?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorLayoutV2({
|
||||||
|
navbarSlot,
|
||||||
|
sidebarTabs = [],
|
||||||
|
renderTabContent,
|
||||||
|
viewerToolbarLeft,
|
||||||
|
viewerToolbarRight,
|
||||||
|
viewerContent,
|
||||||
|
overlays,
|
||||||
|
}: EditorLayoutV2Props) {
|
||||||
|
return (
|
||||||
|
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
||||||
|
{/* Top navbar */}
|
||||||
|
{navbarSlot}
|
||||||
|
|
||||||
|
{/* Main content: left column + right column */}
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{sidebarTabs.length > 0 && (
|
||||||
|
<LeftColumn renderTabContent={renderTabContent} tabs={sidebarTabs} />
|
||||||
|
)}
|
||||||
|
<RightColumn
|
||||||
|
overlays={overlays}
|
||||||
|
toolbarLeft={viewerToolbarLeft}
|
||||||
|
toolbarRight={viewerToolbarRight}
|
||||||
|
>
|
||||||
|
{viewerContent}
|
||||||
|
</RightColumn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
type ZoneNode as ZoneNodeType,
|
type ZoneNode as ZoneNodeType,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { ChevronDown, Command, X } from 'lucide-react'
|
import { Command } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
@@ -46,15 +46,8 @@ import {
|
|||||||
} from '../tools/wall/wall-drafting'
|
} from '../tools/wall/wall-drafting'
|
||||||
import { furnishTools } from '../ui/action-menu/furnish-tools'
|
import { furnishTools } from '../ui/action-menu/furnish-tools'
|
||||||
import { tools as structureTools } from '../ui/action-menu/structure-tools'
|
import { tools as structureTools } from '../ui/action-menu/structure-tools'
|
||||||
import { SliderControl } from '../ui/controls/slider-control'
|
|
||||||
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '../ui/primitives/dropdown-menu'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
import { NodeActionMenu } from './node-action-menu'
|
||||||
@@ -109,7 +102,7 @@ const FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING = 60
|
|||||||
const FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56
|
const FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56
|
||||||
const FLOORPLAN_ACTION_MENU_OFFSET_Y = 10
|
const FLOORPLAN_ACTION_MENU_OFFSET_Y = 10
|
||||||
const FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y = 1.5
|
const FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y = 1.5
|
||||||
const FLOORPLAN_LEVEL_MENU_CLOSE_DELAY_MS = 120
|
|
||||||
// Match the guide plane footprint used in the 3D renderer so the 2D overlay aligns.
|
// Match the guide plane footprint used in the 3D renderer so the 2D overlay aligns.
|
||||||
const FLOORPLAN_GUIDE_BASE_WIDTH = 10
|
const FLOORPLAN_GUIDE_BASE_WIDTH = 10
|
||||||
const FLOORPLAN_GUIDE_MIN_SCALE = 0.01
|
const FLOORPLAN_GUIDE_MIN_SCALE = 0.01
|
||||||
@@ -173,8 +166,6 @@ type OpeningNode = WindowNode | DoorNode
|
|||||||
|
|
||||||
type WallEndpoint = 'start' | 'end'
|
type WallEndpoint = 'start' | 'end'
|
||||||
|
|
||||||
type FloorplanSelectionTool = 'click' | 'marquee'
|
|
||||||
|
|
||||||
type FloorplanCursorIndicator =
|
type FloorplanCursorIndicator =
|
||||||
| {
|
| {
|
||||||
kind: 'asset'
|
kind: 'asset'
|
||||||
@@ -185,40 +176,6 @@ type FloorplanCursorIndicator =
|
|||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLOORPLAN_QUICK_BUILD_TOOL_IDS = ['wall', 'door', 'window', 'slab', 'zone'] as const
|
|
||||||
|
|
||||||
type FloorplanQuickBuildTool = (typeof FLOORPLAN_QUICK_BUILD_TOOL_IDS)[number]
|
|
||||||
|
|
||||||
const FLOORPLAN_QUICK_BUILD_TOOL_LABELS: Record<FloorplanQuickBuildTool, string> = {
|
|
||||||
wall: 'Wall',
|
|
||||||
door: 'Door',
|
|
||||||
window: 'Window',
|
|
||||||
slab: 'Floor',
|
|
||||||
zone: 'Zone',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FLOORPLAN_QUICK_BUILD_TOOL_FALLBACK_ICONS: Record<FloorplanQuickBuildTool, string> = {
|
|
||||||
wall: '/icons/wall.png',
|
|
||||||
door: '/icons/door.png',
|
|
||||||
window: '/icons/window.png',
|
|
||||||
slab: '/icons/floor.png',
|
|
||||||
zone: '/icons/zone.png',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FLOORPLAN_QUICK_BUILD_TOOLS = FLOORPLAN_QUICK_BUILD_TOOL_IDS.map((id) => {
|
|
||||||
const toolConfig = structureTools.find((entry) => entry.id === id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
iconSrc: toolConfig?.iconSrc ?? FLOORPLAN_QUICK_BUILD_TOOL_FALLBACK_ICONS[id],
|
|
||||||
label: FLOORPLAN_QUICK_BUILD_TOOL_LABELS[id],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function getLevelDisplayLabel(level: LevelNode) {
|
|
||||||
return level.name || `Level ${level.level}`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PersistedPanelLayout = {
|
type PersistedPanelLayout = {
|
||||||
rect: PanelRect
|
rect: PanelRect
|
||||||
viewport: ViewportBounds
|
viewport: ViewportBounds
|
||||||
@@ -2116,6 +2073,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
onSlabSelect,
|
onSlabSelect,
|
||||||
onOpeningDoubleClick,
|
onOpeningDoubleClick,
|
||||||
onOpeningHoverChange,
|
onOpeningHoverChange,
|
||||||
|
onOpeningPointerDown,
|
||||||
onOpeningSelect,
|
onOpeningSelect,
|
||||||
onWallClick,
|
onWallClick,
|
||||||
onWallDoubleClick,
|
onWallDoubleClick,
|
||||||
@@ -2134,6 +2092,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||||
onOpeningDoubleClick: (opening: OpeningNode) => void
|
onOpeningDoubleClick: (opening: OpeningNode) => void
|
||||||
onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void
|
onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void
|
||||||
|
onOpeningPointerDown: (openingId: OpeningNode['id'], event: ReactPointerEvent<SVGElement>) => void
|
||||||
onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||||
hoveredWallId: WallNode['id'] | null
|
hoveredWallId: WallNode['id'] | null
|
||||||
onWallClick: (wall: WallNode, event: ReactMouseEvent<SVGElement>) => void
|
onWallClick: (wall: WallNode, event: ReactMouseEvent<SVGElement>) => void
|
||||||
@@ -2368,6 +2327,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onPointerDown={
|
||||||
|
canSelectGeometry && isSelected
|
||||||
|
? (event) => {
|
||||||
|
if (event.button === 0) {
|
||||||
|
onOpeningPointerDown(opening.id, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onPointerEnter={
|
onPointerEnter={
|
||||||
canSelectGeometry
|
canSelectGeometry
|
||||||
? () => {
|
? () => {
|
||||||
@@ -2499,6 +2467,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onPointerDown={
|
||||||
|
canSelectGeometry && isSelected
|
||||||
|
? (event) => {
|
||||||
|
if (event.button === 0) {
|
||||||
|
onOpeningPointerDown(opening.id, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onPointerEnter={
|
onPointerEnter={
|
||||||
canSelectGeometry
|
canSelectGeometry
|
||||||
? () => {
|
? () => {
|
||||||
@@ -3031,9 +3008,9 @@ export function FloorplanPanel() {
|
|||||||
const gestureScaleRef = useRef(1)
|
const gestureScaleRef = useRef(1)
|
||||||
const panelInteractionRef = useRef<PanelInteractionState | null>(null)
|
const panelInteractionRef = useRef<PanelInteractionState | null>(null)
|
||||||
const panelBoundsRef = useRef<ViewportBounds | null>(null)
|
const panelBoundsRef = useRef<ViewportBounds | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const hasUserAdjustedViewportRef = useRef(false)
|
const hasUserAdjustedViewportRef = useRef(false)
|
||||||
const previousLevelIdRef = useRef<string | null>(null)
|
const previousLevelIdRef = useRef<string | null>(null)
|
||||||
const levelMenuCloseTimeoutRef = useRef<number | null>(null)
|
|
||||||
const levelId = useViewer((state) => state.selection.levelId)
|
const levelId = useViewer((state) => state.selection.levelId)
|
||||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||||
@@ -3046,7 +3023,7 @@ export function FloorplanPanel() {
|
|||||||
const setShowGuides = useViewer((state) => state.setShowGuides)
|
const setShowGuides = useViewer((state) => state.setShowGuides)
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
||||||
const setFloorplanOpen = useEditor((state) => state.setFloorplanOpen)
|
|
||||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||||
const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
|
const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
|
||||||
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
||||||
@@ -3205,8 +3182,8 @@ export function FloorplanPanel() {
|
|||||||
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
|
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
|
||||||
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
|
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
|
||||||
const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
|
const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
|
||||||
const [floorplanSelectionTool, setFloorplanSelectionTool] =
|
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||||
useState<FloorplanSelectionTool>('click')
|
const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
|
||||||
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
|
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
@@ -3222,8 +3199,7 @@ export function FloorplanPanel() {
|
|||||||
width: PANEL_DEFAULT_WIDTH,
|
width: PANEL_DEFAULT_WIDTH,
|
||||||
height: PANEL_DEFAULT_HEIGHT,
|
height: PANEL_DEFAULT_HEIGHT,
|
||||||
})
|
})
|
||||||
const [isLevelMenuOpen, setIsLevelMenuOpen] = useState(false)
|
|
||||||
const [isGuideQuickAccessOpen, setIsGuideQuickAccessOpen] = useState(false)
|
|
||||||
const [isPanelReady, setIsPanelReady] = useState(false)
|
const [isPanelReady, setIsPanelReady] = useState(false)
|
||||||
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })
|
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })
|
||||||
const [viewport, setViewport] = useState<FloorplanViewport | null>(null)
|
const [viewport, setViewport] = useState<FloorplanViewport | null>(null)
|
||||||
@@ -3238,11 +3214,6 @@ export function FloorplanPanel() {
|
|||||||
setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))
|
setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset guide panel when level changes
|
|
||||||
useEffect(() => {
|
|
||||||
setIsGuideQuickAccessOpen(false)
|
|
||||||
}, [levelId])
|
|
||||||
|
|
||||||
const sitePolygonEntry = useMemo(() => {
|
const sitePolygonEntry = useMemo(() => {
|
||||||
const polygonPoints = site?.polygon?.points
|
const polygonPoints = site?.polygon?.points
|
||||||
if (!(site && polygonPoints)) {
|
if (!(site && polygonPoints)) {
|
||||||
@@ -3356,20 +3327,6 @@ export function FloorplanPanel() {
|
|||||||
const activeGuideInteractionMode = guideTransformDraft
|
const activeGuideInteractionMode = guideTransformDraft
|
||||||
? (guideInteractionRef.current?.mode ?? null)
|
? (guideInteractionRef.current?.mode ?? null)
|
||||||
: null
|
: null
|
||||||
const hasGuideImages = levelGuides.length > 0
|
|
||||||
const guideImagesDescription = hasGuideImages
|
|
||||||
? `${levelGuides.length} guide image${levelGuides.length === 1 ? '' : 's'} on this level`
|
|
||||||
: 'No guide images on this level'
|
|
||||||
|
|
||||||
const handleGuideOpacityChange = useCallback(
|
|
||||||
(guideId: GuideNode['id'], opacity: number) => {
|
|
||||||
updateNode(guideId, {
|
|
||||||
opacity: Math.round(clamp(opacity, 0, 100)),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[updateNode],
|
|
||||||
)
|
|
||||||
|
|
||||||
const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])
|
const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])
|
||||||
const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
|
const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
|
||||||
const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])
|
const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])
|
||||||
@@ -3559,7 +3516,7 @@ export function FloorplanPanel() {
|
|||||||
return displayZonePolygons.find(({ zone }) => zone.id === selectedZoneId) ?? null
|
return displayZonePolygons.find(({ zone }) => zone.id === selectedZoneId) ?? null
|
||||||
}, [displayZonePolygons, selectedZoneId])
|
}, [displayZonePolygons, selectedZoneId])
|
||||||
|
|
||||||
const isSiteEditActive = phase === 'site' && mode === 'edit'
|
const isSiteEditActive = phase === 'site'
|
||||||
const isWallBuildActive = phase === 'structure' && mode === 'build' && tool === 'wall'
|
const isWallBuildActive = phase === 'structure' && mode === 'build' && tool === 'wall'
|
||||||
const isSlabBuildActive = phase === 'structure' && mode === 'build' && tool === 'slab'
|
const isSlabBuildActive = phase === 'structure' && mode === 'build' && tool === 'slab'
|
||||||
const isZoneBuildActive = phase === 'structure' && mode === 'build' && tool === 'zone'
|
const isZoneBuildActive = phase === 'structure' && mode === 'build' && tool === 'zone'
|
||||||
@@ -3891,46 +3848,25 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Track actual container position and size for SVG coordinate transforms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentBounds = getViewportBounds()
|
const el = containerRef.current
|
||||||
const persistedRect = readPersistedPanelLayout(currentBounds)
|
if (!el) return
|
||||||
setPanelRect(persistedRect ?? getInitialPanelRect(currentBounds))
|
const update = () => {
|
||||||
panelBoundsRef.current = currentBounds
|
const rect = el.getBoundingClientRect()
|
||||||
|
setPanelRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height })
|
||||||
setIsPanelReady(true)
|
setIsPanelReady(true)
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleWindowResize = () => {
|
|
||||||
const nextBounds = getViewportBounds()
|
|
||||||
const previousBounds = panelBoundsRef.current ?? nextBounds
|
|
||||||
setPanelRect((currentRect) => adaptPanelRectToBounds(currentRect, previousBounds, nextBounds))
|
|
||||||
panelBoundsRef.current = nextBounds
|
|
||||||
}
|
}
|
||||||
|
const observer = new ResizeObserver(update)
|
||||||
window.addEventListener('resize', handleWindowResize)
|
observer.observe(el)
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
update()
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleWindowResize)
|
observer.disconnect()
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPanelReady) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
const currentBounds = panelBoundsRef.current ?? getViewportBounds()
|
|
||||||
writePersistedPanelLayout({
|
|
||||||
rect: panelRect,
|
|
||||||
viewport: currentBounds,
|
|
||||||
})
|
|
||||||
}, 120)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
}, [isPanelReady, panelRect])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
|
const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
|
||||||
|
|
||||||
@@ -4030,7 +3966,6 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
}, [selectedOpeningEntry, surfaceSize.height, surfaceSize.width, viewBox])
|
}, [selectedOpeningEntry, surfaceSize.height, surfaceSize.width, viewBox])
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset hovered corner when selected guide changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHoveredGuideCorner(null)
|
setHoveredGuideCorner(null)
|
||||||
}, [selectedGuide?.id])
|
}, [selectedGuide?.id])
|
||||||
@@ -4182,12 +4117,6 @@ export function FloorplanPanel() {
|
|||||||
},
|
},
|
||||||
[theme],
|
[theme],
|
||||||
)
|
)
|
||||||
const floorplanLevelLabel =
|
|
||||||
levelNode?.type === 'level' ? getLevelDisplayLabel(levelNode) : 'Select a level'
|
|
||||||
const isGroundFloorSelected = levelNode?.type === 'level' && levelNode.level === 0
|
|
||||||
const isSiteEditShortcutActive = phase === 'site' && mode === 'edit'
|
|
||||||
const canUseSiteEditShortcut = isGroundFloorSelected
|
|
||||||
const hasFloorplanLevelSwitcher = floorplanLevels.length > 1
|
|
||||||
const gridSteps = useMemo(
|
const gridSteps = useMemo(
|
||||||
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
||||||
[surfaceSize.width, viewBox.width],
|
[surfaceSize.width, viewBox.width],
|
||||||
@@ -4277,50 +4206,6 @@ export function FloorplanPanel() {
|
|||||||
document.body.style.cursor = ''
|
document.body.style.cursor = ''
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearLevelMenuCloseTimeout = useCallback(() => {
|
|
||||||
if (levelMenuCloseTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(levelMenuCloseTimeoutRef.current)
|
|
||||||
levelMenuCloseTimeoutRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const openLevelMenu = useCallback(() => {
|
|
||||||
if (!hasFloorplanLevelSwitcher) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearLevelMenuCloseTimeout()
|
|
||||||
setIsLevelMenuOpen(true)
|
|
||||||
}, [clearLevelMenuCloseTimeout, hasFloorplanLevelSwitcher])
|
|
||||||
|
|
||||||
const scheduleLevelMenuClose = useCallback(() => {
|
|
||||||
clearLevelMenuCloseTimeout()
|
|
||||||
|
|
||||||
levelMenuCloseTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setIsLevelMenuOpen(false)
|
|
||||||
levelMenuCloseTimeoutRef.current = null
|
|
||||||
}, FLOORPLAN_LEVEL_MENU_CLOSE_DELAY_MS)
|
|
||||||
}, [clearLevelMenuCloseTimeout])
|
|
||||||
|
|
||||||
const handleFloorplanLevelSelect = useCallback(
|
|
||||||
(nextLevelId: string) => {
|
|
||||||
const resolvedLevelId = nextLevelId as LevelNode['id']
|
|
||||||
|
|
||||||
if (currentBuildingId) {
|
|
||||||
setSelection({
|
|
||||||
buildingId: currentBuildingId,
|
|
||||||
levelId: resolvedLevelId,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setSelection({ levelId: resolvedLevelId })
|
|
||||||
}
|
|
||||||
|
|
||||||
clearLevelMenuCloseTimeout()
|
|
||||||
setIsLevelMenuOpen(false)
|
|
||||||
},
|
|
||||||
[clearLevelMenuCloseTimeout, currentBuildingId, setSelection],
|
|
||||||
)
|
|
||||||
|
|
||||||
const finishPanelInteraction = useCallback(() => {
|
const finishPanelInteraction = useCallback(() => {
|
||||||
panelInteractionRef.current = null
|
panelInteractionRef.current = null
|
||||||
setIsDraggingPanel(false)
|
setIsDraggingPanel(false)
|
||||||
@@ -4391,12 +4276,6 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
}, [finishPanelInteraction])
|
}, [finishPanelInteraction])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearLevelMenuCloseTimeout()
|
|
||||||
}
|
|
||||||
}, [clearLevelMenuCloseTimeout])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = guideInteractionRef.current
|
const interaction = guideInteractionRef.current
|
||||||
if (interaction && !guideById.has(interaction.guideId)) {
|
if (interaction && !guideById.has(interaction.guideId)) {
|
||||||
@@ -4416,12 +4295,6 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
}, [clearGuideInteraction])
|
}, [clearGuideInteraction])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasFloorplanLevelSwitcher) {
|
|
||||||
setIsLevelMenuOpen(false)
|
|
||||||
}
|
|
||||||
}, [hasFloorplanLevelSwitcher])
|
|
||||||
|
|
||||||
const handlePanelDragStart = useCallback(
|
const handlePanelDragStart = useCallback(
|
||||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
@@ -4845,7 +4718,6 @@ export function FloorplanPanel() {
|
|||||||
walls,
|
walls,
|
||||||
])
|
])
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: clear drag state when level changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearWallEndpointDrag()
|
clearWallEndpointDrag()
|
||||||
}, [clearWallEndpointDrag, levelId])
|
}, [clearWallEndpointDrag, levelId])
|
||||||
@@ -5955,6 +5827,38 @@ export function FloorplanPanel() {
|
|||||||
},
|
},
|
||||||
[emitFloorplanNodeClick],
|
[emitFloorplanNodeClick],
|
||||||
)
|
)
|
||||||
|
const handleOpeningPointerDown = useCallback(
|
||||||
|
(openingId: OpeningNode['id'], event: ReactPointerEvent<SVGElement>) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const opening = selectedOpeningEntry?.opening
|
||||||
|
if (!opening || opening.id !== openingId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
// Suppress the click event that follows this pointer interaction so it
|
||||||
|
// doesn't re-select or interfere with placement.
|
||||||
|
const suppressClick = (clickEvent: MouseEvent) => {
|
||||||
|
clickEvent.stopImmediatePropagation()
|
||||||
|
clickEvent.preventDefault()
|
||||||
|
window.removeEventListener('click', suppressClick, true)
|
||||||
|
}
|
||||||
|
window.addEventListener('click', suppressClick, true)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.removeEventListener('click', suppressClick, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(opening)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
},
|
||||||
|
[selectedOpeningEntry, setMovingNode, setSelection],
|
||||||
|
)
|
||||||
const handleSlabSelect = useCallback(
|
const handleSlabSelect = useCallback(
|
||||||
(slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => {
|
(slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => {
|
||||||
emitFloorplanNodeClick(slabId, event)
|
emitFloorplanNodeClick(slabId, event)
|
||||||
@@ -5988,10 +5892,7 @@ export function FloorplanPanel() {
|
|||||||
},
|
},
|
||||||
[selectedOpeningEntry, setMovingNode, setSelection],
|
[selectedOpeningEntry, setMovingNode, setSelection],
|
||||||
)
|
)
|
||||||
const handleSelectedOpeningDuplicate = useCallback(
|
const duplicateSelectedOpening = useCallback(() => {
|
||||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
const opening = selectedOpeningEntry?.opening
|
const opening = selectedOpeningEntry?.opening
|
||||||
if (!opening?.parentId) {
|
if (!opening?.parentId) {
|
||||||
return
|
return
|
||||||
@@ -6012,8 +5913,13 @@ export function FloorplanPanel() {
|
|||||||
useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId)
|
||||||
setMovingNode(duplicate)
|
setMovingNode(duplicate)
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [selectedOpeningEntry, setMovingNode, setSelection])
|
||||||
|
const handleSelectedOpeningDuplicate = useCallback(
|
||||||
|
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
duplicateSelectedOpening()
|
||||||
},
|
},
|
||||||
[selectedOpeningEntry, setMovingNode, setSelection],
|
[duplicateSelectedOpening],
|
||||||
)
|
)
|
||||||
const handleSelectedOpeningDelete = useCallback(
|
const handleSelectedOpeningDelete = useCallback(
|
||||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
@@ -6718,68 +6624,6 @@ export function FloorplanPanel() {
|
|||||||
setStructureLayer,
|
setStructureLayer,
|
||||||
site,
|
site,
|
||||||
])
|
])
|
||||||
const handleFloorplanSelectionToolChange = useCallback(
|
|
||||||
(nextTool: FloorplanSelectionTool) => {
|
|
||||||
setFloorplanSelectionTool(nextTool)
|
|
||||||
|
|
||||||
if (phase === 'site') {
|
|
||||||
restoreGroundLevelStructureSelection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode !== 'select') {
|
|
||||||
setMode('select')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mode, phase, restoreGroundLevelStructureSelection, setMode],
|
|
||||||
)
|
|
||||||
const handleQuickBuildToolSelect = useCallback(
|
|
||||||
(nextTool: FloorplanQuickBuildTool) => {
|
|
||||||
setPhase('structure')
|
|
||||||
setStructureLayer(nextTool === 'zone' ? 'zones' : 'elements')
|
|
||||||
setMode('build')
|
|
||||||
setTool(nextTool)
|
|
||||||
setCatalogCategory(null)
|
|
||||||
},
|
|
||||||
[setCatalogCategory, setMode, setPhase, setStructureLayer, setTool],
|
|
||||||
)
|
|
||||||
const handleSiteEditShortcutSelect = useCallback(() => {
|
|
||||||
if (!(levelNode?.type === 'level' && levelNode.level === 0)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSiteEditShortcutActive) {
|
|
||||||
restoreGroundLevelStructureSelection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPhase('site')
|
|
||||||
setMode('edit')
|
|
||||||
|
|
||||||
if (currentBuildingId) {
|
|
||||||
setSelection({
|
|
||||||
buildingId: currentBuildingId,
|
|
||||||
levelId: levelNode.id,
|
|
||||||
selectedIds: [],
|
|
||||||
zoneId: null,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelection({
|
|
||||||
levelId: levelNode.id,
|
|
||||||
selectedIds: [],
|
|
||||||
zoneId: null,
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
currentBuildingId,
|
|
||||||
isSiteEditShortcutActive,
|
|
||||||
levelNode,
|
|
||||||
setMode,
|
|
||||||
setPhase,
|
|
||||||
setSelection,
|
|
||||||
restoreGroundLevelStructureSelection,
|
|
||||||
])
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const target = event.target as HTMLElement | null
|
const target = event.target as HTMLElement | null
|
||||||
@@ -6810,6 +6654,36 @@ export function FloorplanPanel() {
|
|||||||
window.removeEventListener('keydown', handleKeyDown, true)
|
window.removeEventListener('keydown', handleKeyDown, true)
|
||||||
}
|
}
|
||||||
}, [isFloorplanHovered, phase, restoreGroundLevelStructureSelection])
|
}, [isFloorplanHovered, phase, restoreGroundLevelStructureSelection])
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(isFloorplanHovered && selectedOpeningEntry)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
const isEditableTarget =
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
Boolean(target?.isContentEditable)
|
||||||
|
|
||||||
|
if (isEditableTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
duplicateSelectedOpening()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown, true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown, true)
|
||||||
|
}
|
||||||
|
}, [duplicateSelectedOpening, isFloorplanHovered, selectedOpeningEntry])
|
||||||
const activeDraftAnchorPoint = draftStart ?? activePolygonDraftPoints[0] ?? null
|
const activeDraftAnchorPoint = draftStart ?? activePolygonDraftPoints[0] ?? null
|
||||||
const floorplanCursorColor = wallEndpointDraft
|
const floorplanCursorColor = wallEndpointDraft
|
||||||
? palette.editCursor
|
? palette.editCursor
|
||||||
@@ -6819,396 +6693,14 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto fixed z-50 flex flex-col overflow-hidden rounded-smooth-xl bg-background/95 shadow-[0_24px_48px_rgba(15,23,42,0.16),0_8px_20px_rgba(15,23,42,0.08)] ring-1 ring-border/35 backdrop-blur-md"
|
className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
|
||||||
onPointerEnter={() => setFloorplanHovered(true)}
|
onPointerEnter={() => setFloorplanHovered(true)}
|
||||||
onPointerLeave={() => {
|
onPointerLeave={() => {
|
||||||
setFloorplanHovered(false)
|
setFloorplanHovered(false)
|
||||||
setFloorplanCursorPosition(null)
|
setFloorplanCursorPosition(null)
|
||||||
}}
|
}}
|
||||||
style={{
|
ref={containerRef}
|
||||||
cursor: activeResizeDirection ? resizeCursorByDirection[activeResizeDirection] : undefined,
|
|
||||||
height: panelRect.height,
|
|
||||||
left: panelRect.x,
|
|
||||||
top: panelRect.y,
|
|
||||||
visibility: isPanelReady ? 'visible' : 'hidden',
|
|
||||||
width: panelRect.width,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{resizeHandleConfigurations.map((handle) => (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={handle.className}
|
|
||||||
key={handle.direction}
|
|
||||||
onPointerDown={(event) => handleResizeStart(handle.direction, event)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-11 shrink-0 select-none items-center justify-between border-border/20 border-b bg-background/80 px-3',
|
|
||||||
isDraggingPanel ? 'cursor-grabbing' : 'cursor-grab',
|
|
||||||
)}
|
|
||||||
onPointerDown={handlePanelDragStart}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center pr-3">
|
|
||||||
<div
|
|
||||||
className="min-w-0"
|
|
||||||
data-floorplan-panel-control="true"
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu
|
|
||||||
modal={false}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
clearLevelMenuCloseTimeout()
|
|
||||||
setIsLevelMenuOpen(hasFloorplanLevelSwitcher ? open : false)
|
|
||||||
}}
|
|
||||||
open={isLevelMenuOpen}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'group/level-switcher flex min-w-0 items-center gap-2 rounded-xl border border-border/45 bg-background/92 py-1 pr-2 pl-1.5 text-left shadow-[0_1px_2px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.04)] transition-[background-color,border-color,color,box-shadow] duration-150 focus-visible:outline-none',
|
|
||||||
hasFloorplanLevelSwitcher
|
|
||||||
? 'hover:border-border/60 hover:bg-background focus-visible:border-border/60 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-border/60'
|
|
||||||
: 'cursor-default',
|
|
||||||
)}
|
|
||||||
disabled={!hasFloorplanLevelSwitcher}
|
|
||||||
onPointerEnter={openLevelMenu}
|
|
||||||
onPointerLeave={scheduleLevelMenuClose}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="flex h-6.5 w-6.5 shrink-0 items-center justify-center rounded-lg bg-background/80 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-4 w-4 object-contain"
|
|
||||||
src="/icons/blueprint.png"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground text-sm tabular-nums">
|
|
||||||
{floorplanLevelLabel}
|
|
||||||
</span>
|
|
||||||
{hasFloorplanLevelSwitcher ? (
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-[transform,opacity,color] duration-150',
|
|
||||||
isLevelMenuOpen
|
|
||||||
? 'rotate-180 text-foreground/70 opacity-100'
|
|
||||||
: 'opacity-45 group-hover/level-switcher:opacity-70 group-focus-visible/level-switcher:opacity-70',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
{hasFloorplanLevelSwitcher ? (
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className="min-w-52 rounded-xl border-border/45 bg-background/96 p-1 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
|
|
||||||
onPointerEnter={openLevelMenu}
|
|
||||||
onPointerLeave={scheduleLevelMenuClose}
|
|
||||||
side="bottom"
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
onValueChange={handleFloorplanLevelSelect}
|
|
||||||
value={levelId ?? ''}
|
|
||||||
>
|
|
||||||
{floorplanLevels.map((level) => (
|
|
||||||
<DropdownMenuRadioItem
|
|
||||||
className="rounded-lg py-2 pr-3 pl-8 data-[state=checked]:bg-accent/60"
|
|
||||||
key={level.id}
|
|
||||||
value={level.id}
|
|
||||||
>
|
|
||||||
<span className="truncate">{getLevelDisplayLabel(level)}</span>
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
data-floorplan-panel-control="true"
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1 rounded-xl border border-border/45 bg-background/92 p-1 shadow-[0_1px_2px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.04)]">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="flex">
|
|
||||||
<button
|
|
||||||
aria-label={isSiteEditShortcutActive ? 'Exit site editing' : 'Edit site'}
|
|
||||||
aria-pressed={isSiteEditShortcutActive}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-[background-color,filter,opacity,transform] duration-200 active:scale-[0.96]',
|
|
||||||
isSiteEditShortcutActive
|
|
||||||
? 'bg-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
|
|
||||||
: canUseSiteEditShortcut
|
|
||||||
? 'opacity-75 grayscale hover:bg-accent hover:opacity-100 hover:grayscale-0'
|
|
||||||
: 'cursor-not-allowed opacity-35 grayscale',
|
|
||||||
)}
|
|
||||||
disabled={!canUseSiteEditShortcut}
|
|
||||||
onClick={handleSiteEditShortcutSelect}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-4.5 w-4.5 object-contain"
|
|
||||||
src="/icons/site.png"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8}>
|
|
||||||
{canUseSiteEditShortcut
|
|
||||||
? isSiteEditShortcutActive
|
|
||||||
? 'Exit site editing'
|
|
||||||
: 'Edit site'
|
|
||||||
: 'Site editing is only available on ground level'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center rounded-xl border border-border/45 bg-background/92 p-1 shadow-[0_1px_2px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.04)]">
|
|
||||||
<Popover onOpenChange={setIsGuideQuickAccessOpen} open={isGuideQuickAccessOpen}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label={showGuides ? 'Hide guide images' : 'Show guide images'}
|
|
||||||
aria-pressed={showGuides}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-[background-color,filter,opacity,transform] duration-200 active:scale-[0.96]',
|
|
||||||
showGuides
|
|
||||||
? 'bg-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
|
|
||||||
: hasGuideImages
|
|
||||||
? 'opacity-75 grayscale hover:bg-accent hover:opacity-100 hover:grayscale-0'
|
|
||||||
: 'opacity-45 grayscale hover:bg-accent/60 hover:opacity-70',
|
|
||||||
)}
|
|
||||||
onClick={() => setShowGuides(!showGuides)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-4.5 w-4.5 object-contain"
|
|
||||||
src="/icons/floorplan.png"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8}>
|
|
||||||
{showGuides ? 'Hide guide images' : 'Show guide images'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border/50" />
|
|
||||||
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-expanded={isGuideQuickAccessOpen}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-label="Adjust guide image opacity"
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-7 items-center justify-center rounded-lg transition-[background-color,opacity,transform] duration-200 active:scale-[0.96]',
|
|
||||||
isGuideQuickAccessOpen
|
|
||||||
? 'bg-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
|
|
||||||
: hasGuideImages
|
|
||||||
? 'opacity-75 hover:bg-accent hover:opacity-100'
|
|
||||||
: 'opacity-45 hover:bg-accent/60 hover:opacity-70',
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 shrink-0 transition-[transform,opacity,color] duration-150',
|
|
||||||
isGuideQuickAccessOpen
|
|
||||||
? 'rotate-180 text-foreground/70 opacity-100'
|
|
||||||
: 'text-muted-foreground opacity-70',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
align="end"
|
|
||||||
className="w-80 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
|
|
||||||
side="bottom"
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-background/80 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-4 w-4 object-contain"
|
|
||||||
src="/icons/floorplan.png"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-foreground text-sm">Guide images</p>
|
|
||||||
<p className="text-muted-foreground text-xs">{guideImagesDescription}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasGuideImages ? (
|
|
||||||
<div className="max-h-80 space-y-2 overflow-y-auto pr-1">
|
|
||||||
{levelGuides.map((guide, index) => (
|
|
||||||
<div
|
|
||||||
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
|
||||||
key={guide.id}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
||||||
src="/icons/floorplan.png"
|
|
||||||
/>
|
|
||||||
<p className="truncate font-medium text-foreground text-sm">
|
|
||||||
{guide.name || `Guide image ${index + 1}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SliderControl
|
|
||||||
label="Opacity"
|
|
||||||
max={100}
|
|
||||||
min={0}
|
|
||||||
onChange={(value) => handleGuideOpacityChange(guide.id, value)}
|
|
||||||
precision={0}
|
|
||||||
step={1}
|
|
||||||
unit="%"
|
|
||||||
value={guide.opacity}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
|
||||||
No guide images on this level yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 rounded-xl border border-border/45 bg-background/92 p-1 shadow-[0_1px_2px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.04)]">
|
|
||||||
{FLOORPLAN_QUICK_BUILD_TOOLS.map((quickTool) => {
|
|
||||||
const isActive = phase === 'structure' && mode === 'build' && tool === quickTool.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={quickTool.id}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label={`Activate ${quickTool.label.toLowerCase()} tool`}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-[background-color,filter,opacity,transform] duration-200 active:scale-[0.96]',
|
|
||||||
isActive
|
|
||||||
? 'bg-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
|
|
||||||
: 'opacity-75 grayscale hover:bg-accent hover:opacity-100 hover:grayscale-0',
|
|
||||||
)}
|
|
||||||
onClick={() => handleQuickBuildToolSelect(quickTool.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-4.5 w-4.5 object-contain"
|
|
||||||
src={quickTool.iconSrc}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8}>
|
|
||||||
{quickTool.label}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 rounded-xl border border-border/45 bg-background/92 p-1 shadow-[0_1px_2px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.04)]',
|
|
||||||
mode !== 'select' && 'opacity-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label="Click select"
|
|
||||||
aria-pressed={floorplanSelectionTool === 'click'}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-[background-color,transform] duration-200 active:scale-[0.96]',
|
|
||||||
floorplanSelectionTool === 'click'
|
|
||||||
? 'bg-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
|
|
||||||
: 'hover:bg-accent',
|
|
||||||
)}
|
|
||||||
onClick={() => handleFloorplanSelectionToolChange('click')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
'h-[18px] w-[18px] object-contain transition-[opacity,filter] duration-200',
|
|
||||||
floorplanSelectionTool === 'click'
|
|
||||||
? 'opacity-100 grayscale-0'
|
|
||||||
: 'opacity-60 grayscale',
|
|
||||||
)}
|
|
||||||
src="/icons/select.png"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8}>
|
|
||||||
Click select
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label="Box select"
|
|
||||||
aria-pressed={floorplanSelectionTool === 'marquee'}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-[background-color,color,transform] duration-200 active:scale-[0.96]',
|
|
||||||
floorplanSelectionTool === 'marquee'
|
|
||||||
? 'bg-accent text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
|
|
||||||
: 'hover:bg-accent hover:text-foreground',
|
|
||||||
)}
|
|
||||||
onClick={() => handleFloorplanSelectionToolChange('marquee')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon color="currentColor" height={18} icon="mdi:select-drag" width={18} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8}>
|
|
||||||
Box select
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label="Close floorplan"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/45 bg-background/92 text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.04)] transition-[background-color,color,transform] duration-200 hover:bg-accent hover:text-foreground active:scale-[0.96]"
|
|
||||||
onClick={() => setFloorplanOpen(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8}>
|
|
||||||
Close floorplan
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
||||||
{activeFloorplanCursorIndicator && floorplanCursorPosition && !isPanning && (
|
{activeFloorplanCursorIndicator && floorplanCursorPosition && !isPanning && (
|
||||||
<div
|
<div
|
||||||
@@ -7318,6 +6810,7 @@ export function FloorplanPanel() {
|
|||||||
hoveredWallId={hoveredWallId}
|
hoveredWallId={hoveredWallId}
|
||||||
onOpeningDoubleClick={handleOpeningDoubleClick}
|
onOpeningDoubleClick={handleOpeningDoubleClick}
|
||||||
onOpeningHoverChange={setHoveredOpeningId}
|
onOpeningHoverChange={setHoveredOpeningId}
|
||||||
|
onOpeningPointerDown={handleOpeningPointerDown}
|
||||||
onOpeningSelect={handleOpeningSelect}
|
onOpeningSelect={handleOpeningSelect}
|
||||||
onSlabDoubleClick={handleSlabDoubleClick}
|
onSlabDoubleClick={handleSlabDoubleClick}
|
||||||
onSlabSelect={handleSlabSelect}
|
onSlabSelect={handleSlabSelect}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react'
|
||||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||||
@@ -21,18 +21,25 @@ import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
|||||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
|
import { BoxSelectTool } from '../tools/select/box-select-tool'
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
|
import { CommandPalette, type CommandPaletteEmptyAction } from '../ui/command-palette'
|
||||||
|
import { EditorCommands } from '../ui/command-palette/editor-commands'
|
||||||
|
import { FloatingLevelSelector } from '../ui/floating-level-selector'
|
||||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
import { PanelManager } from '../ui/panels/panel-manager'
|
||||||
import { ErrorBoundary } from '../ui/primitives/error-boundary'
|
import { ErrorBoundary } from '../ui/primitives/error-boundary'
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { useSidebarStore } from '../ui/primitives/sidebar'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
||||||
import { SceneLoader } from '../ui/scene-loader'
|
import { SceneLoader } from '../ui/scene-loader'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
import type { ExtraPanel } from '../ui/sidebar/icon-rail'
|
||||||
import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
||||||
|
import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||||
|
import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
|
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||||
import { FloatingActionMenu } from './floating-action-menu'
|
import { FloatingActionMenu } from './floating-action-menu'
|
||||||
@@ -56,9 +63,19 @@ function initializeEditorRuntime() {
|
|||||||
hasInitializedEditorRuntime = true
|
hasInitializedEditorRuntime = true
|
||||||
}
|
}
|
||||||
export interface EditorProps {
|
export interface EditorProps {
|
||||||
// UI slots
|
// Layout version — 'v1' (default) or 'v2' (navbar + two-column)
|
||||||
|
layoutVersion?: 'v1' | 'v2'
|
||||||
|
|
||||||
|
// UI slots (v1)
|
||||||
appMenuButton?: ReactNode
|
appMenuButton?: ReactNode
|
||||||
sidebarTop?: ReactNode
|
sidebarTop?: ReactNode
|
||||||
|
|
||||||
|
// UI slots (v2)
|
||||||
|
navbarSlot?: ReactNode
|
||||||
|
sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
|
||||||
|
viewerToolbarLeft?: ReactNode
|
||||||
|
viewerToolbarRight?: ReactNode
|
||||||
|
|
||||||
projectId?: string | null
|
projectId?: string | null
|
||||||
|
|
||||||
// Persistence — defaults to localStorage when omitted
|
// Persistence — defaults to localStorage when omitted
|
||||||
@@ -77,12 +94,16 @@ export interface EditorProps {
|
|||||||
// Thumbnail
|
// Thumbnail
|
||||||
onThumbnailCapture?: (blob: Blob) => void
|
onThumbnailCapture?: (blob: Blob) => void
|
||||||
|
|
||||||
// Panel config (passed through to sidebar panels)
|
// Panel config (passed through to sidebar panels — v1 only)
|
||||||
settingsPanelProps?: SettingsPanelProps
|
settingsPanelProps?: SettingsPanelProps
|
||||||
sitePanelProps?: SitePanelProps
|
sitePanelProps?: SitePanelProps
|
||||||
|
extraSidebarPanels?: ExtraPanel[]
|
||||||
|
|
||||||
// Presets storage backend (defaults to localStorage)
|
// Presets storage backend (defaults to localStorage)
|
||||||
presetsAdapter?: PresetsAdapter
|
presetsAdapter?: PresetsAdapter
|
||||||
|
|
||||||
|
// Command palette fallback when no commands match
|
||||||
|
commandPaletteEmptyAction?: CommandPaletteEmptyAction
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditorSceneCrashFallback() {
|
function EditorSceneCrashFallback() {
|
||||||
@@ -113,6 +134,124 @@ function EditorSceneCrashFallback() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sidebar slot: in-flow, resizable, collapses to a grab strip ──────────────
|
||||||
|
|
||||||
|
function SidebarSlot({ children }: { children: ReactNode }) {
|
||||||
|
const width = useSidebarStore((s) => s.width)
|
||||||
|
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||||
|
const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
|
||||||
|
const setWidth = useSidebarStore((s) => s.setWidth)
|
||||||
|
const isDragging = useSidebarStore((s) => s.isDragging)
|
||||||
|
const setIsDragging = useSidebarStore((s) => s.setIsDragging)
|
||||||
|
|
||||||
|
const isResizing = useRef(false)
|
||||||
|
const isExpanding = useRef(false)
|
||||||
|
|
||||||
|
const handleResizerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isResizing.current = true
|
||||||
|
setIsDragging(true)
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
},
|
||||||
|
[setIsDragging],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleGrabDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isExpanding.current = true
|
||||||
|
setIsDragging(true)
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
},
|
||||||
|
[setIsDragging],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
|
if (isResizing.current) {
|
||||||
|
setWidth(e.clientX)
|
||||||
|
} else if (isExpanding.current && e.clientX > 60) {
|
||||||
|
setIsCollapsed(false)
|
||||||
|
setWidth(Math.max(240, e.clientX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
isResizing.current = false
|
||||||
|
isExpanding.current = false
|
||||||
|
setIsDragging(false)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', handlePointerUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
}
|
||||||
|
}, [setWidth, setIsCollapsed, setIsDragging])
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Outer: no overflow-hidden so the handle can extend into the gap
|
||||||
|
<div
|
||||||
|
className="relative h-full flex-shrink-0 rounded-xl"
|
||||||
|
style={{
|
||||||
|
width: isCollapsed ? 8 : width,
|
||||||
|
transition: isDragging ? 'none' : 'width 150ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Inner: overflow-hidden clips content to rounded corners */}
|
||||||
|
<div className="h-full w-full overflow-hidden rounded-xl">
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 cursor-col-resize transition-colors hover:bg-primary/20"
|
||||||
|
onPointerDown={handleGrabDown}
|
||||||
|
title="Expand sidebar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Handle: extends into the gap, centered on the gap midpoint */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div
|
||||||
|
className="group absolute inset-y-0 -right-3.5 z-10 flex w-4 cursor-col-resize items-stretch justify-center py-4"
|
||||||
|
onPointerDown={handleResizerDown}
|
||||||
|
>
|
||||||
|
<div className="w-px self-stretch rounded-full bg-transparent transition-colors group-hover:bg-neutral-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI overlays: fixed, scoped to viewer area via transform containing block ──
|
||||||
|
|
||||||
|
function ViewerOverlays({ left, children }: { left: number; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left,
|
||||||
|
// Creates a containing block so position:fixed children are scoped here
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
zIndex: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
|
function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
|
||||||
const selection = useViewer((state) => state.selection)
|
const selection = useViewer((state) => state.selection)
|
||||||
|
|
||||||
@@ -268,7 +407,7 @@ function ViewerCanvasControlsHint({
|
|||||||
const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
|
const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed top-4 left-1/2 z-40 max-w-[calc(100vw-2rem)] -translate-x-1/2">
|
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
|
||||||
<section
|
<section
|
||||||
aria-label="Camera controls hint"
|
aria-label="Camera controls hint"
|
||||||
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
|
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
|
||||||
@@ -305,8 +444,13 @@ function ViewerCanvasControlsHint({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
|
layoutVersion = 'v1',
|
||||||
appMenuButton,
|
appMenuButton,
|
||||||
sidebarTop,
|
sidebarTop,
|
||||||
|
navbarSlot,
|
||||||
|
sidebarTabs,
|
||||||
|
viewerToolbarLeft,
|
||||||
|
viewerToolbarRight,
|
||||||
projectId,
|
projectId,
|
||||||
onLoad,
|
onLoad,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -318,7 +462,9 @@ export default function Editor({
|
|||||||
onThumbnailCapture,
|
onThumbnailCapture,
|
||||||
settingsPanelProps,
|
settingsPanelProps,
|
||||||
sitePanelProps,
|
sitePanelProps,
|
||||||
|
extraSidebarPanels,
|
||||||
presetsAdapter,
|
presetsAdapter,
|
||||||
|
commandPaletteEmptyAction,
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
useKeyboard()
|
useKeyboard()
|
||||||
|
|
||||||
@@ -337,6 +483,41 @@ export default function Editor({
|
|||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
||||||
|
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
|
||||||
|
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
|
||||||
|
|
||||||
|
const sidebarWidth = useSidebarStore((s) => s.width)
|
||||||
|
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||||
|
const viewerAreaRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isResizingFloorplan = useRef(false)
|
||||||
|
|
||||||
|
const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isResizingFloorplan.current = true
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
|
if (!isResizingFloorplan.current) return
|
||||||
|
if (!viewerAreaRef.current) return
|
||||||
|
const rect = viewerAreaRef.current.getBoundingClientRect()
|
||||||
|
const newRatio = (e.clientX - rect.left) / rect.width
|
||||||
|
setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
|
||||||
|
}
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
isResizingFloorplan.current = false
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', handlePointerUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeEditorRuntime()
|
initializeEditorRuntime()
|
||||||
@@ -408,71 +589,217 @@ export default function Editor({
|
|||||||
writeCameraControlsHintDismissed(true)
|
writeCameraControlsHintDismissed(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
// ── Shared viewer scene content ──
|
||||||
<PresetsProvider adapter={presetsAdapter}>
|
const viewerSceneContent = (
|
||||||
<div className="dark h-full w-full text-foreground">
|
<>
|
||||||
{showLoader && (
|
{!isFirstPersonMode && <SelectionManager />}
|
||||||
<div className="fixed inset-0 z-60">
|
{!isFirstPersonMode && <BoxSelectTool />}
|
||||||
<SceneLoader />
|
{!isFirstPersonMode && <FloatingActionMenu />}
|
||||||
|
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||||
|
<ExportManager />
|
||||||
|
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||||
|
<CeilingSystem />
|
||||||
|
<RoofEditSystem />
|
||||||
|
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||||
|
{!isLoading && !isFirstPersonMode && <ToolManager />}
|
||||||
|
<CustomCameraControls />
|
||||||
|
{isFirstPersonMode && <FirstPersonControls />}
|
||||||
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
|
<PresetThumbnailGenerator />
|
||||||
|
{!isFirstPersonMode && <SiteEdgeLabels />}
|
||||||
|
{isFirstPersonMode && <InteractiveSystem />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const previewViewerContent = (
|
||||||
|
<Viewer selectionManager="default">
|
||||||
|
<ExportManager />
|
||||||
|
<ViewerZoneSystem />
|
||||||
|
<CeilingSystem />
|
||||||
|
<RoofEditSystem />
|
||||||
|
<CustomCameraControls />
|
||||||
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
|
<PresetThumbnailGenerator />
|
||||||
|
<InteractiveSystem />
|
||||||
|
</Viewer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Shared viewer canvas (handles split/2d/3d) ──
|
||||||
|
const viewMode = useEditor((s) => s.viewMode)
|
||||||
|
|
||||||
|
const show2d = viewMode === '2d' || viewMode === 'split'
|
||||||
|
const show3d = viewMode === '3d' || viewMode === 'split'
|
||||||
|
|
||||||
|
const viewerCanvas = (
|
||||||
|
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||||
|
<div className="flex h-full" ref={viewerAreaRef}>
|
||||||
|
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||||
|
<div
|
||||||
|
className="relative h-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,
|
||||||
|
display: show2d ? undefined : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full overflow-hidden">
|
||||||
|
<FloorplanPanel />
|
||||||
|
</div>
|
||||||
|
{viewMode === 'split' && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
|
||||||
|
onPointerDown={handleFloorplanDividerDown}
|
||||||
|
>
|
||||||
|
<div className="h-8 w-1 rounded-full bg-neutral-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
||||||
|
<div
|
||||||
|
className="relative min-w-0 flex-1 overflow-hidden"
|
||||||
|
style={{ display: show3d ? undefined : 'none' }}
|
||||||
|
>
|
||||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||||
<ViewerCanvasControlsHint
|
<ViewerCanvasControlsHint
|
||||||
isPreviewMode={isPreviewMode}
|
isPreviewMode={isPreviewMode}
|
||||||
onDismiss={dismissCameraControlsHint}
|
onDismiss={dismissCameraControlsHint}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||||
|
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isLoading && <ZoneLabelEditorSystem />}
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
{isFirstPersonMode ? (
|
// ── V2 layout ──
|
||||||
|
if (layoutVersion === 'v2') {
|
||||||
|
const tabMap = new Map(sidebarTabs?.map((t) => [t.id, t]) ?? [])
|
||||||
|
|
||||||
|
const renderTabContent = (tabId: string) => {
|
||||||
|
// Built-in panels
|
||||||
|
if (tabId === 'site') {
|
||||||
|
return <SitePanel {...sitePanelProps} />
|
||||||
|
}
|
||||||
|
if (tabId === 'settings') {
|
||||||
|
return <SettingsPanel {...settingsPanelProps} />
|
||||||
|
}
|
||||||
|
// External tabs (AI chat, catalog, etc.)
|
||||||
|
const tab = tabMap.get(tabId)
|
||||||
|
if (!tab) return null
|
||||||
|
const Component = tab.component
|
||||||
|
return <Component />
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PresetsProvider adapter={presetsAdapter}>
|
||||||
|
{showLoader && (
|
||||||
|
<div className="fixed inset-0 z-60">
|
||||||
|
<SceneLoader />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && isPreviewMode ? (
|
||||||
|
<div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
|
||||||
|
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||||
|
<div className="h-full w-full">{previewViewerContent}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* First-person overlay — rendered on top of normal layout */}
|
||||||
|
{isFirstPersonMode && (
|
||||||
|
<div className="fixed inset-0 z-50 pointer-events-none">
|
||||||
<FirstPersonOverlay
|
<FirstPersonOverlay
|
||||||
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
||||||
/>
|
/>
|
||||||
) : !isLoading && isPreviewMode ? (
|
</div>
|
||||||
|
)}
|
||||||
|
<EditorLayoutV2
|
||||||
|
navbarSlot={navbarSlot}
|
||||||
|
overlays={
|
||||||
|
<>
|
||||||
|
<FloatingLevelSelector />
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<ActionMenu />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<PanelManager />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<HelperManager />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
renderTabContent={renderTabContent}
|
||||||
|
sidebarTabs={tabBarTabs}
|
||||||
|
viewerContent={viewerCanvas}
|
||||||
|
viewerToolbarLeft={viewerToolbarLeft}
|
||||||
|
viewerToolbarRight={viewerToolbarRight}
|
||||||
|
/>
|
||||||
|
<EditorCommands />
|
||||||
|
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PresetsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── V1 layout (existing) ──
|
||||||
|
// p-3 (12px) padding on root + gap-3 (12px) between sidebar and viewer + sidebar width
|
||||||
|
const LAYOUT_PADDING = 12
|
||||||
|
const LAYOUT_GAP = 12
|
||||||
|
const overlayLeft = LAYOUT_PADDING + (isSidebarCollapsed ? 8 : sidebarWidth) + LAYOUT_GAP
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PresetsProvider adapter={presetsAdapter}>
|
||||||
|
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
||||||
|
{showLoader && (
|
||||||
|
<div className="fixed inset-0 z-60">
|
||||||
|
<SceneLoader />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && isPreviewMode ? (
|
||||||
|
<>
|
||||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||||
|
<div className="h-full w-full">{previewViewerContent}</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ActionMenu />
|
{/* Sidebar */}
|
||||||
<PanelManager />
|
<SidebarSlot>
|
||||||
{isFloorplanOpen && <FloorplanPanel />}
|
|
||||||
<HelperManager />
|
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-20">
|
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
appMenuButton={appMenuButton}
|
appMenuButton={appMenuButton}
|
||||||
|
commandPaletteEmptyAction={commandPaletteEmptyAction}
|
||||||
|
extraPanels={extraSidebarPanels}
|
||||||
settingsPanelProps={settingsPanelProps}
|
settingsPanelProps={settingsPanelProps}
|
||||||
sidebarTop={sidebarTop}
|
sidebarTop={sidebarTop}
|
||||||
sitePanelProps={sitePanelProps}
|
sitePanelProps={sitePanelProps}
|
||||||
/>
|
/>
|
||||||
</SidebarProvider>
|
</SidebarSlot>
|
||||||
|
|
||||||
|
{/* Viewer area */}
|
||||||
|
<div className="relative flex-1 overflow-hidden rounded-xl" ref={viewerAreaRef}>
|
||||||
|
{viewerCanvas}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed UI overlays scoped to the viewer area */}
|
||||||
|
<ViewerOverlays left={overlayLeft}>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<ActionMenu />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<PanelManager />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<HelperManager />
|
||||||
|
</div>
|
||||||
|
</ViewerOverlays>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
|
||||||
<div className="h-full w-full">
|
|
||||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
|
||||||
<Viewer selectionManager={isPreviewMode || isFirstPersonMode ? 'default' : 'custom'}>
|
|
||||||
{!isPreviewMode && !isFirstPersonMode && <SelectionManager />}
|
|
||||||
{!isPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
|
||||||
{!isPreviewMode && !isFirstPersonMode && <WallMeasurementLabel />}
|
|
||||||
<ExportManager />
|
|
||||||
{isPreviewMode || isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
|
||||||
<CeilingSystem />
|
|
||||||
<RoofEditSystem />
|
|
||||||
{!isPreviewMode && !isFirstPersonMode && (
|
|
||||||
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
|
|
||||||
)}
|
|
||||||
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ToolManager />}
|
|
||||||
<CustomCameraControls />
|
|
||||||
{isFirstPersonMode && <FirstPersonControls />}
|
|
||||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
|
||||||
<PresetThumbnailGenerator />
|
|
||||||
{!isPreviewMode && !isFirstPersonMode && <SiteEdgeLabels />}
|
|
||||||
{(isPreviewMode || isFirstPersonMode) && <InteractiveSystem />}
|
|
||||||
</Viewer>
|
|
||||||
</div>
|
|
||||||
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ZoneLabelEditorSystem />}
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
</div>
|
||||||
</PresetsProvider>
|
</PresetsProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useThree } from '@react-three/fiber'
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
||||||
|
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
@@ -266,84 +266,14 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Delete mode: click-to-delete (sledgehammer tool)
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'delete') return
|
|
||||||
|
|
||||||
const onClick = (event: NodeEvent) => {
|
|
||||||
const node = event.node
|
|
||||||
if (!isNodeInCurrentLevel(node)) return
|
|
||||||
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
// Play appropriate SFX
|
|
||||||
if (node.type === 'item') {
|
|
||||||
sfxEmitter.emit('sfx:item-delete')
|
|
||||||
} else {
|
|
||||||
sfxEmitter.emit('sfx:structure-delete')
|
|
||||||
}
|
|
||||||
|
|
||||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
|
||||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
||||||
|
|
||||||
// Clear hover since the node is gone
|
|
||||||
if (useViewer.getState().hoveredId === node.id) {
|
|
||||||
useViewer.setState({ hoveredId: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onEnter = (event: NodeEvent) => {
|
|
||||||
const node = event.node
|
|
||||||
if (!isNodeInCurrentLevel(node)) return
|
|
||||||
if (node.type === 'building' || node.type === 'site') return
|
|
||||||
event.stopPropagation()
|
|
||||||
useViewer.setState({ hoveredId: node.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLeave = (event: NodeEvent) => {
|
|
||||||
const nodeId = event?.node?.id
|
|
||||||
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
|
||||||
useViewer.setState({ hoveredId: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onGridClick = () => {
|
|
||||||
// Clicking empty space in delete mode does nothing (stay in delete mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTypes = [
|
|
||||||
'wall',
|
|
||||||
'item',
|
|
||||||
'slab',
|
|
||||||
'ceiling',
|
|
||||||
'roof',
|
|
||||||
'roof-segment',
|
|
||||||
'window',
|
|
||||||
'door',
|
|
||||||
'zone',
|
|
||||||
]
|
|
||||||
allTypes.forEach((type) => {
|
|
||||||
emitter.on(`${type}:click` as any, onClick as any)
|
|
||||||
emitter.on(`${type}:enter` as any, onEnter as any)
|
|
||||||
emitter.on(`${type}:leave` as any, onLeave as any)
|
|
||||||
})
|
|
||||||
emitter.on('grid:click', onGridClick)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
allTypes.forEach((type) => {
|
|
||||||
emitter.off(`${type}:click` as any, onClick as any)
|
|
||||||
emitter.off(`${type}:enter` as any, onEnter as any)
|
|
||||||
emitter.off(`${type}:leave` as any, onLeave as any)
|
|
||||||
})
|
|
||||||
emitter.off('grid:click', onGridClick)
|
|
||||||
}
|
|
||||||
}, [mode])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'select') return
|
if (mode !== 'select') return
|
||||||
if (movingNode) return
|
if (movingNode) return
|
||||||
|
|
||||||
const onClick = (event: NodeEvent) => {
|
const onClick = (event: NodeEvent) => {
|
||||||
|
// Skip if box-select just completed (drag ended over a node)
|
||||||
|
if (boxSelectHandled) return
|
||||||
|
|
||||||
const node = event.node
|
const node = event.node
|
||||||
let currentPhase = useEditor.getState().phase
|
let currentPhase = useEditor.getState().phase
|
||||||
let currentStructureLayer = useEditor.getState().structureLayer
|
let currentStructureLayer = useEditor.getState().structureLayer
|
||||||
@@ -410,6 +340,7 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const onGridClick = () => {
|
const onGridClick = () => {
|
||||||
if (clickHandledRef.current) return
|
if (clickHandledRef.current) return
|
||||||
|
if (boxSelectHandled) return
|
||||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
|
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
|
||||||
if (activeStrategy) activeStrategy.handleDeselect()
|
if (activeStrategy) activeStrategy.handleDeselect()
|
||||||
}
|
}
|
||||||
@@ -548,32 +479,83 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
}, [mode, movingNode])
|
}, [mode, movingNode])
|
||||||
|
|
||||||
|
// Delete mode: click-to-delete (sledgehammer tool)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'delete') return
|
||||||
|
|
||||||
|
const onClick = (event: NodeEvent) => {
|
||||||
|
const node = event.node
|
||||||
|
if (!isNodeInCurrentLevel(node)) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
// Play appropriate SFX
|
||||||
|
if (node.type === 'item') {
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
|
} else {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
}
|
||||||
|
|
||||||
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
|
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||||
|
|
||||||
|
// Clear hover since the node is gone
|
||||||
|
if (useViewer.getState().hoveredId === node.id) {
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnter = (event: NodeEvent) => {
|
||||||
|
const node = event.node
|
||||||
|
if (!isNodeInCurrentLevel(node)) return
|
||||||
|
if (node.type === 'building' || node.type === 'site') return
|
||||||
|
event.stopPropagation()
|
||||||
|
useViewer.setState({ hoveredId: node.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLeave = (event: NodeEvent) => {
|
||||||
|
const nodeId = event?.node?.id
|
||||||
|
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTypes = [
|
||||||
|
'wall',
|
||||||
|
'item',
|
||||||
|
'slab',
|
||||||
|
'ceiling',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'window',
|
||||||
|
'door',
|
||||||
|
'zone',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const type of allTypes) {
|
||||||
|
emitter.on(`${type}:click` as any, onClick as any)
|
||||||
|
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||||
|
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const type of allTypes) {
|
||||||
|
emitter.off(`${type}:click` as any, onClick as any)
|
||||||
|
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||||
|
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||||
|
}
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
}
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DeleteModeCursor />
|
|
||||||
<SelectionStateSync />
|
<SelectionStateSync />
|
||||||
<EditorOutlinerSync />
|
<EditorOutlinerSync />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeleteModeCursor = () => {
|
|
||||||
const mode = useEditor((s) => s.mode)
|
|
||||||
const gl = useThree((s) => s.gl)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = gl.domElement
|
|
||||||
if (mode === 'delete') {
|
|
||||||
canvas.style.cursor = 'crosshair'
|
|
||||||
return () => {
|
|
||||||
canvas.style.cursor = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mode, gl])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const SelectionStateSync = () => {
|
const SelectionStateSync = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useScene.subscribe((state) => {
|
return useScene.subscribe((state) => {
|
||||||
|
|||||||
@@ -2,18 +2,36 @@
|
|||||||
|
|
||||||
import type { SiteNode } from '@pascal-app/core'
|
import type { SiteNode } from '@pascal-app/core'
|
||||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { createPortal, useFrame } from '@react-three/fiber'
|
import { createPortal, useFrame } from '@react-three/fiber'
|
||||||
import { useMemo, useRef, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import type { Object3D } from 'three'
|
import type { Object3D } from 'three'
|
||||||
|
|
||||||
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||||
|
if (unit === 'imperial') {
|
||||||
|
const feet = value * 3.280_84
|
||||||
|
const wholeFeet = Math.floor(feet)
|
||||||
|
const inches = Math.round((feet - wholeFeet) * 12)
|
||||||
|
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||||
|
return `${wholeFeet}'${inches}"`
|
||||||
|
}
|
||||||
|
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||||
|
}
|
||||||
|
|
||||||
export function SiteEdgeLabels() {
|
export function SiteEdgeLabels() {
|
||||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||||
const nodes = useScene((state) => state.nodes)
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
const theme = useViewer((state) => state.theme)
|
||||||
|
|
||||||
const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
|
const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
|
||||||
const siteNodeId = siteNode?.id
|
const siteNodeId = siteNode?.id
|
||||||
|
|
||||||
|
const isNight = theme === 'dark'
|
||||||
|
const color = isNight ? '#ffffff' : '#111111'
|
||||||
|
const shadowColor = isNight ? '#111111' : '#ffffff'
|
||||||
|
|
||||||
const [siteObj, setSiteObj] = useState<Object3D | null>(null)
|
const [siteObj, setSiteObj] = useState<Object3D | null>(null)
|
||||||
const prevSiteNodeIdRef = useRef<string | undefined>(undefined)
|
const prevSiteNodeIdRef = useRef<string | undefined>(undefined)
|
||||||
|
|
||||||
@@ -55,8 +73,14 @@ export function SiteEdgeLabels() {
|
|||||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
zIndexRange={[10, 0]}
|
zIndexRange={[10, 0]}
|
||||||
>
|
>
|
||||||
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
|
<div
|
||||||
{edge.dist.toFixed(2)}m
|
className="whitespace-nowrap font-bold font-mono text-[15px]"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatMeasurement(edge.dist, unit)}
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import { Icon } from '@iconify/react'
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type CeilingNode,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
type ItemNode,
|
||||||
|
type LevelNode,
|
||||||
|
type SlabNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
type ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useThree } from '@react-three/fiber'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
BufferAttribute,
|
||||||
|
BufferGeometry,
|
||||||
|
DoubleSide,
|
||||||
|
type Group,
|
||||||
|
LineBasicMaterial,
|
||||||
|
LineSegments,
|
||||||
|
type Mesh,
|
||||||
|
Plane,
|
||||||
|
Raycaster,
|
||||||
|
Vector2,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level flag to prevent the SelectionManager from deselecting
|
||||||
|
* on the grid:click that fires right after a box-select drag completes.
|
||||||
|
*/
|
||||||
|
export let boxSelectHandled = false
|
||||||
|
|
||||||
|
// ── Geometry helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
|
||||||
|
|
||||||
|
function pointInBounds(x: number, z: number, b: Bounds): boolean {
|
||||||
|
return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsIntersect(
|
||||||
|
ax1: number,
|
||||||
|
az1: number,
|
||||||
|
ax2: number,
|
||||||
|
az2: number,
|
||||||
|
bx1: number,
|
||||||
|
bz1: number,
|
||||||
|
bx2: number,
|
||||||
|
bz2: number,
|
||||||
|
): boolean {
|
||||||
|
const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)
|
||||||
|
const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)
|
||||||
|
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
|
||||||
|
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
|
||||||
|
|
||||||
|
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
|
||||||
|
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true
|
||||||
|
if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true
|
||||||
|
if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number {
|
||||||
|
return (bx - ax) * (cz - az) - (bz - az) * (cx - ax)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSeg(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): boolean {
|
||||||
|
return (
|
||||||
|
Math.min(ax, bx) <= cx &&
|
||||||
|
cx <= Math.max(ax, bx) &&
|
||||||
|
Math.min(az, bz) <= cz &&
|
||||||
|
cz <= Math.max(az, bz)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentIntersectsBounds(
|
||||||
|
x1: number,
|
||||||
|
z1: number,
|
||||||
|
x2: number,
|
||||||
|
z2: number,
|
||||||
|
b: Bounds,
|
||||||
|
): boolean {
|
||||||
|
if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true
|
||||||
|
|
||||||
|
const edges: [number, number, number, number][] = [
|
||||||
|
[b.minX, b.minZ, b.maxX, b.minZ],
|
||||||
|
[b.maxX, b.minZ, b.maxX, b.maxZ],
|
||||||
|
[b.maxX, b.maxZ, b.minX, b.maxZ],
|
||||||
|
[b.minX, b.maxZ, b.minX, b.minZ],
|
||||||
|
]
|
||||||
|
for (const [ex1, ez1, ex2, ez2] of edges) {
|
||||||
|
if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean {
|
||||||
|
if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true
|
||||||
|
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[b.minX, b.minZ],
|
||||||
|
[b.maxX, b.minZ],
|
||||||
|
[b.maxX, b.maxZ],
|
||||||
|
[b.minX, b.maxZ],
|
||||||
|
]
|
||||||
|
if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true
|
||||||
|
|
||||||
|
const edges: [number, number, number, number][] = [
|
||||||
|
[b.minX, b.minZ, b.maxX, b.minZ],
|
||||||
|
[b.maxX, b.minZ, b.maxX, b.maxZ],
|
||||||
|
[b.maxX, b.maxZ, b.minX, b.maxZ],
|
||||||
|
[b.minX, b.maxZ, b.minX, b.minZ],
|
||||||
|
]
|
||||||
|
for (let i = 0; i < polygon.length; i++) {
|
||||||
|
const [px1, pz1] = polygon[i]!
|
||||||
|
const [px2, pz2] = polygon[(i + 1) % polygon.length]!
|
||||||
|
for (const [ex1, ez1, ex2, ez2] of edges) {
|
||||||
|
if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean {
|
||||||
|
let inside = false
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const [xi, zi] = polygon[i]!
|
||||||
|
const [xj, zj] = polygon[j]!
|
||||||
|
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) {
|
||||||
|
inside = !inside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node-in-bounds checks ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _tempVec = new Vector3()
|
||||||
|
|
||||||
|
function getNodeWorldXZ(nodeId: string): [number, number] | null {
|
||||||
|
const obj = sceneRegistry.nodes.get(nodeId)
|
||||||
|
if (!obj) return null
|
||||||
|
obj.getWorldPosition(_tempVec)
|
||||||
|
return [_tempVec.x, _tempVec.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||||
|
const { levelId } = useViewer.getState().selection
|
||||||
|
const { nodes } = useScene.getState()
|
||||||
|
const { phase, structureLayer } = useEditor.getState()
|
||||||
|
|
||||||
|
if (!levelId) return []
|
||||||
|
const levelNode = nodes[levelId] as LevelNode | undefined
|
||||||
|
if (!levelNode || levelNode.type !== 'level') return []
|
||||||
|
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
if (phase === 'structure' && structureLayer === 'elements') {
|
||||||
|
for (const childId of levelNode.children) {
|
||||||
|
const node = nodes[childId as AnyNodeId]
|
||||||
|
if (!node) continue
|
||||||
|
|
||||||
|
if (node.type === 'wall') {
|
||||||
|
const wall = node as WallNode
|
||||||
|
if (
|
||||||
|
segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
|
||||||
|
) {
|
||||||
|
result.push(wall.id)
|
||||||
|
}
|
||||||
|
// Check wall children (doors/windows)
|
||||||
|
for (const itemId of wall.children) {
|
||||||
|
const child = nodes[itemId as AnyNodeId]
|
||||||
|
if (!child) continue
|
||||||
|
if (
|
||||||
|
child.type === 'window' ||
|
||||||
|
child.type === 'door' ||
|
||||||
|
(child.type === 'item' &&
|
||||||
|
((child as ItemNode).asset.category === 'door' ||
|
||||||
|
(child as ItemNode).asset.category === 'window'))
|
||||||
|
) {
|
||||||
|
const xz = getNodeWorldXZ(child.id)
|
||||||
|
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||||
|
result.push(child.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.type === 'slab') {
|
||||||
|
const slab = node as SlabNode
|
||||||
|
if (polygonIntersectsBounds(slab.polygon, bounds)) {
|
||||||
|
result.push(slab.id)
|
||||||
|
}
|
||||||
|
} else if (node.type === 'ceiling') {
|
||||||
|
const ceiling = node as CeilingNode
|
||||||
|
if (polygonIntersectsBounds(ceiling.polygon, bounds)) {
|
||||||
|
result.push(ceiling.id)
|
||||||
|
}
|
||||||
|
} else if (node.type === 'roof') {
|
||||||
|
const xz = getNodeWorldXZ(node.id)
|
||||||
|
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||||
|
result.push(node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
|
for (const childId of levelNode.children) {
|
||||||
|
const node = nodes[childId as AnyNodeId]
|
||||||
|
if (!node || node.type !== 'zone') continue
|
||||||
|
const zone = node as ZoneNode
|
||||||
|
if (polygonIntersectsBounds(zone.polygon, bounds)) {
|
||||||
|
result.push(zone.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (phase === 'furnish') {
|
||||||
|
for (const childId of levelNode.children) {
|
||||||
|
const node = nodes[childId as AnyNodeId]
|
||||||
|
if (!node) continue
|
||||||
|
if (node.type === 'item') {
|
||||||
|
const item = node as ItemNode
|
||||||
|
if (item.asset.category === 'door' || item.asset.category === 'window') continue
|
||||||
|
const xz = getNodeWorldXZ(item.id)
|
||||||
|
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||||
|
result.push(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visual helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateRectVisuals(
|
||||||
|
fillMesh: Mesh,
|
||||||
|
outline: LineSegments,
|
||||||
|
start: Vector3,
|
||||||
|
end: Vector3,
|
||||||
|
y: number,
|
||||||
|
) {
|
||||||
|
const cx = (start.x + end.x) / 2
|
||||||
|
const cz = (start.z + end.z) / 2
|
||||||
|
const w = Math.abs(end.x - start.x)
|
||||||
|
const h = Math.abs(end.z - start.z)
|
||||||
|
|
||||||
|
if (w < 0.01 && h < 0.01) {
|
||||||
|
fillMesh.visible = false
|
||||||
|
outline.visible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill rect (unit plane scaled)
|
||||||
|
fillMesh.visible = true
|
||||||
|
fillMesh.position.set(cx, y + 0.02, cz)
|
||||||
|
fillMesh.scale.set(w, h, 1)
|
||||||
|
|
||||||
|
// Outline — 4 edges as line segment pairs (8 vertices)
|
||||||
|
outline.visible = true
|
||||||
|
const oy = y + 0.03
|
||||||
|
const x0 = cx - w / 2
|
||||||
|
const x1 = cx + w / 2
|
||||||
|
const z0 = cz - h / 2
|
||||||
|
const z1 = cz + h / 2
|
||||||
|
const pos = outline.geometry.attributes.position as BufferAttribute
|
||||||
|
// bottom: (x0,z0)→(x1,z0)
|
||||||
|
pos.setXYZ(0, x0, oy, z0)
|
||||||
|
pos.setXYZ(1, x1, oy, z0)
|
||||||
|
// right: (x1,z0)→(x1,z1)
|
||||||
|
pos.setXYZ(2, x1, oy, z0)
|
||||||
|
pos.setXYZ(3, x1, oy, z1)
|
||||||
|
// top: (x1,z1)→(x0,z1)
|
||||||
|
pos.setXYZ(4, x1, oy, z1)
|
||||||
|
pos.setXYZ(5, x0, oy, z1)
|
||||||
|
// left: (x0,z1)→(x0,z0)
|
||||||
|
pos.setXYZ(6, x0, oy, z1)
|
||||||
|
pos.setXYZ(7, x0, oy, z0)
|
||||||
|
pos.needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outline geometry (allocated once, reused) ───────────────────────────────
|
||||||
|
|
||||||
|
function createOutlineSegments(): LineSegments {
|
||||||
|
const geo = new BufferGeometry()
|
||||||
|
// 4 edges × 2 vertices each = 8 vertices
|
||||||
|
const positions = new Float32Array(8 * 3)
|
||||||
|
geo.setAttribute('position', new BufferAttribute(positions, 3))
|
||||||
|
|
||||||
|
const mat = new LineBasicMaterial({
|
||||||
|
color: '#818cf8',
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.6,
|
||||||
|
})
|
||||||
|
|
||||||
|
const segments = new LineSegments(geo, mat)
|
||||||
|
segments.layers.set(EDITOR_LAYER)
|
||||||
|
segments.renderOrder = 2
|
||||||
|
segments.visible = false
|
||||||
|
segments.frustumCulled = false
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag threshold (pixels) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD_PX = 4
|
||||||
|
|
||||||
|
// ── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BoxSelectTool: React.FC = () => {
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||||
|
const isActive = mode === 'select' && selectionTool === 'marquee'
|
||||||
|
|
||||||
|
if (!isActive) return null
|
||||||
|
|
||||||
|
return <BoxSelectToolInner />
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOX_SELECT_TOOLTIP = (
|
||||||
|
<Icon
|
||||||
|
color="currentColor"
|
||||||
|
height={24}
|
||||||
|
icon="mdi:select-drag"
|
||||||
|
style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }}
|
||||||
|
width={24}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BoxSelectToolInner: React.FC = () => {
|
||||||
|
const { camera, gl } = useThree()
|
||||||
|
const cursorRef = useRef<Group>(null)
|
||||||
|
const rectFillRef = useRef<Mesh>(null!)
|
||||||
|
const outlineRef = useRef(createOutlineSegments())
|
||||||
|
const startPoint = useRef(new Vector3())
|
||||||
|
const currentPoint = useRef(new Vector3())
|
||||||
|
const pointerDown = useRef(false)
|
||||||
|
const isDragging = useRef(false)
|
||||||
|
const startClientX = useRef(0)
|
||||||
|
const startClientY = useRef(0)
|
||||||
|
const gridY = useRef(0)
|
||||||
|
const prevHitCount = useRef(0)
|
||||||
|
|
||||||
|
// Raycasting helpers (same technique as useGridEvents)
|
||||||
|
const raycasterRef = useRef(new Raycaster())
|
||||||
|
const pointerNDC = useRef(new Vector2())
|
||||||
|
const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
|
||||||
|
const hitPoint = useRef(new Vector3())
|
||||||
|
|
||||||
|
// Cleanup outline geometry on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
const outline = outlineRef.current
|
||||||
|
return () => {
|
||||||
|
outline.geometry.dispose()
|
||||||
|
;(outline.material as LineBasicMaterial).dispose()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Sync ground plane Y with the current level
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = useViewer.subscribe((state) => {
|
||||||
|
const levelId = state.selection.levelId
|
||||||
|
if (!levelId) return
|
||||||
|
const obj = sceneRegistry.nodes.get(levelId)
|
||||||
|
if (obj) groundPlane.current.constant = -obj.position.y
|
||||||
|
})
|
||||||
|
// Set initial value
|
||||||
|
const levelId = useViewer.getState().selection.levelId
|
||||||
|
if (levelId) {
|
||||||
|
const obj = sceneRegistry.nodes.get(levelId)
|
||||||
|
if (obj) groundPlane.current.constant = -obj.position.y
|
||||||
|
}
|
||||||
|
return unsubscribe
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const raycastToGround = (e: PointerEvent): Vector3 | null => {
|
||||||
|
const rect = gl.domElement.getBoundingClientRect()
|
||||||
|
pointerNDC.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||||
|
pointerNDC.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
|
||||||
|
raycasterRef.current.setFromCamera(pointerNDC.current, camera)
|
||||||
|
if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) {
|
||||||
|
return hitPoint.current
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement
|
||||||
|
|
||||||
|
const onCanvasPointerDown = (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
if (useViewer.getState().cameraDragging) return
|
||||||
|
|
||||||
|
const point = raycastToGround(e)
|
||||||
|
if (!point) return
|
||||||
|
|
||||||
|
startPoint.current.copy(point)
|
||||||
|
currentPoint.current.copy(point)
|
||||||
|
gridY.current = point.y
|
||||||
|
pointerDown.current = true
|
||||||
|
isDragging.current = false
|
||||||
|
prevHitCount.current = 0
|
||||||
|
startClientX.current = e.clientX
|
||||||
|
startClientY.current = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasPointerUp = (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
if (!pointerDown.current) return
|
||||||
|
|
||||||
|
if (isDragging.current) {
|
||||||
|
const point = raycastToGround(e)
|
||||||
|
if (point) currentPoint.current.copy(point)
|
||||||
|
|
||||||
|
const bounds: Bounds = {
|
||||||
|
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
||||||
|
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
|
||||||
|
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
|
||||||
|
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = collectNodeIdsInBounds(bounds)
|
||||||
|
|
||||||
|
const shouldAppend = e.metaKey || e.ctrlKey
|
||||||
|
const { phase, structureLayer } = useEditor.getState()
|
||||||
|
|
||||||
|
if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
|
if (ids.length > 0) {
|
||||||
|
useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] })
|
||||||
|
} else if (!shouldAppend) {
|
||||||
|
useViewer.getState().setSelection({ zoneId: null })
|
||||||
|
}
|
||||||
|
} else if (shouldAppend) {
|
||||||
|
const currentIds = useViewer.getState().selection.selectedIds
|
||||||
|
const merged = Array.from(new Set([...currentIds, ...ids]))
|
||||||
|
useViewer.getState().setSelection({ selectedIds: merged })
|
||||||
|
} else {
|
||||||
|
useViewer.getState().setSelection({ selectedIds: ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent the subsequent grid:click from deselecting
|
||||||
|
boxSelectHandled = true
|
||||||
|
setTimeout(() => {
|
||||||
|
boxSelectHandled = false
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
// NOTE: Short clicks (no drag) fall through to the SelectionManager's
|
||||||
|
// existing grid:click / node:click handlers — no extra logic needed here.
|
||||||
|
|
||||||
|
// Hide visuals
|
||||||
|
if (rectFillRef.current) rectFillRef.current.visible = false
|
||||||
|
if (outlineRef.current) outlineRef.current.visible = false
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
pointerDown.current = false
|
||||||
|
isDragging.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', onCanvasPointerDown)
|
||||||
|
canvas.addEventListener('pointerup', onCanvasPointerUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('pointerdown', onCanvasPointerDown)
|
||||||
|
canvas.removeEventListener('pointerup', onCanvasPointerUp)
|
||||||
|
}
|
||||||
|
}, [camera, gl])
|
||||||
|
|
||||||
|
// grid:move for cursor tracking + rectangle update during drag
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (event: GridEvent) => {
|
||||||
|
// Always update cursor position
|
||||||
|
if (cursorRef.current) {
|
||||||
|
cursorRef.current.position.set(event.position[0], event.position[1], event.position[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pointerDown.current) return
|
||||||
|
|
||||||
|
currentPoint.current.set(event.position[0], event.position[1], event.position[2])
|
||||||
|
|
||||||
|
// Check drag threshold (screen pixels)
|
||||||
|
const nativeEvent = event.nativeEvent as unknown as PointerEvent
|
||||||
|
const dx = nativeEvent.clientX - startClientX.current
|
||||||
|
const dy = nativeEvent.clientY - startClientY.current
|
||||||
|
if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {
|
||||||
|
isDragging.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging.current && rectFillRef.current && outlineRef.current) {
|
||||||
|
updateRectVisuals(
|
||||||
|
rectFillRef.current,
|
||||||
|
outlineRef.current,
|
||||||
|
startPoint.current,
|
||||||
|
currentPoint.current,
|
||||||
|
gridY.current,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Play snap sound when the set of captured nodes changes
|
||||||
|
const bounds: Bounds = {
|
||||||
|
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
||||||
|
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
|
||||||
|
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
|
||||||
|
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
|
||||||
|
}
|
||||||
|
const hitCount = collectNodeIdsInBounds(bounds).length
|
||||||
|
if (hitCount !== prevHitCount.current) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
prevHitCount.current = hitCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onMove)
|
||||||
|
return () => {
|
||||||
|
emitter.off('grid:move', onMove)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Cursor indicator */}
|
||||||
|
<CursorSphere ref={cursorRef} tooltipContent={BOX_SELECT_TOOLTIP} />
|
||||||
|
|
||||||
|
{/* Selection rectangle fill */}
|
||||||
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
ref={rectFillRef}
|
||||||
|
renderOrder={1}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
visible={false}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[1, 1]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#818cf8"
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.12}
|
||||||
|
side={DoubleSide}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Outline (LineLoop added as primitive — allocated once in ref) */}
|
||||||
|
<primitive object={outlineRef.current} />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,10 +12,12 @@ interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
|
|||||||
depthWrite?: boolean
|
depthWrite?: boolean
|
||||||
showTooltip?: boolean
|
showTooltip?: boolean
|
||||||
height?: number
|
height?: number
|
||||||
|
/** Custom tooltip content — overrides the auto-detected build tool icon */
|
||||||
|
tooltipContent?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
|
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
|
||||||
{ color = '#818cf8', showTooltip = true, height = 2.5, visible = true, ...props },
|
{ color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const tool = useEditor((s) => s.tool)
|
const tool = useEditor((s) => s.tool)
|
||||||
@@ -79,7 +81,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tool Icon Tooltip at the top of the line */}
|
{/* Tool Icon Tooltip at the top of the line */}
|
||||||
{isVisible && showTooltip && activeToolConfig && (
|
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
|
||||||
<Html
|
<Html
|
||||||
center
|
center
|
||||||
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
||||||
@@ -97,10 +99,11 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
|||||||
height: '36px',
|
height: '36px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{tooltipContent || (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
alt={activeToolConfig.label}
|
alt={activeToolConfig!.label}
|
||||||
src={activeToolConfig.iconSrc}
|
src={activeToolConfig!.iconSrc}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -108,6 +111,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
|||||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
|
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Html>
|
</Html>
|
||||||
)}
|
)}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export const ToolManager: React.FC = () => {
|
|||||||
| CeilingNode['id']
|
| CeilingNode['id']
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
// Show site boundary editor when in site phase and edit mode
|
// Show site boundary editor when in site phase (toggle controls entry/exit)
|
||||||
const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
|
const showSiteBoundaryEditor = phase === 'site'
|
||||||
|
|
||||||
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
||||||
const showSlabBoundaryEditor =
|
const showSlabBoundaryEditor =
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type LucideIcon, Pencil, Trash2 } from 'lucide-react'
|
import { Icon } from '@iconify/react'
|
||||||
|
import { type LevelNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { type LucideIcon, Trash2 } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor, { type Mode, type Phase } from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
type ModeConfig = {
|
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'delete'
|
||||||
id: Mode
|
|
||||||
|
type ControlConfig = {
|
||||||
|
id: ControlId
|
||||||
icon?: LucideIcon
|
icon?: LucideIcon
|
||||||
|
iconifyIcon?: string
|
||||||
imageSrc?: string
|
imageSrc?: string
|
||||||
label: string
|
label: string
|
||||||
shortcut: string
|
shortcut?: string
|
||||||
color: string
|
color: string
|
||||||
activeColor: string
|
activeColor: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// All available control modes
|
// Fixed set of controls — always visible, never morphs
|
||||||
const allModes: ModeConfig[] = [
|
const controls: ControlConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
imageSrc: '/icons/select.png',
|
imageSrc: '/icons/select.png',
|
||||||
@@ -27,12 +33,18 @@ const allModes: ModeConfig[] = [
|
|||||||
activeColor: 'bg-blue-500/20 text-blue-400',
|
activeColor: 'bg-blue-500/20 text-blue-400',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'edit',
|
id: 'box-select',
|
||||||
icon: Pencil,
|
iconifyIcon: 'mdi:select-drag',
|
||||||
label: 'Edit',
|
label: 'Box select',
|
||||||
shortcut: 'E',
|
color: 'hover:bg-white/5',
|
||||||
color: 'hover:bg-orange-500/20 hover:text-orange-400',
|
activeColor: 'bg-white/10 hover:bg-white/10',
|
||||||
activeColor: 'bg-orange-500/20 text-orange-400',
|
},
|
||||||
|
{
|
||||||
|
id: 'site-edit',
|
||||||
|
imageSrc: '/icons/site.png',
|
||||||
|
label: 'Edit site',
|
||||||
|
color: 'hover:bg-white/5',
|
||||||
|
activeColor: 'bg-white/10 hover:bg-white/10',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'build',
|
id: 'build',
|
||||||
@@ -50,80 +62,128 @@ const allModes: ModeConfig[] = [
|
|||||||
color: 'hover:bg-red-500/20 hover:text-red-400',
|
color: 'hover:bg-red-500/20 hover:text-red-400',
|
||||||
activeColor: 'bg-red-500/20 text-red-400',
|
activeColor: 'bg-red-500/20 text-red-400',
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: 'painting',
|
|
||||||
// icon: Paintbrush,
|
|
||||||
// label: 'Painting',
|
|
||||||
// shortcut: 'P',
|
|
||||||
// color: 'hover:bg-cyan-500/20 hover:text-cyan-400',
|
|
||||||
// activeColor: 'bg-cyan-500/20 text-cyan-400',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'guide',
|
|
||||||
// icon: Image,
|
|
||||||
// label: 'Guide',
|
|
||||||
// shortcut: 'G',
|
|
||||||
// color: 'hover:bg-purple-500/20 hover:text-purple-400',
|
|
||||||
// activeColor: 'bg-purple-500/20 text-purple-400',
|
|
||||||
// },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Define which modes are available in each editor mode
|
|
||||||
const modesByPhase: Record<Phase, Mode[]> = {
|
|
||||||
site: ['select', 'edit'],
|
|
||||||
structure: ['select', 'delete', 'build'],
|
|
||||||
furnish: ['select', 'delete', 'build'],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ControlModes() {
|
export function ControlModes() {
|
||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const phase = useEditor((state) => state.phase)
|
const phase = useEditor((state) => state.phase)
|
||||||
|
const selectionTool = useEditor((state) => state.floorplanSelectionTool)
|
||||||
const setMode = useEditor((state) => state.setMode)
|
const setMode = useEditor((state) => state.setMode)
|
||||||
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||||
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||||
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
|
||||||
const availableModeIds = modesByPhase[phase]
|
const levelNode = useScene((state) =>
|
||||||
const availableModes = allModes.filter((m) => availableModeIds.includes(m.id))
|
levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
const handleModeClick = (mode: Mode) => {
|
const isSiteEditing = phase === 'site'
|
||||||
setMode(mode)
|
const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
|
||||||
|
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
||||||
|
|
||||||
|
const getIsActive = (id: ControlId): boolean => {
|
||||||
|
if (isSiteEditing) return id === 'site-edit'
|
||||||
|
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
||||||
|
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
||||||
|
if (id === 'site-edit') return false
|
||||||
|
return mode === id
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (id: ControlId) => {
|
||||||
|
if (id === 'site-edit') {
|
||||||
|
if (isSiteEditing) {
|
||||||
|
// Toggle off → back to structure/select
|
||||||
|
setPhase('structure')
|
||||||
|
setMode('select')
|
||||||
|
setStructureLayer('elements')
|
||||||
|
} else if (isGroundFloor) {
|
||||||
|
// Enter site editing — set state directly to preserve level selection.
|
||||||
|
// setPhase('site') calls viewer.resetSelection() which clears levelId,
|
||||||
|
// breaking the 2D floorplan (it needs a level to render the SVG).
|
||||||
|
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit site editing first if needed
|
||||||
|
if (isSiteEditing) {
|
||||||
|
setPhase('structure')
|
||||||
|
setStructureLayer('elements')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'select') {
|
||||||
|
setMode('select')
|
||||||
|
setSelectionTool('click')
|
||||||
|
} else if (id === 'box-select') {
|
||||||
|
setMode('select')
|
||||||
|
setSelectionTool('marquee')
|
||||||
|
} else {
|
||||||
|
setMode(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{availableModes.map((m) => {
|
{controls.map((c) => {
|
||||||
const Icon = m.icon
|
const ModeIcon = c.icon
|
||||||
const isActive = mode === m.id
|
const isImageMode = Boolean(c.imageSrc)
|
||||||
const isImageMode = Boolean(m.imageSrc)
|
const isSiteButton = c.id === 'site-edit'
|
||||||
|
const isActive = getIsActive(c.id)
|
||||||
|
const isDisabled = isSiteButton && !canEnterSiteEdit
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground',
|
'group text-muted-foreground',
|
||||||
!(isImageMode || isActive) && m.color,
|
isSiteButton
|
||||||
!isImageMode && isActive && m.activeColor,
|
? isActive
|
||||||
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
? c.activeColor
|
||||||
isImageMode && !isActive && 'hover:bg-white/5',
|
: canEnterSiteEdit
|
||||||
|
? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
||||||
|
: 'cursor-not-allowed opacity-35 grayscale'
|
||||||
|
: !(isImageMode || isActive) && c.color,
|
||||||
|
!(isSiteButton || isImageMode) && isActive && c.activeColor,
|
||||||
|
!isSiteButton && isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
||||||
|
!isSiteButton && isImageMode && !isActive && 'hover:bg-white/5',
|
||||||
)}
|
)}
|
||||||
key={m.id}
|
disabled={isDisabled}
|
||||||
label={m.label}
|
key={c.id}
|
||||||
onClick={() => handleModeClick(m.id)}
|
label={
|
||||||
shortcut={m.shortcut}
|
isSiteButton
|
||||||
|
? isActive
|
||||||
|
? 'Exit site editing'
|
||||||
|
: canEnterSiteEdit
|
||||||
|
? 'Edit site'
|
||||||
|
: 'Site editing (ground level only)'
|
||||||
|
: c.label
|
||||||
|
}
|
||||||
|
onClick={() => handleClick(c.id)}
|
||||||
|
shortcut={c.shortcut}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{m.imageSrc ? (
|
{c.imageSrc ? (
|
||||||
<Image
|
<Image
|
||||||
alt={m.label}
|
alt={c.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
||||||
!isActive && 'opacity-60 grayscale',
|
isSiteButton
|
||||||
isActive && 'opacity-100 grayscale-0',
|
? isActive
|
||||||
|
? 'opacity-100 grayscale-0'
|
||||||
|
: ''
|
||||||
|
: isActive
|
||||||
|
? 'opacity-100 grayscale-0'
|
||||||
|
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
||||||
)}
|
)}
|
||||||
height={28}
|
height={28}
|
||||||
src={m.imageSrc}
|
src={c.imageSrc}
|
||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
|
) : c.iconifyIcon ? (
|
||||||
|
<Icon color="currentColor" height={18} icon={c.iconifyIcon} width={18} />
|
||||||
) : (
|
) : (
|
||||||
Icon && <Icon className="h-5 w-5" />
|
ModeIcon && <ModeIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,163 +1,197 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type GuideNode,
|
||||||
|
type LevelNode,
|
||||||
|
type ScanNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Diamond } from 'lucide-react'
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
import useEditor from '../../../store/use-editor'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
// ── Helper: get guide images for the current level ──────────────────────────
|
||||||
stacked: 'Stacked',
|
|
||||||
exploded: 'Exploded',
|
function useLevelGuides(): GuideNode[] {
|
||||||
solo: 'Solo',
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
return useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!levelId) return [] as GuideNode[]
|
||||||
|
const level = state.nodes[levelId]
|
||||||
|
if (!level || level.type !== 'level') return [] as GuideNode[]
|
||||||
|
return (level as LevelNode).children
|
||||||
|
.map((id) => state.nodes[id])
|
||||||
|
.filter((node): node is GuideNode => node?.type === 'guide')
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
// ── Helper: get scans for the current level ─────────────────────────────────
|
||||||
manual: 'Stack',
|
|
||||||
stacked: 'Stack',
|
function useLevelScans(): ScanNode[] {
|
||||||
exploded: 'Exploded',
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
solo: 'Solo',
|
return useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!levelId) return [] as ScanNode[]
|
||||||
|
const level = state.nodes[levelId]
|
||||||
|
if (!level || level.type !== 'level') return [] as ScanNode[]
|
||||||
|
return (level as LevelNode).children
|
||||||
|
.map((id) => state.nodes[id])
|
||||||
|
.filter((node): node is ScanNode => node?.type === 'scan')
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
// ── Guides toggle + dropdown ────────────────────────────────────────────────
|
||||||
|
|
||||||
type WallMode = 'up' | 'cutaway' | 'down'
|
function GuidesControl() {
|
||||||
|
|
||||||
const wallModeConfig: Record<
|
|
||||||
WallMode,
|
|
||||||
{ icon: React.FC<React.ComponentProps<'img'>>; label: string }
|
|
||||||
> = {
|
|
||||||
up: {
|
|
||||||
icon: (props) => (
|
|
||||||
<img alt="Full Height" height={20} src="/icons/room.png" width={20} {...props} />
|
|
||||||
),
|
|
||||||
label: 'Full Height',
|
|
||||||
},
|
|
||||||
cutaway: {
|
|
||||||
icon: (props) => (
|
|
||||||
<img alt="Cutaway" height={20} src="/icons/wallcut.png" width={20} {...props} />
|
|
||||||
),
|
|
||||||
label: 'Cutaway',
|
|
||||||
},
|
|
||||||
down: {
|
|
||||||
icon: (props) => <img alt="Low" height={20} src="/icons/walllow.png" width={20} {...props} />,
|
|
||||||
label: 'Low',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const wallModeOrder: WallMode[] = ['cutaway', 'up', 'down']
|
|
||||||
|
|
||||||
export function ViewToggles() {
|
|
||||||
const cameraMode = useViewer((state) => state.cameraMode)
|
|
||||||
const setCameraMode = useViewer((state) => state.setCameraMode)
|
|
||||||
const levelMode = useViewer((state) => state.levelMode)
|
|
||||||
const setLevelMode = useViewer((state) => state.setLevelMode)
|
|
||||||
const wallMode = useViewer((state) => state.wallMode)
|
|
||||||
const setWallMode = useViewer((state) => state.setWallMode)
|
|
||||||
const showScans = useViewer((state) => state.showScans)
|
|
||||||
const setShowScans = useViewer((state) => state.setShowScans)
|
|
||||||
const showGuides = useViewer((state) => state.showGuides)
|
const showGuides = useViewer((state) => state.showGuides)
|
||||||
const setShowGuides = useViewer((state) => state.setShowGuides)
|
const setShowGuides = useViewer((state) => state.setShowGuides)
|
||||||
const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
const toggleFloorplanOpen = useEditor((state) => state.toggleFloorplanOpen)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
const toggleCameraMode = () => {
|
const guides = useLevelGuides()
|
||||||
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
const hasGuides = guides.length > 0
|
||||||
}
|
|
||||||
|
|
||||||
const cycleLevelMode = () => {
|
const handleOpacityChange = useCallback(
|
||||||
if (levelMode === 'manual') {
|
(guideId: GuideNode['id'], opacity: number) => {
|
||||||
setLevelMode('stacked')
|
updateNode(guideId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
|
||||||
return
|
},
|
||||||
}
|
[updateNode],
|
||||||
const currentIndex = levelModeOrder.indexOf(levelMode as 'stacked' | 'exploded' | 'solo')
|
)
|
||||||
const nextIndex = (currentIndex + 1) % levelModeOrder.length
|
|
||||||
const nextMode = levelModeOrder[nextIndex]
|
|
||||||
if (nextMode) setLevelMode(nextMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cycleWallMode = () => {
|
|
||||||
const currentIndex = wallModeOrder.indexOf(wallMode)
|
|
||||||
const nextIndex = (currentIndex + 1) % wallModeOrder.length
|
|
||||||
const nextMode = wallModeOrder[nextIndex]
|
|
||||||
if (nextMode) setWallMode(nextMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
||||||
{/* Camera Mode */}
|
<div className="flex items-center">
|
||||||
|
{/* Toggle button */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className={cn(
|
className={cn(
|
||||||
cameraMode === 'orthographic'
|
'rounded-r-none p-0',
|
||||||
? 'bg-violet-500/20 text-violet-400'
|
showGuides
|
||||||
: 'hover:text-violet-400',
|
|
||||||
)}
|
|
||||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
|
||||||
onClick={toggleCameraMode}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{cameraMode === 'perspective' ? (
|
|
||||||
<Icon color="currentColor" height={24} icon="icon-park-outline:perspective" width={24} />
|
|
||||||
) : (
|
|
||||||
<Icon color="currentColor" height={24} icon="vaadin:grid" width={24} />
|
|
||||||
)}
|
|
||||||
</ActionButton>
|
|
||||||
|
|
||||||
{/* Level Mode */}
|
|
||||||
<ActionButton
|
|
||||||
className={cn(
|
|
||||||
'p-0',
|
|
||||||
levelMode === 'stacked' || levelMode === 'manual'
|
|
||||||
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
|
||||||
: 'bg-white/10 text-foreground',
|
|
||||||
)}
|
|
||||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
|
||||||
onClick={cycleLevelMode}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<span className="relative flex h-full w-full items-center justify-center pb-1">
|
|
||||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
|
||||||
{levelMode === 'exploded' && (
|
|
||||||
<Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
|
|
||||||
)}
|
|
||||||
{(levelMode === 'stacked' || levelMode === 'manual') && (
|
|
||||||
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{levelModeBadgeLabels[levelMode]}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</ActionButton>
|
|
||||||
|
|
||||||
{/* Wall Mode */}
|
|
||||||
<ActionButton
|
|
||||||
className={cn(
|
|
||||||
'p-0',
|
|
||||||
wallMode !== 'cutaway'
|
|
||||||
? 'bg-white/10'
|
? 'bg-white/10'
|
||||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||||
)}
|
)}
|
||||||
label={`Walls: ${wallModeConfig[wallMode].label}`}
|
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||||
onClick={cycleWallMode}
|
onClick={() => setShowGuides(!showGuides)}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{(() => {
|
<img
|
||||||
const Icon = wallModeConfig[wallMode].icon
|
alt="Guides"
|
||||||
return <Icon className="h-[28px] w-[28px]" />
|
className="h-[28px] w-[28px] object-contain"
|
||||||
})()}
|
src="/icons/floorplan.png"
|
||||||
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
{/* Show Scans */}
|
{/* Dropdown chevron */}
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label="Guide image settings"
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
||||||
|
isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
align="center"
|
||||||
|
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
|
||||||
|
side="top"
|
||||||
|
sideOffset={14}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
||||||
|
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground text-sm">Guide images</p>
|
||||||
|
{hasGuides && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{guides.length} guide image{guides.length !== 1 ? 's' : ''} on this level
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasGuides ? (
|
||||||
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{guides.map((guide, index) => (
|
||||||
|
<div
|
||||||
|
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
||||||
|
key={guide.id}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
||||||
|
src="/icons/floorplan.png"
|
||||||
|
/>
|
||||||
|
<p className="truncate font-medium text-foreground text-sm">
|
||||||
|
{guide.name || `Guide image ${index + 1}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SliderControl
|
||||||
|
label="Opacity"
|
||||||
|
max={100}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => handleOpacityChange(guide.id, value)}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
unit="%"
|
||||||
|
value={guide.opacity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
||||||
|
No guide images on this level yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ScansControl() {
|
||||||
|
const showScans = useViewer((state) => state.showScans)
|
||||||
|
const setShowScans = useViewer((state) => state.setShowScans)
|
||||||
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const scans = useLevelScans()
|
||||||
|
const hasScans = scans.length > 0
|
||||||
|
|
||||||
|
const handleOpacityChange = useCallback(
|
||||||
|
(scanId: ScanNode['id'], opacity: number) => {
|
||||||
|
updateNode(scanId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
|
||||||
|
},
|
||||||
|
[updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{/* Toggle button */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-0',
|
'rounded-r-none p-0',
|
||||||
showScans
|
showScans
|
||||||
? 'bg-white/10'
|
? 'bg-white/10'
|
||||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||||
@@ -170,52 +204,94 @@ export function ViewToggles() {
|
|||||||
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
{/* Show Guides */}
|
{/* Dropdown chevron */}
|
||||||
<ActionButton
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label="Scan settings"
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-0',
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
||||||
showGuides
|
isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
||||||
? 'bg-white/10'
|
|
||||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
||||||
)}
|
)}
|
||||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
type="button"
|
||||||
onClick={() => setShowGuides(!showGuides)}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
>
|
||||||
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
|
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
||||||
</ActionButton>
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ActionButton
|
<PopoverContent
|
||||||
className={cn('overflow-visible p-0', isFloorplanOpen ? 'bg-white/10' : 'hover:bg-white/5')}
|
align="center"
|
||||||
label={`2D floor plan: ${isFloorplanOpen ? 'Visible' : 'Hidden'}`}
|
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
|
||||||
onClick={toggleFloorplanOpen}
|
side="top"
|
||||||
size="icon"
|
sideOffset={14}
|
||||||
variant="ghost"
|
|
||||||
>
|
>
|
||||||
<span className="relative flex h-full w-full items-center justify-center pb-1">
|
<div className="space-y-3">
|
||||||
<img
|
<div className="flex items-center gap-2">
|
||||||
alt="2D floor plan"
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
||||||
className={cn(
|
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
|
||||||
'h-[28px] w-[28px] object-contain transition-[filter,opacity] duration-200',
|
</span>
|
||||||
isFloorplanOpen ? 'opacity-100 grayscale-0' : 'opacity-60 grayscale',
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground text-sm">Scans</p>
|
||||||
|
{hasScans && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{scans.length} scan{scans.length !== 1 ? 's' : ''} on this level
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
src="/icons/blueprint.png"
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasScans ? (
|
||||||
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{scans.map((scan, index) => (
|
||||||
|
<div
|
||||||
|
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
||||||
|
key={scan.id}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
||||||
|
src="/icons/mesh.png"
|
||||||
/>
|
/>
|
||||||
<span
|
<p className="truncate font-medium text-foreground text-sm">
|
||||||
aria-hidden="true"
|
{scan.name || `Scan ${index + 1}`}
|
||||||
className="pointer-events-none absolute -top-1 -right-1 z-10 rounded-full border border-background/80 bg-emerald-600 px-1.5 py-0.5 font-semibold text-[7px] text-white leading-none shadow-[0_4px_10px_rgba(5,150,105,0.24)]"
|
</p>
|
||||||
>
|
</div>
|
||||||
New
|
<SliderControl
|
||||||
</span>
|
label="Opacity"
|
||||||
<span
|
max={100}
|
||||||
aria-hidden="true"
|
min={0}
|
||||||
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
|
onChange={(value) => handleOpacityChange(scan.id, value)}
|
||||||
>
|
precision={0}
|
||||||
2D
|
step={1}
|
||||||
</span>
|
unit="%"
|
||||||
</span>
|
value={scan.opacity}
|
||||||
</ActionButton>
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
||||||
|
No scans on this level yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ViewToggles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ViewToggles() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Scans (toggle + dropdown) */}
|
||||||
|
<ScansControl />
|
||||||
|
|
||||||
|
{/* Guides (toggle + dropdown) */}
|
||||||
|
<GuidesControl />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function getLevelDisplayLabel(level: LevelNode) {
|
||||||
|
return level.name || `Level ${level.level}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingLevelSelector() {
|
||||||
|
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
|
||||||
|
// Resolve the effective building ID — selected or first in scene (scalar, stable reference)
|
||||||
|
const resolvedBuildingId = useScene((state) => {
|
||||||
|
if (selectedBuildingId) return selectedBuildingId
|
||||||
|
const first = Object.values(state.nodes).find((n) => n?.type === 'building') as
|
||||||
|
| BuildingNode
|
||||||
|
| undefined
|
||||||
|
return first?.id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get levels for the resolved building (array, useShallow for stable reference)
|
||||||
|
const levels = useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!resolvedBuildingId) return [] as LevelNode[]
|
||||||
|
const building = state.nodes[resolvedBuildingId]
|
||||||
|
if (!building || building.type !== 'building') return [] as LevelNode[]
|
||||||
|
return (building as BuildingNode).children
|
||||||
|
.map((id) => state.nodes[id])
|
||||||
|
.filter((node): node is LevelNode => node?.type === 'level')
|
||||||
|
.sort((a, b) => a.level - b.level)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (levels.length <= 1) return null
|
||||||
|
|
||||||
|
// Display highest level at top, ground at bottom
|
||||||
|
const reversedLevels = [...levels].reverse()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto absolute top-14 left-3 z-20">
|
||||||
|
{/* Outer: rounded-xl (12px) with p-1 (4px) → inner: rounded-lg (8px) for concentric radii */}
|
||||||
|
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
|
||||||
|
{reversedLevels.map((level) => {
|
||||||
|
const isSelected = level.id === levelId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-[80px] items-center justify-start rounded-lg px-2.5 py-1.5 font-medium text-xs transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-white/10 text-foreground'
|
||||||
|
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
|
||||||
|
)}
|
||||||
|
key={level.id}
|
||||||
|
onClick={() =>
|
||||||
|
setSelection(
|
||||||
|
resolvedBuildingId
|
||||||
|
? { buildingId: resolvedBuildingId, levelId: level.id }
|
||||||
|
: { levelId: level.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={getLevelDisplayLabel(level)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate">{getLevelDisplayLabel(level)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,9 +33,14 @@ const SIDEBAR_WIDTH_MOBILE = '18rem'
|
|||||||
const SIDEBAR_WIDTH_ICON = '3rem'
|
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
||||||
|
const SIDEBAR_MAX_WIDTH = 800
|
||||||
|
|
||||||
type SidebarStore = {
|
type SidebarStore = {
|
||||||
width: number
|
width: number
|
||||||
setWidth: (width: number) => void
|
setWidth: (width: number) => void
|
||||||
|
isCollapsed: boolean
|
||||||
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
setIsDragging: (isDragging: boolean) => void
|
setIsDragging: (isDragging: boolean) => void
|
||||||
}
|
}
|
||||||
@@ -44,13 +49,21 @@ export const useSidebarStore = create<SidebarStore>()(
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
width: 288, // 18rem = 288px
|
width: 288, // 18rem = 288px
|
||||||
setWidth: (width) => set({ width: Math.max(288, Math.min(width, 800)) }),
|
setWidth: (width) => {
|
||||||
|
if (width < SIDEBAR_COLLAPSE_THRESHOLD) {
|
||||||
|
set({ isCollapsed: true })
|
||||||
|
} else {
|
||||||
|
set({ width: Math.min(width, SIDEBAR_MAX_WIDTH), isCollapsed: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isCollapsed: false,
|
||||||
|
setIsCollapsed: (collapsed) => set({ isCollapsed: collapsed }),
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
setIsDragging: (isDragging) => set({ isDragging }),
|
setIsDragging: (isDragging) => set({ isDragging }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'sidebar-preferences',
|
name: 'sidebar-preferences',
|
||||||
partialize: (state) => ({ width: state.width }), // Only persist width
|
partialize: (state) => ({ width: state.width, isCollapsed: state.isCollapsed }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from './../../../lib/utils'
|
||||||
|
|
||||||
|
export type SidebarTab = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
tabs: SidebarTab[]
|
||||||
|
activeTab: string
|
||||||
|
onTabChange: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 shrink-0 items-center gap-0.5 border-border/50 border-b px-2">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'relative h-7 rounded-md px-3 font-medium text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/react'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import useEditor from '../../store/use-editor'
|
||||||
|
import type { ViewMode } from '../../store/use-editor'
|
||||||
|
import { useSidebarStore } from './primitives/sidebar'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
|
||||||
|
|
||||||
|
// ── Shared styles ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Container for a group of buttons — no padding, overflow-hidden clips children flush. */
|
||||||
|
const TOOLBAR_CONTAINER =
|
||||||
|
'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md'
|
||||||
|
|
||||||
|
/** Ghost button inside a container — flush edges, no individual border/radius. */
|
||||||
|
const TOOLBAR_BTN =
|
||||||
|
'flex items-center justify-center w-8 text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
|
||||||
|
|
||||||
|
// ── View mode segmented control ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
id: '3d',
|
||||||
|
label: '3D',
|
||||||
|
icon: <img alt="" className="h-3.5 w-3.5 object-contain" src="/icons/building.png" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2d',
|
||||||
|
label: '2D',
|
||||||
|
icon: <img alt="" className="h-3.5 w-3.5 object-contain" src="/icons/blueprint.png" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'split',
|
||||||
|
label: 'Split',
|
||||||
|
icon: <Columns2 className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function ViewModeControl() {
|
||||||
|
const viewMode = useEditor((s) => s.viewMode)
|
||||||
|
const setViewMode = useEditor((s) => s.setViewMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={TOOLBAR_CONTAINER}>
|
||||||
|
{VIEW_MODES.map((mode) => {
|
||||||
|
const isActive = viewMode === mode.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-1.5 px-2.5 font-medium text-xs transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-white/10 text-foreground'
|
||||||
|
: 'text-muted-foreground/70 hover:bg-white/8 hover:text-muted-foreground',
|
||||||
|
)}
|
||||||
|
key={mode.id}
|
||||||
|
onClick={() => setViewMode(mode.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{mode.icon}
|
||||||
|
<span>{mode.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collapse sidebar button ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CollapseSidebarButton() {
|
||||||
|
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||||
|
const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setIsCollapsed(!isCollapsed)
|
||||||
|
}, [isCollapsed, setIsCollapsed])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={TOOLBAR_CONTAINER}>
|
||||||
|
<button
|
||||||
|
className={TOOLBAR_BTN}
|
||||||
|
onClick={toggle}
|
||||||
|
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronsRight className="h-4 w-4" /> : <ChevronsLeft className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Right toolbar buttons ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WalkthroughButton() {
|
||||||
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
|
const setFirstPersonMode = useEditor((s) => s.setFirstPersonMode)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setFirstPersonMode(!isFirstPersonMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
TOOLBAR_BTN,
|
||||||
|
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
|
||||||
|
)}
|
||||||
|
onClick={toggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Footprints className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Walkthrough</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnitToggle() {
|
||||||
|
const unit = useViewer((s) => s.unit)
|
||||||
|
const setUnit = useViewer((s) => s.setUnit)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={TOOLBAR_BTN}
|
||||||
|
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-[10px]">{unit === 'metric' ? 'm' : 'ft'}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const theme = useViewer((s) => s.theme)
|
||||||
|
const setTheme = useViewer((s) => s.setTheme)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(TOOLBAR_BTN, theme === 'dark' ? 'text-indigo-400/60' : 'text-amber-400/60')}
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{theme === 'dark' ? 'Dark' : 'Light'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Level mode toggle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
|
||||||
|
const levelModeLabels: Record<string, string> = {
|
||||||
|
manual: 'Stack',
|
||||||
|
stacked: 'Stack',
|
||||||
|
exploded: 'Exploded',
|
||||||
|
solo: 'Solo',
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelModeToggle() {
|
||||||
|
const levelMode = useViewer((s) => s.levelMode)
|
||||||
|
const setLevelMode = useViewer((s) => s.setLevelMode)
|
||||||
|
|
||||||
|
const cycle = () => {
|
||||||
|
if (levelMode === 'manual') {
|
||||||
|
setLevelMode('stacked')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idx = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number])
|
||||||
|
const next = levelModeOrder[(idx + 1) % levelModeOrder.length]
|
||||||
|
if (next) setLevelMode(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDefault = levelMode === 'stacked' || levelMode === 'manual'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
TOOLBAR_BTN,
|
||||||
|
'w-auto gap-1.5 px-2.5',
|
||||||
|
!isDefault && 'bg-white/10 text-foreground/90',
|
||||||
|
)}
|
||||||
|
onClick={cycle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{levelMode === 'solo' ? (
|
||||||
|
<IconifyIcon height={14} icon="lucide:diamond" width={14} />
|
||||||
|
) : levelMode === 'exploded' ? (
|
||||||
|
<IconifyIcon height={14} icon="charm:stack-pop" width={14} />
|
||||||
|
) : (
|
||||||
|
<IconifyIcon height={14} icon="charm:stack-push" width={14} />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? 'Stack'}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
Levels: {levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode]}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wall mode toggle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const wallModeOrder = ['cutaway', 'up', 'down'] as const
|
||||||
|
const wallModeConfig: Record<string, { icon: string; label: string }> = {
|
||||||
|
up: { icon: '/icons/room.png', label: 'Full height' },
|
||||||
|
cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' },
|
||||||
|
down: { icon: '/icons/walllow.png', label: 'Low' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function WallModeToggle() {
|
||||||
|
const wallMode = useViewer((s) => s.wallMode)
|
||||||
|
const setWallMode = useViewer((s) => s.setWallMode)
|
||||||
|
|
||||||
|
const cycle = () => {
|
||||||
|
const idx = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number])
|
||||||
|
const next = wallModeOrder[(idx + 1) % wallModeOrder.length]
|
||||||
|
if (next) setWallMode(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway!
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
TOOLBAR_BTN,
|
||||||
|
'w-auto gap-1.5 px-2.5',
|
||||||
|
wallMode !== 'cutaway'
|
||||||
|
? 'bg-white/10'
|
||||||
|
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
|
||||||
|
)}
|
||||||
|
onClick={cycle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img alt={config.label} className="h-4 w-4 object-contain" src={config.icon} />
|
||||||
|
<span className="font-medium text-xs">{config.label}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Walls: {config.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Camera mode toggle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CameraModeToggle() {
|
||||||
|
const cameraMode = useViewer((s) => s.cameraMode)
|
||||||
|
const setCameraMode = useViewer((s) => s.setCameraMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
TOOLBAR_BTN,
|
||||||
|
cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90',
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{cameraMode === 'perspective' ? (
|
||||||
|
<IconifyIcon height={16} icon="icon-park-outline:perspective" width={16} />
|
||||||
|
) : (
|
||||||
|
<IconifyIcon height={16} icon="vaadin:grid" width={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewButton() {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
|
||||||
|
onClick={() => useEditor.getState().setPreviewMode(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>Preview</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Preview mode</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Composed toolbar sections ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ViewerToolbarLeft() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CollapseSidebarButton />
|
||||||
|
<ViewModeControl />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewerToolbarRight() {
|
||||||
|
return (
|
||||||
|
<div className={TOOLBAR_CONTAINER}>
|
||||||
|
<LevelModeToggle />
|
||||||
|
<WallModeToggle />
|
||||||
|
<div className="my-1.5 w-px bg-border/50" />
|
||||||
|
<UnitToggle />
|
||||||
|
<ThemeToggle />
|
||||||
|
<CameraModeToggle />
|
||||||
|
<div className="my-1.5 w-px bg-border/50" />
|
||||||
|
<WalkthroughButton />
|
||||||
|
<PreviewButton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,12 +19,10 @@ export const useKeyboard = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// In first-person mode, all shortcuts are handled by FirstPersonControls
|
|
||||||
if (useEditor.getState().isFirstPersonMode) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
// If in walkthrough mode, let WalkthroughControls handle ESC
|
||||||
|
if (useViewer.getState().walkthroughMode) return
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
_toolCancelConsumed = false
|
_toolCancelConsumed = false
|
||||||
emitter.emit('tool:cancel')
|
emitter.emit('tool:cancel')
|
||||||
@@ -35,6 +33,7 @@ export const useKeyboard = () => {
|
|||||||
// Return to the default select tool while keeping the active building/level context.
|
// Return to the default select tool while keeping the active building/level context.
|
||||||
useEditor.getState().setEditingHole(null)
|
useEditor.getState().setEditingHole(null)
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
|
useEditor.getState().setFloorplanSelectionTool('click')
|
||||||
|
|
||||||
// Clear selections to close UI panels, but KEEP the active building and level context.
|
// Clear selections to close UI panels, but KEEP the active building and level context.
|
||||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||||
@@ -67,13 +66,7 @@ export const useKeyboard = () => {
|
|||||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
|
useEditor.getState().setFloorplanSelectionTool('click')
|
||||||
e.preventDefault()
|
|
||||||
const phase = useEditor.getState().phase
|
|
||||||
if (phase === 'structure' || phase === 'furnish') {
|
|
||||||
useEditor.getState().setMode('delete')
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [] })
|
|
||||||
}
|
|
||||||
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setMode('build')
|
useEditor.getState().setMode('build')
|
||||||
@@ -116,26 +109,23 @@ export const useKeyboard = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.key === 'r' || e.key === 'R') {
|
} else if (e.key === 'r' || e.key === 'R') {
|
||||||
// Rotate selected node if it supports rotation (items, roofs, etc.)
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedNodeIds.length === 1) {
|
if (selectedNodeIds.length === 1) {
|
||||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||||
if (node && 'rotation' in node) {
|
if (node && 'rotation' in node) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ROTATION_STEP = Math.PI / 4
|
const ROTATION_STEP = Math.PI / 4
|
||||||
let newRotationY = 0
|
|
||||||
|
|
||||||
// Handle different rotation types (number for roof, array for items/windows/doors)
|
// Handle different rotation types (number for roof, array for items/windows/doors)
|
||||||
if (typeof node.rotation === 'number') {
|
if (typeof node.rotation === 'number') {
|
||||||
newRotationY = node.rotation + ROTATION_STEP
|
useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP })
|
||||||
useScene.getState().updateNode(node.id, { rotation: newRotationY })
|
|
||||||
} else if (Array.isArray(node.rotation)) {
|
} else if (Array.isArray(node.rotation)) {
|
||||||
newRotationY = node.rotation[1] + ROTATION_STEP
|
|
||||||
useScene.getState().updateNode(node.id, {
|
useScene.getState().updateNode(node.id, {
|
||||||
rotation: [node.rotation[0], newRotationY, node.rotation[2]],
|
rotation: [node.rotation[0], node.rotation[1] + ROTATION_STEP, node.rotation[2]],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.key === 't' || e.key === 'T') {
|
} else if (e.key === 't' || e.key === 'T') {
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
export type { EditorProps } from './components/editor'
|
export type { EditorProps } from './components/editor'
|
||||||
export { default as Editor } from './components/editor'
|
export { default as Editor } from './components/editor'
|
||||||
export { useCommandPalette } from './components/ui/command-palette'
|
export { useCommandPalette } from './components/ui/command-palette'
|
||||||
|
export { SliderControl } from './components/ui/controls/slider-control'
|
||||||
|
export { FloatingLevelSelector } from './components/ui/floating-level-selector'
|
||||||
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
|
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
|
||||||
|
export { useSidebarStore } from './components/ui/primitives/sidebar'
|
||||||
export { Slider } from './components/ui/primitives/slider'
|
export { Slider } from './components/ui/primitives/slider'
|
||||||
export { SceneLoader } from './components/ui/scene-loader'
|
export { SceneLoader } from './components/ui/scene-loader'
|
||||||
export type {
|
export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
|
||||||
ProjectVisibility,
|
export {
|
||||||
SettingsPanelProps,
|
type ProjectVisibility,
|
||||||
|
SettingsPanel,
|
||||||
|
type SettingsPanelProps,
|
||||||
} from './components/ui/sidebar/panels/settings-panel'
|
} from './components/ui/sidebar/panels/settings-panel'
|
||||||
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
||||||
|
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
||||||
|
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
|
||||||
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
||||||
export { PresetsProvider } from './contexts/presets-context'
|
export { PresetsProvider } from './contexts/presets-context'
|
||||||
export type { SaveStatus } from './hooks/use-auto-save'
|
export type { SaveStatus } from './hooks/use-auto-save'
|
||||||
@@ -16,6 +23,7 @@ export type { SceneGraph } from './lib/scene'
|
|||||||
export { applySceneGraphToEditor } from './lib/scene'
|
export { applySceneGraphToEditor } from './lib/scene'
|
||||||
export { default as useAudio } from './store/use-audio'
|
export { default as useAudio } from './store/use-audio'
|
||||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||||
|
export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'
|
||||||
export { default as useEditor } from './store/use-editor'
|
export { default as useEditor } from './store/use-editor'
|
||||||
export {
|
export {
|
||||||
type PaletteView,
|
type PaletteView,
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
|
||||||
|
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
||||||
|
const MIN_FLOORPLAN_PANE_RATIO = 0.15
|
||||||
|
const MAX_FLOORPLAN_PANE_RATIO = 0.85
|
||||||
|
|
||||||
|
export type ViewMode = '3d' | '2d' | 'split'
|
||||||
|
export type SplitOrientation = 'horizontal' | 'vertical'
|
||||||
|
|
||||||
export type Phase = 'site' | 'structure' | 'furnish'
|
export type Phase = 'site' | 'structure' | 'furnish'
|
||||||
|
|
||||||
export type Mode = 'select' | 'edit' | 'delete' | 'build'
|
export type Mode = 'select' | 'edit' | 'delete' | 'build'
|
||||||
@@ -53,6 +61,8 @@ export type CatalogCategory =
|
|||||||
|
|
||||||
export type StructureLayer = 'zones' | 'elements'
|
export type StructureLayer = 'zones' | 'elements'
|
||||||
|
|
||||||
|
export type FloorplanSelectionTool = 'click' | 'marquee'
|
||||||
|
|
||||||
// Combined tool type
|
// Combined tool type
|
||||||
export type Tool = SiteTool | StructureTool | FurnishTool
|
export type Tool = SiteTool | StructureTool | FurnishTool
|
||||||
|
|
||||||
@@ -84,25 +94,42 @@ type EditorState = {
|
|||||||
// Preview mode (viewer-like experience inside the editor)
|
// Preview mode (viewer-like experience inside the editor)
|
||||||
isPreviewMode: boolean
|
isPreviewMode: boolean
|
||||||
setPreviewMode: (preview: boolean) => void
|
setPreviewMode: (preview: boolean) => void
|
||||||
// Toggleable 2D floorplan overlay
|
// View mode (3D only, 2D only, or split 2D+3D)
|
||||||
|
viewMode: ViewMode
|
||||||
|
setViewMode: (mode: ViewMode) => void
|
||||||
|
splitOrientation: SplitOrientation
|
||||||
|
setSplitOrientation: (orientation: SplitOrientation) => void
|
||||||
|
// Toggleable 2D floorplan overlay (backward compat — derived from viewMode)
|
||||||
isFloorplanOpen: boolean
|
isFloorplanOpen: boolean
|
||||||
setFloorplanOpen: (open: boolean) => void
|
setFloorplanOpen: (open: boolean) => void
|
||||||
toggleFloorplanOpen: () => void
|
toggleFloorplanOpen: () => void
|
||||||
isFloorplanHovered: boolean
|
isFloorplanHovered: boolean
|
||||||
setFloorplanHovered: (hovered: boolean) => void
|
setFloorplanHovered: (hovered: boolean) => void
|
||||||
|
floorplanSelectionTool: FloorplanSelectionTool
|
||||||
|
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
||||||
// Development-only camera debug flag for inspecting underside geometry
|
// Development-only camera debug flag for inspecting underside geometry
|
||||||
allowUndergroundCamera: boolean
|
allowUndergroundCamera: boolean
|
||||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||||
// First-person walkthrough mode (street view)
|
// First-person walkthrough mode (street view)
|
||||||
isFirstPersonMode: boolean
|
isFirstPersonMode: boolean
|
||||||
setFirstPersonMode: (enabled: boolean) => void
|
setFirstPersonMode: (enabled: boolean) => void
|
||||||
|
activeSidebarPanel: string
|
||||||
|
setActiveSidebarPanel: (id: string) => void
|
||||||
|
floorplanPaneRatio: number
|
||||||
|
setFloorplanPaneRatio: (ratio: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersistedEditorUiState = Pick<
|
export type PersistedEditorUiState = Pick<
|
||||||
EditorState,
|
EditorState,
|
||||||
'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen'
|
'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' | 'viewMode'
|
||||||
>
|
>
|
||||||
|
|
||||||
|
type PersistedEditorLayoutState = Pick<
|
||||||
|
EditorState,
|
||||||
|
'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool'
|
||||||
|
>
|
||||||
|
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
|
||||||
|
|
||||||
export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
|
export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
|
||||||
phase: 'site',
|
phase: 'site',
|
||||||
mode: 'select',
|
mode: 'select',
|
||||||
@@ -110,28 +137,53 @@ export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
|
|||||||
structureLayer: 'elements',
|
structureLayer: 'elements',
|
||||||
catalogCategory: null,
|
catalogCategory: null,
|
||||||
isFloorplanOpen: false,
|
isFloorplanOpen: false,
|
||||||
|
viewMode: '3d',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = {
|
||||||
|
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||||
|
floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
|
||||||
|
splitOrientation: 'horizontal',
|
||||||
|
floorplanSelectionTool: 'click',
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
|
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
|
||||||
if (phase === 'site') {
|
if (phase === 'site') {
|
||||||
return mode === 'edit' ? 'edit' : 'select'
|
return 'select'
|
||||||
}
|
}
|
||||||
|
|
||||||
return mode === 'build' || mode === 'delete' ? mode : 'select'
|
return mode === 'build' || mode === 'delete' ? mode : 'select'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFloorplanPaneRatio(value: unknown): number {
|
||||||
|
if (!(typeof value === 'number' && Number.isFinite(value))) {
|
||||||
|
return DEFAULT_FLOORPLAN_PANE_RATIO
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(MAX_FLOORPLAN_PANE_RATIO, Math.max(MIN_FLOORPLAN_PANE_RATIO, value))
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePersistedEditorUiState(
|
export function normalizePersistedEditorUiState(
|
||||||
state: Partial<PersistedEditorUiState> | null | undefined,
|
state: Partial<PersistedEditorUiState> | null | undefined,
|
||||||
): PersistedEditorUiState {
|
): PersistedEditorUiState {
|
||||||
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
|
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
|
||||||
const mode = normalizeModeForPhase(phase, state?.mode)
|
const mode = normalizeModeForPhase(phase, state?.mode)
|
||||||
const isFloorplanOpen = Boolean(state?.isFloorplanOpen)
|
|
||||||
|
// Migrate old isFloorplanOpen to viewMode
|
||||||
|
let viewMode: ViewMode = '3d'
|
||||||
|
if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {
|
||||||
|
viewMode = state.viewMode
|
||||||
|
} else if (state?.isFloorplanOpen) {
|
||||||
|
viewMode = 'split'
|
||||||
|
}
|
||||||
|
const isFloorplanOpen = viewMode !== '3d'
|
||||||
|
|
||||||
if (phase === 'site') {
|
if (phase === 'site') {
|
||||||
return {
|
return {
|
||||||
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
|
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
|
||||||
phase,
|
phase,
|
||||||
mode,
|
mode,
|
||||||
|
viewMode,
|
||||||
isFloorplanOpen,
|
isFloorplanOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +195,7 @@ export function normalizePersistedEditorUiState(
|
|||||||
tool: mode === 'build' ? 'item' : null,
|
tool: mode === 'build' ? 'item' : null,
|
||||||
structureLayer: 'elements',
|
structureLayer: 'elements',
|
||||||
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
|
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
|
||||||
|
viewMode,
|
||||||
isFloorplanOpen,
|
isFloorplanOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +209,7 @@ export function normalizePersistedEditorUiState(
|
|||||||
tool: null,
|
tool: null,
|
||||||
structureLayer,
|
structureLayer,
|
||||||
catalogCategory: null,
|
catalogCategory: null,
|
||||||
|
viewMode,
|
||||||
isFloorplanOpen,
|
isFloorplanOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +221,7 @@ export function normalizePersistedEditorUiState(
|
|||||||
tool: 'zone',
|
tool: 'zone',
|
||||||
structureLayer,
|
structureLayer,
|
||||||
catalogCategory: null,
|
catalogCategory: null,
|
||||||
|
viewMode,
|
||||||
isFloorplanOpen,
|
isFloorplanOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,10 +233,25 @@ export function normalizePersistedEditorUiState(
|
|||||||
state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
|
state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
|
||||||
structureLayer,
|
structureLayer,
|
||||||
catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
|
catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
|
||||||
|
viewMode,
|
||||||
isFloorplanOpen,
|
isFloorplanOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePersistedEditorLayoutState(
|
||||||
|
state: Partial<PersistedEditorLayoutState> | null | undefined,
|
||||||
|
): PersistedEditorLayoutState {
|
||||||
|
return {
|
||||||
|
activeSidebarPanel:
|
||||||
|
typeof state?.activeSidebarPanel === 'string' && state.activeSidebarPanel.trim()
|
||||||
|
? state.activeSidebarPanel
|
||||||
|
: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||||
|
floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
|
||||||
|
splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
|
||||||
|
floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function hasCustomPersistedEditorUiState(
|
export function hasCustomPersistedEditorUiState(
|
||||||
state: Partial<PersistedEditorUiState> | null | undefined,
|
state: Partial<PersistedEditorUiState> | null | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -193,43 +263,19 @@ export function hasCustomPersistedEditorUiState(
|
|||||||
normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
|
normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
|
||||||
normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
|
normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
|
||||||
normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
|
normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
|
||||||
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen
|
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen ||
|
||||||
|
normalizedState.viewMode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditor = create<EditorState>()(
|
/**
|
||||||
persist(
|
* Selects the first building and level 0 in the scene.
|
||||||
(set, get) => ({
|
* Safe to call any time — no-ops if already selected or scene is empty.
|
||||||
phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
|
*/
|
||||||
setPhase: (phase) => {
|
export function selectDefaultBuildingAndLevel() {
|
||||||
const currentPhase = get().phase
|
|
||||||
if (currentPhase === phase) return
|
|
||||||
|
|
||||||
set({ phase })
|
|
||||||
|
|
||||||
const { mode, structureLayer } = get()
|
|
||||||
|
|
||||||
if (mode === 'build') {
|
|
||||||
// Stay in build mode, select the first tool for the new phase
|
|
||||||
if (phase === 'site') {
|
|
||||||
set({ tool: 'property-line', catalogCategory: null })
|
|
||||||
} else if (phase === 'structure' && structureLayer === 'zones') {
|
|
||||||
set({ tool: 'zone', catalogCategory: null })
|
|
||||||
} else if (phase === 'structure') {
|
|
||||||
set({ tool: 'wall', catalogCategory: null })
|
|
||||||
} else if (phase === 'furnish') {
|
|
||||||
set({ tool: 'item', catalogCategory: 'furniture' })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Reset to select mode and clear tool/catalog when switching phases
|
|
||||||
set({ mode: 'select', tool: null, catalogCategory: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewer = useViewer.getState()
|
const viewer = useViewer.getState()
|
||||||
const scene = useScene.getState()
|
const scene = useScene.getState()
|
||||||
|
|
||||||
// Helper to find building and level 0
|
|
||||||
const selectBuildingAndLevel0 = () => {
|
|
||||||
let buildingId = viewer.selection.buildingId
|
let buildingId = viewer.selection.buildingId
|
||||||
|
|
||||||
// If no building selected, find the first one from site's children
|
// If no building selected, find the first one from site's children
|
||||||
@@ -262,6 +308,36 @@ const useEditor = create<EditorState>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useEditor = create<EditorState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
|
||||||
|
setPhase: (phase) => {
|
||||||
|
const currentPhase = get().phase
|
||||||
|
if (currentPhase === phase) return
|
||||||
|
|
||||||
|
set({ phase })
|
||||||
|
|
||||||
|
const { mode, structureLayer } = get()
|
||||||
|
|
||||||
|
if (mode === 'build') {
|
||||||
|
// Stay in build mode, select the first tool for the new phase
|
||||||
|
if (phase === 'site') {
|
||||||
|
set({ tool: 'property-line', catalogCategory: null })
|
||||||
|
} else if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
|
set({ tool: 'zone', catalogCategory: null })
|
||||||
|
} else if (phase === 'structure') {
|
||||||
|
set({ tool: 'wall', catalogCategory: null })
|
||||||
|
} else if (phase === 'furnish') {
|
||||||
|
set({ tool: 'item', catalogCategory: 'furniture' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset to select mode and clear tool/catalog when switching phases
|
||||||
|
set({ mode: 'select', tool: null, catalogCategory: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewer = useViewer.getState()
|
||||||
|
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case 'site':
|
case 'site':
|
||||||
// In Site mode, we zoom out and deselect specific levels/buildings
|
// In Site mode, we zoom out and deselect specific levels/buildings
|
||||||
@@ -269,11 +345,11 @@ const useEditor = create<EditorState>()(
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'structure':
|
case 'structure':
|
||||||
selectBuildingAndLevel0()
|
selectDefaultBuildingAndLevel()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'furnish':
|
case 'furnish':
|
||||||
selectBuildingAndLevel0()
|
selectDefaultBuildingAndLevel()
|
||||||
// Furnish mode only supports elements layer, not zones
|
// Furnish mode only supports elements layer, not zones
|
||||||
set({ structureLayer: 'elements' })
|
set({ structureLayer: 'elements' })
|
||||||
break
|
break
|
||||||
@@ -343,36 +419,64 @@ const useEditor = create<EditorState>()(
|
|||||||
set({ isPreviewMode: false })
|
set({ isPreviewMode: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
|
||||||
|
setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
|
||||||
|
splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
|
||||||
|
setSplitOrientation: (orientation) => set({ splitOrientation: orientation }),
|
||||||
isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
|
isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
|
||||||
setFloorplanOpen: (open) => set({ isFloorplanOpen: open }),
|
setFloorplanOpen: (open) => set({ isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }),
|
||||||
toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })),
|
toggleFloorplanOpen: () =>
|
||||||
|
set((state) => {
|
||||||
|
const open = !state.isFloorplanOpen
|
||||||
|
return { isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }
|
||||||
|
}),
|
||||||
isFloorplanHovered: false,
|
isFloorplanHovered: false,
|
||||||
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||||
|
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
|
||||||
|
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
||||||
allowUndergroundCamera: false,
|
allowUndergroundCamera: false,
|
||||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||||
isFirstPersonMode: false,
|
isFirstPersonMode: false,
|
||||||
|
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
||||||
setFirstPersonMode: (enabled) => {
|
setFirstPersonMode: (enabled) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
// Save current view mode and force 3D for immersive walkthrough
|
||||||
|
const currentViewMode = get().viewMode
|
||||||
// Force perspective camera and full-height walls for immersive walkthrough
|
// Force perspective camera and full-height walls for immersive walkthrough
|
||||||
useViewer.getState().setCameraMode('perspective')
|
useViewer.getState().setCameraMode('perspective')
|
||||||
useViewer.getState().setWallMode('up')
|
useViewer.getState().setWallMode('up')
|
||||||
set({
|
set({
|
||||||
isFirstPersonMode: true,
|
isFirstPersonMode: true,
|
||||||
|
_viewModeBeforeFirstPerson: currentViewMode,
|
||||||
|
viewMode: '3d',
|
||||||
|
isFloorplanOpen: false,
|
||||||
mode: 'select',
|
mode: 'select',
|
||||||
tool: null,
|
tool: null,
|
||||||
catalogCategory: null,
|
catalogCategory: null,
|
||||||
})
|
})
|
||||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||||
} else {
|
} else {
|
||||||
set({ isFirstPersonMode: false })
|
// Restore previous view mode
|
||||||
|
const prevMode = get()._viewModeBeforeFirstPerson
|
||||||
|
set({
|
||||||
|
isFirstPersonMode: false,
|
||||||
|
_viewModeBeforeFirstPerson: null,
|
||||||
|
...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||||
|
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
||||||
|
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
||||||
|
setFloorplanPaneRatio: (ratio) =>
|
||||||
|
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'pascal-editor-ui-preferences',
|
name: 'pascal-editor-ui-preferences',
|
||||||
merge: (persistedState, currentState) => ({
|
merge: (persistedState, currentState) => ({
|
||||||
...currentState,
|
...currentState,
|
||||||
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorUiState>),
|
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
|
||||||
|
...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
|
||||||
}),
|
}),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
phase: state.phase,
|
phase: state.phase,
|
||||||
@@ -381,6 +485,11 @@ const useEditor = create<EditorState>()(
|
|||||||
structureLayer: state.structureLayer,
|
structureLayer: state.structureLayer,
|
||||||
catalogCategory: state.catalogCategory,
|
catalogCategory: state.catalogCategory,
|
||||||
isFloorplanOpen: state.isFloorplanOpen,
|
isFloorplanOpen: state.isFloorplanOpen,
|
||||||
|
viewMode: state.viewMode,
|
||||||
|
activeSidebarPanel: state.activeSidebarPanel,
|
||||||
|
floorplanPaneRatio: state.floorplanPaneRatio,
|
||||||
|
splitOrientation: state.splitOrientation,
|
||||||
|
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user