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'
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() {
return (
<div className="h-screen w-screen">
<Editor projectId="local-editor" />
<Editor
layoutVersion="v2"
projectId="local-editor"
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />}
/>
</div>
)
}
+2 -2
View File
@@ -48,7 +48,7 @@
},
"packages/core": {
"name": "@pascal-app/core",
"version": "0.3.1",
"version": "0.3.3",
"dependencies": {
"dedent": "^1.7.1",
"idb-keyval": "^6.2.2",
@@ -166,7 +166,7 @@
},
"packages/viewer": {
"name": "@pascal-app/viewer",
"version": "0.3.1",
"version": "0.3.3",
"dependencies": {
"polygon-clipping": "^0.15.7",
"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,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { ChevronDown, Command, X } from 'lucide-react'
import { Command } from 'lucide-react'
import {
memo,
type MouseEvent as ReactMouseEvent,
@@ -46,15 +46,8 @@ import {
} from '../tools/wall/wall-drafting'
import { furnishTools } from '../ui/action-menu/furnish-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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '../ui/primitives/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
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_OFFSET_Y = 10
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.
const FLOORPLAN_GUIDE_BASE_WIDTH = 10
const FLOORPLAN_GUIDE_MIN_SCALE = 0.01
@@ -173,8 +166,6 @@ type OpeningNode = WindowNode | DoorNode
type WallEndpoint = 'start' | 'end'
type FloorplanSelectionTool = 'click' | 'marquee'
type FloorplanCursorIndicator =
| {
kind: 'asset'
@@ -185,40 +176,6 @@ type FloorplanCursorIndicator =
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 = {
rect: PanelRect
viewport: ViewportBounds
@@ -2116,6 +2073,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
onSlabSelect,
onOpeningDoubleClick,
onOpeningHoverChange,
onOpeningPointerDown,
onOpeningSelect,
onWallClick,
onWallDoubleClick,
@@ -2134,6 +2092,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => void
onOpeningDoubleClick: (opening: OpeningNode) => void
onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void
onOpeningPointerDown: (openingId: OpeningNode['id'], event: ReactPointerEvent<SVGElement>) => void
onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent<SVGElement>) => void
hoveredWallId: WallNode['id'] | null
onWallClick: (wall: WallNode, event: ReactMouseEvent<SVGElement>) => void
@@ -2368,6 +2327,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
}
: undefined
}
onPointerDown={
canSelectGeometry && isSelected
? (event) => {
if (event.button === 0) {
onOpeningPointerDown(opening.id, event)
}
}
: undefined
}
onPointerEnter={
canSelectGeometry
? () => {
@@ -2499,6 +2467,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
}
: undefined
}
onPointerDown={
canSelectGeometry && isSelected
? (event) => {
if (event.button === 0) {
onOpeningPointerDown(opening.id, event)
}
}
: undefined
}
onPointerEnter={
canSelectGeometry
? () => {
@@ -3031,9 +3008,9 @@ export function FloorplanPanel() {
const gestureScaleRef = useRef(1)
const panelInteractionRef = useRef<PanelInteractionState | null>(null)
const panelBoundsRef = useRef<ViewportBounds | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const hasUserAdjustedViewportRef = useRef(false)
const previousLevelIdRef = useRef<string | null>(null)
const levelMenuCloseTimeoutRef = useRef<number | null>(null)
const levelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
@@ -3046,7 +3023,7 @@ export function FloorplanPanel() {
const setShowGuides = useViewer((state) => state.setShowGuides)
const catalogCategory = useEditor((state) => state.catalogCategory)
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
const setFloorplanOpen = useEditor((state) => state.setFloorplanOpen)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
@@ -3205,8 +3182,8 @@ export function FloorplanPanel() {
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
const [floorplanSelectionTool, setFloorplanSelectionTool] =
useState<FloorplanSelectionTool>('click')
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
null,
)
@@ -3222,8 +3199,7 @@ export function FloorplanPanel() {
width: PANEL_DEFAULT_WIDTH,
height: PANEL_DEFAULT_HEIGHT,
})
const [isLevelMenuOpen, setIsLevelMenuOpen] = useState(false)
const [isGuideQuickAccessOpen, setIsGuideQuickAccessOpen] = useState(false)
const [isPanelReady, setIsPanelReady] = useState(false)
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })
const [viewport, setViewport] = useState<FloorplanViewport | null>(null)
@@ -3238,11 +3214,6 @@ export function FloorplanPanel() {
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 polygonPoints = site?.polygon?.points
if (!(site && polygonPoints)) {
@@ -3356,20 +3327,6 @@ export function FloorplanPanel() {
const activeGuideInteractionMode = guideTransformDraft
? (guideInteractionRef.current?.mode ?? 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 wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
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
}, [displayZonePolygons, selectedZoneId])
const isSiteEditActive = phase === 'site' && mode === 'edit'
const isSiteEditActive = phase === 'site'
const isWallBuildActive = phase === 'structure' && mode === 'build' && tool === 'wall'
const isSlabBuildActive = phase === 'structure' && mode === 'build' && tool === 'slab'
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(() => {
const currentBounds = getViewportBounds()
const persistedRect = readPersistedPanelLayout(currentBounds)
setPanelRect(persistedRect ?? getInitialPanelRect(currentBounds))
panelBoundsRef.current = currentBounds
setIsPanelReady(true)
}, [])
useEffect(() => {
const handleWindowResize = () => {
const nextBounds = getViewportBounds()
const previousBounds = panelBoundsRef.current ?? nextBounds
setPanelRect((currentRect) => adaptPanelRectToBounds(currentRect, previousBounds, nextBounds))
panelBoundsRef.current = nextBounds
const el = containerRef.current
if (!el) return
const update = () => {
const rect = el.getBoundingClientRect()
setPanelRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height })
setIsPanelReady(true)
}
window.addEventListener('resize', handleWindowResize)
const observer = new ResizeObserver(update)
observer.observe(el)
window.addEventListener('resize', update)
update()
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(() => {
const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
@@ -4030,7 +3966,6 @@ export function FloorplanPanel() {
}
}, [selectedOpeningEntry, surfaceSize.height, surfaceSize.width, viewBox])
// biome-ignore lint/correctness/useExhaustiveDependencies: reset hovered corner when selected guide changes
useEffect(() => {
setHoveredGuideCorner(null)
}, [selectedGuide?.id])
@@ -4182,12 +4117,6 @@ export function FloorplanPanel() {
},
[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(
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
[surfaceSize.width, viewBox.width],
@@ -4277,50 +4206,6 @@ export function FloorplanPanel() {
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(() => {
panelInteractionRef.current = null
setIsDraggingPanel(false)
@@ -4391,12 +4276,6 @@ export function FloorplanPanel() {
}
}, [finishPanelInteraction])
useEffect(() => {
return () => {
clearLevelMenuCloseTimeout()
}
}, [clearLevelMenuCloseTimeout])
useEffect(() => {
const interaction = guideInteractionRef.current
if (interaction && !guideById.has(interaction.guideId)) {
@@ -4416,12 +4295,6 @@ export function FloorplanPanel() {
}
}, [clearGuideInteraction])
useEffect(() => {
if (!hasFloorplanLevelSwitcher) {
setIsLevelMenuOpen(false)
}
}, [hasFloorplanLevelSwitcher])
const handlePanelDragStart = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
@@ -4845,7 +4718,6 @@ export function FloorplanPanel() {
walls,
])
// biome-ignore lint/correctness/useExhaustiveDependencies: clear drag state when level changes
useEffect(() => {
clearWallEndpointDrag()
}, [clearWallEndpointDrag, levelId])
@@ -5955,6 +5827,38 @@ export function FloorplanPanel() {
},
[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(
(slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(slabId, event)
@@ -5988,32 +5892,34 @@ export function FloorplanPanel() {
},
[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(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
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: [] })
duplicateSelectedOpening()
},
[selectedOpeningEntry, setMovingNode, setSelection],
[duplicateSelectedOpening],
)
const handleSelectedOpeningDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
@@ -6718,68 +6624,6 @@ export function FloorplanPanel() {
setStructureLayer,
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(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
@@ -6810,6 +6654,36 @@ export function FloorplanPanel() {
window.removeEventListener('keydown', handleKeyDown, true)
}
}, [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 floorplanCursorColor = wallEndpointDraft
? palette.editCursor
@@ -6819,396 +6693,14 @@ export function FloorplanPanel() {
return (
<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)}
onPointerLeave={() => {
setFloorplanHovered(false)
setFloorplanCursorPosition(null)
}}
style={{
cursor: activeResizeDirection ? resizeCursorByDirection[activeResizeDirection] : undefined,
height: panelRect.height,
left: panelRect.x,
top: panelRect.y,
visibility: isPanelReady ? 'visible' : 'hidden',
width: panelRect.width,
}}
ref={containerRef}
>
{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}>
{activeFloorplanCursorIndicator && floorplanCursorPosition && !isPanning && (
<div
@@ -7318,6 +6810,7 @@ export function FloorplanPanel() {
hoveredWallId={hoveredWallId}
onOpeningDoubleClick={handleOpeningDoubleClick}
onOpeningHoverChange={setHoveredOpeningId}
onOpeningPointerDown={handleOpeningPointerDown}
onOpeningSelect={handleOpeningSelect}
onSlabDoubleClick={handleSlabDoubleClick}
onSlabSelect={handleSlabSelect}
File diff suppressed because it is too large Load Diff
@@ -11,10 +11,10 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
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(() => {
if (mode !== 'select') return
if (movingNode) return
const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node)
if (boxSelectHandled) return
const node = event.node
let currentPhase = useEditor.getState().phase
let currentStructureLayer = useEditor.getState().structureLayer
@@ -410,6 +340,7 @@ export const SelectionManager = () => {
const onGridClick = () => {
if (clickHandledRef.current) return
if (boxSelectHandled) return
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
if (activeStrategy) activeStrategy.handleDeselect()
}
@@ -548,32 +479,83 @@ export const SelectionManager = () => {
}
}, [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 (
<>
<DeleteModeCursor />
<SelectionStateSync />
<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 = () => {
useEffect(() => {
return useScene.subscribe((state) => {
@@ -2,18 +2,36 @@
import type { SiteNode } from '@pascal-app/core'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useRef, useState } from 'react'
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() {
const rootNodeIds = useScene((state) => state.rootNodeIds)
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 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 prevSiteNodeIdRef = useRef<string | undefined>(undefined)
@@ -55,8 +73,14 @@ export function SiteEdgeLabels() {
style={{ pointerEvents: 'none', userSelect: 'none' }}
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">
{edge.dist.toFixed(2)}m
<div
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>
</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
showTooltip?: boolean
height?: number
/** Custom tooltip content — overrides the auto-detected build tool icon */
tooltipContent?: React.ReactNode
}
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,
) {
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 */}
{isVisible && showTooltip && activeToolConfig && (
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
<Html
center
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
@@ -97,17 +99,19 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
height: '36px',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt={activeToolConfig.label}
src={activeToolConfig.iconSrc}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
}}
/>
{tooltipContent || (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={activeToolConfig!.label}
src={activeToolConfig!.iconSrc}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
}}
/>
)}
</Html>
)}
</group>
@@ -56,8 +56,8 @@ export const ToolManager: React.FC = () => {
| CeilingNode['id']
| undefined
// Show site boundary editor when in site phase and edit mode
const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
// Show site boundary editor when in site phase (toggle controls entry/exit)
const showSiteBoundaryEditor = phase === 'site'
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
const showSlabBoundaryEditor =
@@ -1,23 +1,29 @@
'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 { 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'
type ModeConfig = {
id: Mode
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'delete'
type ControlConfig = {
id: ControlId
icon?: LucideIcon
iconifyIcon?: string
imageSrc?: string
label: string
shortcut: string
shortcut?: string
color: string
activeColor: string
}
// All available control modes
const allModes: ModeConfig[] = [
// Fixed set of controls — always visible, never morphs
const controls: ControlConfig[] = [
{
id: 'select',
imageSrc: '/icons/select.png',
@@ -27,12 +33,18 @@ const allModes: ModeConfig[] = [
activeColor: 'bg-blue-500/20 text-blue-400',
},
{
id: 'edit',
icon: Pencil,
label: 'Edit',
shortcut: 'E',
color: 'hover:bg-orange-500/20 hover:text-orange-400',
activeColor: 'bg-orange-500/20 text-orange-400',
id: 'box-select',
iconifyIcon: 'mdi:select-drag',
label: 'Box select',
color: 'hover:bg-white/5',
activeColor: 'bg-white/10 hover:bg-white/10',
},
{
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',
@@ -50,80 +62,128 @@ const allModes: ModeConfig[] = [
color: 'hover:bg-red-500/20 hover: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() {
const mode = useEditor((state) => state.mode)
const phase = useEditor((state) => state.phase)
const selectionTool = useEditor((state) => state.floorplanSelectionTool)
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 availableModes = allModes.filter((m) => availableModeIds.includes(m.id))
const levelNode = useScene((state) =>
levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
)
const handleModeClick = (mode: Mode) => {
setMode(mode)
const isSiteEditing = phase === 'site'
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 (
<div className="flex items-center gap-1">
{availableModes.map((m) => {
const Icon = m.icon
const isActive = mode === m.id
const isImageMode = Boolean(m.imageSrc)
{controls.map((c) => {
const ModeIcon = c.icon
const isImageMode = Boolean(c.imageSrc)
const isSiteButton = c.id === 'site-edit'
const isActive = getIsActive(c.id)
const isDisabled = isSiteButton && !canEnterSiteEdit
return (
<ActionButton
className={cn(
'text-muted-foreground',
!(isImageMode || isActive) && m.color,
!isImageMode && isActive && m.activeColor,
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
isImageMode && !isActive && 'hover:bg-white/5',
'group text-muted-foreground',
isSiteButton
? isActive
? c.activeColor
: 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}
label={m.label}
onClick={() => handleModeClick(m.id)}
shortcut={m.shortcut}
disabled={isDisabled}
key={c.id}
label={
isSiteButton
? isActive
? 'Exit site editing'
: canEnterSiteEdit
? 'Edit site'
: 'Site editing (ground level only)'
: c.label
}
onClick={() => handleClick(c.id)}
shortcut={c.shortcut}
size="icon"
variant="ghost"
>
{m.imageSrc ? (
{c.imageSrc ? (
<Image
alt={m.label}
alt={c.label}
className={cn(
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
!isActive && 'opacity-60 grayscale',
isActive && 'opacity-100 grayscale-0',
isSiteButton
? isActive
? 'opacity-100 grayscale-0'
: ''
: isActive
? 'opacity-100 grayscale-0'
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
)}
height={28}
src={m.imageSrc}
src={c.imageSrc}
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>
)
@@ -1,221 +1,297 @@
'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 { 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 useEditor from '../../../store/use-editor'
import { SliderControl } from '../controls/slider-control'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button'
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
solo: 'Solo',
// ── Helper: get guide images for the current level ──────────────────────────
function useLevelGuides(): GuideNode[] {
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> = {
manual: 'Stack',
stacked: 'Stack',
exploded: 'Exploded',
solo: 'Solo',
// ── Helper: get scans for the current level ─────────────────────────────────
function useLevelScans(): ScanNode[] {
const levelId = useViewer((s) => s.selection.levelId)
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'
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)
function GuidesControl() {
const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides)
const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
const toggleFloorplanOpen = useEditor((state) => state.toggleFloorplanOpen)
const updateNode = useScene((state) => state.updateNode)
const [isOpen, setIsOpen] = useState(false)
const toggleCameraMode = () => {
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
}
const guides = useLevelGuides()
const hasGuides = guides.length > 0
const cycleLevelMode = () => {
if (levelMode === 'manual') {
setLevelMode('stacked')
return
}
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)
}
const handleOpacityChange = useCallback(
(guideId: GuideNode['id'], opacity: number) => {
updateNode(guideId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
},
[updateNode],
)
return (
<div className="flex items-center gap-1">
{/* Camera Mode */}
<ActionButton
className={cn(
cameraMode === 'orthographic'
? 'bg-violet-500/20 text-violet-400'
: 'hover:text-violet-400',
)}
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
onClick={toggleCameraMode}
size="icon"
variant="ghost"
>
{cameraMode === 'perspective' ? (
<Icon color="currentColor" height={24} icon="icon-park-outline:perspective" width={24} />
) : (
<Icon color="currentColor" height={24} icon="vaadin:grid" width={24} />
)}
</ActionButton>
{/* Level Mode */}
<ActionButton
className={cn(
'p-0',
levelMode === 'stacked' || levelMode === 'manual'
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
: 'bg-white/10 text-foreground',
)}
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
onClick={cycleLevelMode}
size="icon"
variant="ghost"
>
<span className="relative flex h-full w-full items-center justify-center pb-1">
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
{levelMode === 'exploded' && (
<Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
{/* Toggle button */}
<ActionButton
className={cn(
'rounded-r-none p-0',
showGuides
? 'bg-white/10'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
{(levelMode === 'stacked' || levelMode === 'manual') && (
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
)}
<span
aria-hidden="true"
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
>
{levelModeBadgeLabels[levelMode]}
</span>
</span>
</ActionButton>
{/* Wall Mode */}
<ActionButton
className={cn(
'p-0',
wallMode !== 'cutaway'
? 'bg-white/10'
: '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">
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
onClick={() => setShowGuides(!showGuides)}
size="icon"
variant="ghost"
>
<img
alt="2D floor plan"
className={cn(
'h-[28px] w-[28px] object-contain transition-[filter,opacity] duration-200',
isFloorplanOpen ? 'opacity-100 grayscale-0' : 'opacity-60 grayscale',
)}
src="/icons/blueprint.png"
alt="Guides"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png"
/>
<span
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)]"
</ActionButton>
{/* 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
</span>
<span
aria-hidden="true"
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
</button>
</PopoverTrigger>
</div>
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
side="top"
sideOffset={14}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
</span>
<div className="min-w-0">
<p className="font-medium text-foreground text-sm">Guide images</p>
{hasGuides && (
<p className="text-muted-foreground text-xs">
{guides.length} guide image{guides.length !== 1 ? 's' : ''} on this level
</p>
)}
</div>
</div>
{hasGuides ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{guides.map((guide, index) => (
<div
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
key={guide.id}
>
<div className="flex min-w-0 items-center gap-2">
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/floorplan.png"
/>
<p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`}
</p>
</div>
<SliderControl
label="Opacity"
max={100}
min={0}
onChange={(value) => handleOpacityChange(guide.id, value)}
precision={0}
step={1}
unit="%"
value={guide.opacity}
/>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
No guide images on this level yet.
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
function ScansControl() {
const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans)
const updateNode = useScene((state) => state.updateNode)
const [isOpen, setIsOpen] = useState(false)
const scans = useLevelScans()
const hasScans = scans.length > 0
const handleOpacityChange = useCallback(
(scanId: ScanNode['id'], opacity: number) => {
updateNode(scanId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
},
[updateNode],
)
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
{/* Toggle button */}
<ActionButton
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
</span>
</span>
</ActionButton>
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
</button>
</PopoverTrigger>
</div>
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
side="top"
sideOffset={14}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/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>
)
}
@@ -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_KEYBOARD_SHORTCUT = 'b'
const SIDEBAR_COLLAPSE_THRESHOLD = 220
const SIDEBAR_MAX_WIDTH = 800
type SidebarStore = {
width: number
setWidth: (width: number) => void
isCollapsed: boolean
setIsCollapsed: (collapsed: boolean) => void
isDragging: boolean
setIsDragging: (isDragging: boolean) => void
}
@@ -44,13 +49,21 @@ export const useSidebarStore = create<SidebarStore>()(
persist(
(set) => ({
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,
setIsDragging: (isDragging) => set({ isDragging }),
}),
{
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
}
// In first-person mode, all shortcuts are handled by FirstPersonControls
if (useEditor.getState().isFirstPersonMode) {
return
}
if (e.key === 'Escape') {
// If in walkthrough mode, let WalkthroughControls handle ESC
if (useViewer.getState().walkthroughMode) return
e.preventDefault()
_toolCancelConsumed = false
emitter.emit('tool:cancel')
@@ -35,6 +33,7 @@ export const useKeyboard = () => {
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
useEditor.getState().setFloorplanSelectionTool('click')
// Clear selections to close UI panels, but KEEP the active building and level context.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
@@ -67,13 +66,7 @@ export const useKeyboard = () => {
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('select')
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
const phase = useEditor.getState().phase
if (phase === 'structure' || phase === 'furnish') {
useEditor.getState().setMode('delete')
useViewer.getState().setSelection({ selectedIds: [] })
}
useEditor.getState().setFloorplanSelectionTool('click')
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('build')
@@ -116,26 +109,23 @@ export const useKeyboard = () => {
}
}
} 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[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
let newRotationY = 0
// Handle different rotation types (number for roof, array for items/windows/doors)
if (typeof node.rotation === 'number') {
newRotationY = node.rotation + ROTATION_STEP
useScene.getState().updateNode(node.id, { rotation: newRotationY })
useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP })
} else if (Array.isArray(node.rotation)) {
newRotationY = node.rotation[1] + ROTATION_STEP
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') {
+11 -3
View File
@@ -1,14 +1,21 @@
export type { EditorProps } from './components/editor'
export { default as Editor } from './components/editor'
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 { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider'
export { SceneLoader } from './components/ui/scene-loader'
export type {
ProjectVisibility,
SettingsPanelProps,
export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
export {
type ProjectVisibility,
SettingsPanel,
type SettingsPanelProps,
} from './components/ui/sidebar/panels/settings-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 { PresetsProvider } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save'
@@ -16,6 +23,7 @@ export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { default as useAudio } from './store/use-audio'
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 {
type PaletteView,
+498 -389
View File
@@ -1,389 +1,498 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import {
type BuildingNode,
type DoorNode,
type ItemNode,
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type Space,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build'
// Structure mode tools (building elements)
export type StructureTool =
| 'wall'
| 'room'
| 'custom-room'
| 'slab'
| 'ceiling'
| 'roof'
| 'column'
| 'stair'
| 'item'
| 'zone'
| 'window'
| 'door'
// Furnish mode tools (items and decoration)
export type FurnishTool = 'item'
// Site mode tools
export type SiteTool = 'property-line'
// Catalog categories for furnish mode items
export type CatalogCategory =
| 'furniture'
| 'appliance'
| 'bathroom'
| 'kitchen'
| 'outdoor'
| 'window'
| 'door'
export type StructureLayer = 'zones' | 'elements'
// Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
mode: Mode
setMode: (mode: Mode) => void
tool: Tool | null
setTool: (tool: Tool | null) => void
structureLayer: StructureLayer
setStructureLayer: (layer: StructureLayer) => void
catalogCategory: CatalogCategory | null
setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null
setSelectedItem: (item: AssetInput) => void
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null
setMovingNode: (
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
editingHole: { nodeId: string; holeIndex: number } | null
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
// Preview mode (viewer-like experience inside the editor)
isPreviewMode: boolean
setPreviewMode: (preview: boolean) => void
// Toggleable 2D floorplan overlay
isFloorplanOpen: boolean
setFloorplanOpen: (open: boolean) => void
toggleFloorplanOpen: () => void
isFloorplanHovered: boolean
setFloorplanHovered: (hovered: boolean) => void
// Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean
setAllowUndergroundCamera: (enabled: boolean) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
setFirstPersonMode: (enabled: boolean) => void
}
export type PersistedEditorUiState = Pick<
EditorState,
'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen'
>
export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
phase: 'site',
mode: 'select',
tool: null,
structureLayer: 'elements',
catalogCategory: null,
isFloorplanOpen: false,
}
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
if (phase === 'site') {
return mode === 'edit' ? 'edit' : 'select'
}
return mode === 'build' || mode === 'delete' ? mode : 'select'
}
export function normalizePersistedEditorUiState(
state: Partial<PersistedEditorUiState> | null | undefined,
): PersistedEditorUiState {
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
const mode = normalizeModeForPhase(phase, state?.mode)
const isFloorplanOpen = Boolean(state?.isFloorplanOpen)
if (phase === 'site') {
return {
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
phase,
mode,
isFloorplanOpen,
}
}
if (phase === 'furnish') {
return {
phase,
mode,
tool: mode === 'build' ? 'item' : null,
structureLayer: 'elements',
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
isFloorplanOpen,
}
}
const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
if (mode !== 'build') {
return {
phase,
mode,
tool: null,
structureLayer,
catalogCategory: null,
isFloorplanOpen,
}
}
if (structureLayer === 'zones') {
return {
phase,
mode,
tool: 'zone',
structureLayer,
catalogCategory: null,
isFloorplanOpen,
}
}
return {
phase,
mode,
tool:
state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
structureLayer,
catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
isFloorplanOpen,
}
}
export function hasCustomPersistedEditorUiState(
state: Partial<PersistedEditorUiState> | null | undefined,
): boolean {
const normalizedState = normalizePersistedEditorUiState(state)
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 ||
normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen
)
}
const useEditor = create<EditorState>()(
persist(
(set, get) => ({
phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
setPhase: (phase) => {
const currentPhase = get().phase
if (currentPhase === phase) return
set({ phase })
const { mode, structureLayer } = get()
if (mode === 'build') {
// Stay in build mode, select the first tool for the new phase
if (phase === 'site') {
set({ tool: 'property-line', catalogCategory: null })
} else if (phase === 'structure' && structureLayer === 'zones') {
set({ tool: 'zone', catalogCategory: null })
} else if (phase === 'structure') {
set({ tool: 'wall', catalogCategory: null })
} else if (phase === 'furnish') {
set({ tool: 'item', catalogCategory: 'furniture' })
}
} else {
// Reset to select mode and clear tool/catalog when switching phases
set({ mode: 'select', tool: null, catalogCategory: null })
}
const viewer = useViewer.getState()
const scene = useScene.getState()
// Helper to find building and level 0
const selectBuildingAndLevel0 = () => {
let buildingId = viewer.selection.buildingId
// If no building selected, find the first one from site's children
if (!buildingId) {
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
if (siteNode?.type === 'site') {
const firstBuilding = siteNode.children
.map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
.find((node) => node?.type === 'building')
if (firstBuilding) {
buildingId = firstBuilding.id as BuildingNode['id']
viewer.setSelection({ buildingId })
}
}
}
// If no level selected, find level 0 in the building
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
return levelNode?.type === 'level' && levelNode.level === 0
})
if (level0Id) {
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
} else if (buildingNode.children[0]) {
// Fallback to first level if level 0 doesn't exist
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
}
}
}
switch (phase) {
case 'site':
// In Site mode, we zoom out and deselect specific levels/buildings
viewer.resetSelection()
break
case 'structure':
selectBuildingAndLevel0()
break
case 'furnish':
selectBuildingAndLevel0()
// Furnish mode only supports elements layer, not zones
set({ structureLayer: 'elements' })
break
}
},
mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
setMode: (mode) => {
set({ mode })
const { phase, structureLayer, tool } = get()
if (mode === 'build') {
// Ensure a tool is selected in build mode
if (!tool) {
if (phase === 'structure' && structureLayer === 'zones') {
set({ tool: 'zone' })
} else if (phase === 'structure' && structureLayer === 'elements') {
set({ tool: 'wall' })
} else if (phase === 'furnish') {
set({ tool: 'item', catalogCategory: 'furniture' })
}
}
}
// When leaving build mode, clear tool
else if (tool) {
set({ tool: null })
}
},
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
setTool: (tool) => set({ tool }),
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
setStructureLayer: (layer) => {
const { mode } = get()
if (mode === 'build') {
const tool = layer === 'zones' ? 'zone' : 'wall'
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 })
}
},
isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
setFloorplanOpen: (open) => set({ isFloorplanOpen: open }),
toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })),
isFloorplanHovered: false,
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
isFirstPersonMode: false,
setFirstPersonMode: (enabled) => {
if (enabled) {
// Force perspective camera and full-height walls for immersive walkthrough
useViewer.getState().setCameraMode('perspective')
useViewer.getState().setWallMode('up')
set({
isFirstPersonMode: true,
mode: 'select',
tool: null,
catalogCategory: null,
})
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else {
set({ isFirstPersonMode: false })
}
},
}),
{
name: 'pascal-editor-ui-preferences',
merge: (persistedState, currentState) => ({
...currentState,
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorUiState>),
}),
partialize: (state) => ({
phase: state.phase,
mode: state.mode,
tool: state.tool,
structureLayer: state.structureLayer,
catalogCategory: state.catalogCategory,
isFloorplanOpen: state.isFloorplanOpen,
}),
},
),
)
export default useEditor
'use client'
import type { AssetInput } from '@pascal-app/core'
import {
type BuildingNode,
type DoorNode,
type ItemNode,
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type Space,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
const MIN_FLOORPLAN_PANE_RATIO = 0.15
const MAX_FLOORPLAN_PANE_RATIO = 0.85
export type ViewMode = '3d' | '2d' | 'split'
export type SplitOrientation = 'horizontal' | 'vertical'
export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build'
// Structure mode tools (building elements)
export type StructureTool =
| 'wall'
| 'room'
| 'custom-room'
| 'slab'
| 'ceiling'
| 'roof'
| 'column'
| 'stair'
| 'item'
| 'zone'
| 'window'
| 'door'
// Furnish mode tools (items and decoration)
export type FurnishTool = 'item'
// Site mode tools
export type SiteTool = 'property-line'
// Catalog categories for furnish mode items
export type CatalogCategory =
| 'furniture'
| 'appliance'
| 'bathroom'
| 'kitchen'
| 'outdoor'
| 'window'
| 'door'
export type StructureLayer = 'zones' | 'elements'
export type FloorplanSelectionTool = 'click' | 'marquee'
// Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
mode: Mode
setMode: (mode: Mode) => void
tool: Tool | null
setTool: (tool: Tool | null) => void
structureLayer: StructureLayer
setStructureLayer: (layer: StructureLayer) => void
catalogCategory: CatalogCategory | null
setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null
setSelectedItem: (item: AssetInput) => void
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null
setMovingNode: (
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
editingHole: { nodeId: string; holeIndex: number } | null
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
// Preview mode (viewer-like experience inside the editor)
isPreviewMode: boolean
setPreviewMode: (preview: boolean) => void
// View mode (3D only, 2D only, or split 2D+3D)
viewMode: ViewMode
setViewMode: (mode: ViewMode) => void
splitOrientation: SplitOrientation
setSplitOrientation: (orientation: SplitOrientation) => void
// Toggleable 2D floorplan overlay (backward compat — derived from viewMode)
isFloorplanOpen: boolean
setFloorplanOpen: (open: boolean) => void
toggleFloorplanOpen: () => void
isFloorplanHovered: boolean
setFloorplanHovered: (hovered: boolean) => void
floorplanSelectionTool: FloorplanSelectionTool
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
// Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean
setAllowUndergroundCamera: (enabled: boolean) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
setFirstPersonMode: (enabled: boolean) => void
activeSidebarPanel: string
setActiveSidebarPanel: (id: string) => void
floorplanPaneRatio: number
setFloorplanPaneRatio: (ratio: number) => void
}
export type PersistedEditorUiState = Pick<
EditorState,
'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' | 'viewMode'
>
type PersistedEditorLayoutState = Pick<
EditorState,
'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool'
>
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
phase: 'site',
mode: 'select',
tool: null,
structureLayer: 'elements',
catalogCategory: null,
isFloorplanOpen: false,
viewMode: '3d',
}
export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = {
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
splitOrientation: 'horizontal',
floorplanSelectionTool: 'click',
}
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
if (phase === 'site') {
return 'select'
}
return mode === 'build' || mode === 'delete' ? mode : 'select'
}
function normalizeFloorplanPaneRatio(value: unknown): number {
if (!(typeof value === 'number' && Number.isFinite(value))) {
return DEFAULT_FLOORPLAN_PANE_RATIO
}
return Math.min(MAX_FLOORPLAN_PANE_RATIO, Math.max(MIN_FLOORPLAN_PANE_RATIO, value))
}
export function normalizePersistedEditorUiState(
state: Partial<PersistedEditorUiState> | null | undefined,
): PersistedEditorUiState {
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
const mode = normalizeModeForPhase(phase, state?.mode)
// Migrate old isFloorplanOpen to viewMode
let viewMode: ViewMode = '3d'
if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {
viewMode = state.viewMode
} else if (state?.isFloorplanOpen) {
viewMode = 'split'
}
const isFloorplanOpen = viewMode !== '3d'
if (phase === 'site') {
return {
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
phase,
mode,
viewMode,
isFloorplanOpen,
}
}
if (phase === 'furnish') {
return {
phase,
mode,
tool: mode === 'build' ? 'item' : null,
structureLayer: 'elements',
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
viewMode,
isFloorplanOpen,
}
}
const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
if (mode !== 'build') {
return {
phase,
mode,
tool: null,
structureLayer,
catalogCategory: null,
viewMode,
isFloorplanOpen,
}
}
if (structureLayer === 'zones') {
return {
phase,
mode,
tool: 'zone',
structureLayer,
catalogCategory: null,
viewMode,
isFloorplanOpen,
}
}
return {
phase,
mode,
tool:
state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
structureLayer,
catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
viewMode,
isFloorplanOpen,
}
}
function normalizePersistedEditorLayoutState(
state: Partial<PersistedEditorLayoutState> | null | undefined,
): PersistedEditorLayoutState {
return {
activeSidebarPanel:
typeof state?.activeSidebarPanel === 'string' && state.activeSidebarPanel.trim()
? state.activeSidebarPanel
: DEFAULT_ACTIVE_SIDEBAR_PANEL,
floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
}
}
export function hasCustomPersistedEditorUiState(
state: Partial<PersistedEditorUiState> | null | undefined,
): boolean {
const normalizedState = normalizePersistedEditorUiState(state)
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 ||
normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen ||
normalizedState.viewMode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode
)
}
/**
* Selects the first building and level 0 in the scene.
* Safe to call any time — no-ops if already selected or scene is empty.
*/
export function selectDefaultBuildingAndLevel() {
const viewer = useViewer.getState()
const scene = useScene.getState()
let buildingId = viewer.selection.buildingId
// If no building selected, find the first one from site's children
if (!buildingId) {
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
if (siteNode?.type === 'site') {
const firstBuilding = siteNode.children
.map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
.find((node) => node?.type === 'building')
if (firstBuilding) {
buildingId = firstBuilding.id as BuildingNode['id']
viewer.setSelection({ buildingId })
}
}
}
// If no level selected, find level 0 in the building
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
return levelNode?.type === 'level' && levelNode.level === 0
})
if (level0Id) {
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
} else if (buildingNode.children[0]) {
// Fallback to first level if level 0 doesn't exist
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
}
}
}
const useEditor = create<EditorState>()(
persist(
(set, get) => ({
phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
setPhase: (phase) => {
const currentPhase = get().phase
if (currentPhase === phase) return
set({ phase })
const { mode, structureLayer } = get()
if (mode === 'build') {
// Stay in build mode, select the first tool for the new phase
if (phase === 'site') {
set({ tool: 'property-line', catalogCategory: null })
} else if (phase === 'structure' && structureLayer === 'zones') {
set({ tool: 'zone', catalogCategory: null })
} else if (phase === 'structure') {
set({ tool: 'wall', catalogCategory: null })
} else if (phase === 'furnish') {
set({ tool: 'item', catalogCategory: 'furniture' })
}
} else {
// Reset to select mode and clear tool/catalog when switching phases
set({ mode: 'select', tool: null, catalogCategory: null })
}
const viewer = useViewer.getState()
switch (phase) {
case 'site':
// In Site mode, we zoom out and deselect specific levels/buildings
viewer.resetSelection()
break
case 'structure':
selectDefaultBuildingAndLevel()
break
case 'furnish':
selectDefaultBuildingAndLevel()
// Furnish mode only supports elements layer, not zones
set({ structureLayer: 'elements' })
break
}
},
mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
setMode: (mode) => {
set({ mode })
const { phase, structureLayer, tool } = get()
if (mode === 'build') {
// Ensure a tool is selected in build mode
if (!tool) {
if (phase === 'structure' && structureLayer === 'zones') {
set({ tool: 'zone' })
} else if (phase === 'structure' && structureLayer === 'elements') {
set({ tool: 'wall' })
} else if (phase === 'furnish') {
set({ tool: 'item', catalogCategory: 'furniture' })
}
}
}
// When leaving build mode, clear tool
else if (tool) {
set({ tool: null })
}
},
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
setTool: (tool) => set({ tool }),
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
setStructureLayer: (layer) => {
const { mode } = get()
if (mode === 'build') {
const tool = layer === 'zones' ? 'zone' : 'wall'
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