feat: editor layout redesign v2 + 3D box select (#207)
Major layout redesign introducing v2 editor layout: **New Features** - V2 two-column layout (resizable sidebar + viewer panel with toolbar slots) - 3D box select tool with marquee selection (561 lines pure Three.js geometry) - View mode system (3D/2D/Split) replacing old floorplan toggle - Floating level selector on viewer panel - Viewer toolbar: level mode, wall mode, units, theme, camera, walkthrough, preview - Horizontal tab bar for sidebar panels **Layout Changes** - Fixed 5-button control bar: Select, Box Select, Site Edit, Build, Delete - Simplified view toggles (scans/guides only) - Always-mounted viewers (display:none) to preserve WebGL context - First-person walkthrough forces 3D view, restores on exit - Site edit as permanent control (phase='site' is sole signal) **Technical** - layoutVersion prop: 'v1' (default) or 'v2' - New exports: FloatingLevelSelector, SettingsPanel, SidebarTab, ViewMode, ViewerToolbarLeft/Right - isCollapsed/setIsCollapsed added to sidebar store - isFirstPersonMode restored for walkthrough support 19 files changed, +3230/-1898 Built and verified: bun run build passes (all tasks, 0 errors)
This commit is contained in:
@@ -1,11 +1,30 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Icon } from '@iconify/react'
|
||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||
@@ -21,18 +21,25 @@ import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { BoxSelectTool } from '../tools/select/box-select-tool'
|
||||
import { ToolManager } from '../tools/tool-manager'
|
||||
import { ActionMenu } from '../ui/action-menu'
|
||||
import { CommandPalette, type CommandPaletteEmptyAction } from '../ui/command-palette'
|
||||
import { EditorCommands } from '../ui/command-palette/editor-commands'
|
||||
import { FloatingLevelSelector } from '../ui/floating-level-selector'
|
||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||
import { PanelManager } from '../ui/panels/panel-manager'
|
||||
import { ErrorBoundary } from '../ui/primitives/error-boundary'
|
||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||
import { useSidebarStore } from '../ui/primitives/sidebar'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
||||
import { SceneLoader } from '../ui/scene-loader'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
||||
import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import type { ExtraPanel } from '../ui/sidebar/icon-rail'
|
||||
import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
||||
import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
@@ -56,9 +63,19 @@ function initializeEditorRuntime() {
|
||||
hasInitializedEditorRuntime = true
|
||||
}
|
||||
export interface EditorProps {
|
||||
// UI slots
|
||||
// Layout version — 'v1' (default) or 'v2' (navbar + two-column)
|
||||
layoutVersion?: 'v1' | 'v2'
|
||||
|
||||
// UI slots (v1)
|
||||
appMenuButton?: ReactNode
|
||||
sidebarTop?: ReactNode
|
||||
|
||||
// UI slots (v2)
|
||||
navbarSlot?: ReactNode
|
||||
sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
|
||||
viewerToolbarLeft?: ReactNode
|
||||
viewerToolbarRight?: ReactNode
|
||||
|
||||
projectId?: string | null
|
||||
|
||||
// Persistence — defaults to localStorage when omitted
|
||||
@@ -77,12 +94,16 @@ export interface EditorProps {
|
||||
// Thumbnail
|
||||
onThumbnailCapture?: (blob: Blob) => void
|
||||
|
||||
// Panel config (passed through to sidebar panels)
|
||||
// Panel config (passed through to sidebar panels — v1 only)
|
||||
settingsPanelProps?: SettingsPanelProps
|
||||
sitePanelProps?: SitePanelProps
|
||||
extraSidebarPanels?: ExtraPanel[]
|
||||
|
||||
// Presets storage backend (defaults to localStorage)
|
||||
presetsAdapter?: PresetsAdapter
|
||||
|
||||
// Command palette fallback when no commands match
|
||||
commandPaletteEmptyAction?: CommandPaletteEmptyAction
|
||||
}
|
||||
|
||||
function EditorSceneCrashFallback() {
|
||||
@@ -113,6 +134,124 @@ function EditorSceneCrashFallback() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sidebar slot: in-flow, resizable, collapses to a grab strip ──────────────
|
||||
|
||||
function SidebarSlot({ children }: { children: ReactNode }) {
|
||||
const width = useSidebarStore((s) => s.width)
|
||||
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||
const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
|
||||
const setWidth = useSidebarStore((s) => s.setWidth)
|
||||
const isDragging = useSidebarStore((s) => s.isDragging)
|
||||
const setIsDragging = useSidebarStore((s) => s.setIsDragging)
|
||||
|
||||
const isResizing = useRef(false)
|
||||
const isExpanding = useRef(false)
|
||||
|
||||
const handleResizerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
isResizing.current = true
|
||||
setIsDragging(true)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
},
|
||||
[setIsDragging],
|
||||
)
|
||||
|
||||
const handleGrabDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
isExpanding.current = true
|
||||
setIsDragging(true)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
},
|
||||
[setIsDragging],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (isResizing.current) {
|
||||
setWidth(e.clientX)
|
||||
} else if (isExpanding.current && e.clientX > 60) {
|
||||
setIsCollapsed(false)
|
||||
setWidth(Math.max(240, e.clientX))
|
||||
}
|
||||
}
|
||||
const handlePointerUp = () => {
|
||||
isResizing.current = false
|
||||
isExpanding.current = false
|
||||
setIsDragging(false)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [setWidth, setIsCollapsed, setIsDragging])
|
||||
|
||||
return (
|
||||
// Outer: no overflow-hidden so the handle can extend into the gap
|
||||
<div
|
||||
className="relative h-full flex-shrink-0 rounded-xl"
|
||||
style={{
|
||||
width: isCollapsed ? 8 : width,
|
||||
transition: isDragging ? 'none' : 'width 150ms ease',
|
||||
}}
|
||||
>
|
||||
{/* Inner: overflow-hidden clips content to rounded corners */}
|
||||
<div className="h-full w-full overflow-hidden rounded-xl">
|
||||
{isCollapsed ? (
|
||||
<div
|
||||
className="absolute inset-0 z-10 cursor-col-resize transition-colors hover:bg-primary/20"
|
||||
onPointerDown={handleGrabDown}
|
||||
title="Expand sidebar"
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Handle: extends into the gap, centered on the gap midpoint */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className="group absolute inset-y-0 -right-3.5 z-10 flex w-4 cursor-col-resize items-stretch justify-center py-4"
|
||||
onPointerDown={handleResizerDown}
|
||||
>
|
||||
<div className="w-px self-stretch rounded-full bg-transparent transition-colors group-hover:bg-neutral-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── UI overlays: fixed, scoped to viewer area via transform containing block ──
|
||||
|
||||
function ViewerOverlays({ left, children }: { left: number; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left,
|
||||
// Creates a containing block so position:fixed children are scoped here
|
||||
transform: 'translateZ(0)',
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
|
||||
const selection = useViewer((state) => state.selection)
|
||||
|
||||
@@ -268,7 +407,7 @@ function ViewerCanvasControlsHint({
|
||||
const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-4 left-1/2 z-40 max-w-[calc(100vw-2rem)] -translate-x-1/2">
|
||||
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
|
||||
<section
|
||||
aria-label="Camera controls hint"
|
||||
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
|
||||
@@ -305,8 +444,13 @@ function ViewerCanvasControlsHint({
|
||||
}
|
||||
|
||||
export default function Editor({
|
||||
layoutVersion = 'v1',
|
||||
appMenuButton,
|
||||
sidebarTop,
|
||||
navbarSlot,
|
||||
sidebarTabs,
|
||||
viewerToolbarLeft,
|
||||
viewerToolbarRight,
|
||||
projectId,
|
||||
onLoad,
|
||||
onSave,
|
||||
@@ -318,7 +462,9 @@ export default function Editor({
|
||||
onThumbnailCapture,
|
||||
settingsPanelProps,
|
||||
sitePanelProps,
|
||||
extraSidebarPanels,
|
||||
presetsAdapter,
|
||||
commandPaletteEmptyAction,
|
||||
}: EditorProps) {
|
||||
useKeyboard()
|
||||
|
||||
@@ -337,6 +483,41 @@ export default function Editor({
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
||||
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
|
||||
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
|
||||
|
||||
const sidebarWidth = useSidebarStore((s) => s.width)
|
||||
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||
const viewerAreaRef = useRef<HTMLDivElement>(null)
|
||||
const isResizingFloorplan = useRef(false)
|
||||
|
||||
const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
isResizingFloorplan.current = true
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isResizingFloorplan.current) return
|
||||
if (!viewerAreaRef.current) return
|
||||
const rect = viewerAreaRef.current.getBoundingClientRect()
|
||||
const newRatio = (e.clientX - rect.left) / rect.width
|
||||
setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
|
||||
}
|
||||
const handlePointerUp = () => {
|
||||
isResizingFloorplan.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
initializeEditorRuntime()
|
||||
@@ -408,71 +589,217 @@ export default function Editor({
|
||||
writeCameraControlsHintDismissed(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PresetsProvider adapter={presetsAdapter}>
|
||||
<div className="dark h-full w-full text-foreground">
|
||||
// ── Shared viewer scene content ──
|
||||
const viewerSceneContent = (
|
||||
<>
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!isFirstPersonMode && <BoxSelectTool />}
|
||||
{!isFirstPersonMode && <FloatingActionMenu />}
|
||||
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||
<ExportManager />
|
||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||
{!isLoading && !isFirstPersonMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
{isFirstPersonMode && <FirstPersonControls />}
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isFirstPersonMode && <SiteEdgeLabels />}
|
||||
{isFirstPersonMode && <InteractiveSystem />}
|
||||
</>
|
||||
)
|
||||
|
||||
const previewViewerContent = (
|
||||
<Viewer selectionManager="default">
|
||||
<ExportManager />
|
||||
<ViewerZoneSystem />
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
<InteractiveSystem />
|
||||
</Viewer>
|
||||
)
|
||||
|
||||
// ── Shared viewer canvas (handles split/2d/3d) ──
|
||||
const viewMode = useEditor((s) => s.viewMode)
|
||||
|
||||
const show2d = viewMode === '2d' || viewMode === 'split'
|
||||
const show3d = viewMode === '3d' || viewMode === 'split'
|
||||
|
||||
const viewerCanvas = (
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<div className="flex h-full" ref={viewerAreaRef}>
|
||||
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||
<div
|
||||
className="relative h-full flex-shrink-0"
|
||||
style={{
|
||||
width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,
|
||||
display: show2d ? undefined : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<FloorplanPanel />
|
||||
</div>
|
||||
{viewMode === 'split' && (
|
||||
<div
|
||||
className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
|
||||
onPointerDown={handleFloorplanDividerDown}
|
||||
>
|
||||
<div className="h-8 w-1 rounded-full bg-neutral-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
||||
<div
|
||||
className="relative min-w-0 flex-1 overflow-hidden"
|
||||
style={{ display: show3d ? undefined : 'none' }}
|
||||
>
|
||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||
<ViewerCanvasControlsHint
|
||||
isPreviewMode={isPreviewMode}
|
||||
onDismiss={dismissCameraControlsHint}
|
||||
/>
|
||||
) : null}
|
||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
|
||||
</div>
|
||||
</div>
|
||||
{!isLoading && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// ── V2 layout ──
|
||||
if (layoutVersion === 'v2') {
|
||||
const tabMap = new Map(sidebarTabs?.map((t) => [t.id, t]) ?? [])
|
||||
|
||||
const renderTabContent = (tabId: string) => {
|
||||
// Built-in panels
|
||||
if (tabId === 'site') {
|
||||
return <SitePanel {...sitePanelProps} />
|
||||
}
|
||||
if (tabId === 'settings') {
|
||||
return <SettingsPanel {...settingsPanelProps} />
|
||||
}
|
||||
// External tabs (AI chat, catalog, etc.)
|
||||
const tab = tabMap.get(tabId)
|
||||
if (!tab) return null
|
||||
const Component = tab.component
|
||||
return <Component />
|
||||
}
|
||||
|
||||
const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
|
||||
|
||||
return (
|
||||
<PresetsProvider adapter={presetsAdapter}>
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||
<ViewerCanvasControlsHint
|
||||
isPreviewMode={isPreviewMode}
|
||||
onDismiss={dismissCameraControlsHint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFirstPersonMode ? (
|
||||
<FirstPersonOverlay
|
||||
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
||||
/>
|
||||
) : !isLoading && isPreviewMode ? (
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
{!isLoading && isPreviewMode ? (
|
||||
<div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
<div className="h-full w-full">{previewViewerContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
{isFloorplanOpen && <FloorplanPanel />}
|
||||
<HelperManager />
|
||||
{/* First-person overlay — rendered on top of normal layout */}
|
||||
{isFirstPersonMode && (
|
||||
<div className="fixed inset-0 z-50 pointer-events-none">
|
||||
<FirstPersonOverlay
|
||||
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<EditorLayoutV2
|
||||
navbarSlot={navbarSlot}
|
||||
overlays={
|
||||
<>
|
||||
<FloatingLevelSelector />
|
||||
<div className="pointer-events-auto">
|
||||
<ActionMenu />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<PanelManager />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<HelperManager />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
renderTabContent={renderTabContent}
|
||||
sidebarTabs={tabBarTabs}
|
||||
viewerContent={viewerCanvas}
|
||||
viewerToolbarLeft={viewerToolbarLeft}
|
||||
viewerToolbarRight={viewerToolbarRight}
|
||||
/>
|
||||
<EditorCommands />
|
||||
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||
</>
|
||||
)}
|
||||
</PresetsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
<SidebarProvider className="fixed z-20">
|
||||
// ── V1 layout (existing) ──
|
||||
// p-3 (12px) padding on root + gap-3 (12px) between sidebar and viewer + sidebar width
|
||||
const LAYOUT_PADDING = 12
|
||||
const LAYOUT_GAP = 12
|
||||
const overlayLeft = LAYOUT_PADDING + (isSidebarCollapsed ? 8 : sidebarWidth) + LAYOUT_GAP
|
||||
|
||||
return (
|
||||
<PresetsProvider adapter={presetsAdapter}>
|
||||
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && isPreviewMode ? (
|
||||
<>
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
<div className="h-full w-full">{previewViewerContent}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Sidebar */}
|
||||
<SidebarSlot>
|
||||
<AppSidebar
|
||||
appMenuButton={appMenuButton}
|
||||
commandPaletteEmptyAction={commandPaletteEmptyAction}
|
||||
extraPanels={extraSidebarPanels}
|
||||
settingsPanelProps={settingsPanelProps}
|
||||
sidebarTop={sidebarTop}
|
||||
sitePanelProps={sitePanelProps}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</SidebarSlot>
|
||||
|
||||
{/* Viewer area */}
|
||||
<div className="relative flex-1 overflow-hidden rounded-xl" ref={viewerAreaRef}>
|
||||
{viewerCanvas}
|
||||
</div>
|
||||
|
||||
{/* Fixed UI overlays scoped to the viewer area */}
|
||||
<ViewerOverlays left={overlayLeft}>
|
||||
<div className="pointer-events-auto">
|
||||
<ActionMenu />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<PanelManager />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<HelperManager />
|
||||
</div>
|
||||
</ViewerOverlays>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<div className="h-full w-full">
|
||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||
<Viewer selectionManager={isPreviewMode || isFirstPersonMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && !isFirstPersonMode && <SelectionManager />}
|
||||
{!isPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
||||
{!isPreviewMode && !isFirstPersonMode && <WallMeasurementLabel />}
|
||||
<ExportManager />
|
||||
{isPreviewMode || isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
{!isPreviewMode && !isFirstPersonMode && (
|
||||
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
|
||||
)}
|
||||
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
{isFirstPersonMode && <FirstPersonControls />}
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && !isFirstPersonMode && <SiteEdgeLabels />}
|
||||
{(isPreviewMode || isFirstPersonMode) && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
</div>
|
||||
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</PresetsProvider>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,14 @@ 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'
|
||||
@@ -53,6 +61,8 @@ export type CatalogCategory =
|
||||
|
||||
export type StructureLayer = 'zones' | 'elements'
|
||||
|
||||
export type FloorplanSelectionTool = 'click' | 'marquee'
|
||||
|
||||
// Combined tool type
|
||||
export type Tool = SiteTool | StructureTool | FurnishTool
|
||||
|
||||
@@ -84,25 +94,42 @@ type EditorState = {
|
||||
// Preview mode (viewer-like experience inside the editor)
|
||||
isPreviewMode: boolean
|
||||
setPreviewMode: (preview: boolean) => void
|
||||
// Toggleable 2D floorplan overlay
|
||||
// View mode (3D only, 2D only, or split 2D+3D)
|
||||
viewMode: ViewMode
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
splitOrientation: SplitOrientation
|
||||
setSplitOrientation: (orientation: SplitOrientation) => void
|
||||
// Toggleable 2D floorplan overlay (backward compat — derived from viewMode)
|
||||
isFloorplanOpen: boolean
|
||||
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'
|
||||
'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',
|
||||
@@ -110,28 +137,53 @@ export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
|
||||
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 mode === 'edit' ? 'edit' : 'select'
|
||||
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)
|
||||
const isFloorplanOpen = Boolean(state?.isFloorplanOpen)
|
||||
|
||||
// Migrate old isFloorplanOpen to viewMode
|
||||
let viewMode: ViewMode = '3d'
|
||||
if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {
|
||||
viewMode = state.viewMode
|
||||
} else if (state?.isFloorplanOpen) {
|
||||
viewMode = 'split'
|
||||
}
|
||||
const isFloorplanOpen = viewMode !== '3d'
|
||||
|
||||
if (phase === 'site') {
|
||||
return {
|
||||
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
|
||||
phase,
|
||||
mode,
|
||||
viewMode,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
@@ -143,6 +195,7 @@ export function normalizePersistedEditorUiState(
|
||||
tool: mode === 'build' ? 'item' : null,
|
||||
structureLayer: 'elements',
|
||||
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
|
||||
viewMode,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
@@ -156,6 +209,7 @@ export function normalizePersistedEditorUiState(
|
||||
tool: null,
|
||||
structureLayer,
|
||||
catalogCategory: null,
|
||||
viewMode,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
@@ -167,6 +221,7 @@ export function normalizePersistedEditorUiState(
|
||||
tool: 'zone',
|
||||
structureLayer,
|
||||
catalogCategory: null,
|
||||
viewMode,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
@@ -178,10 +233,25 @@ export function normalizePersistedEditorUiState(
|
||||
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 {
|
||||
@@ -193,10 +263,51 @@ export function hasCustomPersistedEditorUiState(
|
||||
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.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) => ({
|
||||
@@ -226,41 +337,6 @@ const useEditor = create<EditorState>()(
|
||||
}
|
||||
|
||||
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':
|
||||
@@ -269,11 +345,11 @@ const useEditor = create<EditorState>()(
|
||||
break
|
||||
|
||||
case 'structure':
|
||||
selectBuildingAndLevel0()
|
||||
selectDefaultBuildingAndLevel()
|
||||
break
|
||||
|
||||
case 'furnish':
|
||||
selectBuildingAndLevel0()
|
||||
selectDefaultBuildingAndLevel()
|
||||
// Furnish mode only supports elements layer, not zones
|
||||
set({ structureLayer: 'elements' })
|
||||
break
|
||||
@@ -343,36 +419,64 @@ const useEditor = create<EditorState>()(
|
||||
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 }),
|
||||
toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !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 {
|
||||
set({ isFirstPersonMode: false })
|
||||
// Restore previous view mode
|
||||
const prevMode = get()._viewModeBeforeFirstPerson
|
||||
set({
|
||||
isFirstPersonMode: false,
|
||||
_viewModeBeforeFirstPerson: null,
|
||||
...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
|
||||
})
|
||||
}
|
||||
},
|
||||
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
||||
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
||||
setFloorplanPaneRatio: (ratio) =>
|
||||
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
||||
}),
|
||||
{
|
||||
name: 'pascal-editor-ui-preferences',
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorUiState>),
|
||||
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
|
||||
...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
|
||||
}),
|
||||
partialize: (state) => ({
|
||||
phase: state.phase,
|
||||
@@ -381,6 +485,11 @@ const useEditor = create<EditorState>()(
|
||||
structureLayer: state.structureLayer,
|
||||
catalogCategory: state.catalogCategory,
|
||||
isFloorplanOpen: state.isFloorplanOpen,
|
||||
viewMode: state.viewMode,
|
||||
activeSidebarPanel: state.activeSidebarPanel,
|
||||
floorplanPaneRatio: state.floorplanPaneRatio,
|
||||
splitOrientation: state.splitOrientation,
|
||||
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user