diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx
index a7e850fe..d48a82e8 100644
--- a/apps/editor/app/page.tsx
+++ b/apps/editor/app/page.tsx
@@ -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 (
-
+ }
+ viewerToolbarRight={}
+ />
)
}
diff --git a/bun.lock b/bun.lock
index 91bc61f7..ebf1df96 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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",
diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx
new file mode 100644
index 00000000..a2fc0d26
--- /dev/null
+++ b/packages/editor/src/components/editor/editor-layout-v2.tsx
@@ -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 (
+
+ )
+ }
+
+ return (
+
+
+
{renderTabContent(activePanel)}
+
+ {/* Resize handle + hit area */}
+
+
+ )
+}
+
+// ── Right column: viewer area with toolbar ───────────────────────────────────
+
+function RightColumn({
+ toolbarLeft,
+ toolbarRight,
+ children,
+ overlays,
+}: {
+ toolbarLeft?: ReactNode
+ toolbarRight?: ReactNode
+ children: ReactNode
+ overlays?: ReactNode
+}) {
+ return (
+
+ {/* Viewer toolbar */}
+ {(toolbarLeft || toolbarRight) && (
+
+
{toolbarLeft}
+
{toolbarRight}
+
+ )}
+ {/* Canvas area */}
+
{children}
+ {/* Overlays scoped to the viewer column */}
+ {overlays && (
+
+ {overlays}
+
+ )}
+
+ )
+}
+
+// ── 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 (
+
+ {/* Top navbar */}
+ {navbarSlot}
+
+ {/* Main content: left column + right column */}
+
+ {sidebarTabs.length > 0 && (
+
+ )}
+
+ {viewerContent}
+
+
+
+ )
+}
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 57c2b4d7..69579334 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -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 = {
- wall: 'Wall',
- door: 'Door',
- window: 'Window',
- slab: 'Floor',
- zone: 'Zone',
-}
-
-const FLOORPLAN_QUICK_BUILD_TOOL_FALLBACK_ICONS: Record = {
- 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) => void
onOpeningDoubleClick: (opening: OpeningNode) => void
onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void
+ onOpeningPointerDown: (openingId: OpeningNode['id'], event: ReactPointerEvent) => void
onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent) => void
hoveredWallId: WallNode['id'] | null
onWallClick: (wall: WallNode, event: ReactMouseEvent) => 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(null)
const panelBoundsRef = useRef(null)
+ const containerRef = useRef(null)
const hasUserAdjustedViewportRef = useRef(false)
const previousLevelIdRef = useRef(null)
- const levelMenuCloseTimeoutRef = useRef(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(null)
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState(null)
const [hoveredGuideCorner, setHoveredGuideCorner] = useState(null)
- const [floorplanSelectionTool, setFloorplanSelectionTool] =
- useState('click')
+ const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
+ const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState(
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(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
- }
-
- window.addEventListener('resize', handleWindowResize)
+ 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)
+ }
+ 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) => {
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) => {
+ 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) => {
emitFloorplanNodeClick(slabId, event)
@@ -5988,32 +5892,34 @@ export function FloorplanPanel() {
},
[selectedOpeningEntry, setMovingNode, setSelection],
)
- const handleSelectedOpeningDuplicate = useCallback(
- (event: ReactMouseEvent) => {
- event.stopPropagation()
-
- const opening = selectedOpeningEntry?.opening
- if (!opening?.parentId) {
- return
- }
+ const duplicateSelectedOpening = useCallback(() => {
+ const opening = selectedOpeningEntry?.opening
+ if (!opening?.parentId) {
+ return
+ }
- sfxEmitter.emit('sfx:item-pick')
- useScene.temporal.getState().pause()
+ sfxEmitter.emit('sfx:item-pick')
+ useScene.temporal.getState().pause()
- const cloned = structuredClone(opening) as Record
- delete cloned.id
- cloned.metadata = {
- ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
- isNew: true,
- }
+ const cloned = structuredClone(opening) as Record
+ 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)
+ const duplicate = opening.type === 'door' ? DoorNode.parse(cloned) : WindowNode.parse(cloned)
- useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId)
- setMovingNode(duplicate)
- setSelection({ selectedIds: [] })
+ useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId)
+ setMovingNode(duplicate)
+ setSelection({ selectedIds: [] })
+ }, [selectedOpeningEntry, setMovingNode, setSelection])
+ const handleSelectedOpeningDuplicate = useCallback(
+ (event: ReactMouseEvent) => {
+ event.stopPropagation()
+ duplicateSelectedOpening()
},
- [selectedOpeningEntry, setMovingNode, setSelection],
+ [duplicateSelectedOpening],
)
const handleSelectedOpeningDelete = useCallback(
(event: ReactMouseEvent) => {
@@ -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 (
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) => (
-
handleResizeStart(handle.direction, event)}
- />
- ))}
-
-
-
-
event.stopPropagation()}
- >
-
{
- clearLevelMenuCloseTimeout()
- setIsLevelMenuOpen(hasFloorplanLevelSwitcher ? open : false)
- }}
- open={isLevelMenuOpen}
- >
-
-
-
- {hasFloorplanLevelSwitcher ? (
-
-
- {floorplanLevels.map((level) => (
-
- {getLevelDisplayLabel(level)}
-
- ))}
-
-
- ) : null}
-
-
-
-
-
event.stopPropagation()}
- >
-
-
-
-
-
-
-
-
- {canUseSiteEditShortcut
- ? isSiteEditShortcutActive
- ? 'Exit site editing'
- : 'Edit site'
- : 'Site editing is only available on ground level'}
-
-
-
-
-
-
-
-
-
-
-
- {showGuides ? 'Hide guide images' : 'Show guide images'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Guide images
-
{guideImagesDescription}
-
-
-
- {hasGuideImages ? (
-
- {levelGuides.map((guide, index) => (
-
-
-

-
- {guide.name || `Guide image ${index + 1}`}
-
-
-
-
handleGuideOpacityChange(guide.id, value)}
- precision={0}
- step={1}
- unit="%"
- value={guide.opacity}
- />
-
- ))}
-
- ) : (
-
- No guide images on this level yet.
-
- )}
-
-
-
-
-
-
- {FLOORPLAN_QUICK_BUILD_TOOLS.map((quickTool) => {
- const isActive = phase === 'structure' && mode === 'build' && tool === quickTool.id
-
- return (
-
-
-
-
-
- {quickTool.label}
-
-
- )
- })}
-
-
-
-
-
-
-
-
- Click select
-
-
-
-
-
-
-
-
- Box select
-
-
-
-
-
-
-
-
-
- Close floorplan
-
-
-
-
-
{activeFloorplanCursorIndicator && floorplanCursorPosition && !isPanning && (
Promise
- onSave?: (scene: SceneGraph) => Promise
- onDirty?: () => void
- onSaveStatusChange?: (status: SaveStatus) => void
-
- // Version preview
- previewScene?: SceneGraph
- isVersionPreviewMode?: boolean
-
- // Loading indicator (e.g. project fetching in community mode)
- isLoading?: boolean
-
- // Thumbnail
- onThumbnailCapture?: (blob: Blob) => void
-
- // Panel config (passed through to sidebar panels)
- settingsPanelProps?: SettingsPanelProps
- sitePanelProps?: SitePanelProps
-
- // Presets storage backend (defaults to localStorage)
- presetsAdapter?: PresetsAdapter
-}
-
-function EditorSceneCrashFallback() {
- return (
-
-
-
The editor scene failed to render
-
- You can retry the scene or return home without reloading the whole app shell.
-
-
-
-
- Back to home
-
-
-
-
- )
-}
-
-function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
- const selection = useViewer((state) => state.selection)
-
- useEffect(() => {
- if (!enabled) {
- return
- }
-
- writePersistedSelection(selection)
- }, [enabled, selection])
-
- return null
-}
-
-type ShortcutKey = {
- value: string
-}
-
-type CameraControlHint = {
- action: string
- keys: ShortcutKey[]
- alternativeKeys?: ShortcutKey[]
-}
-
-const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
- {
- action: 'Pan',
- keys: [{ value: 'Space' }, { value: 'Left click' }],
- },
- { action: 'Rotate', keys: [{ value: 'Right click' }] },
- { action: 'Zoom', keys: [{ value: 'Scroll' }] },
-]
-
-const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
- { action: 'Pan', keys: [{ value: 'Left click' }] },
- { action: 'Rotate', keys: [{ value: 'Right click' }] },
- { action: 'Zoom', keys: [{ value: 'Scroll' }] },
-]
-
-const CAMERA_SHORTCUT_KEY_META: Record = {
- 'Left click': {
- icon: 'ph:mouse-left-click-fill',
- label: 'Left click',
- },
- 'Middle click': {
- icon: 'qlementine-icons:mouse-middle-button-16',
- label: 'Middle click',
- },
- 'Right click': {
- icon: 'ph:mouse-right-click-fill',
- label: 'Right click',
- },
- Scroll: {
- icon: 'qlementine-icons:mouse-middle-button-16',
- label: 'Scroll wheel',
- },
- Space: {
- icon: 'lucide:space',
- label: 'Space',
- },
-}
-
-function readCameraControlsHintDismissed(): boolean {
- if (typeof window === 'undefined') {
- return false
- }
-
- try {
- return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1'
- } catch {
- return false
- }
-}
-
-function writeCameraControlsHintDismissed(dismissed: boolean) {
- if (typeof window === 'undefined') {
- return
- }
-
- try {
- if (dismissed) {
- window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1')
- return
- }
-
- window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY)
- } catch {}
-}
-
-function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) {
- const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value]
-
- if (meta?.icon) {
- return (
-
-
- {meta.label}
-
- )
- }
-
- return (
-
- {meta?.text ?? shortcutKey.value}
-
- )
-}
-
-function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) {
- return (
-
- {keys.map((key, index) => (
-
- {index > 0 ? + : null}
-
-
- ))}
-
- )
-}
-
-function CameraControlHintItem({ hint }: { hint: CameraControlHint }) {
- return (
-
-
- {hint.action}
-
-
-
- {hint.alternativeKeys ? (
- <>
- /
-
- >
- ) : null}
-
-
- )
-}
-
-function ViewerCanvasControlsHint({
- isPreviewMode,
- onDismiss,
-}: {
- isPreviewMode: boolean
- onDismiss: () => void
-}) {
- const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
-
- return (
-
-
-
- {hints.map((hint) => (
-
- ))}
-
-
-
-
-
-
- Dismiss
-
-
-
-
- )
-}
-
-export default function Editor({
- appMenuButton,
- sidebarTop,
- projectId,
- onLoad,
- onSave,
- onDirty,
- onSaveStatusChange,
- previewScene,
- isVersionPreviewMode = false,
- isLoading = false,
- onThumbnailCapture,
- settingsPanelProps,
- sitePanelProps,
- presetsAdapter,
-}: EditorProps) {
- useKeyboard()
-
- const { isLoadingSceneRef } = useAutoSave({
- onSave,
- onDirty,
- onSaveStatusChange,
- isVersionPreviewMode,
- })
-
- const [isSceneLoading, setIsSceneLoading] = useState(false)
- const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
- const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState(
- null,
- )
- const isPreviewMode = useEditor((s) => s.isPreviewMode)
- const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
- const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
-
- useEffect(() => {
- initializeEditorRuntime()
- }, [])
-
- useEffect(() => {
- useViewer.getState().setProjectId(projectId ?? null)
-
- return () => {
- useViewer.getState().setProjectId(null)
- }
- }, [projectId])
-
- // Load scene on mount (or when onLoad identity changes, e.g. project switch)
- useEffect(() => {
- let cancelled = false
-
- async function load() {
- isLoadingSceneRef.current = true
- setHasLoadedInitialScene(false)
- setIsSceneLoading(true)
-
- try {
- const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
- if (!cancelled) {
- applySceneGraphToEditor(sceneGraph)
- }
- } catch {
- if (!cancelled) applySceneGraphToEditor(null)
- } finally {
- if (!cancelled) {
- setIsSceneLoading(false)
- setHasLoadedInitialScene(true)
- requestAnimationFrame(() => {
- isLoadingSceneRef.current = false
- })
- }
- }
- }
-
- load()
-
- return () => {
- cancelled = true
- }
- }, [onLoad, isLoadingSceneRef])
-
- // Apply preview scene when version preview mode changes
- useEffect(() => {
- if (isVersionPreviewMode && previewScene) {
- applySceneGraphToEditor(previewScene)
- }
- }, [isVersionPreviewMode, previewScene])
-
- useEffect(() => {
- document.body.classList.add('dark')
- return () => {
- document.body.classList.remove('dark')
- }
- }, [])
-
- useEffect(() => {
- setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
- }, [])
-
- const showLoader = isLoading || isSceneLoading
- const dismissCameraControlsHint = useCallback(() => {
- setIsCameraControlsHintVisible(false)
- writeCameraControlsHintDismissed(true)
- }, [])
-
- return (
-
-
- {showLoader && (
-
-
-
- )}
-
- {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
-
- ) : null}
-
- {isFirstPersonMode ? (
-
useEditor.getState().setFirstPersonMode(false)}
- />
- ) : !isLoading && isPreviewMode ? (
- useEditor.getState().setPreviewMode(false)} />
- ) : (
- <>
-
-
- {isFloorplanOpen && }
-
-
-
-
-
- >
- )}
-
- }>
-
-
-
- {!isPreviewMode && !isFirstPersonMode && }
- {!isPreviewMode && !isFirstPersonMode && }
- {!isPreviewMode && !isFirstPersonMode && }
-
- {isPreviewMode || isFirstPersonMode ? : }
-
-
- {!isPreviewMode && !isFirstPersonMode && (
-
- )}
- {!(isPreviewMode || isFirstPersonMode || isLoading) && }
-
- {isFirstPersonMode && }
-
-
- {!isPreviewMode && !isFirstPersonMode && }
- {(isPreviewMode || isFirstPersonMode) && }
-
-
- {!(isPreviewMode || isFirstPersonMode || isLoading) && }
-
-
-
- )
-}
+'use client'
+
+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, 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'
+import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
+import { useKeyboard } from '../../hooks/use-keyboard'
+import {
+ applySceneGraphToEditor,
+ loadSceneFromLocalStorage,
+ type SceneGraph,
+ writePersistedSelection,
+} from '../../lib/scene'
+import { initSFXBus } from '../../lib/sfx-bus'
+import useEditor from '../../store/use-editor'
+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 { 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 { 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'
+import { FloorplanPanel } from './floorplan-panel'
+import { Grid } from './grid'
+import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
+import { SelectionManager } from './selection-manager'
+import { SiteEdgeLabels } from './site-edge-labels'
+import { ThumbnailGenerator } from './thumbnail-generator'
+import { WallMeasurementLabel } from './wall-measurement-label'
+
+let hasInitializedEditorRuntime = false
+const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
+
+function initializeEditorRuntime() {
+ if (hasInitializedEditorRuntime) return
+ initSpatialGridSync()
+ initSpaceDetectionSync(useScene, useEditor)
+ initSFXBus()
+
+ hasInitializedEditorRuntime = true
+}
+export interface EditorProps {
+ // 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
+ onLoad?: () => Promise
+ onSave?: (scene: SceneGraph) => Promise
+ onDirty?: () => void
+ onSaveStatusChange?: (status: SaveStatus) => void
+
+ // Version preview
+ previewScene?: SceneGraph
+ isVersionPreviewMode?: boolean
+
+ // Loading indicator (e.g. project fetching in community mode)
+ isLoading?: boolean
+
+ // Thumbnail
+ onThumbnailCapture?: (blob: Blob) => void
+
+ // 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() {
+ return (
+
+
+
The editor scene failed to render
+
+ You can retry the scene or return home without reloading the whole app shell.
+
+
+
+
+ Back to home
+
+
+
+
+ )
+}
+
+// ── 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
+
+ {/* Inner: overflow-hidden clips content to rounded corners */}
+
+ {isCollapsed ? (
+
+ ) : (
+ children
+ )}
+
+
+ {/* Handle: extends into the gap, centered on the gap midpoint */}
+ {!isCollapsed && (
+
+ )}
+
+ )
+}
+
+// ── UI overlays: fixed, scoped to viewer area via transform containing block ──
+
+function ViewerOverlays({ left, children }: { left: number; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
+ const selection = useViewer((state) => state.selection)
+
+ useEffect(() => {
+ if (!enabled) {
+ return
+ }
+
+ writePersistedSelection(selection)
+ }, [enabled, selection])
+
+ return null
+}
+
+type ShortcutKey = {
+ value: string
+}
+
+type CameraControlHint = {
+ action: string
+ keys: ShortcutKey[]
+ alternativeKeys?: ShortcutKey[]
+}
+
+const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
+ {
+ action: 'Pan',
+ keys: [{ value: 'Space' }, { value: 'Left click' }],
+ },
+ { action: 'Rotate', keys: [{ value: 'Right click' }] },
+ { action: 'Zoom', keys: [{ value: 'Scroll' }] },
+]
+
+const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
+ { action: 'Pan', keys: [{ value: 'Left click' }] },
+ { action: 'Rotate', keys: [{ value: 'Right click' }] },
+ { action: 'Zoom', keys: [{ value: 'Scroll' }] },
+]
+
+const CAMERA_SHORTCUT_KEY_META: Record = {
+ 'Left click': {
+ icon: 'ph:mouse-left-click-fill',
+ label: 'Left click',
+ },
+ 'Middle click': {
+ icon: 'qlementine-icons:mouse-middle-button-16',
+ label: 'Middle click',
+ },
+ 'Right click': {
+ icon: 'ph:mouse-right-click-fill',
+ label: 'Right click',
+ },
+ Scroll: {
+ icon: 'qlementine-icons:mouse-middle-button-16',
+ label: 'Scroll wheel',
+ },
+ Space: {
+ icon: 'lucide:space',
+ label: 'Space',
+ },
+}
+
+function readCameraControlsHintDismissed(): boolean {
+ if (typeof window === 'undefined') {
+ return false
+ }
+
+ try {
+ return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+function writeCameraControlsHintDismissed(dismissed: boolean) {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ try {
+ if (dismissed) {
+ window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1')
+ return
+ }
+
+ window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY)
+ } catch {}
+}
+
+function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) {
+ const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value]
+
+ if (meta?.icon) {
+ return (
+
+
+ {meta.label}
+
+ )
+ }
+
+ return (
+
+ {meta?.text ?? shortcutKey.value}
+
+ )
+}
+
+function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) {
+ return (
+
+ {keys.map((key, index) => (
+
+ {index > 0 ? + : null}
+
+
+ ))}
+
+ )
+}
+
+function CameraControlHintItem({ hint }: { hint: CameraControlHint }) {
+ return (
+
+
+ {hint.action}
+
+
+
+ {hint.alternativeKeys ? (
+ <>
+ /
+
+ >
+ ) : null}
+
+
+ )
+}
+
+function ViewerCanvasControlsHint({
+ isPreviewMode,
+ onDismiss,
+}: {
+ isPreviewMode: boolean
+ onDismiss: () => void
+}) {
+ const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
+
+ return (
+
+
+
+ {hints.map((hint) => (
+
+ ))}
+
+
+
+
+
+
+ Dismiss
+
+
+
+
+ )
+}
+
+export default function Editor({
+ layoutVersion = 'v1',
+ appMenuButton,
+ sidebarTop,
+ navbarSlot,
+ sidebarTabs,
+ viewerToolbarLeft,
+ viewerToolbarRight,
+ projectId,
+ onLoad,
+ onSave,
+ onDirty,
+ onSaveStatusChange,
+ previewScene,
+ isVersionPreviewMode = false,
+ isLoading = false,
+ onThumbnailCapture,
+ settingsPanelProps,
+ sitePanelProps,
+ extraSidebarPanels,
+ presetsAdapter,
+ commandPaletteEmptyAction,
+}: EditorProps) {
+ useKeyboard()
+
+ const { isLoadingSceneRef } = useAutoSave({
+ onSave,
+ onDirty,
+ onSaveStatusChange,
+ isVersionPreviewMode,
+ })
+
+ const [isSceneLoading, setIsSceneLoading] = useState(false)
+ const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
+ const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState(
+ null,
+ )
+ 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(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()
+ }, [])
+
+ useEffect(() => {
+ useViewer.getState().setProjectId(projectId ?? null)
+
+ return () => {
+ useViewer.getState().setProjectId(null)
+ }
+ }, [projectId])
+
+ // Load scene on mount (or when onLoad identity changes, e.g. project switch)
+ useEffect(() => {
+ let cancelled = false
+
+ async function load() {
+ isLoadingSceneRef.current = true
+ setHasLoadedInitialScene(false)
+ setIsSceneLoading(true)
+
+ try {
+ const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
+ if (!cancelled) {
+ applySceneGraphToEditor(sceneGraph)
+ }
+ } catch {
+ if (!cancelled) applySceneGraphToEditor(null)
+ } finally {
+ if (!cancelled) {
+ setIsSceneLoading(false)
+ setHasLoadedInitialScene(true)
+ requestAnimationFrame(() => {
+ isLoadingSceneRef.current = false
+ })
+ }
+ }
+ }
+
+ load()
+
+ return () => {
+ cancelled = true
+ }
+ }, [onLoad, isLoadingSceneRef])
+
+ // Apply preview scene when version preview mode changes
+ useEffect(() => {
+ if (isVersionPreviewMode && previewScene) {
+ applySceneGraphToEditor(previewScene)
+ }
+ }, [isVersionPreviewMode, previewScene])
+
+ useEffect(() => {
+ document.body.classList.add('dark')
+ return () => {
+ document.body.classList.remove('dark')
+ }
+ }, [])
+
+ useEffect(() => {
+ setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
+ }, [])
+
+ const showLoader = isLoading || isSceneLoading
+ const dismissCameraControlsHint = useCallback(() => {
+ setIsCameraControlsHintVisible(false)
+ writeCameraControlsHintDismissed(true)
+ }, [])
+
+ // ── Shared viewer scene content ──
+ const viewerSceneContent = (
+ <>
+ {!isFirstPersonMode && }
+ {!isFirstPersonMode && }
+ {!isFirstPersonMode && }
+ {!isFirstPersonMode && }
+
+ {isFirstPersonMode ? : }
+
+
+ {!isLoading && !isFirstPersonMode && }
+ {!isLoading && !isFirstPersonMode && }
+
+ {isFirstPersonMode && }
+
+
+ {!isFirstPersonMode && }
+ {isFirstPersonMode && }
+ >
+ )
+
+ const previewViewerContent = (
+
+
+
+
+
+
+
+
+
+
+ )
+
+ // ── 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 = (
+ }>
+
+ {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
+
+
+
+
+ {viewMode === 'split' && (
+
+ )}
+
+
+ {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
+
+ {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
+
+ ) : null}
+
+ {viewerSceneContent}
+
+
+ {!isLoading && }
+
+ )
+
+ // ── 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
+ }
+ if (tabId === 'settings') {
+ return
+ }
+ // External tabs (AI chat, catalog, etc.)
+ const tab = tabMap.get(tabId)
+ if (!tab) return null
+ const Component = tab.component
+ return
+ }
+
+ const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
+
+ return (
+
+ {showLoader && (
+
+
+
+ )}
+
+ {!isLoading && isPreviewMode ? (
+
+
useEditor.getState().setPreviewMode(false)} />
+ {previewViewerContent}
+
+ ) : (
+ <>
+ {/* First-person overlay — rendered on top of normal layout */}
+ {isFirstPersonMode && (
+
+ useEditor.getState().setFirstPersonMode(false)}
+ />
+
+ )}
+
+
+
+
+
+
+
+ >
+ }
+ renderTabContent={renderTabContent}
+ sidebarTabs={tabBarTabs}
+ viewerContent={viewerCanvas}
+ viewerToolbarLeft={viewerToolbarLeft}
+ viewerToolbarRight={viewerToolbarRight}
+ />
+
+
+ >
+ )}
+
+ )
+ }
+
+ // ── 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 (
+
+
+ {showLoader && (
+
+
+
+ )}
+
+ {!isLoading && isPreviewMode ? (
+ <>
+
useEditor.getState().setPreviewMode(false)} />
+ {previewViewerContent}
+ >
+ ) : (
+ <>
+ {/* Sidebar */}
+
+
+
+
+ {/* Viewer area */}
+
+ {viewerCanvas}
+
+
+ {/* Fixed UI overlays scoped to the viewer area */}
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx
index 81cf3cf7..3f801a2a 100644
--- a/packages/editor/src/components/editor/selection-manager.tsx
+++ b/packages/editor/src/components/editor/selection-manager.tsx
@@ -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 (
<>
-
>
)
}
-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) => {
diff --git a/packages/editor/src/components/editor/site-edge-labels.tsx b/packages/editor/src/components/editor/site-edge-labels.tsx
index 70e362d9..62a841be 100644
--- a/packages/editor/src/components/editor/site-edge-labels.tsx
+++ b/packages/editor/src/components/editor/site-edge-labels.tsx
@@ -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(null)
const prevSiteNodeIdRef = useRef(undefined)
@@ -55,8 +73,14 @@ export function SiteEdgeLabels() {
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[10, 0]}
>
-
- {edge.dist.toFixed(2)}m
+
+ {formatMeasurement(edge.dist, unit)}