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:
Pascal
2026-04-02 11:24:19 -04:00
committed by GitHub
parent b4d9f3fbf6
commit 50eca057ca
19 changed files with 3230 additions and 1898 deletions
+21 -2
View File
@@ -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>
) )
} }
+2 -2
View File
@@ -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()
setIsPanelReady(true) setPanelRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height })
}, []) 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,32 +5892,34 @@ export function FloorplanPanel() {
}, },
[selectedOpeningEntry, setMovingNode, setSelection], [selectedOpeningEntry, setMovingNode, setSelection],
) )
const duplicateSelectedOpening = useCallback(() => {
const opening = selectedOpeningEntry?.opening
if (!opening?.parentId) {
return
}
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const cloned = structuredClone(opening) as Record<string, unknown>
delete cloned.id
cloned.metadata = {
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
isNew: true,
}
const duplicate = opening.type === 'door' ? DoorNode.parse(cloned) : WindowNode.parse(cloned)
useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [selectedOpeningEntry, setMovingNode, setSelection])
const handleSelectedOpeningDuplicate = useCallback( const handleSelectedOpeningDuplicate = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => { (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()
duplicateSelectedOpening()
const opening = selectedOpeningEntry?.opening
if (!opening?.parentId) {
return
}
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const cloned = structuredClone(opening) as Record<string, unknown>
delete cloned.id
cloned.metadata = {
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
isNew: true,
}
const duplicate = opening.type === 'door' ? DoorNode.parse(cloned) : WindowNode.parse(cloned)
useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, },
[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}
File diff suppressed because it is too large Load Diff
@@ -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,17 +99,19 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
height: '36px', height: '36px',
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {tooltipContent || (
<img // eslint-disable-next-line @next/next/no-img-element
alt={activeToolConfig.label} <img
src={activeToolConfig.iconSrc} alt={activeToolConfig!.label}
style={{ src={activeToolConfig!.iconSrc}
width: '100%', style={{
height: '100%', width: '100%',
objectFit: 'contain', height: '100%',
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))', objectFit: 'contain',
}} 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,221 +1,297 @@
'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">
<ActionButton {/* Toggle button */}
className={cn( <ActionButton
cameraMode === 'orthographic' className={cn(
? 'bg-violet-500/20 text-violet-400' 'rounded-r-none p-0',
: 'hover:text-violet-400', showGuides
)} ? 'bg-white/10'
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`} : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
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') && ( label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} /> onClick={() => setShowGuides(!showGuides)}
)} size="icon"
<span variant="ghost"
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'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`Walls: ${wallModeConfig[wallMode].label}`}
onClick={cycleWallMode}
size="icon"
variant="ghost"
>
{(() => {
const Icon = wallModeConfig[wallMode].icon
return <Icon className="h-[28px] w-[28px]" />
})()}
</ActionButton>
{/* Show Scans */}
<ActionButton
className={cn(
'p-0',
showScans
? 'bg-white/10'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
onClick={() => setShowScans(!showScans)}
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
</ActionButton>
{/* Show Guides */}
<ActionButton
className={cn(
'p-0',
showGuides
? 'bg-white/10'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
onClick={() => setShowGuides(!showGuides)}
size="icon"
variant="ghost"
>
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
</ActionButton>
<ActionButton
className={cn('overflow-visible p-0', isFloorplanOpen ? 'bg-white/10' : 'hover:bg-white/5')}
label={`2D floor plan: ${isFloorplanOpen ? 'Visible' : 'Hidden'}`}
onClick={toggleFloorplanOpen}
size="icon"
variant="ghost"
>
<span className="relative flex h-full w-full items-center justify-center pb-1">
<img <img
alt="2D floor plan" alt="Guides"
className={cn( className="h-[28px] w-[28px] object-contain"
'h-[28px] w-[28px] object-contain transition-[filter,opacity] duration-200', src="/icons/floorplan.png"
isFloorplanOpen ? 'opacity-100 grayscale-0' : 'opacity-60 grayscale',
)}
src="/icons/blueprint.png"
/> />
<span </ActionButton>
aria-hidden="true"
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)]" {/* 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"
> >
New <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
</span> </button>
<span </PopoverTrigger>
aria-hidden="true" </div>
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"
<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
className={cn(
'rounded-r-none p-0',
showScans
? 'bg-white/10'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
onClick={() => setShowScans(!showScans)}
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
</ActionButton>
{/* Dropdown chevron */}
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-label="Scan 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"
> >
2D <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
</span> </button>
</span> </PopoverTrigger>
</ActionButton> </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/mesh.png" />
</span>
<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>
)}
</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"
/>
<p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`}
</p>
</div>
<SliderControl
label="Opacity"
max={100}
min={0}
onChange={(value) => handleOpacityChange(scan.id, value)}
precision={0}
step={1}
unit="%"
value={scan.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 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>
)
}
+9 -19
View File
@@ -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') {
+11 -3
View File
@@ -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,
+498 -389
View File
@@ -1,389 +1,498 @@
'use client' 'use client'
import type { AssetInput } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
import { import {
type BuildingNode, type BuildingNode,
type DoorNode, type DoorNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type Space, type Space,
useScene, useScene,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
export type Phase = 'site' | 'structure' | 'furnish' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
export type Mode = 'select' | 'edit' | 'delete' | 'build' const MIN_FLOORPLAN_PANE_RATIO = 0.15
const MAX_FLOORPLAN_PANE_RATIO = 0.85
// Structure mode tools (building elements)
export type StructureTool = export type ViewMode = '3d' | '2d' | 'split'
| 'wall' export type SplitOrientation = 'horizontal' | 'vertical'
| 'room'
| 'custom-room' export type Phase = 'site' | 'structure' | 'furnish'
| 'slab'
| 'ceiling' export type Mode = 'select' | 'edit' | 'delete' | 'build'
| 'roof'
| 'column' // Structure mode tools (building elements)
| 'stair' export type StructureTool =
| 'item' | 'wall'
| 'zone' | 'room'
| 'window' | 'custom-room'
| 'door' | 'slab'
| 'ceiling'
// Furnish mode tools (items and decoration) | 'roof'
export type FurnishTool = 'item' | 'column'
| 'stair'
// Site mode tools | 'item'
export type SiteTool = 'property-line' | 'zone'
| 'window'
// Catalog categories for furnish mode items | 'door'
export type CatalogCategory =
| 'furniture' // Furnish mode tools (items and decoration)
| 'appliance' export type FurnishTool = 'item'
| 'bathroom'
| 'kitchen' // Site mode tools
| 'outdoor' export type SiteTool = 'property-line'
| 'window'
| 'door' // Catalog categories for furnish mode items
export type CatalogCategory =
export type StructureLayer = 'zones' | 'elements' | 'furniture'
| 'appliance'
// Combined tool type | 'bathroom'
export type Tool = SiteTool | StructureTool | FurnishTool | 'kitchen'
| 'outdoor'
type EditorState = { | 'window'
phase: Phase | 'door'
setPhase: (phase: Phase) => void
mode: Mode export type StructureLayer = 'zones' | 'elements'
setMode: (mode: Mode) => void
tool: Tool | null export type FloorplanSelectionTool = 'click' | 'marquee'
setTool: (tool: Tool | null) => void
structureLayer: StructureLayer // Combined tool type
setStructureLayer: (layer: StructureLayer) => void export type Tool = SiteTool | StructureTool | FurnishTool
catalogCategory: CatalogCategory | null
setCatalogCategory: (category: CatalogCategory | null) => void type EditorState = {
selectedItem: AssetInput | null phase: Phase
setSelectedItem: (item: AssetInput) => void setPhase: (phase: Phase) => void
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null mode: Mode
setMovingNode: ( setMode: (mode: Mode) => void
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, tool: Tool | null
) => void setTool: (tool: Tool | null) => void
selectedReferenceId: string | null structureLayer: StructureLayer
setSelectedReferenceId: (id: string | null) => void setStructureLayer: (layer: StructureLayer) => void
// Space detection for cutaway mode catalogCategory: CatalogCategory | null
spaces: Record<string, Space> setCatalogCategory: (category: CatalogCategory | null) => void
setSpaces: (spaces: Record<string, Space>) => void selectedItem: AssetInput | null
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes) setSelectedItem: (item: AssetInput) => void
editingHole: { nodeId: string; holeIndex: number } | null movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void setMovingNode: (
// Preview mode (viewer-like experience inside the editor) node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
isPreviewMode: boolean ) => void
setPreviewMode: (preview: boolean) => void selectedReferenceId: string | null
// Toggleable 2D floorplan overlay setSelectedReferenceId: (id: string | null) => void
isFloorplanOpen: boolean // Space detection for cutaway mode
setFloorplanOpen: (open: boolean) => void spaces: Record<string, Space>
toggleFloorplanOpen: () => void setSpaces: (spaces: Record<string, Space>) => void
isFloorplanHovered: boolean // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
setFloorplanHovered: (hovered: boolean) => void editingHole: { nodeId: string; holeIndex: number } | null
// Development-only camera debug flag for inspecting underside geometry setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
allowUndergroundCamera: boolean // Preview mode (viewer-like experience inside the editor)
setAllowUndergroundCamera: (enabled: boolean) => void isPreviewMode: boolean
// First-person walkthrough mode (street view) setPreviewMode: (preview: boolean) => void
isFirstPersonMode: boolean // View mode (3D only, 2D only, or split 2D+3D)
setFirstPersonMode: (enabled: boolean) => void viewMode: ViewMode
} setViewMode: (mode: ViewMode) => void
splitOrientation: SplitOrientation
export type PersistedEditorUiState = Pick< setSplitOrientation: (orientation: SplitOrientation) => void
EditorState, // Toggleable 2D floorplan overlay (backward compat — derived from viewMode)
'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' isFloorplanOpen: boolean
> setFloorplanOpen: (open: boolean) => void
toggleFloorplanOpen: () => void
export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = { isFloorplanHovered: boolean
phase: 'site', setFloorplanHovered: (hovered: boolean) => void
mode: 'select', floorplanSelectionTool: FloorplanSelectionTool
tool: null, setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
structureLayer: 'elements', // Development-only camera debug flag for inspecting underside geometry
catalogCategory: null, allowUndergroundCamera: boolean
isFloorplanOpen: false, setAllowUndergroundCamera: (enabled: boolean) => void
} // First-person walkthrough mode (street view)
isFirstPersonMode: boolean
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { setFirstPersonMode: (enabled: boolean) => void
if (phase === 'site') { activeSidebarPanel: string
return mode === 'edit' ? 'edit' : 'select' setActiveSidebarPanel: (id: string) => void
} floorplanPaneRatio: number
setFloorplanPaneRatio: (ratio: number) => void
return mode === 'build' || mode === 'delete' ? mode : 'select' }
}
export type PersistedEditorUiState = Pick<
export function normalizePersistedEditorUiState( EditorState,
state: Partial<PersistedEditorUiState> | null | undefined, 'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' | 'viewMode'
): PersistedEditorUiState { >
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
const mode = normalizeModeForPhase(phase, state?.mode) type PersistedEditorLayoutState = Pick<
const isFloorplanOpen = Boolean(state?.isFloorplanOpen) EditorState,
'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool'
if (phase === 'site') { >
return { type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
phase, export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
mode, phase: 'site',
isFloorplanOpen, mode: 'select',
} tool: null,
} structureLayer: 'elements',
catalogCategory: null,
if (phase === 'furnish') { isFloorplanOpen: false,
return { viewMode: '3d',
phase, }
mode,
tool: mode === 'build' ? 'item' : null, export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = {
structureLayer: 'elements', activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null, floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
isFloorplanOpen, splitOrientation: 'horizontal',
} floorplanSelectionTool: 'click',
} }
const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements' function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
if (phase === 'site') {
if (mode !== 'build') { return 'select'
return { }
phase,
mode, return mode === 'build' || mode === 'delete' ? mode : 'select'
tool: null, }
structureLayer,
catalogCategory: null, function normalizeFloorplanPaneRatio(value: unknown): number {
isFloorplanOpen, if (!(typeof value === 'number' && Number.isFinite(value))) {
} return DEFAULT_FLOORPLAN_PANE_RATIO
} }
if (structureLayer === 'zones') { return Math.min(MAX_FLOORPLAN_PANE_RATIO, Math.max(MIN_FLOORPLAN_PANE_RATIO, value))
return { }
phase,
mode, export function normalizePersistedEditorUiState(
tool: 'zone', state: Partial<PersistedEditorUiState> | null | undefined,
structureLayer, ): PersistedEditorUiState {
catalogCategory: null, const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
isFloorplanOpen, const mode = normalizeModeForPhase(phase, state?.mode)
}
} // Migrate old isFloorplanOpen to viewMode
let viewMode: ViewMode = '3d'
return { if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {
phase, viewMode = state.viewMode
mode, } else if (state?.isFloorplanOpen) {
tool: viewMode = 'split'
state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall', }
structureLayer, const isFloorplanOpen = viewMode !== '3d'
catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
isFloorplanOpen, if (phase === 'site') {
} return {
} ...DEFAULT_PERSISTED_EDITOR_UI_STATE,
phase,
export function hasCustomPersistedEditorUiState( mode,
state: Partial<PersistedEditorUiState> | null | undefined, viewMode,
): boolean { isFloorplanOpen,
const normalizedState = normalizePersistedEditorUiState(state) }
}
return (
normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase || if (phase === 'furnish') {
normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode || return {
normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool || phase,
normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer || mode,
normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory || tool: mode === 'build' ? 'item' : null,
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen structureLayer: 'elements',
) catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
} viewMode,
isFloorplanOpen,
const useEditor = create<EditorState>()( }
persist( }
(set, get) => ({
phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase, const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
setPhase: (phase) => {
const currentPhase = get().phase if (mode !== 'build') {
if (currentPhase === phase) return return {
phase,
set({ phase }) mode,
tool: null,
const { mode, structureLayer } = get() structureLayer,
catalogCategory: null,
if (mode === 'build') { viewMode,
// Stay in build mode, select the first tool for the new phase isFloorplanOpen,
if (phase === 'site') { }
set({ tool: 'property-line', catalogCategory: null }) }
} else if (phase === 'structure' && structureLayer === 'zones') {
set({ tool: 'zone', catalogCategory: null }) if (structureLayer === 'zones') {
} else if (phase === 'structure') { return {
set({ tool: 'wall', catalogCategory: null }) phase,
} else if (phase === 'furnish') { mode,
set({ tool: 'item', catalogCategory: 'furniture' }) tool: 'zone',
} structureLayer,
} else { catalogCategory: null,
// Reset to select mode and clear tool/catalog when switching phases viewMode,
set({ mode: 'select', tool: null, catalogCategory: null }) isFloorplanOpen,
} }
}
const viewer = useViewer.getState()
const scene = useScene.getState() return {
phase,
// Helper to find building and level 0 mode,
const selectBuildingAndLevel0 = () => { tool:
let buildingId = viewer.selection.buildingId state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
structureLayer,
// If no building selected, find the first one from site's children catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
if (!buildingId) { viewMode,
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null isFloorplanOpen,
if (siteNode?.type === 'site') { }
const firstBuilding = siteNode.children }
.map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
.find((node) => node?.type === 'building') function normalizePersistedEditorLayoutState(
if (firstBuilding) { state: Partial<PersistedEditorLayoutState> | null | undefined,
buildingId = firstBuilding.id as BuildingNode['id'] ): PersistedEditorLayoutState {
viewer.setSelection({ buildingId }) return {
} activeSidebarPanel:
} typeof state?.activeSidebarPanel === 'string' && state.activeSidebarPanel.trim()
} ? state.activeSidebarPanel
: DEFAULT_ACTIVE_SIDEBAR_PANEL,
// If no level selected, find level 0 in the building floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
if (buildingId && !viewer.selection.levelId) { splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
const buildingNode = scene.nodes[buildingId] as BuildingNode floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
const level0Id = buildingNode.children.find((childId) => { }
const levelNode = scene.nodes[childId] as LevelNode }
return levelNode?.type === 'level' && levelNode.level === 0
}) export function hasCustomPersistedEditorUiState(
if (level0Id) { state: Partial<PersistedEditorUiState> | null | undefined,
viewer.setSelection({ levelId: level0Id as LevelNode['id'] }) ): boolean {
} else if (buildingNode.children[0]) { const normalizedState = normalizePersistedEditorUiState(state)
// Fallback to first level if level 0 doesn't exist
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] }) return (
} normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase ||
} normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||
} normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
switch (phase) { normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
case 'site': normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen ||
// In Site mode, we zoom out and deselect specific levels/buildings normalizedState.viewMode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode
viewer.resetSelection() )
break }
case 'structure': /**
selectBuildingAndLevel0() * Selects the first building and level 0 in the scene.
break * Safe to call any time — no-ops if already selected or scene is empty.
*/
case 'furnish': export function selectDefaultBuildingAndLevel() {
selectBuildingAndLevel0() const viewer = useViewer.getState()
// Furnish mode only supports elements layer, not zones const scene = useScene.getState()
set({ structureLayer: 'elements' })
break let buildingId = viewer.selection.buildingId
}
}, // If no building selected, find the first one from site's children
mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode, if (!buildingId) {
setMode: (mode) => { const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
set({ mode }) if (siteNode?.type === 'site') {
const firstBuilding = siteNode.children
const { phase, structureLayer, tool } = get() .map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
.find((node) => node?.type === 'building')
if (mode === 'build') { if (firstBuilding) {
// Ensure a tool is selected in build mode buildingId = firstBuilding.id as BuildingNode['id']
if (!tool) { viewer.setSelection({ buildingId })
if (phase === 'structure' && structureLayer === 'zones') { }
set({ tool: 'zone' }) }
} else if (phase === 'structure' && structureLayer === 'elements') { }
set({ tool: 'wall' })
} else if (phase === 'furnish') { // If no level selected, find level 0 in the building
set({ tool: 'item', catalogCategory: 'furniture' }) if (buildingId && !viewer.selection.levelId) {
} const buildingNode = scene.nodes[buildingId] as BuildingNode
} const level0Id = buildingNode.children.find((childId) => {
} const levelNode = scene.nodes[childId] as LevelNode
// When leaving build mode, clear tool return levelNode?.type === 'level' && levelNode.level === 0
else if (tool) { })
set({ tool: null }) if (level0Id) {
} viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
}, } else if (buildingNode.children[0]) {
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, // Fallback to first level if level 0 doesn't exist
setTool: (tool) => set({ tool }), viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer, }
setStructureLayer: (layer) => { }
const { mode } = get() }
if (mode === 'build') { const useEditor = create<EditorState>()(
const tool = layer === 'zones' ? 'zone' : 'wall' persist(
set({ structureLayer: layer, tool }) (set, get) => ({
} else { phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
set({ structureLayer: layer, mode: 'select', tool: null }) setPhase: (phase) => {
} const currentPhase = get().phase
if (currentPhase === phase) return
const viewer = useViewer.getState()
viewer.setSelection({ set({ phase })
selectedIds: [],
zoneId: null, const { mode, structureLayer } = get()
})
}, if (mode === 'build') {
catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory, // Stay in build mode, select the first tool for the new phase
setCatalogCategory: (category) => set({ catalogCategory: category }), if (phase === 'site') {
selectedItem: null, set({ tool: 'property-line', catalogCategory: null })
setSelectedItem: (item) => set({ selectedItem: item }), } else if (phase === 'structure' && structureLayer === 'zones') {
movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, set({ tool: 'zone', catalogCategory: null })
setMovingNode: (node) => set({ movingNode: node }), } else if (phase === 'structure') {
selectedReferenceId: null, set({ tool: 'wall', catalogCategory: null })
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), } else if (phase === 'furnish') {
spaces: {}, set({ tool: 'item', catalogCategory: 'furniture' })
setSpaces: (spaces) => set({ spaces }), }
editingHole: null, } else {
setEditingHole: (hole) => set({ editingHole: hole }), // Reset to select mode and clear tool/catalog when switching phases
isPreviewMode: false, set({ mode: 'select', tool: null, catalogCategory: null })
setPreviewMode: (preview) => { }
if (preview) {
set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null }) const viewer = useViewer.getState()
// Clear zone/item selection for clean viewer drill-down hierarchy
useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) switch (phase) {
} else { case 'site':
set({ isPreviewMode: false }) // In Site mode, we zoom out and deselect specific levels/buildings
} viewer.resetSelection()
}, break
isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
setFloorplanOpen: (open) => set({ isFloorplanOpen: open }), case 'structure':
toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })), selectDefaultBuildingAndLevel()
isFloorplanHovered: false, break
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
allowUndergroundCamera: false, case 'furnish':
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), selectDefaultBuildingAndLevel()
isFirstPersonMode: false, // Furnish mode only supports elements layer, not zones
setFirstPersonMode: (enabled) => { set({ structureLayer: 'elements' })
if (enabled) { break
// Force perspective camera and full-height walls for immersive walkthrough }
useViewer.getState().setCameraMode('perspective') },
useViewer.getState().setWallMode('up') mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
set({ setMode: (mode) => {
isFirstPersonMode: true, set({ mode })
mode: 'select',
tool: null, const { phase, structureLayer, tool } = get()
catalogCategory: null,
}) if (mode === 'build') {
useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) // Ensure a tool is selected in build mode
} else { if (!tool) {
set({ isFirstPersonMode: false }) if (phase === 'structure' && structureLayer === 'zones') {
} set({ tool: 'zone' })
}, } else if (phase === 'structure' && structureLayer === 'elements') {
}), set({ tool: 'wall' })
{ } else if (phase === 'furnish') {
name: 'pascal-editor-ui-preferences', set({ tool: 'item', catalogCategory: 'furniture' })
merge: (persistedState, currentState) => ({ }
...currentState, }
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorUiState>), }
}), // When leaving build mode, clear tool
partialize: (state) => ({ else if (tool) {
phase: state.phase, set({ tool: null })
mode: state.mode, }
tool: state.tool, },
structureLayer: state.structureLayer, tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
catalogCategory: state.catalogCategory, setTool: (tool) => set({ tool }),
isFloorplanOpen: state.isFloorplanOpen, structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
}), setStructureLayer: (layer) => {
}, const { mode } = get()
),
) if (mode === 'build') {
const tool = layer === 'zones' ? 'zone' : 'wall'
export default useEditor set({ structureLayer: layer, tool })
} else {
set({ structureLayer: layer, mode: 'select', tool: null })
}
const viewer = useViewer.getState()
viewer.setSelection({
selectedIds: [],
zoneId: null,
})
},
catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }),
movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
setMovingNode: (node) => set({ movingNode: node }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},
setSpaces: (spaces) => set({ spaces }),
editingHole: null,
setEditingHole: (hole) => set({ editingHole: hole }),
isPreviewMode: false,
setPreviewMode: (preview) => {
if (preview) {
set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
// Clear zone/item selection for clean viewer drill-down hierarchy
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else {
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,
setFloorplanOpen: (open) => set({ isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }),
toggleFloorplanOpen: () =>
set((state) => {
const open = !state.isFloorplanOpen
return { isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }
}),
isFloorplanHovered: false,
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
isFirstPersonMode: false,
_viewModeBeforeFirstPerson: null as ViewMode | null,
setFirstPersonMode: (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
useViewer.getState().setCameraMode('perspective')
useViewer.getState().setWallMode('up')
set({
isFirstPersonMode: true,
_viewModeBeforeFirstPerson: currentViewMode,
viewMode: '3d',
isFloorplanOpen: false,
mode: 'select',
tool: null,
catalogCategory: null,
})
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else {
// 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',
merge: (persistedState, currentState) => ({
...currentState,
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
}),
partialize: (state) => ({
phase: state.phase,
mode: state.mode,
tool: state.tool,
structureLayer: state.structureLayer,
catalogCategory: state.catalogCategory,
isFloorplanOpen: state.isFloorplanOpen,
viewMode: state.viewMode,
activeSidebarPanel: state.activeSidebarPanel,
floorplanPaneRatio: state.floorplanPaneRatio,
splitOrientation: state.splitOrientation,
floorplanSelectionTool: state.floorplanSelectionTool,
}),
},
),
)
export default useEditor