The editor scene failed to render
@@ -125,9 +112,201 @@ function EditorSceneCrashFallback() {
)
}
+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,
@@ -150,7 +329,24 @@ export default function Editor({
})
const [isSceneLoading, setIsSceneLoading] = useState(false)
+ const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
+ const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState(
+ null,
+ )
const isPreviewMode = useEditor((s) => s.isPreviewMode)
+ 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(() => {
@@ -158,6 +354,7 @@ export default function Editor({
async function load() {
isLoadingSceneRef.current = true
+ setHasLoadedInitialScene(false)
setIsSceneLoading(true)
try {
@@ -170,6 +367,7 @@ export default function Editor({
} finally {
if (!cancelled) {
setIsSceneLoading(false)
+ setHasLoadedInitialScene(true)
requestAnimationFrame(() => {
isLoadingSceneRef.current = false
})
@@ -198,19 +396,39 @@ export default function Editor({
}
}, [])
+ useEffect(() => {
+ setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
+ }, [])
+
const showLoader = isLoading || isSceneLoading
+ const dismissCameraControlsHint = useCallback(() => {
+ setIsCameraControlsHintVisible(false)
+ writeCameraControlsHintDismissed(true)
+ }, [])
return (
- {showLoader &&
}
+ {showLoader && (
+
+
+
+ )}
- {isPreviewMode ? (
+ {!showLoader && isCameraControlsHintVisible ? (
+
+ ) : null}
+
+ {!isLoading && isPreviewMode ? (
useEditor.getState().setPreviewMode(false)} />
) : (
<>
+ {isFloorplanOpen && }
@@ -225,21 +443,26 @@ export default function Editor({
)}
}>
-
- {!isPreviewMode && }
- {!isPreviewMode && }
-
- {isPreviewMode ? : }
-
- {!isPreviewMode && }
- {!isPreviewMode && }
-
-
-
- {!isPreviewMode && }
- {isPreviewMode && }
-
- {!isPreviewMode && }
+
+
+
+ {!isPreviewMode && }
+ {!isPreviewMode && }
+ {!isPreviewMode && }
+
+ {isPreviewMode ? : }
+
+
+ {!isPreviewMode && }
+ {!(isPreviewMode || isLoading) && }
+
+
+
+ {!isPreviewMode && }
+ {isPreviewMode && }
+
+
+ {!(isPreviewMode || isLoading) && }
diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx
new file mode 100644
index 00000000..94bab4cb
--- /dev/null
+++ b/packages/editor/src/components/editor/node-action-menu.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { Copy, Move, Trash2 } from 'lucide-react'
+import type { MouseEventHandler, PointerEventHandler } from 'react'
+
+type NodeActionMenuProps = {
+ onDelete: MouseEventHandler
+ onDuplicate: MouseEventHandler
+ onMove: MouseEventHandler
+ onPointerDown?: PointerEventHandler
+ onPointerUp?: PointerEventHandler
+ onPointerEnter?: PointerEventHandler
+ onPointerLeave?: PointerEventHandler
+}
+
+export function NodeActionMenu({
+ onDelete,
+ onDuplicate,
+ onMove,
+ onPointerDown,
+ onPointerUp,
+ onPointerEnter,
+ onPointerLeave,
+}: NodeActionMenuProps) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx
index 9ac2b43e..2cae4682 100644
--- a/packages/editor/src/components/editor/selection-manager.tsx
+++ b/packages/editor/src/components/editor/selection-manager.tsx
@@ -1,5 +1,6 @@
import {
type AnyNode,
+ type AnyNodeId,
type BuildingNode,
emitter,
type ItemNode,
@@ -11,7 +12,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
-import useEditor from './../../store/use-editor'
+import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
const currentLevelId = useViewer.getState().selection.levelId
@@ -28,6 +29,7 @@ type SelectableNodeType =
| 'slab'
| 'ceiling'
| 'roof'
+ | 'roof-segment'
| 'window'
| 'door'
@@ -43,6 +45,11 @@ interface SelectionStrategy {
isValid: (node: AnyNode) => boolean
}
+type SelectionTarget = {
+ phase: Phase
+ structureLayer?: StructureLayer
+}
+
export const resolveBuildingId = (
levelId: string,
nodes: Record,
@@ -64,16 +71,6 @@ const computeNextIds = (
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
- console.log('computeNextIds:', {
- nodeId: node.id,
- selectedIds,
- isMeta,
- isCtrl,
- eventMeta: event?.metaKey,
- nativeMeta: event?.nativeEvent?.metaKey,
- modMeta: modifierKeys?.meta,
- })
-
if (isMeta || isCtrl) {
if (selectedIds.includes(node.id)) {
return selectedIds.filter((id) => id !== node.id)
@@ -98,7 +95,7 @@ const SELECTION_STRATEGIES: Record = {
},
structure: {
- types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'window', 'door'],
+ types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes
@@ -142,7 +139,8 @@ const SELECTION_STRATEGIES: Record = {
node.type === 'wall' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
- node.type === 'roof'
+ node.type === 'roof' ||
+ node.type === 'roof-segment'
)
return true
if (node.type === 'item') {
@@ -188,6 +186,46 @@ const SELECTION_STRATEGIES: Record = {
},
}
+const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
+ if (node.type === 'zone') {
+ return {
+ phase: 'structure',
+ structureLayer: 'zones',
+ }
+ }
+
+ if (
+ node.type === 'wall' ||
+ node.type === 'slab' ||
+ node.type === 'ceiling' ||
+ node.type === 'roof' ||
+ node.type === 'roof-segment' ||
+ node.type === 'window' ||
+ node.type === 'door'
+ ) {
+ return {
+ phase: 'structure',
+ structureLayer: 'elements',
+ }
+ }
+
+ if (node.type === 'item') {
+ const item = node as ItemNode
+ if (item.asset.category === 'door' || item.asset.category === 'window') {
+ return {
+ phase: 'structure',
+ structureLayer: 'elements',
+ }
+ }
+
+ return {
+ phase: 'furnish',
+ }
+ }
+
+ return null
+}
+
export const SelectionManager = () => {
const phase = useEditor((s) => s.phase)
const mode = useEditor((s) => s.mode)
@@ -233,35 +271,26 @@ export const SelectionManager = () => {
const onClick = (event: NodeEvent) => {
const node = event.node
let currentPhase = useEditor.getState().phase
- let targetPhase = currentPhase
+ let currentStructureLayer = useEditor.getState().structureLayer
- // Auto-switch between structure and furnish phases when clicking elements on the same level
+ // Auto-switch between zones, structure, and furnish when clicking elements on the same level.
if (currentPhase === 'structure' || currentPhase === 'furnish') {
if (isNodeInCurrentLevel(node)) {
- if (
- node.type === 'wall' ||
- node.type === 'slab' ||
- node.type === 'ceiling' ||
- node.type === 'roof' ||
- node.type === 'window' ||
- node.type === 'door'
- ) {
- targetPhase = 'structure'
- } else if (node.type === 'item') {
- const item = node as ItemNode
- if (item.asset.category === 'door' || item.asset.category === 'window') {
- targetPhase = 'structure'
- } else {
- targetPhase = 'furnish'
+ const target = getSelectionTarget(node)
+ if (target) {
+ if (target.phase !== currentPhase) {
+ useEditor.getState().setPhase(target.phase)
+ currentPhase = target.phase
}
- }
- if (targetPhase !== currentPhase) {
- useEditor.getState().setPhase(targetPhase)
- if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
- useEditor.getState().setStructureLayer('elements')
+ if (
+ target.phase === 'structure' &&
+ target.structureLayer &&
+ target.structureLayer !== currentStructureLayer
+ ) {
+ useEditor.getState().setStructureLayer(target.structureLayer)
+ currentStructureLayer = target.structureLayer
}
- currentPhase = targetPhase
}
}
}
@@ -271,14 +300,15 @@ export const SelectionManager = () => {
event.stopPropagation()
clickHandledRef.current = true
- console.log(
- '[SelectionManager] Valid click on:',
- node.type,
- node.id,
- 'Shift:',
- event.nativeEvent.shiftKey,
- )
- activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
+ let nodeToSelect = node
+ if (node.type === 'roof-segment' && node.parentId) {
+ const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
+ if (parentNode && parentNode.type === 'roof') {
+ nodeToSelect = parentNode
+ }
+ }
+
+ activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
@@ -295,6 +325,7 @@ export const SelectionManager = () => {
'slab',
'ceiling',
'roof',
+ 'roof-segment',
'window',
'door',
]
@@ -304,7 +335,6 @@ export const SelectionManager = () => {
const onGridClick = () => {
if (clickHandledRef.current) return
- console.log('onGridClick triggered! Deselecting.')
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
if (activeStrategy) activeStrategy.handleDeselect()
}
@@ -351,7 +381,8 @@ export const SelectionManager = () => {
}
const onLeave = (event: NodeEvent) => {
- if (useViewer.getState().hoveredId === event.node.id) {
+ const nodeId = event?.node?.id
+ if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
}
}
@@ -361,6 +392,7 @@ export const SelectionManager = () => {
const currentPhase = useEditor.getState().phase
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
+ let forceSelect = false
if (node.type === 'building' || node.type === 'site') {
if (currentPhase === 'structure' || currentPhase === 'furnish') {
@@ -374,10 +406,14 @@ export const SelectionManager = () => {
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
+ node.type === 'roof-segment' ||
node.type === 'window' ||
node.type === 'door'
) {
targetPhase = 'structure'
+ if (node.type === 'roof-segment' && currentPhase === 'structure') {
+ forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
+ }
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') {
@@ -391,16 +427,18 @@ export const SelectionManager = () => {
return
}
- if (targetPhase && targetPhase !== useEditor.getState().phase) {
+ if ((targetPhase && targetPhase !== useEditor.getState().phase) || forceSelect) {
event.stopPropagation()
- useEditor.getState().setPhase(targetPhase)
+ if (targetPhase && targetPhase !== useEditor.getState().phase) {
+ useEditor.getState().setPhase(targetPhase)
+ }
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
useEditor.getState().setStructureLayer('elements')
}
- const strategy = SELECTION_STRATEGIES[targetPhase]
+ const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
if (strategy) {
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
}
@@ -414,6 +452,7 @@ export const SelectionManager = () => {
'slab',
'ceiling',
'roof',
+ 'roof-segment',
'window',
'door',
'zone',
@@ -434,7 +473,44 @@ export const SelectionManager = () => {
}
}, [mode, movingNode])
- return
+ return (
+ <>
+
+
+ >
+ )
+}
+
+const SelectionStateSync = () => {
+ useEffect(() => {
+ return useScene.subscribe((state) => {
+ const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
+
+ if (buildingId && !state.nodes[buildingId as AnyNodeId]) {
+ useViewer.getState().setSelection({ buildingId: null })
+ return
+ }
+
+ if (levelId && !state.nodes[levelId as AnyNodeId]) {
+ useViewer.getState().setSelection({ levelId: null })
+ return
+ }
+
+ if (zoneId && !state.nodes[zoneId as AnyNodeId]) {
+ useViewer.getState().setSelection({ zoneId: null })
+ return
+ }
+
+ if (selectedIds.length === 0) return
+
+ const nextSelectedIds = selectedIds.filter((id) => state.nodes[id as AnyNodeId])
+ if (nextSelectedIds.length !== selectedIds.length) {
+ useViewer.getState().setSelection({ selectedIds: nextSelectedIds })
+ }
+ })
+ }, [])
+
+ return null
}
const EditorOutlinerSync = () => {
diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx
new file mode 100644
index 00000000..37ad795a
--- /dev/null
+++ b/packages/editor/src/components/editor/wall-measurement-label.tsx
@@ -0,0 +1,259 @@
+'use client'
+
+import {
+ type AnyNodeId,
+ calculateLevelMiters,
+ DEFAULT_WALL_HEIGHT,
+ getWallPlanFootprint,
+ type Point2D,
+ pointToKey,
+ sceneRegistry,
+ useScene,
+ type WallMiterData,
+ type WallNode,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { Html } from '@react-three/drei'
+import { createPortal, useFrame } from '@react-three/fiber'
+import { useEffect, useMemo, useState } from 'react'
+import * as THREE from 'three'
+
+const GUIDE_Y_OFFSET = 0.08
+const LABEL_LIFT = 0.08
+const BAR_THICKNESS = 0.012
+const LINE_OPACITY = 0.95
+
+const BAR_AXIS = new THREE.Vector3(0, 1, 0)
+
+type Vec3 = [number, number, number]
+
+type MeasurementGuide = {
+ guideStart: Vec3
+ guideEnd: Vec3
+ extStartStart: Vec3
+ extStartEnd: Vec3
+ extEndStart: Vec3
+ extEndEnd: Vec3
+ labelPosition: Vec3
+}
+
+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 WallMeasurementLabel() {
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
+ const nodes = useScene((state) => state.nodes)
+
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
+ const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
+ const wall = selectedNode?.type === 'wall' ? selectedNode : null
+
+ const [wallObject, setWallObject] = useState(null)
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset cached object when selection changes
+ useEffect(() => {
+ setWallObject(null)
+ }, [selectedId])
+
+ useFrame(() => {
+ if (!selectedId || wallObject) return
+
+ const nextWallObject = sceneRegistry.nodes.get(selectedId)
+ if (nextWallObject) {
+ setWallObject(nextWallObject)
+ }
+ })
+
+ if (!(wall && wallObject)) return null
+
+ return createPortal(, wallObject)
+}
+
+function getLevelWalls(
+ wall: WallNode,
+ nodes: Record,
+): WallNode[] {
+ if (!wall.parentId) return [wall]
+
+ const levelNode = nodes[wall.parentId as AnyNodeId]
+ if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) {
+ return [wall]
+ }
+
+ return levelNode.children
+ .map((childId) => nodes[childId as AnyNodeId])
+ .filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
+}
+
+function getWallMiddlePoints(
+ wall: WallNode,
+ miterData: WallMiterData,
+): { start: Point2D; end: Point2D } | null {
+ const footprint = getWallPlanFootprint(wall, miterData)
+ if (footprint.length < 4) return null
+
+ const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
+ const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
+
+ const rightStart = footprint[0]
+ const rightEnd = footprint[1]
+ const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2]
+ const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1]
+
+ if (!(leftStart && leftEnd && rightStart && rightEnd)) return null
+
+ return {
+ start: {
+ x: (leftStart.x + rightStart.x) / 2,
+ y: (leftStart.y + rightStart.y) / 2,
+ },
+ end: {
+ x: (leftEnd.x + rightEnd.x) / 2,
+ y: (leftEnd.y + rightEnd.y) / 2,
+ },
+ }
+}
+
+function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
+ const dx = point.x - wall.start[0]
+ const dz = point.y - wall.start[1]
+ const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
+ const cosA = Math.cos(-angle)
+ const sinA = Math.sin(-angle)
+
+ return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
+}
+
+function buildMeasurementGuide(
+ wall: WallNode,
+ nodes: Record,
+): MeasurementGuide | null {
+ const levelWalls = getLevelWalls(wall, nodes)
+ const miterData = calculateLevelMiters(levelWalls)
+ const middlePoints = getWallMiddlePoints(wall, miterData)
+ if (!middlePoints) return null
+
+ const height = wall.height ?? DEFAULT_WALL_HEIGHT
+ const startLocal = worldPointToWallLocal(wall, middlePoints.start)
+ const endLocal = worldPointToWallLocal(wall, middlePoints.end)
+
+ const guideStart: Vec3 = [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]]
+ const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]]
+
+ const dirX = guideEnd[0] - guideStart[0]
+ const dirZ = guideEnd[2] - guideStart[2]
+ const dirLength = Math.hypot(dirX, dirZ)
+
+ if (!Number.isFinite(dirLength) || dirLength < 0.001) return null
+
+ // Extension lines coming out of the extremity markers of the wall
+ const extOvershoot = 0.04
+
+ return {
+ guideStart,
+ guideEnd,
+ extStartStart: [startLocal[0], height, startLocal[2]],
+ extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]],
+ extEndStart: [endLocal[0], height, endLocal[2]],
+ extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]],
+ labelPosition: [
+ (guideStart[0] + guideEnd[0]) / 2,
+ guideStart[1] + LABEL_LIFT,
+ (guideStart[2] + guideEnd[2]) / 2,
+ ],
+ }
+}
+
+function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) {
+ const segment = useMemo(() => {
+ const startVector = new THREE.Vector3(...start)
+ const endVector = new THREE.Vector3(...end)
+ const direction = endVector.clone().sub(startVector)
+ const length = direction.length()
+
+ if (!Number.isFinite(length) || length < 0.0001) return null
+
+ return {
+ length,
+ position: startVector.clone().add(endVector).multiplyScalar(0.5),
+ quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()),
+ }
+ }, [end, start])
+
+ if (!segment) return null
+
+ return (
+
+
+
+
+ )
+}
+
+function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
+ const nodes = useScene((state) => state.nodes)
+ const theme = useViewer((state) => state.theme)
+ const unit = useViewer((state) => state.unit)
+ const isNight = theme === 'dark'
+ const color = isNight ? '#ffffff' : '#111111'
+ const shadowColor = isNight ? '#111111' : '#ffffff'
+
+ const dx = wall.end[0] - wall.start[0]
+ const dz = wall.end[1] - wall.start[1]
+ const length = Math.hypot(dx, dz)
+ const label = formatMeasurement(length, unit)
+ const guide = useMemo(
+ () =>
+ buildMeasurementGuide(
+ wall,
+ nodes as Record,
+ ),
+ [nodes, wall],
+ )
+
+ if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+
+ )
+}
diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx
new file mode 100644
index 00000000..3a7f0b01
--- /dev/null
+++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx
@@ -0,0 +1,69 @@
+import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { useEffect, useRef } from 'react'
+
+/**
+ * Imperatively toggles the Three.js visibility of roof objects based on the
+ * editor selection — without causing React re-renders in RoofRenderer.
+ *
+ * When a roof (or one of its segments) is selected:
+ * - merged-roof mesh is hidden
+ * - segments-wrapper group is shown (individual segments visible for editing)
+ * - all children are marked dirty so RoofSystem rebuilds their geometry
+ *
+ * When deselected:
+ * - merged-roof mesh is shown
+ * - segments-wrapper group is hidden
+ */
+export const RoofEditSystem = () => {
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const prevActiveRoofIds = useRef(new Set())
+
+ useEffect(() => {
+ const nodes = useScene.getState().nodes
+
+ // Collect which roof nodes should be in "edit mode"
+ const activeRoofIds = new Set()
+ for (const id of selectedIds) {
+ const node = nodes[id as AnyNodeId]
+ if (!node) continue
+ if (node.type === 'roof') {
+ activeRoofIds.add(id)
+ } else if (node.type === 'roof-segment' && node.parentId) {
+ activeRoofIds.add(node.parentId)
+ }
+ }
+
+ // Update all roofs that are currently active OR were previously active
+ const roofIdsToUpdate = new Set([...activeRoofIds, ...prevActiveRoofIds.current])
+
+ for (const roofId of roofIdsToUpdate) {
+ const group = sceneRegistry.nodes.get(roofId)
+ if (!group) continue
+
+ const mergedMesh = group.getObjectByName('merged-roof')
+ const segmentsWrapper = group.getObjectByName('segments-wrapper')
+ const isActive = activeRoofIds.has(roofId)
+
+ if (mergedMesh) mergedMesh.visible = !isActive
+ if (segmentsWrapper) segmentsWrapper.visible = isActive
+
+ const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
+ if (roofNode?.children?.length) {
+ const wasActive = prevActiveRoofIds.current.has(roofId)
+ if (isActive !== wasActive) {
+ // Entering edit mode: rebuild individual segment geometries
+ // Exiting edit mode: sync transforms + rebuild merged mesh
+ const { markDirty } = useScene.getState()
+ for (const childId of roofNode.children) {
+ markDirty(childId as AnyNodeId)
+ }
+ }
+ }
+ }
+
+ prevActiveRoofIds.current = activeRoofIds
+ }, [selectedIds])
+
+ return null
+}
diff --git a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx b/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx
index 6622f799..0c6da435 100644
--- a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx
+++ b/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx
@@ -27,7 +27,7 @@ export const CeilingBoundaryEditor: React.FC = ({ ce
[ceilingId, updateNode, setSelection],
)
- if (!(ceiling && ceiling.polygon) || ceiling.polygon.length < 3) return null
+ if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
return (
{
{
{
{
= ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef(null!)
- const exitMoveMode = () => {
+ const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
- }
+ }, [])
useEffect(() => {
useScene.temporal.getState().pause()
@@ -352,7 +352,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
- }, [movingDoorNode])
+ }, [movingDoorNode, exitMoveMode])
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(
diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx
index f1dda762..2a01a38e 100644
--- a/packages/editor/src/components/tools/item/move-tool.tsx
+++ b/packages/editor/src/components/tools/item/move-tool.tsx
@@ -1,8 +1,9 @@
-import type { DoorNode, ItemNode, WindowNode } from '@pascal-app/core'
+import type { DoorNode, ItemNode, RoofNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { MoveDoorTool } from '../door/move-door-tool'
+import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node'
@@ -73,5 +74,7 @@ export const MoveTool: React.FC = () => {
if (!movingNode) return null
if (movingNode.type === 'door') return
if (movingNode.type === 'window') return
+ if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
+ return
return
}
diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/editor/src/components/tools/roof/move-roof-tool.tsx
new file mode 100644
index 00000000..4128c7d6
--- /dev/null
+++ b/packages/editor/src/components/tools/roof/move-roof-tool.tsx
@@ -0,0 +1,258 @@
+import {
+ type AnyNodeId,
+ emitter,
+ type GridEvent,
+ type RoofNode,
+ type RoofSegmentNode,
+ sceneRegistry,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import * as THREE from 'three'
+import { sfxEmitter } from '../../../lib/sfx-bus'
+import useEditor from '../../../store/use-editor'
+import { CursorSphere } from '../shared/cursor-sphere'
+
+export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
+ node: movingNode,
+}) => {
+ const exitMoveMode = useCallback(() => {
+ useEditor.getState().setMovingNode(null)
+ }, [])
+
+ const previousGridPosRef = useRef<[number, number] | null>(null)
+
+ const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
+ const obj = sceneRegistry.nodes.get(movingNode.id)
+ if (obj) {
+ const pos = new THREE.Vector3()
+ obj.getWorldPosition(pos)
+ return [pos.x, pos.y, pos.z]
+ }
+ // Fallback if not registered (e.g. newly created duplicate without mesh yet)
+ if (movingNode.type === 'roof-segment' && movingNode.parentId) {
+ const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
+ if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
+ const parentAngle = parentNode.rotation as number
+ const px = parentNode.position[0] as number
+ const py = parentNode.position[1] as number
+ const pz = parentNode.position[2] as number
+ const lx = movingNode.position[0]
+ const ly = movingNode.position[1]
+ const lz = movingNode.position[2]
+
+ const wx = lx * Math.cos(parentAngle) - lz * Math.sin(parentAngle) + px
+ const wz = lx * Math.sin(parentAngle) + lz * Math.cos(parentAngle) + pz
+ return [wx, py + ly, wz]
+ }
+ }
+ return [movingNode.position[0], movingNode.position[1], movingNode.position[2]]
+ })
+
+ useEffect(() => {
+ useScene.temporal.getState().pause()
+
+ const meta =
+ typeof movingNode.metadata === 'object' && movingNode.metadata !== null
+ ? (movingNode.metadata as Record)
+ : {}
+ const isNew = !!meta.isNew
+ const committedMeta: RoofNode['metadata'] = (() => {
+ if (
+ typeof movingNode.metadata !== 'object' ||
+ movingNode.metadata === null ||
+ Array.isArray(movingNode.metadata)
+ ) {
+ return movingNode.metadata
+ }
+
+ const nextMeta = { ...movingNode.metadata } as Record
+ delete nextMeta.isNew
+ delete nextMeta.isTransient
+ return nextMeta as RoofNode['metadata']
+ })()
+
+ const original = {
+ position: [...movingNode.position] as [number, number, number],
+ rotation: movingNode.rotation,
+ parentId: movingNode.parentId,
+ metadata: movingNode.metadata,
+ }
+
+ // Track whether the move was committed so cleanup knows whether to revert.
+ // We avoid setting isTransient on the store to prevent RoofSystem from
+ // resetting the mesh position (it resets on dirty) and from triggering
+ // expensive merged-mesh CSG rebuilds on every frame.
+ let wasCommitted = false
+
+ // Track pending rotation — no store updates during drag
+ let pendingRotation: number = movingNode.rotation as number
+
+ // For roof-segment moves: the selection was cleared before entering move mode,
+ // so isSelected=false on the parent roof, hiding individual segment meshes and
+ // showing only the merged mesh. We directly flip Three.js visibility so the
+ // user sees the individual segment tracking the cursor.
+ let segmentWrapperGroup: THREE.Object3D | null = null
+ let mergedRoofMesh: THREE.Object3D | null = null
+ if (movingNode.type === 'roof-segment') {
+ const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
+ if (segmentMesh?.parent) {
+ // segmentMesh.parent = wrapper in RoofRenderer
+ // segmentMesh.parent.parent = the registered roof group
+ segmentWrapperGroup = segmentMesh.parent
+ mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName('merged-roof') ?? null
+ segmentWrapperGroup.visible = true
+ if (mergedRoofMesh) mergedRoofMesh.visible = false
+ }
+ }
+
+ const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => {
+ let localX = gridX
+ let localZ = gridZ
+
+ if (movingNode.type === 'roof-segment' && movingNode.parentId) {
+ const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
+ if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
+ const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
+ if (parentObj) {
+ const worldVec = new THREE.Vector3(gridX, y, gridZ)
+ parentObj.worldToLocal(worldVec)
+ localX = worldVec.x
+ localZ = worldVec.z
+ } else {
+ const dx = gridX - (parentNode.position[0] as number)
+ const dz = gridZ - (parentNode.position[2] as number)
+ const angle = -(parentNode.rotation as number)
+ localX = dx * Math.cos(angle) - dz * Math.sin(angle)
+ localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
+ }
+ }
+ }
+
+ return [localX, localZ]
+ }
+
+ const onGridMove = (event: GridEvent) => {
+ const gridX = Math.round(event.position[0] * 2) / 2
+ const gridZ = Math.round(event.position[2] * 2) / 2
+ const y = event.position[1]
+
+ if (
+ previousGridPosRef.current &&
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
+ ) {
+ sfxEmitter.emit('sfx:grid-snap')
+ }
+
+ previousGridPosRef.current = [gridX, gridZ]
+ setCursorWorldPos([gridX, y, gridZ])
+
+ const [localX, localZ] = computeLocal(gridX, gridZ, y)
+
+ // Directly update the Three.js mesh — no store update during drag
+ const mesh = sceneRegistry.nodes.get(movingNode.id)
+ if (mesh) {
+ mesh.position.x = localX
+ mesh.position.z = localZ
+ }
+ }
+
+ const onGridClick = (event: GridEvent) => {
+ const gridX = Math.round(event.position[0] * 2) / 2
+ const gridZ = Math.round(event.position[2] * 2) / 2
+ const y = event.position[1]
+
+ const [localX, localZ] = computeLocal(gridX, gridZ, y)
+
+ wasCommitted = true
+
+ // The store still holds the original values (we didn't update during drag).
+ // Resume temporal and apply the final state as a single undoable step.
+ useScene.temporal.getState().resume()
+
+ useScene.getState().updateNode(movingNode.id, {
+ position: [localX, movingNode.position[1], localZ],
+ rotation: pendingRotation,
+ metadata: committedMeta,
+ })
+
+ useScene.temporal.getState().pause()
+
+ sfxEmitter.emit('sfx:item-place')
+ useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
+ exitMoveMode()
+ event.nativeEvent?.stopPropagation?.()
+ }
+
+ const onCancel = () => {
+ if (isNew) {
+ useScene.getState().deleteNode(movingNode.id)
+ } else {
+ useScene.getState().updateNode(movingNode.id, {
+ position: original.position,
+ rotation: original.rotation,
+ metadata: original.metadata,
+ })
+ }
+ useScene.temporal.getState().resume()
+ exitMoveMode()
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
+ return
+ }
+
+ const ROTATION_STEP = Math.PI / 4
+ let rotationDelta = 0
+ if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
+ else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
+
+ if (rotationDelta !== 0) {
+ event.preventDefault()
+ sfxEmitter.emit('sfx:item-rotate')
+
+ pendingRotation += rotationDelta
+
+ // Directly update the Three.js mesh — no store update during drag
+ const mesh = sceneRegistry.nodes.get(movingNode.id)
+ if (mesh) mesh.rotation.y = pendingRotation
+ }
+ }
+
+ emitter.on('grid:move', onGridMove)
+ emitter.on('grid:click', onGridClick)
+ emitter.on('tool:cancel', onCancel)
+ window.addEventListener('keydown', onKeyDown)
+
+ return () => {
+ // Restore segment wrapper visibility (React will re-sync on next render)
+ if (segmentWrapperGroup) segmentWrapperGroup.visible = false
+ if (mergedRoofMesh) mergedRoofMesh.visible = true
+
+ if (!wasCommitted) {
+ if (isNew) {
+ useScene.getState().deleteNode(movingNode.id)
+ } else {
+ useScene.getState().updateNode(movingNode.id, {
+ position: original.position,
+ rotation: original.rotation,
+ metadata: original.metadata,
+ })
+ }
+ }
+ useScene.temporal.getState().resume()
+ emitter.off('grid:move', onGridMove)
+ emitter.off('grid:click', onGridClick)
+ emitter.off('tool:cancel', onCancel)
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [movingNode, exitMoveMode])
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx
index ee6c5022..4ebb9d85 100644
--- a/packages/editor/src/components/tools/roof/roof-tool.tsx
+++ b/packages/editor/src/components/tools/roof/roof-tool.tsx
@@ -1,58 +1,118 @@
import {
type AnyNode,
+ type AnyNodeId,
emitter,
type GridEvent,
type LevelNode,
RoofNode,
+ RoofSegmentNode,
+ sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
+import * as THREE from 'three'
import { BufferGeometry, DoubleSide, type Group, type Line, 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'
-// Default roof dimensions
-const DEFAULT_HEIGHT = 1.5
-const CEILING_HEIGHT = 2.52
+const DEFAULT_WALL_HEIGHT = 0.5
+const DEFAULT_ROOF_HEIGHT = 2.5
const GRID_OFFSET = 0.02
/**
- * Creates a roof with the given corners
+ * Creates a roof group with one default gable segment
*/
const commitRoofPlacement = (
levelId: LevelNode['id'],
corner1: [number, number, number],
corner2: [number, number, number],
-): RoofNode['id'] => {
- const { createNode, nodes } = useScene.getState()
+ selectedIds: string[],
+): AnyNode['id'] => {
+ const { createNode, createNodes, nodes } = useScene.getState()
- // Calculate center position and dimensions from corners
const centerX = (corner1[0] + corner2[0]) / 2
const centerZ = (corner1[2] + corner2[2]) / 2
- const length = Math.abs(corner2[0] - corner1[0])
- const width = Math.abs(corner2[2] - corner1[2])
+ const width = Math.max(Math.abs(corner2[0] - corner1[0]), 1)
+ const depth = Math.max(Math.abs(corner2[2] - corner1[2]), 1)
- // Split width evenly between left and right slopes
- const slopeWidth = Math.max(width / 2, 0.5)
+ // Determine if there is an active roof node we should add to
+ let targetRoofId: RoofNode['id'] | null = null
+ const selectedId = selectedIds[0]
+ if (selectedIds.length === 1 && selectedId) {
+ const selectedNode = nodes[selectedId as AnyNodeId]
+ if (selectedNode?.type === 'roof') {
+ targetRoofId = selectedNode.id
+ } else if (selectedNode?.type === 'roof-segment' && selectedNode.parentId) {
+ targetRoofId = selectedNode.parentId as RoofNode['id']
+ }
+ }
+
+ if (targetRoofId) {
+ const targetRoof = nodes[targetRoofId] as RoofNode
+ let localX = centerX
+ let localZ = centerZ
+
+ // Convert world coordinates to the local space of the parent roof
+ const targetObj = sceneRegistry.nodes.get(targetRoofId)
+ if (targetObj) {
+ const worldVec = new THREE.Vector3(centerX, 0, centerZ)
+ targetObj.worldToLocal(worldVec)
+ localX = worldVec.x
+ localZ = worldVec.z
+ } else {
+ // Math fallback if mesh isn't ready
+ const dx = centerX - targetRoof.position[0]
+ const dz = centerZ - targetRoof.position[2]
+ const angle = -targetRoof.rotation
+ localX = dx * Math.cos(angle) - dz * Math.sin(angle)
+ localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
+ }
+
+ const segment = RoofSegmentNode.parse({
+ width,
+ depth,
+ wallHeight: DEFAULT_WALL_HEIGHT,
+ roofHeight: DEFAULT_ROOF_HEIGHT,
+ roofType: 'gable',
+ position: [localX, 0, localZ],
+ })
+
+ createNode(segment, targetRoofId as AnyNode['id'])
+ sfxEmitter.emit('sfx:structure-build')
+ return segment.id // Returns segment ID so it can be selected immediately
+ }
// Count existing roofs for naming
const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
const name = `Roof ${roofCount + 1}`
- const roof = RoofNode.parse({
- name,
- position: [centerX, 0, centerZ], // Y is always 0
- length: Math.max(length, 0.5),
- height: DEFAULT_HEIGHT,
- leftWidth: slopeWidth,
- rightWidth: slopeWidth,
+ // Create the segment first (centered in its new parent)
+ const segment = RoofSegmentNode.parse({
+ width,
+ depth,
+ wallHeight: DEFAULT_WALL_HEIGHT,
+ roofHeight: DEFAULT_ROOF_HEIGHT,
+ roofType: 'gable',
+ position: [0, 0, 0],
})
- createNode(roof, levelId)
+ // Create the roof container
+ const roof = RoofNode.parse({
+ name,
+ position: [centerX, 0, centerZ],
+ children: [segment.id],
+ })
+
+ // Create roof first (so segment can be parented to it), then segment
+ createNodes([
+ { node: roof, parentId: levelId },
+ { node: segment, parentId: roof.id },
+ ])
+
sfxEmitter.emit('sfx:structure-build')
return roof.id
}
@@ -67,10 +127,16 @@ export const RoofTool: React.FC = () => {
const cursorRef = useRef(null)
const outlineRef = useRef(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
const setSelection = useViewer((state) => state.setSelection)
const setTool = useEditor((state) => state.setTool)
const setMode = useEditor((state) => state.setMode)
+ const selectedIdsRef = useRef(selectedIds)
+ useEffect(() => {
+ selectedIdsRef.current = selectedIds
+ }, [selectedIds])
+
const corner1Ref = useRef<[number, number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const [preview, setPreview] = useState({
@@ -82,7 +148,6 @@ export const RoofTool: React.FC = () => {
useEffect(() => {
if (!currentLevelId) return
- // Initialize outline geometry
outlineRef.current.geometry = new BufferGeometry()
const updateOutline = (
@@ -96,7 +161,7 @@ export const RoofTool: React.FC = () => {
new Vector3(corner2[0], gridY, corner1[2]),
new Vector3(corner2[0], gridY, corner2[2]),
new Vector3(corner1[0], gridY, corner2[2]),
- new Vector3(corner1[0], gridY, corner1[2]), // Close the loop
+ new Vector3(corner1[0], gridY, corner1[2]),
]
outlineRef.current.geometry.dispose()
@@ -107,19 +172,15 @@ export const RoofTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
- // Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
-
- // Update cursors
const gridY = y + GRID_OFFSET
cursorRef.current.position.set(gridX, gridY, gridZ)
- // Play snap sound when grid position changes (only when placing)
if (
corner1Ref.current &&
previousGridPosRef.current &&
@@ -136,7 +197,6 @@ export const RoofTool: React.FC = () => {
levelY: y,
})
- // Update outline if we have first corner
if (corner1Ref.current) {
updateOutline(corner1Ref.current, cursorPosition)
}
@@ -150,17 +210,18 @@ export const RoofTool: React.FC = () => {
const y = event.position[1]
if (corner1Ref.current) {
- // Second click - create the roof
- const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
+ const roofId = commitRoofPlacement(
+ currentLevelId,
+ corner1Ref.current,
+ [gridX, y, gridZ],
+ selectedIdsRef.current,
+ )
- // Auto-select the newly created roof
setSelection({ selectedIds: [roofId as AnyNode['id']] })
- // Reset state
corner1Ref.current = null
outlineRef.current.visible = false
} else {
- // First click - set corner 1
corner1Ref.current = [gridX, y, gridZ]
setPreview((prev) => ({
...prev,
@@ -177,7 +238,6 @@ export const RoofTool: React.FC = () => {
}
}
- // Subscribe to events
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
@@ -187,14 +247,12 @@ export const RoofTool: React.FC = () => {
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
- // Reset state on unmount
corner1Ref.current = null
}
- }, [currentLevelId, setTool, setSelection, setMode])
+ }, [currentLevelId, setSelection])
const { corner1, cursorPosition, levelY } = preview
- // Calculate preview dimensions for display
const previewDimensions = useMemo(() => {
if (!corner1) return null
const length = Math.abs(cursorPosition[0] - corner1[0])
@@ -206,14 +264,13 @@ export const RoofTool: React.FC = () => {
return (
- {/* Cursor at ground height */}
- {/* Outline showing rectangle being drawn (Ground) */}
{/* @ts-ignore */}
{
/>
- {/* First corner marker */}
{corner1 && (
{
/>
)}
- {/* Thin preview fill when drawing (Ground) */}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
{
}
export const CursorSphere = forwardRef(function CursorSphere(
- { color = '#818cf8', showTooltip = true, height = 2.5, ...props },
+ { color = '#818cf8', showTooltip = true, height = 2.5, visible = true, ...props },
ref,
) {
const tool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const catalogCategory = useEditor((s) => s.catalogCategory)
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
// Find the icon for the current tool
let activeToolConfig = null
@@ -32,8 +33,10 @@ export const CursorSphere = forwardRef(function Cursor
}
}
+ const isVisible = visible && !isFloorplanHovered
+
return (
-
+
{/* Flat marker on the ground */}
{/* Center dot */}
@@ -76,7 +79,7 @@ export const CursorSphere = forwardRef(function Cursor
)}
{/* Tool Icon Tooltip at the top of the line */}
- {showTooltip && activeToolConfig && (
+ {isVisible && showTooltip && activeToolConfig && (
0 ? height + 0.2 : 0.6, 0]}
diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx
index 592c5895..4180aff4 100644
--- a/packages/editor/src/components/tools/shared/polygon-editor.tsx
+++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx
@@ -236,10 +236,10 @@ export const PolygonEditor: React.FC = ({
{/* Border line */}
element conflicts with SVG type
frustumCulled={false}
layers={EDITOR_LAYER}
raycast={() => {}}
+ // @ts-expect-error R3F element conflicts with SVG type
ref={lineRef}
renderOrder={10}
>
diff --git a/packages/editor/src/components/tools/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx
index 9e3c551d..3fc38f11 100644
--- a/packages/editor/src/components/tools/site/site-boundary-editor.tsx
+++ b/packages/editor/src/components/tools/site/site-boundary-editor.tsx
@@ -29,7 +29,7 @@ export const SiteBoundaryEditor: React.FC = () => {
[site, updateNode],
)
- if (!(site && site.polygon?.points) || site.polygon.points.length < 3) return null
+ if (!site?.polygon?.points || site.polygon.points.length < 3) return null
return (
= ({ slabId }
[slabId, updateNode, setSelection],
)
- if (!(slab && slab.polygon) || slab.polygon.length < 3) return null
+ if (!slab?.polygon || slab.polygon.length < 3) return null
return (
{
{
= 1) {
+ return null
+ }
+
+ return [x1 + dx * t, z1 + dz * t]
+}
+
+export function findWallSnapTarget(
+ point: WallPlanPoint,
+ walls: WallNode[],
+ options?: { ignoreWallIds?: string[]; radius?: number },
+): WallPlanPoint | null {
+ const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
+ const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
+ let bestTarget: WallPlanPoint | null = null
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
+
+ for (const wall of walls) {
+ if (ignoreWallIds.has(wall.id)) {
+ continue
+ }
+
+ const candidates: Array = [
+ wall.start,
+ wall.end,
+ projectPointOntoWall(point, wall),
+ ]
+ for (const candidate of candidates) {
+ if (!candidate) {
+ continue
+ }
+
+ const candidateDistanceSquared = distanceSquared(point, candidate)
+ if (
+ candidateDistanceSquared > radiusSquared ||
+ candidateDistanceSquared >= bestDistanceSquared
+ ) {
+ continue
+ }
+
+ bestTarget = candidate
+ bestDistanceSquared = candidateDistanceSquared
+ }
+ }
+
+ return bestTarget
+}
+
+export function snapWallDraftPoint(args: {
+ point: WallPlanPoint
+ walls: WallNode[]
+ start?: WallPlanPoint
+ angleSnap?: boolean
+ ignoreWallIds?: string[]
+}): WallPlanPoint {
+ const { point, walls, start, angleSnap = false, ignoreWallIds } = args
+ const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
+
+ return (
+ findWallSnapTarget(basePoint, walls, {
+ ignoreWallIds,
+ }) ?? basePoint
+ )
+}
+
+export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
+ return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
+}
+
+export function createWallOnCurrentLevel(
+ start: WallPlanPoint,
+ end: WallPlanPoint,
+): WallNode | null {
+ const currentLevelId = useViewer.getState().selection.levelId
+ const { createNode, nodes } = useScene.getState()
+
+ if (!(currentLevelId && isWallLongEnough(start, end))) {
+ return null
+ }
+
+ const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
+ const wall = WallSchema.parse({
+ name: `Wall ${wallCount + 1}`,
+ start,
+ end,
+ })
+
+ createNode(wall, currentLevelId)
+ sfxEmitter.emit('sfx:structure-build')
+
+ return wall
+}
diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx
index eb0d45bf..4a58c4ab 100644
--- a/packages/editor/src/components/tools/wall/wall-tool.tsx
+++ b/packages/editor/src/components/tools/wall/wall-tool.tsx
@@ -1,41 +1,13 @@
-import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
+import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
+import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
const WALL_HEIGHT = 2.5
-const WALL_THICKNESS = 0.15
-
-/**
- * Snap point to 45° angle increments relative to start point
- * Also snaps end point to 0.5 grid
- */
-const snapTo45Degrees = (start: Vector3, cursor: Vector3): Vector3 => {
- const dx = cursor.x - start.x
- const dz = cursor.z - start.z
-
- // Calculate angle in radians
- const angle = Math.atan2(dz, dx)
-
- // Round to nearest 45° (π/4 radians)
- const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
-
- // Calculate distance from start to cursor
- const distance = Math.sqrt(dx * dx + dz * dz)
-
- // Project end point along snapped angle
- let snappedX = start.x + Math.cos(snappedAngle) * distance
- let snappedZ = start.z + Math.sin(snappedAngle) * distance
-
- // Snap to 0.5 grid
- snappedX = Math.round(snappedX * 2) / 2
- snappedZ = Math.round(snappedZ * 2) / 2
-
- return new Vector3(snappedX, cursor.y, snappedZ)
-}
/**
* Update wall preview mesh geometry to create a vertical plane between two points
@@ -53,9 +25,6 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
mesh.visible = true
direction.normalize()
- // Perpendicular vector for thickness
- const perpendicular = new Vector3(-direction.z, 0, direction.x).multiplyScalar(WALL_THICKNESS / 2)
-
// Create wall shape (vertical rectangle in XY plane)
const shape = new Shape()
shape.moveTo(0, 0)
@@ -82,19 +51,18 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
mesh.geometry = geometry
}
-const commitWallDrawing = (start: [number, number], end: [number, number]) => {
+const getCurrentLevelWalls = (): WallNode[] => {
const currentLevelId = useViewer.getState().selection.levelId
- const { createNode, nodes } = useScene.getState()
+ const { nodes } = useScene.getState()
- if (!currentLevelId) return
+ if (!currentLevelId) return []
- const wallCount = Object.values(nodes).filter((n) => n.type === 'wall').length
- const name = `Wall ${wallCount + 1}`
+ const levelNode = nodes[currentLevelId]
+ if (!levelNode || levelNode.type !== 'level') return []
- const wall = WallNode.parse({ name, start, end })
-
- createNode(wall, currentLevelId)
- sfxEmitter.emit('sfx:structure-build')
+ return (levelNode as LevelNode).children
+ .map((childId) => nodes[childId])
+ .filter((node): node is WallNode => node?.type === 'wall')
}
export const WallTool: React.FC = () => {
@@ -106,20 +74,27 @@ export const WallTool: React.FC = () => {
const shiftPressed = useRef(false)
useEffect(() => {
- let gridPosition: [number, number] = [0, 0]
+ let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return
- gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2]
- const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1])
+ const walls = getCurrentLevelWalls()
+ const cursorPoint: WallPlanPoint = [event.position[0], event.position[2]]
+ gridPosition = snapWallDraftPoint({
+ point: cursorPoint,
+ walls,
+ })
if (buildingState.current === 1) {
- // Snap to 45° angles only if shift is not pressed
- const snapped = shiftPressed.current
- ? cursorPosition
- : snapTo45Degrees(startingPoint.current, cursorPosition)
+ const snappedPoint = snapWallDraftPoint({
+ point: cursorPoint,
+ walls,
+ start: [startingPoint.current.x, startingPoint.current.z],
+ angleSnap: !shiftPressed.current,
+ })
+ const snapped = new Vector3(snappedPoint[0], event.position[1], snappedPoint[1])
endingPoint.current.copy(snapped)
// Position the cursor at the end of the wall being drawn
@@ -138,21 +113,37 @@ export const WallTool: React.FC = () => {
// Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
} else {
- // Not drawing a wall, just follow the grid position
+ // Not drawing a wall yet, show the snapped anchor point.
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
}
}
const onGridClick = (event: GridEvent) => {
+ const walls = getCurrentLevelWalls()
+ const clickPoint: WallPlanPoint = [event.position[0], event.position[2]]
+
if (buildingState.current === 0) {
- startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1])
+ const snappedStart = snapWallDraftPoint({
+ point: clickPoint,
+ walls,
+ })
+ gridPosition = snappedStart
+ startingPoint.current.set(snappedStart[0], event.position[1], snappedStart[1])
+ endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
wallPreviewRef.current.visible = true
} else if (buildingState.current === 1) {
+ const snappedEnd = snapWallDraftPoint({
+ point: clickPoint,
+ walls,
+ start: [startingPoint.current.x, startingPoint.current.z],
+ angleSnap: !shiftPressed.current,
+ })
+ endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
- commitWallDrawing(
+ createWallOnCurrentLevel(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
)
diff --git a/packages/editor/src/components/tools/window/move-window-tool.tsx b/packages/editor/src/components/tools/window/move-window-tool.tsx
index 5cc64b2c..792812a1 100644
--- a/packages/editor/src/components/tools/window/move-window-tool.tsx
+++ b/packages/editor/src/components/tools/window/move-window-tool.tsx
@@ -8,7 +8,7 @@ import {
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
-import { useEffect, useMemo, useRef } from 'react'
+import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
@@ -45,9 +45,9 @@ const edgeMaterial = new LineBasicNodeMaterial({
export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
const cursorGroupRef = useRef(null!)
- const exitMoveMode = () => {
+ const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
- }
+ }, [])
useEffect(() => {
useScene.temporal.getState().pause()
@@ -389,7 +389,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
- }, [movingWindowNode])
+ }, [movingWindowNode, exitMoveMode])
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(
diff --git a/packages/editor/src/components/tools/zone/zone-boundary-editor.tsx b/packages/editor/src/components/tools/zone/zone-boundary-editor.tsx
index 8b2bcbd0..983c7efc 100644
--- a/packages/editor/src/components/tools/zone/zone-boundary-editor.tsx
+++ b/packages/editor/src/components/tools/zone/zone-boundary-editor.tsx
@@ -23,7 +23,7 @@ export const ZoneBoundaryEditor: React.FC = ({ zoneId }
[zoneId, updateNode],
)
- if (!(zone && zone.polygon) || zone.polygon.length < 3) return null
+ if (!zone?.polygon || zone.polygon.length < 3) return null
const zoneColor = zone.color || '#3b82f6'
diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx
index 1ae50734..76c3b099 100644
--- a/packages/editor/src/components/tools/zone/zone-tool.tsx
+++ b/packages/editor/src/components/tools/zone/zone-tool.tsx
@@ -256,7 +256,7 @@ export const ZoneTool: React.FC = () => {
// Reset state on unmount
pointsRef.current = []
}
- }, [currentLevelId, setTool])
+ }, [currentLevelId])
const { points, cursorPoint, levelY } = preview
@@ -318,6 +318,7 @@ export const ZoneTool: React.FC = () => {
{
= {
@@ -11,6 +13,13 @@ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
solo: 'Solo',
}
+const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
+ manual: 'Stack',
+ stacked: 'Stack',
+ exploded: 'Exploded',
+ solo: 'Solo',
+}
+
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
type WallMode = 'up' | 'cutaway' | 'down'
@@ -50,6 +59,8 @@ export function ViewToggles() {
const setShowScans = useViewer((state) => state.setShowScans)
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 toggleCameraMode = () => {
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
@@ -87,22 +98,41 @@ export function ViewToggles() {
size="icon"
variant="ghost"
>
-
+ {cameraMode === 'perspective' ? (
+
+ ) : (
+
+ )}
{/* Level Mode */}
- {levelMode === 'solo' && }
- {levelMode === 'exploded' && }
- {(levelMode === 'stacked' || levelMode === 'manual') && }
+
+ {levelMode === 'solo' && }
+ {levelMode === 'exploded' && (
+
+ )}
+ {(levelMode === 'stacked' || levelMode === 'manual') && (
+
+ )}
+
+ {levelModeBadgeLabels[levelMode]}
+
+
{/* Wall Mode */}
@@ -155,6 +185,37 @@ export function ViewToggles() {
>
+
+
+
+
+
+ New
+
+
+ 2D
+
+
+
)
}
diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx
new file mode 100644
index 00000000..00a954f5
--- /dev/null
+++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx
@@ -0,0 +1,388 @@
+'use client'
+
+import type { AnyNodeId } from '@pascal-app/core'
+import { LevelNode, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import {
+ AppWindow,
+ ArrowRight,
+ Box,
+ Building2,
+ Camera,
+ Copy,
+ DoorOpen,
+ Eye,
+ EyeOff,
+ FileJson,
+ Grid3X3,
+ Hexagon,
+ Layers,
+ Map,
+ Maximize2,
+ Minimize2,
+ Moon,
+ MousePointer2,
+ Package,
+ PencilLine,
+ Plus,
+ Redo2,
+ Square,
+ SquareStack,
+ Sun,
+ Trash2,
+ Undo2,
+ Video,
+} from 'lucide-react'
+import { useEffect } from 'react'
+import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
+import { useCommandRegistry } from '../../../store/use-command-registry'
+import type { StructureTool } from '../../../store/use-editor'
+import useEditor from '../../../store/use-editor'
+import { useCommandPalette } from './index'
+
+export function EditorCommands() {
+ const register = useCommandRegistry((s) => s.register)
+ const { navigateTo, setInputValue, setOpen } = useCommandPalette()
+
+ const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
+ useEditor()
+
+ const exportScene = useViewer((s) => s.exportScene)
+
+ // Re-register when exportScene availability changes (it's a conditional action)
+ useEffect(() => {
+ const run = (fn: () => void) => {
+ fn()
+ setOpen(false)
+ }
+
+ const activateTool = (tool: StructureTool) => {
+ run(() => {
+ setPhase('structure')
+ setMode('build')
+ if (tool === 'zone') setStructureLayer('zones')
+ setTool(tool)
+ })
+ }
+
+ return register([
+ // ── Scene ────────────────────────────────────────────────────────────
+ {
+ id: 'editor.tool.wall',
+ label: 'Wall Tool',
+ group: 'Scene',
+ icon:
,
+ keywords: ['draw', 'build', 'structure'],
+ execute: () => activateTool('wall'),
+ },
+ {
+ id: 'editor.tool.slab',
+ label: 'Slab Tool',
+ group: 'Scene',
+ icon:
,
+ keywords: ['floor', 'build'],
+ execute: () => activateTool('slab'),
+ },
+ {
+ id: 'editor.tool.ceiling',
+ label: 'Ceiling Tool',
+ group: 'Scene',
+ icon:
,
+ keywords: ['top', 'build'],
+ execute: () => activateTool('ceiling'),
+ },
+ {
+ id: 'editor.tool.door',
+ label: 'Door Tool',
+ group: 'Scene',
+ icon:
,
+ keywords: ['opening', 'entrance'],
+ execute: () => activateTool('door'),
+ },
+ {
+ id: 'editor.tool.window',
+ label: 'Window Tool',
+ group: 'Scene',
+ icon:
,
+ keywords: ['opening', 'glass'],
+ execute: () => activateTool('window'),
+ },
+ {
+ id: 'editor.tool.item',
+ label: 'Item Tool',
+ group: 'Scene',
+ icon:
,
+ keywords: ['furniture', 'object', 'asset', 'furnish'],
+ execute: () => activateTool('item'),
+ },
+ {
+ id: 'editor.tool.zone',
+ label: 'Zone Tool',
+ group: 'Scene',
+ icon:
,
+ keywords: ['area', 'room', 'space'],
+ execute: () => activateTool('zone'),
+ },
+ {
+ id: 'editor.delete-selection',
+ label: 'Delete Selection',
+ group: 'Scene',
+ icon:
,
+ keywords: ['remove', 'erase'],
+ shortcut: ['⌫'],
+ when: () => useViewer.getState().selection.selectedIds.length > 0,
+ execute: () =>
+ run(() => {
+ const { selectedIds } = useViewer.getState().selection
+ useScene.getState().deleteNodes(selectedIds as any[])
+ }),
+ },
+
+ // ── Levels ───────────────────────────────────────────────────────────
+ {
+ id: 'editor.level.goto',
+ label: 'Go to Level',
+ group: 'Levels',
+ icon:
,
+ keywords: ['level', 'floor', 'go', 'navigate', 'switch', 'select'],
+ navigate: true,
+ when: () => Object.values(useScene.getState().nodes).some((n) => n.type === 'level'),
+ execute: () => navigateTo('goto-level'),
+ },
+ {
+ id: 'editor.level.add',
+ label: 'Add Level',
+ group: 'Levels',
+ icon:
,
+ keywords: ['level', 'floor', 'add', 'create', 'new'],
+ execute: () =>
+ run(() => {
+ const { nodes } = useScene.getState()
+ const building = Object.values(nodes).find((n) => n.type === 'building')
+ if (!building) return
+ const newLevel = LevelNode.parse({
+ level: building.children.length,
+ children: [],
+ parentId: building.id,
+ })
+ useScene.getState().createNode(newLevel, building.id)
+ useViewer.getState().setSelection({ levelId: newLevel.id })
+ }),
+ },
+ {
+ id: 'editor.level.rename',
+ label: 'Rename Level',
+ group: 'Levels',
+ icon:
,
+ keywords: ['level', 'floor', 'rename', 'name'],
+ navigate: true,
+ when: () => !!useViewer.getState().selection.levelId,
+ execute: () => {
+ const activeLevelId = useViewer.getState().selection.levelId
+ if (!activeLevelId) return
+ const level = useScene.getState().nodes[activeLevelId as AnyNodeId] as LevelNode
+ setInputValue(level?.name ?? '')
+ navigateTo('rename-level')
+ },
+ },
+ {
+ id: 'editor.level.delete',
+ label: 'Delete Level',
+ group: 'Levels',
+ icon:
,
+ keywords: ['level', 'floor', 'delete', 'remove'],
+ when: () => {
+ const levelId = useViewer.getState().selection.levelId
+ if (!levelId) return false
+ const node = useScene.getState().nodes[levelId as AnyNodeId] as LevelNode
+ return node?.type === 'level' && node.level !== 0
+ },
+ execute: () =>
+ run(() => {
+ const activeLevelId = useViewer.getState().selection.levelId
+ if (!activeLevelId) return
+ deleteLevelWithFallbackSelection(activeLevelId as AnyNodeId)
+ }),
+ },
+
+ // ── Viewer Controls ──────────────────────────────────────────────────
+ {
+ id: 'editor.viewer.wall-mode',
+ label: 'Wall Mode',
+ group: 'Viewer Controls',
+ icon:
,
+ keywords: ['wall', 'cutaway', 'up', 'down', 'view'],
+ badge: () => {
+ const mode = useViewer.getState().wallMode
+ return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode]
+ },
+ navigate: true,
+ execute: () => navigateTo('wall-mode'),
+ },
+ {
+ id: 'editor.viewer.level-mode',
+ label: 'Level Mode',
+ group: 'Viewer Controls',
+ icon:
,
+ keywords: ['level', 'floor', 'exploded', 'stacked', 'solo'],
+ badge: () => {
+ const mode = useViewer.getState().levelMode
+ return { manual: 'Manual', stacked: 'Stacked', exploded: 'Exploded', solo: 'Solo' }[mode]
+ },
+ navigate: true,
+ execute: () => navigateTo('level-mode'),
+ },
+ {
+ id: 'editor.viewer.camera-mode',
+ label: () => {
+ const mode = useViewer.getState().cameraMode
+ return `Camera: Switch to ${mode === 'perspective' ? 'Orthographic' : 'Perspective'}`
+ },
+ group: 'Viewer Controls',
+ icon:
,
+ keywords: ['camera', 'ortho', 'perspective', '2d', '3d', 'view'],
+ execute: () =>
+ run(() => {
+ const { cameraMode, setCameraMode } = useViewer.getState()
+ setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
+ }),
+ },
+ {
+ id: 'editor.viewer.theme',
+ label: () => {
+ const theme = useViewer.getState().theme
+ return theme === 'dark' ? 'Switch to Light Theme' : 'Switch to Dark Theme'
+ },
+ group: 'Viewer Controls',
+ icon:
, // icon is static; label conveys the action
+ keywords: ['theme', 'dark', 'light', 'appearance', 'color'],
+ execute: () =>
+ run(() => {
+ const { theme, setTheme } = useViewer.getState()
+ setTheme(theme === 'dark' ? 'light' : 'dark')
+ }),
+ },
+ {
+ id: 'editor.viewer.camera-snapshot',
+ label: 'Camera Snapshot',
+ group: 'Viewer Controls',
+ icon:
,
+ keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],
+ navigate: true,
+ execute: () => navigateTo('camera-view'),
+ },
+
+ // ── View ─────────────────────────────────────────────────────────────
+ {
+ id: 'editor.view.preview',
+ label: () => (isPreviewMode ? 'Exit Preview' : 'Enter Preview'),
+ group: 'View',
+ icon: isPreviewMode ?
:
,
+ keywords: ['preview', 'view', 'read-only', 'present'],
+ execute: () => run(() => setPreviewMode(!isPreviewMode)),
+ },
+ {
+ id: 'editor.view.fullscreen',
+ label: 'Toggle Fullscreen',
+ group: 'View',
+ icon:
,
+ keywords: ['fullscreen', 'maximize', 'expand', 'window'],
+ execute: () =>
+ run(() => {
+ if (document.fullscreenElement) document.exitFullscreen()
+ else document.documentElement.requestFullscreen()
+ }),
+ },
+
+ // ── History ──────────────────────────────────────────────────────────
+ {
+ id: 'editor.history.undo',
+ label: 'Undo',
+ group: 'History',
+ icon:
,
+ keywords: ['undo', 'revert', 'back'],
+ execute: () => run(() => useScene.temporal.getState().undo()),
+ },
+ {
+ id: 'editor.history.redo',
+ label: 'Redo',
+ group: 'History',
+ icon:
,
+ keywords: ['redo', 'forward', 'repeat'],
+ execute: () => run(() => useScene.temporal.getState().redo()),
+ },
+
+ // ── Export & Share ───────────────────────────────────────────────────
+ {
+ id: 'editor.export.json',
+ label: 'Export Scene (JSON)',
+ group: 'Export & Share',
+ icon:
,
+ keywords: ['export', 'download', 'json', 'save', 'data'],
+ execute: () =>
+ run(() => {
+ const { nodes, rootNodeIds } = useScene.getState()
+ const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], {
+ type: 'application/json',
+ })
+ const url = URL.createObjectURL(blob)
+ Object.assign(document.createElement('a'), {
+ href: url,
+ download: `scene_${new Date().toISOString().split('T')[0]}.json`,
+ }).click()
+ URL.revokeObjectURL(url)
+ }),
+ },
+ ...(exportScene
+ ? [
+ {
+ id: 'editor.export.glb',
+ label: 'Export 3D Model (GLB)',
+ group: 'Export & Share',
+ icon:
,
+ keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
+ execute: () => run(() => exportScene()),
+ },
+ ]
+ : []),
+ {
+ id: 'editor.export.share-link',
+ label: 'Copy Share Link',
+ group: 'Export & Share',
+ icon:
,
+ keywords: ['share', 'copy', 'url', 'link'],
+ execute: () => run(() => navigator.clipboard.writeText(window.location.href)),
+ },
+ {
+ id: 'editor.export.screenshot',
+ label: 'Take Screenshot',
+ group: 'Export & Share',
+ icon:
,
+ keywords: ['screenshot', 'capture', 'image', 'photo', 'png'],
+ execute: () =>
+ run(() => {
+ const canvas = document.querySelector('canvas')
+ if (!canvas) return
+ Object.assign(document.createElement('a'), {
+ href: canvas.toDataURL('image/png'),
+ download: `screenshot_${new Date().toISOString().split('T')[0]}.png`,
+ }).click()
+ }),
+ },
+ ])
+ }, [
+ register,
+ navigateTo,
+ setInputValue,
+ setOpen,
+ setPhase,
+ setMode,
+ setTool,
+ setStructureLayer,
+ isPreviewMode,
+ setPreviewMode,
+ exportScene,
+ ])
+
+ return null
+}
diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx
index a5b15651..42e55244 100644
--- a/packages/editor/src/components/ui/command-palette/index.tsx
+++ b/packages/editor/src/components/ui/command-palette/index.tsx
@@ -1,64 +1,63 @@
'use client'
-import type { AnyNodeId } from '@pascal-app/core'
-import { emitter, LevelNode, useScene } from '@pascal-app/core'
+import type { AnyNodeId, LevelNode } from '@pascal-app/core'
+import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Command } from 'cmdk'
-import {
- AppWindow,
- ArrowRight,
- Box,
- Building2,
- Camera,
- ChevronRight,
- Copy,
- DoorOpen,
- Eye,
- EyeOff,
- FileJson,
- Grid3X3,
- Hexagon,
- Layers,
- Map,
- Maximize2,
- Minimize2,
- Moon,
- MousePointer2,
- Package,
- PencilLine,
- Plus,
- Redo2,
- Search,
- Square,
- SquareStack,
- Sun,
- Trash2,
- Undo2,
- Video,
-} from 'lucide-react'
+import { ChevronRight, Search } from 'lucide-react'
import { useEffect, useState } from 'react'
import { create } from 'zustand'
import { useShallow } from 'zustand/shallow'
-import { Dialog, DialogContent } from './../../../components/ui/primitives/dialog'
-import type { StructureTool } from './../../../store/use-editor'
-import useEditor from './../../../store/use-editor'
+import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'
+import { useCommandRegistry } from '../../../store/use-command-registry'
+import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'
// ---------------------------------------------------------------------------
-// Open-state store — imported by icon-rail to trigger the palette
+// Open + navigation state store
// ---------------------------------------------------------------------------
interface CommandPaletteStore {
open: boolean
setOpen: (open: boolean) => void
+ /** Current rendering mode. 'command' = normal palette; anything else = registered mode view. */
+ mode: string
+ setMode: (mode: string) => void
+ pages: string[]
+ inputValue: string
+ setInputValue: (value: string) => void
+ navigateTo: (page: string) => void
+ goBack: () => void
+ cameraScope: { nodeId: string; label: string } | null
+ setCameraScope: (scope: { nodeId: string; label: string } | null) => void
}
-export const useCommandPalette = create
((set) => ({
+export const useCommandPalette = create((set, get) => ({
open: false,
- setOpen: (open) => set({ open }),
+ setOpen: (open) => {
+ set({ open })
+ if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' })
+ },
+ mode: 'command',
+ setMode: (mode) => set({ mode }),
+ pages: [],
+ inputValue: '',
+ setInputValue: (value) => set({ inputValue: value }),
+ navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),
+ goBack: () => {
+ const { pages } = get()
+ if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })
+ set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' }))
+ },
+ cameraScope: null,
+ setCameraScope: (scope) => set({ cameraScope: scope }),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+function resolve(value: string | (() => string)): string {
+ return typeof value === 'function' ? value() : value
+}
+
function Shortcut({ keys }: { keys: string[] }) {
return (
@@ -85,33 +84,38 @@ function Item({
navigate = false,
}: {
icon: React.ReactNode
- label: string
+ label: string | (() => string)
onSelect: () => void
shortcut?: string[]
disabled?: boolean
keywords?: string[]
- badge?: string
+ badge?: string | (() => string)
navigate?: boolean
}) {
+ const resolvedLabel = resolve(label)
+ const resolvedBadge = badge ? resolve(badge) : undefined
+
return (
{icon}
- {label}
- {badge && (
+ {resolvedLabel}
+ {resolvedBadge && (
- {badge}
+ {resolvedBadge}
)}
{shortcut && }
- {(badge || navigate) && }
+ {(resolvedBadge || navigate) && (
+
+ )}
)
}
@@ -153,46 +157,42 @@ const PAGE_LABEL: Record = {
'rename-level': 'Rename Level',
'goto-level': 'Go to Level',
'camera-view': 'Camera Snapshot',
- 'camera-scope': '', // dynamic — overridden in breadcrumb
+ 'camera-scope': '',
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function CommandPalette() {
- const { open, setOpen } = useCommandPalette()
+ const {
+ open,
+ setOpen,
+ mode,
+ setMode,
+ pages,
+ inputValue,
+ setInputValue,
+ navigateTo,
+ goBack,
+ cameraScope,
+ setCameraScope,
+ } = useCommandPalette()
+
const [meta, setMeta] = useState('⌘')
const [isFullscreen, setIsFullscreen] = useState(false)
- const [pages, setPages] = useState([])
- const [inputValue, setInputValue] = useState('')
- const [cameraScope, setCameraScope] = useState<{ nodeId: string; label: string } | null>(null)
const page = pages[pages.length - 1]
- const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
- useEditor()
+ const actions = useCommandRegistry((s) => s.actions)
+ const views = usePaletteViewRegistry((s) => s.views)
+
+ const activeLevelId = useViewer((s) => s.selection.levelId)
+ const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
- const cameraMode = useViewer((s) => s.cameraMode)
- const setCameraMode = useViewer((s) => s.setCameraMode)
- const levelMode = useViewer((s) => s.levelMode)
- const setLevelMode = useViewer((s) => s.setLevelMode)
const wallMode = useViewer((s) => s.wallMode)
const setWallMode = useViewer((s) => s.setWallMode)
- const theme = useViewer((s) => s.theme)
- const setTheme = useViewer((s) => s.setTheme)
- const selection = useViewer((s) => s.selection)
- const exportScene = useViewer((s) => s.exportScene)
-
- const activeLevelId = selection.levelId
- const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
- const isLevelZero =
- activeLevelNode?.type === 'level' && (activeLevelNode as LevelNode).level === 0
-
- // Reactive snapshot status for the selected camera scope
- const cameraScopeNode = useScene((s) =>
- cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,
- )
- const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera
+ const levelMode = useViewer((s) => s.levelMode)
+ const setLevelMode = useViewer((s) => s.setLevelMode)
const allLevels = useScene(
useShallow((s) =>
@@ -202,7 +202,10 @@ export function CommandPalette() {
),
)
- const hasSelection = selection.selectedIds.length > 0
+ const cameraScopeNode = useScene((s) =>
+ cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,
+ )
+ const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera
// Platform detection
useEffect(() => {
@@ -228,59 +231,11 @@ export function CommandPalette() {
return () => window.removeEventListener('keydown', handler)
}, [setOpen])
- // Reset sub-pages when palette closes
- useEffect(() => {
- if (!open) {
- setPages([])
- setInputValue('')
- setCameraScope(null)
- }
- }, [open])
-
- // ---------------------------------------------------------------------------
- // Navigation helpers
- // ---------------------------------------------------------------------------
- const goBack = () => {
- const leavingPage = pages[pages.length - 1]
- if (leavingPage === 'camera-scope') setCameraScope(null)
- setPages((p) => p.slice(0, -1))
- setInputValue('')
- }
-
- const navigateTo = (p: string) => {
- // Pre-fill the rename input with the current level name
- if (p === 'rename-level' && activeLevelId) {
- const level = useScene.getState().nodes[activeLevelId] as LevelNode
- setInputValue(level?.name ?? '')
- } else {
- setInputValue('')
- }
- setPages((prev) => [...prev, p])
- }
-
- const navigateToCameraScope = (nodeId: string, label: string) => {
- setCameraScope({ nodeId, label })
- setInputValue('')
- setPages((prev) => [...prev, 'camera-scope'])
- }
-
- // ---------------------------------------------------------------------------
- // Action helpers
- // ---------------------------------------------------------------------------
const run = (fn: () => void) => {
fn()
setOpen(false)
}
- const activateTool = (tool: StructureTool) => {
- run(() => {
- setPhase('structure')
- setMode('build')
- if (tool === 'zone') setStructureLayer('zones')
- setTool(tool)
- })
- }
-
const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = {
cutaway: 'Cutaway',
up: 'Up',
@@ -293,40 +248,7 @@ export function CommandPalette() {
solo: 'Solo',
}
- const deleteSelection = () => {
- if (!hasSelection) return
- run(() => {
- useScene.getState().deleteNodes(selection.selectedIds as any[])
- })
- }
-
- // Level management
- const addLevel = () =>
- run(() => {
- const { nodes } = useScene.getState()
- const building = Object.values(nodes).find((n) => n.type === 'building')
- if (!building) return
- const newLevel = LevelNode.parse({
- level: building.children.length,
- children: [],
- parentId: building.id,
- })
- useScene.getState().createNode(newLevel, building.id)
- useViewer.getState().setSelection({ levelId: newLevel.id })
- })
-
- const deleteActiveLevel = () => {
- if (!activeLevelId || isLevelZero) return
- run(() => {
- useScene.getState().deleteNode(activeLevelId as AnyNodeId)
- const { nodes } = useScene.getState()
- const level0 = Object.values(nodes).find(
- (n) => n.type === 'level' && (n as LevelNode).level === 0,
- )
- if (level0) useViewer.getState().setSelection({ levelId: level0.id as `level_${string}` })
- })
- }
-
+ // Camera snapshot helpers (used by sub-pages registered via EditorCommands)
const confirmRename = () => {
if (!(activeLevelId && inputValue.trim())) return
run(() => {
@@ -334,15 +256,20 @@ export function CommandPalette() {
})
}
- // Camera snapshot (scoped to the currently selected camera scope)
const takeSnapshot = () => {
if (!cameraScope) return
- run(() => emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }))
+ import('@pascal-app/core').then(({ emitter }) => {
+ run(() =>
+ emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }),
+ )
+ })
}
const viewSnapshot = () => {
if (!(cameraScope && hasScopeSnapshot)) return
- run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))
+ import('@pascal-app/core').then(({ emitter }) => {
+ run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))
+ })
}
const clearSnapshot = () => {
@@ -352,461 +279,416 @@ export function CommandPalette() {
})
}
- // Export helpers
- const exportJson = () =>
- run(() => {
- const { nodes, rootNodeIds } = useScene.getState()
- const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], {
- type: 'application/json',
- })
- const url = URL.createObjectURL(blob)
- const a = Object.assign(document.createElement('a'), {
- href: url,
- download: `scene_${new Date().toISOString().split('T')[0]}.json`,
- })
- a.click()
- URL.revokeObjectURL(url)
- })
+ // ---------------------------------------------------------------------------
+ // Group registered actions by group (preserving insertion order)
+ // ---------------------------------------------------------------------------
+ const grouped = actions.reduce