splitting editor and community

This commit is contained in:
wass08
2026-03-11 12:16:26 +01:00
parent 5108636d49
commit 7359c1fcbf
586 changed files with 6477 additions and 1546 deletions
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@pascal-app/editor",
"version": "0.1.0",
"description": "Pascal building editor component",
"type": "module",
"exports": {
".": "./src/index.tsx"
},
"scripts": {
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"@pascal-app/core": "*",
"@pascal-app/viewer": "*",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"next": ">=15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
"three": "^0.183"
},
"dependencies": {
"@number-flow/react": "^0.5.14",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/uikit-lucide": "^1.0.62",
"@visual-json/react": "latest",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"howler": "^2.2.4",
"lucide-react": "^0.562.0",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@pascal-app/core": "*",
"@pascal-app/viewer": "*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@repo/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/react": "19.2.2",
"@types/three": "^0.183.1",
"react": "^19.2.4",
"three": "^0.183.1",
"typescript": "5.9.3"
}
}
@@ -0,0 +1,322 @@
'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three'
import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor'
const currentTarget = new Vector3()
const tempBox = new Box3()
const tempCenter = new Vector3()
const tempSize = new Vector3()
export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const selection = useViewer((s) => s.selection)
const currentLevelId = selection.levelId
const firstLoad = useRef(true)
const camera = useThree((state) => state.camera)
const raycaster = useThree((state) => state.raycaster)
useEffect(() => {
camera.layers.enable(EDITOR_LAYER)
raycaster.layers.enable(EDITOR_LAYER)
raycaster.layers.enable(2)
}, [camera, raycaster])
useEffect(() => {
if (isPreviewMode) return // Preview mode uses auto-navigate instead
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
}
}
if (firstLoad.current) {
firstLoad.current = false
;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)
}
;(controls.current as CameraControlsImpl).getTarget(currentTarget)
;(controls.current as CameraControlsImpl).moveTo(
currentTarget.x,
targetY,
currentTarget.z,
true,
)
}, [currentLevelId, isPreviewMode])
// Configure mouse buttons based on control mode and camera mode
const cameraMode = useViewer((state) => state.cameraMode)
const mouseButtons = useMemo(() => {
// Use ZOOM for orthographic camera, DOLLY for perspective camera
const wheelAction =
cameraMode === 'orthographic'
? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY
return {
left: isPreviewMode
? CameraControlsImpl.ACTION.SCREEN_PAN
: CameraControlsImpl.ACTION.NONE,
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction,
}
}, [cameraMode, isPreviewMode])
useEffect(() => {
const keyState = {
shiftRight: false,
shiftLeft: false,
controlRight: false,
controlLeft: false,
space: false,
}
const updateConfig = () => {
if (!controls.current) return
const shift = keyState.shiftRight || keyState.shiftLeft
const control = keyState.controlRight || keyState.controlLeft
const space = keyState.space
const wheelAction =
cameraMode === 'orthographic'
? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY
controls.current.mouseButtons.wheel = wheelAction
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
if (isPreviewMode) {
// In preview mode, left-click is always pan (viewer-style)
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
} else if (space) {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
} else {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.code === 'Space') {
keyState.space = true
document.body.style.cursor = 'grab'
}
if (event.code === 'ShiftRight') {
keyState.shiftRight = true
}
if (event.code === 'ShiftLeft') {
keyState.shiftLeft = true
}
if (event.code === 'ControlRight') {
keyState.controlRight = true
}
if (event.code === 'ControlLeft') {
keyState.controlLeft = true
}
updateConfig()
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') {
keyState.space = false
document.body.style.cursor = ''
}
if (event.code === 'ShiftRight') {
keyState.shiftRight = false
}
if (event.code === 'ShiftLeft') {
keyState.shiftLeft = false
}
if (event.code === 'ControlRight') {
keyState.controlRight = false
}
if (event.code === 'ControlLeft') {
keyState.controlLeft = false
}
updateConfig()
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
updateConfig()
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
}
}, [cameraMode, isPreviewMode])
// Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode
? (selection.zoneId ?? selection.levelId ?? selection.buildingId)
: null
useEffect(() => {
if (!isPreviewMode || !controls.current) return
const nodes = useScene.getState().nodes
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
if (!previewTargetNodeId) {
const site = Object.values(nodes).find((n) => n.type === 'site')
node = site || null
}
if (!node) return
// Check if node has a saved camera
if (node.camera) {
const { position, target } = node.camera
requestAnimationFrame(() => {
if (!controls.current) return
controls.current.setLookAt(
position[0], position[1], position[2],
target[0], target[1], target[2],
true,
)
})
return
}
if (!previewTargetNodeId) return
// Calculate camera position from bounding box
const object3D = sceneRegistry.nodes.get(previewTargetNodeId)
if (!object3D) return
tempBox.setFromObject(object3D)
tempBox.getCenter(tempCenter)
tempBox.getSize(tempSize)
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
const distance = Math.max(maxDim * 2, 15)
controls.current.setLookAt(
tempCenter.x + distance * 0.7,
tempCenter.y + distance * 0.5,
tempCenter.z + distance * 0.7,
tempCenter.x,
tempCenter.y,
tempCenter.z,
true,
)
}, [isPreviewMode, previewTargetNodeId])
useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return
const position = new Vector3()
const target = new Vector3()
controls.current.getPosition(position)
controls.current.getTarget(target)
const state = useScene.getState()
state.updateNode(nodeId, {
camera: {
position: [position.x, position.y, position.z],
target: [target.x, target.y, target.z],
mode: useViewer.getState().cameraMode,
},
})
}
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return
const node = useScene.getState().nodes[nodeId]
if (!node || !node.camera) return
const { position, target } = node.camera
controls.current.setLookAt(
position[0],
position[1],
position[2],
target[0],
target[1],
target[2],
true,
)
}
const handleTopView = () => {
if (!controls.current) return
const currentPolarAngle = controls.current.polarAngle
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
// Otherwise, go to top view (0°)
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
controls.current.rotatePolarTo(targetAngle, true)
}
const handleOrbitCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded - Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
const handleOrbitCCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° counter-clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded + Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
emitter.on('camera-controls:capture', handleNodeCapture)
emitter.on('camera-controls:view', handleNodeView)
emitter.on('camera-controls:top-view', handleTopView)
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
return () => {
emitter.off('camera-controls:capture', handleNodeCapture)
emitter.off('camera-controls:view', handleNodeView)
emitter.off('camera-controls:top-view', handleTopView)
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
}
}, [])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
return (
<CameraControls
makeDefault
maxDistance={100}
maxPolarAngle={Math.PI / 2 - 0.1}
minDistance={10}
minPolarAngle={0}
ref={controls}
mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
onSleep={onRest}
restThreshold={0.01}
/>
)
}
@@ -0,0 +1,54 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
export function ExportManager() {
const scene = useThree((state) => state.scene)
const setExportScene = useViewer((state) => state.setExportScene)
useEffect(() => {
const exportFn = async () => {
// Find the scene renderer group by name
const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) {
console.error('scene-renderer group not found')
return
}
const exporter = new GLTFExporter()
const date = new Date().toISOString().split('T')[0]
return new Promise<void>((resolve, reject) => {
exporter.parse(
sceneGroup,
(gltf) => {
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `model_${date}.glb`
link.click()
URL.revokeObjectURL(url)
resolve()
},
(error) => {
console.error('Export error:', error)
reject(error)
},
{ binary: true }
)
})
}
setExportScene(exportFn)
return () => {
setExportScene(null)
}
}, [scene, setExportScene])
return null
}
@@ -0,0 +1,157 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
DoorNode,
ItemNode,
sceneRegistry,
useScene,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
const ALLOWED_TYPES = ['item', 'door', 'window']
export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const nodes = useScene((s) => s.nodes)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection)
const groupRef = useRef<THREE.Group>(null)
// Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const node = selectedId ? nodes[selectedId as AnyNodeId] : null
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
useFrame(() => {
if (!selectedId || !isValidType || !groupRef.current) return
const obj = sceneRegistry.nodes.get(selectedId)
if (obj) {
// Calculate bounding box in world space
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position slightly above the object
groupRef.current.position.set(center.x, box.max.y + 0.3, center.z)
}
}
})
const handleMove = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!node) return
sfxEmitter.emit('sfx:item-pick')
if (node.type === 'item' || node.type === 'window' || node.type === 'door') {
setMovingNode(node as any)
}
setSelection({ selectedIds: [] })
},
[node, setMovingNode, setSelection],
)
const handleDuplicate = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
let duplicate: AnyNode | null = null
try {
if (node.type === 'door') {
duplicate = DoorNode.parse(duplicateInfo)
} else if (node.type === 'window') {
duplicate = WindowNode.parse(duplicateInfo)
} else if (node.type === 'item') {
duplicate = ItemNode.parse(duplicateInfo)
}
} catch (error) {
console.error('Failed to parse duplicate', error)
return
}
if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}
if (duplicate.type === 'item' || duplicate.type === 'window' || duplicate.type === 'door') {
setMovingNode(duplicate as any)
}
setSelection({ selectedIds: [] })
}
},
[node, setMovingNode, setSelection],
)
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNodeId)
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
},
[selectedId, node, deleteNode, setSelection],
)
if (!selectedId || !node || !isValidType) return null
return (
<group ref={groupRef}>
<Html
center
zIndexRange={[100, 0]}
style={{
pointerEvents: 'auto',
touchAction: 'none',
}}
>
<div
className="flex items-center gap-1 p-1 rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<button
onClick={handleMove}
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
title="Move"
>
<Move className="w-4 h-4" />
</button>
<button
onClick={handleDuplicate}
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
title="Duplicate"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={handleDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors tooltip-trigger"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Html>
</group>
)
}
@@ -0,0 +1,155 @@
'use client'
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh, Vector2 } from 'three'
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useGridEvents } from '../../hooks/use-grid-events'
import { EDITOR_LAYER } from '../../lib/constants'
export const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
cellColor = '#888888',
sectionSize = 1,
sectionThickness = 1,
sectionColor = '#000000',
fadeDistance = 100,
fadeStrength = 1,
revealRadius = 10,
}: {
cellSize?: number
cellThickness?: number
cellColor?: string
sectionSize?: number
sectionThickness?: number
sectionColor?: string
fadeDistance?: number
fadeStrength?: number
revealRadius?: number
}) => {
const theme = useViewer((state) => state.theme)
// Use slightly lighter colors for dark mode grid to make it apparent
const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor
const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor
const cursorPositionRef = useRef(new Vector2(0, 0))
const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy
// Cursor position uniform
const cursorPos = uniform(cursorPositionRef.current)
// Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const r = pos.div(size)
const fw = fwidth(r)
// Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs()
// Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub(
grid.x
.div(fw.x)
.add(1 - thickness)
.min(1),
)
const lineY = float(1).sub(
grid.y
.div(fw.y)
.add(1 - thickness)
.min(1),
)
// Combine both axes - max gives us lines in both directions
return lineX.max(lineY)
}
const g1 = getGrid(cellSize, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center
const dist = pos.length()
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)
// Cursor reveal effect - distance from cursor
const cursorDist = pos.sub(cursorPos).length()
const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1)
// Mix colors based on section grid
const gridColor = mix(
color(effectiveCellColor),
color(effectiveSectionColor),
float(sectionThickness).mul(g2).min(1),
)
// Baseline alpha: small amount of opacity everywhere the grid exists
const baseAlpha = float(0.4) // Subtle global visibility
// Combined alpha with cursor fade and baseline minimum
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
depthWrite: false,
})
}, [
cellSize,
cellThickness,
effectiveCellColor,
sectionSize,
sectionThickness,
effectiveSectionColor,
fadeDistance,
fadeStrength,
revealRadius
])
const gridRef = useRef<Mesh>(null!)
const [gridY, setGridY] = useState(0)
// Use custom raycasting for grid events (independent of mesh events)
useGridEvents(gridY)
// Update cursor position from grid:move events
useEffect(() => {
const onGridMove = (event: GridEvent) => {
cursorPositionRef.current.set(event.position[0], -event.position[2])
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [])
useFrame((_, delta) => {
const currentLevelId = useViewer.getState().selection.levelId
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
}
}
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y = newY
setGridY(newY)
})
const showGrid = useViewer((state) => state.showGrid)
return (
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid} layers={EDITOR_LAYER}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
)
}
@@ -0,0 +1,233 @@
'use client'
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { type ReactNode, useEffect, useState } from 'react'
import { useAutoSave, type SaveStatus } from '../../hooks/use-auto-save'
import { applySceneGraphToEditor, loadSceneFromLocalStorage, type SceneGraph } from '../../lib/scene'
import { useKeyboard } from '../../hooks/use-keyboard'
import { initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
import { HelperManager } from '../ui/helpers/helper-manager'
import { PanelManager } from '../ui/panels/panel-manager'
import { ErrorBoundary } from '../ui/primitives/error-boundary'
import { SidebarProvider } from '../ui/primitives/sidebar'
import { SceneLoader } from '../ui/scene-loader'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager'
import { FloatingActionMenu } from './floating-action-menu'
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'
// Load default scene initially (will be replaced when onLoad runs)
useScene.getState().loadScene()
initSpatialGridSync()
initSpaceDetectionSync(useScene, useEditor)
// Auto-select the first building and level for the default scene
const sceneNodes = useScene.getState().nodes as Record<string, any>
const sceneRootIds = useScene.getState().rootNodeIds
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
if (firstBuilding && firstLevel) {
useViewer.getState().setSelection({
buildingId: firstBuilding.id,
levelId: firstLevel.id,
selectedIds: [],
zoneId: null,
})
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
if (!firstLevel.children || firstLevel.children.length === 0) {
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
}
}
initSFXBus()
export interface EditorProps {
// UI slots
appMenuButton?: ReactNode
sidebarTop?: ReactNode
// Persistence — defaults to localStorage when omitted
onLoad?: () => Promise<SceneGraph | null>
onSave?: (scene: SceneGraph) => Promise<void>
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
}
function EditorSceneCrashFallback() {
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 p-4 text-foreground">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold">The editor scene failed to render</h2>
<p className="mt-2 text-sm text-muted-foreground">
You can retry the scene or return home without reloading the whole app shell.
</p>
<div className="mt-4 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
onClick={() => window.location.reload()}
type="button"
>
Reload editor
</button>
<a
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</a>
</div>
</div>
</div>
)
}
export default function Editor({
appMenuButton,
sidebarTop,
onLoad,
onSave,
onDirty,
onSaveStatusChange,
previewScene,
isVersionPreviewMode = false,
isLoading = false,
onThumbnailCapture,
settingsPanelProps,
sitePanelProps,
}: EditorProps) {
useKeyboard()
const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
onSaveStatusChange,
isVersionPreviewMode,
})
const [isSceneLoading, setIsSceneLoading] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
// Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => {
let cancelled = false
async function load() {
isLoadingSceneRef.current = true
setIsSceneLoading(true)
try {
const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
if (!cancelled) {
applySceneGraphToEditor(sceneGraph)
}
} catch {
if (!cancelled) applySceneGraphToEditor(null)
} finally {
if (!cancelled) {
setIsSceneLoading(false)
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')
}
}, [])
const showLoader = isLoading || isSceneLoading
return (
<div className="w-full h-full dark text-foreground">
{showLoader && <SceneLoader />}
{isPreviewMode ? (
<ViewerOverlay
onBack={() => useEditor.getState().setPreviewMode(false)}
/>
) : (
<>
<ActionMenu />
<PanelManager />
<HelperManager />
<SidebarProvider className="fixed z-20">
<AppSidebar appMenuButton={appMenuButton} sidebarTop={sidebarTop} settingsPanelProps={settingsPanelProps} sitePanelProps={sitePanelProps} />
</SidebarProvider>
</>
)}
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
{!isPreviewMode && <SelectionManager />}
{!isPreviewMode && <FloatingActionMenu />}
<ExportManager />
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
{!isPreviewMode && (
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
)}
{!isPreviewMode && <ToolManager />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<PresetThumbnailGenerator />
{!isPreviewMode && <SiteEdgeLabels />}
{isPreviewMode && <InteractiveSystem />}
</Viewer>
{!isPreviewMode && <ZoneLabelEditorSystem />}
</ErrorBoundary>
</div>
)
}
@@ -0,0 +1,123 @@
'use client'
import { emitter, sceneRegistry } from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect } from 'react'
import * as THREE from 'three'
const THUMBNAIL_SIZE = 1080
const CAMERA_FOV = 45
export const PresetThumbnailGenerator = () => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const generate = useCallback(
async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => {
const target = sceneRegistry.nodes.get(nodeId)
if (!target) {
console.error('❌ PresetThumbnail: node not found', nodeId)
return
}
// Compute each mesh's transform relative to the target node (cancels world
// position/rotation), so the item is always rendered at origin with a known
// neutral orientation regardless of where it's placed in the scene.
target.updateWorldMatrix(true, true)
const targetInverse = new THREE.Matrix4().copy(target.matrixWorld).invert()
const relMatrix = new THREE.Matrix4()
const clones: THREE.Object3D[] = []
target.traverse((obj) => {
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
const c = obj.clone(false) // shallow clone: copies geometry, material, visible — no children
relMatrix.multiplyMatrices(targetInverse, obj.matrixWorld)
relMatrix.decompose(c.position, c.quaternion, c.scale)
scene.add(c)
clones.push(c)
})
if (clones.length === 0) {
console.error('❌ PresetThumbnail: no renderable objects found', nodeId)
return
}
// Combined bounding box across all clones
const box = new THREE.Box3()
for (const c of clones) box.expandByObject(c)
if (box.isEmpty()) {
for (const c of clones) scene.remove(c)
console.error('❌ PresetThumbnail: empty bounding box', nodeId)
return
}
const sphere = new THREE.Sphere()
box.getBoundingSphere(sphere)
// Camera: aspect matches canvas (center-cropped to square after render)
const { width, height } = gl.domElement
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, width / height, 0.01, 1000)
const dir = new THREE.Vector3(-0.5, 0.5, 0.5).normalize()
const fovRad = (CAMERA_FOV * Math.PI) / 180
const dist = (sphere.radius / Math.tan(fovRad / 2)) * 1.3
camera.position.copy(sphere.center).addScaledVector(dir, dist)
camera.lookAt(sphere.center)
camera.updateProjectionMatrix()
// Hide all scene geometry except the clones — leave lights, cameras, etc. intact
const cloneSet = new Set<THREE.Object3D>(clones)
const snapshot = new Map<THREE.Object3D, boolean>()
scene.traverse((obj) => {
if (cloneSet.has(obj)) return
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
snapshot.set(obj, obj.visible)
obj.visible = false
})
gl.render(scene, camera)
// Restore visibility and remove clones
snapshot.forEach((wasVisible, obj) => {
obj.visible = wasVisible
})
for (const c of clones) scene.remove(c)
// Center-crop to square and scale to THUMBNAIL_SIZE
const minDim = Math.min(width, height)
const sx = Math.round((width - minDim) / 2)
const sy = Math.round((height - minDim) / 2)
const offscreen = document.createElement('canvas')
offscreen.width = THUMBNAIL_SIZE
offscreen.height = THUMBNAIL_SIZE
const ctx = offscreen.getContext('2d')!
ctx.drawImage(gl.domElement, sx, sy, minDim, minDim, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE)
offscreen.toBlob(async (blob) => {
if (!blob) {
console.error('❌ PresetThumbnail: failed to create blob')
return
}
const res = await fetch(`/api/presets/${presetId}/thumbnail`, {
method: 'POST',
body: blob,
headers: { 'Content-Type': 'image/png' },
})
if (res.ok) {
const json = await res.json()
emitter.emit('preset:thumbnail-updated', { presetId, thumbnailUrl: json.thumbnail_url })
} else {
console.error('❌ PresetThumbnail: upload failed', await res.text())
}
}, 'image/png')
},
[gl, scene],
)
useEffect(() => {
emitter.on('preset:generate-thumbnail', generate)
return () => emitter.off('preset:generate-thumbnail', generate)
}, [generate])
return null
}
@@ -0,0 +1,449 @@
import {
type AnyNode,
type BuildingNode,
emitter,
type ItemNode,
type NodeEvent,
resolveLevelId,
sceneRegistry,
useScene,
} from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useEffect, useRef } from "react";
import useEditor from "./../../store/use-editor";
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
const currentLevelId = useViewer.getState().selection.levelId;
if (!currentLevelId) return true; // No level selected, allow all
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes);
return nodeLevelId === currentLevelId;
};
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window' | 'door';
type ModifierKeys = {
meta: boolean;
ctrl: boolean;
};
interface SelectionStrategy {
types: SelectableNodeType[];
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void;
handleDeselect: () => void;
isValid: (node: AnyNode) => boolean;
}
export const resolveBuildingId = (levelId: string, nodes: Record<string, AnyNode>): string | null => {
const level = nodes[levelId];
if (!level) return null;
if (level.parentId && nodes[level.parentId]?.type === "building") {
return level.parentId;
}
return null;
};
const computeNextIds = (
node: AnyNode,
selectedIds: string[],
event?: any,
modifierKeys?: ModifierKeys
): string[] => {
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta || false;
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl || false;
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);
} else {
return [...selectedIds, node.id];
}
}
// Not holding modifiers: select only this node
return [node.id];
};
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
site: {
types: ["building"],
handleSelect: (node) => {
useViewer
.getState()
.setSelection({ buildingId: (node as BuildingNode).id });
},
handleDeselect: () => {
useViewer.getState().setSelection({ buildingId: null });
},
isValid: (node) => node.type === "building",
},
structure: {
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"],
handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState();
const nodes = useScene.getState().nodes;
const nodeLevelId = resolveLevelId(node, nodes);
const buildingId = resolveBuildingId(nodeLevelId, nodes);
const updates: any = {};
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
updates.levelId = nodeLevelId;
}
if (buildingId && buildingId !== selection.buildingId) {
updates.buildingId = buildingId;
}
if (node.type === 'zone') {
updates.zoneId = node.id;
// Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
setSelection(updates);
} else {
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
setSelection(updates);
}
},
handleDeselect: () => {
const structureLayer = useEditor.getState().structureLayer;
if (structureLayer === "zones") {
useViewer.getState().setSelection({ zoneId: null });
} else {
useViewer.getState().setSelection({ selectedIds: [] });
}
},
isValid: (node) => {
if (!isNodeInCurrentLevel(node)) return false;
const structureLayer = useEditor.getState().structureLayer;
if (structureLayer === "zones") {
if (node.type === "zone") return true;
return false;
} else {
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling" || node.type === "roof") return true;
if (node.type === "item") {
return (
(node as ItemNode).asset.category === "door" ||
(node as ItemNode).asset.category === "window"
);
}
if (node.type === "window" || node.type === "door") return true;
return false;
}
},
},
furnish: {
types: ["item"],
handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState();
const nodes = useScene.getState().nodes;
const nodeLevelId = resolveLevelId(node, nodes);
const buildingId = resolveBuildingId(nodeLevelId, nodes);
const updates: any = {};
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
updates.levelId = nodeLevelId;
}
if (buildingId && buildingId !== selection.buildingId) {
updates.buildingId = buildingId;
}
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
setSelection(updates);
},
handleDeselect: () => {
useViewer.getState().setSelection({ selectedIds: [] });
},
isValid: (node) => {
if (!isNodeInCurrentLevel(node)) return false;
if (node.type !== "item") return false;
const item = node as ItemNode;
return item.asset.category !== "door" && item.asset.category !== "window";
},
},
};
export const SelectionManager = () => {
const phase = useEditor((s) => s.phase);
const mode = useEditor((s) => s.mode);
const modifierKeysRef = useRef<ModifierKeys>({
meta: false,
ctrl: false,
});
const clickHandledRef = useRef(false);
const movingNode = useEditor((s) => s.movingNode);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Meta") modifierKeysRef.current.meta = true;
if (event.key === "Control") modifierKeysRef.current.ctrl = true;
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === "Meta") modifierKeysRef.current.meta = false;
if (event.key === "Control") modifierKeysRef.current.ctrl = false;
};
const clearModifiers = () => {
modifierKeysRef.current.meta = false;
modifierKeysRef.current.ctrl = false;
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", clearModifiers);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", clearModifiers);
};
}, []);
useEffect(() => {
if (mode !== "select") return;
if (movingNode) return;
const onClick = (event: NodeEvent) => {
const node = event.node;
let currentPhase = useEditor.getState().phase;
let targetPhase = currentPhase;
// Auto-switch between structure and furnish phases 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";
}
}
if (targetPhase !== currentPhase) {
useEditor.getState().setPhase(targetPhase);
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
useEditor.getState().setStructureLayer("elements");
}
currentPhase = targetPhase;
}
}
}
const activeStrategy = SELECTION_STRATEGIES[currentPhase];
if (activeStrategy?.isValid(node)) {
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);
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false;
}, 50);
}
};
const allTypes = ["wall", "item", "building", "zone", "slab", "ceiling", "roof", "window", "door"];
allTypes.forEach((type) => {
emitter.on(`${type}:click` as any, onClick as any);
});
const onGridClick = () => {
if (clickHandledRef.current) return;
console.log("onGridClick triggered! Deselecting.");
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase];
if (activeStrategy) activeStrategy.handleDeselect();
};
emitter.on("grid:click", onGridClick);
return () => {
allTypes.forEach((type) => {
emitter.off(`${type}:click` as any, onClick as any);
});
emitter.off("grid:click", onGridClick);
};
}, [mode, movingNode]);
// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
if (mode !== "select") return;
if (movingNode) return;
const onEnter = (event: NodeEvent) => {
const node = event.node;
const currentPhase = useEditor.getState().phase;
// Ignore site/building if we are already inside a building
if (node.type === "building" || node.type === "site") {
if (currentPhase === "structure" || currentPhase === "furnish") {
return;
}
}
// Ignore zones unless specifically in zones layer
if (node.type === "zone") {
if (currentPhase !== "structure" || useEditor.getState().structureLayer !== "zones") {
return;
}
}
// Check level constraint for interior nodes
if (currentPhase === "structure" || currentPhase === "furnish") {
if (!isNodeInCurrentLevel(node)) return;
}
event.stopPropagation();
useViewer.setState({ hoveredId: node.id });
};
const onLeave = (event: NodeEvent) => {
if (useViewer.getState().hoveredId === event.node.id) {
useViewer.setState({ hoveredId: null });
}
};
const onDoubleClick = (event: NodeEvent) => {
const node = event.node;
const currentPhase = useEditor.getState().phase;
let targetPhase: "site" | "structure" | "furnish" | null = null;
if (node.type === "building" || node.type === "site") {
if (currentPhase === "structure" || currentPhase === "furnish") {
return; // Ignore building/site double clicks if we are already inside a building
}
if (node.type === "building") {
targetPhase = "structure";
}
} else 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";
}
}
if (node.type === "zone") {
return;
}
if (targetPhase && targetPhase !== useEditor.getState().phase) {
event.stopPropagation();
useEditor.getState().setPhase(targetPhase);
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
useEditor.getState().setStructureLayer("elements");
}
const strategy = SELECTION_STRATEGIES[targetPhase];
if (strategy) {
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
}
}
};
const allTypes = ["wall", "item", "building", "slab", "ceiling", "roof", "window", "door", "zone", "site"];
allTypes.forEach((type) => {
emitter.on(`${type}:enter` as any, onEnter as any);
emitter.on(`${type}:leave` as any, onLeave as any);
emitter.on(`${type}:double-click` as any, onDoubleClick as any);
});
return () => {
allTypes.forEach((type) => {
emitter.off(`${type}:enter` as any, onEnter as any);
emitter.off(`${type}:leave` as any, onLeave as any);
emitter.off(`${type}:double-click` as any, onDoubleClick as any);
});
};
}, [mode, movingNode]);
return <EditorOutlinerSync />;
};
const EditorOutlinerSync = () => {
const phase = useEditor((s) => s.phase);
const selection = useViewer((s) => s.selection);
const hoveredId = useViewer((s) => s.hoveredId);
const outliner = useViewer((s) => s.outliner);
useEffect(() => {
let idsToHighlight: string[] = [];
// 1. Determine what should be highlighted based on Phase
switch (phase) {
case "site":
// Only highlight the building if one is selected
if (selection.buildingId) idsToHighlight = [selection.buildingId];
break;
case "structure":
// Highlight selected items (walls/slabs)
// We IGNORE buildingId even if it's set in the store
idsToHighlight = selection.selectedIds;
break;
case "furnish":
// Highlight selected furniture/items
idsToHighlight = selection.selectedIds;
break;
default:
// Pure Viewer mode: Highlight based on the "deepest" selection
if (selection.selectedIds.length > 0)
idsToHighlight = selection.selectedIds;
else if (selection.levelId) idsToHighlight = [selection.levelId];
else if (selection.buildingId) idsToHighlight = [selection.buildingId];
}
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
outliner.selectedObjects.length = 0;
for (const id of idsToHighlight) {
const obj = sceneRegistry.nodes.get(id);
if (obj) outliner.selectedObjects.push(obj);
}
outliner.hoveredObjects.length = 0;
if (hoveredId) {
const obj = sceneRegistry.nodes.get(hoveredId);
if (obj) outliner.hoveredObjects.push(obj);
}
}, [phase, selection, hoveredId, outliner]);
return null;
};
@@ -0,0 +1,66 @@
'use client'
import { sceneRegistry, useScene } from '@pascal-app/core'
import type { SiteNode } from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useRef, useState } from 'react'
import type { Object3D } from 'three'
export function SiteEdgeLabels() {
const rootNodeIds = useScene((state) => state.rootNodeIds)
const nodes = useScene((state) => state.nodes)
const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
const siteNodeId = siteNode?.id
const [siteObj, setSiteObj] = useState<Object3D | null>(null)
const prevSiteNodeIdRef = useRef<string | undefined>(undefined)
// Poll each frame until the site group is registered.
// Also resets when the site node ID changes (new project loaded).
useFrame(() => {
if (siteNodeId !== prevSiteNodeIdRef.current) {
prevSiteNodeIdRef.current = siteNodeId
setSiteObj(null)
return
}
if (siteObj || !siteNodeId) return
const obj = sceneRegistry.nodes.get(siteNodeId)
if (obj) setSiteObj(obj)
})
const edges = useMemo(() => {
const polygon = siteNode?.polygon?.points ?? []
if (polygon.length < 2) return []
return polygon.map(([x1, z1], i) => {
const [x2, z2] = polygon[(i + 1) % polygon.length]!
const midX = (x1! + x2) / 2
const midZ = (z1! + z2) / 2
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
return { midX, midZ, dist }
})
}, [siteNode?.polygon?.points])
if (!siteObj || edges.length === 0) return null
return createPortal(
<>
{edges.map((edge, i) => (
<Html
center
key={`edge-${i}`}
position={[edge.midX, 0.5, edge.midZ]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[10, 0]}
occlude
>
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
{edge.dist.toFixed(2)}m
</div>
</Html>
))}
</>,
siteObj,
)
}
@@ -0,0 +1,156 @@
'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { snapLevelsToTruePositions } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import * as THREE from 'three'
import { EDITOR_LAYER } from '../../lib/constants'
const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080
const AUTO_SAVE_DELAY = 10_000
interface ThumbnailGeneratorProps {
onThumbnailCapture?: (blob: Blob) => void
}
export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false)
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
useEffect(() => { onThumbnailCaptureRef.current = onThumbnailCapture }, [onThumbnailCapture])
const generate = useCallback(async () => {
if (isGenerating.current) return
if (!onThumbnailCaptureRef.current) return
isGenerating.current = true
try {
const thumbnailCamera = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
const nodes = useScene.getState().nodes
const siteNode = Object.values(nodes).find((n) => n.type === 'site')
if (siteNode?.camera) {
const { position, target } = siteNode.camera
thumbnailCamera.position.set(position[0], position[1], position[2])
thumbnailCamera.lookAt(target[0], target[1], target[2])
} else {
thumbnailCamera.position.set(8, 8, 8)
thumbnailCamera.lookAt(0, 0, 0)
}
thumbnailCamera.layers.disable(EDITOR_LAYER)
const { width, height } = gl.domElement
thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix()
const restoreLevels = snapLevelsToTruePositions()
const visibilitySnapshot = new Map<string, boolean>()
for (const type of ['scan', 'guide'] as const) {
sceneRegistry.byType[type].forEach((id) => {
const obj = sceneRegistry.nodes.get(id)
if (obj) {
visibilitySnapshot.set(id, obj.visible)
obj.visible = false
}
})
}
gl.render(scene, thumbnailCamera)
restoreLevels()
visibilitySnapshot.forEach((wasVisible, id) => {
const obj = sceneRegistry.nodes.get(id)
if (obj) obj.visible = wasVisible
})
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0, sy = 0, sWidth = width, sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
const offscreen = document.createElement('canvas')
offscreen.width = THUMBNAIL_WIDTH
offscreen.height = THUMBNAIL_HEIGHT
const ctx = offscreen.getContext('2d')!
ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
offscreen.toBlob((blob) => {
if (blob) {
onThumbnailCaptureRef.current?.(blob)
} else {
console.error('❌ Failed to create blob from canvas')
}
isGenerating.current = false
}, 'image/png')
} catch (error) {
console.error('❌ Failed to generate thumbnail:', error)
isGenerating.current = false
}
}, [gl, scene])
// Manual trigger via emitter
useEffect(() => {
const handleGenerateThumbnail = async () => {
await generate()
}
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}, [generate])
// Auto-trigger: debounced on scene changes, deferred if tab is hidden
useEffect(() => {
if (!onThumbnailCapture) return
const triggerNow = () => generate()
const scheduleOrDefer = () => {
if (document.visibilityState === 'visible') {
triggerNow()
} else {
pendingAutoRef.current = true
}
}
const onSceneChange = () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && pendingAutoRef.current) {
pendingAutoRef.current = false
triggerNow()
}
}
const unsubscribe = useScene.subscribe((state, prevState) => {
if (state.nodes !== prevState.nodes) onSceneChange()
})
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
unsubscribe()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [onThumbnailCapture, generate])
return null
}
@@ -0,0 +1,257 @@
'use client'
import { useScene } from '@pascal-app/core'
import { ImageIcon, MessageSquare, X } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { Button } from './ui/primitives/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/primitives/dialog'
const MAX_IMAGES = 5
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
type ImagePreview = { file: File; url: string }
export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
projectId?: string
onSubmit?: (data: { message: string; projectId?: string; sceneGraph: unknown; images: File[] }) => Promise<{ success: boolean; error?: string }>
}) {
const projectId = projectIdProp
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [images, setImages] = useState<ImagePreview[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const handleOpen = () => {
setOpen(true)
setSent(false)
setError(null)
setMessage('')
setImages([])
setIsDragging(false)
dragCounter.current = 0
}
const handleClose = () => {
if (isSubmitting) return
setOpen(false)
images.forEach((img) => {
URL.revokeObjectURL(img.url)
})
}
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter(
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
)
setImages((prev) => {
const remaining = MAX_IMAGES - prev.length
const added = incoming.slice(0, remaining).map((file) => ({
file,
url: URL.createObjectURL(file),
}))
return [...prev, ...added]
})
}, [])
const removeImage = (index: number) => {
setImages((prev) => {
const img = prev[index]
if (img) URL.revokeObjectURL(img.url)
return prev.filter((_, i) => i !== index)
})
}
// ── Drag handlers (on the entire dialog content) ──
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true)
}
}
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
if (!onSubmit) return
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
const result = await onSubmit({
message,
projectId,
sceneGraph,
images: images.map(img => img.file),
})
if (result.success) {
setSent(true)
setTimeout(() => setOpen(false), 1500)
} else {
setError(result.error ?? 'Something went wrong')
}
} finally {
setIsSubmitting(false)
}
}
return (
<>
<button
onClick={handleOpen}
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/90 transition-colors"
>
<MessageSquare className="h-4 w-4" />
Feedback
</button>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="sm:max-w-[460px]"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Drag overlay — only visible when dragging files over the dialog */}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary/50 bg-primary/5 backdrop-blur-sm transition-all">
<div className="flex flex-col items-center gap-2 text-primary/70">
<ImageIcon className="h-8 w-8" />
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
)}
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
</DialogHeader>
{sent ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Thanks for your feedback!
</p>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="feedback-message" className="text-sm font-medium">
Your feedback
</label>
<textarea
id="feedback-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
rows={5}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isSubmitting}
autoFocus
/>
</div>
{/* Image thumbnails */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<div
key={img.url}
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removeImage(i)}
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
))}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex items-center justify-between">
{/* Subtle attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting || images.length >= MAX_IMAGES}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
>
<ImageIcon className="h-3.5 w-3.5" />
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files)
e.target.value = ''
}}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !message.trim() || !onSubmit}>
{isSubmitting ? 'Sending...' : 'Send Feedback'}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,280 @@
'use client'
import { AnimatePresence, motion } from 'motion/react'
import { Howl } from 'howler'
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Slider } from '../components/ui/slider'
import { cn } from '../lib/utils'
import useAudio from '../store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
const soundRef = useRef<Howl | null>(null)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
// Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
// without those values being part of its dependency array (which would restart the song).
const effectiveVolumeRef = useRef(effectiveVolume)
const mutedRef = useRef(muted)
const isPlayingRef = useRef(isRadioPlaying)
effectiveVolumeRef.current = effectiveVolume
mutedRef.current = muted
isPlayingRef.current = isRadioPlaying
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler only when the track changes — not on volume/mute/play-state changes.
// Volume and mute are handled by the separate effect below.
useEffect(() => {
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlayingRef.current
soundRef.current = new Howl({
src: [currentTrack.file],
volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
onend: handleNext,
})
if (wasPlaying && !mutedRef.current) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isRadioPlaying) {
soundRef.current.pause()
} else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
soundRef.current.play()
} else if (!isRadioPlaying && soundRef.current.playing()) {
soundRef.current.pause()
}
}
}, [effectiveVolume, muted, isRadioPlaying])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isRadioPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setRadioPlaying(!isRadioPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
// Handle click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<motion.div
ref={containerRef}
layout
onClick={() => {
if (!isOpen) setIsOpen(true)
}}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
className={cn(
'flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden',
!isOpen && 'cursor-pointer hover:bg-accent/90 transition-colors',
)}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium">
<div className="flex items-center gap-2">
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
<span className="hidden sm:inline whitespace-nowrap">Radio Pascal</span>
</div>
<div className="flex items-center gap-2">
<div
onClick={(e) => {
e.stopPropagation()
handlePlayPause()
}}
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
role="button"
tabIndex={0}
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handlePlayPause()
}
}}
>
{isRadioPlaying ? (
<Volume2 className="h-3.5 w-3.5" />
) : (
<VolumeX className="h-3.5 w-3.5" />
)}
</div>
<button
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
className={cn(
'rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground',
isOpen && 'bg-accent text-accent-foreground',
)}
aria-label="Radio Settings"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="px-3 pb-3 space-y-3 w-[16rem]">
<div className="h-px w-full bg-border/50 mb-3" />
{/* Current song info with prev/next */}
<div>
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
onClick={handlePrevious}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Previous"
>
<SkipBack className="h-4 w-4" />
</button>
<p
className="text-sm font-medium text-center flex-1 truncate"
title={currentTrack.title}
>
{currentTrack.title}
</p>
<button
onClick={handleNext}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Next"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Slider
value={[radioVolume]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-label="Radio Volume"
/>
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">
{radioVolume}%
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -0,0 +1,16 @@
'use client'
import { Eye } from 'lucide-react'
import useEditor from '../store/use-editor'
export function PreviewButton() {
return (
<button
onClick={() => useEditor.getState().setPreviewMode(true)}
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-accent/90 transition-colors"
>
<Eye className="h-4 w-4 shrink-0" />
<span className="hidden sm:inline whitespace-nowrap">Preview</span>
</button>
)
}
@@ -0,0 +1,77 @@
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import useEditor from '../../../store/use-editor'
export const CeilingSystem = () => {
const tool = useEditor((state) => state.tool)
const selectedItem = useEditor((state) => state.selectedItem)
const movingNode = useEditor((state) => state.movingNode)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeLevelId = useViewer((state) => state.selection.levelId)
useEffect(() => {
const nodes = useScene.getState().nodes
const levelsToShowCeilings = new Set<string>()
const isCeilingToolActive =
tool === 'ceiling' ||
selectedItem?.attachTo === 'ceiling' ||
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
if (isCeilingToolActive && activeLevelId) {
levelsToShowCeilings.add(activeLevelId)
}
for (const id of selectedIds) {
let currentId: string | null = id
let isCeilingRelated = false
let levelId: string | null = null
while (currentId && nodes[currentId as AnyNodeId]) {
const node = nodes[currentId as AnyNodeId]
if (node?.type === 'ceiling') {
isCeilingRelated = true
}
if (node?.type === 'level') {
levelId = node.id
break
}
currentId = node?.parentId as string | null
}
if (isCeilingRelated && levelId) {
levelsToShowCeilings.add(levelId)
}
}
const ceilings = sceneRegistry.byType.ceiling
ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) {
const ceilingGrid = mesh.getObjectByName('ceiling-grid')
if (ceilingGrid) {
let belongsToVisibleLevel = false
let currentId: string | null = ceiling
while (currentId && nodes[currentId as AnyNodeId]) {
const node = nodes[currentId as AnyNodeId]
if (node && levelsToShowCeilings.has(node.id)) {
belongsToVisibleLevel = true
break
}
currentId = node?.parentId as string | null
}
const shouldShowGrid = belongsToVisibleLevel ||
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
ceilingGrid.visible = shouldShowGrid
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
}
}
})
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId])
return null
}
@@ -0,0 +1,183 @@
'use client'
import { useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Check, Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '../../../store/use-editor'
// ─── Per-zone label editor ────────────────────────────────────────────────────
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
const updateNode = useScene((s) => s.updateNode)
const setSelection = useViewer((s) => s.setSelection)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const [labelEl, setLabelEl] = useState<HTMLElement | null>(null)
// Keep a ref so the click handler never has a stale zone name
const zoneNameRef = useRef(zone?.name ?? '')
useEffect(() => { zoneNameRef.current = zone?.name ?? '' }, [zone?.name])
// Setup: find the label element, enable pointer events, and hide the
// zone-renderer's own text node (children[0]) — we replace it via portal.
useEffect(() => {
const el = document.getElementById(`${zoneId}-label`)
if (!el) return
setLabelEl(el)
const textEl = el.children[0] as HTMLElement | undefined
if (textEl) textEl.style.display = 'none'
return () => {
if (textEl) textEl.style.display = ''
}
}, [zoneId])
// Focus + select-all when entering edit mode
useEffect(() => {
if (editing) {
inputRef.current?.focus()
inputRef.current?.select()
}
}, [editing])
const save = useCallback(() => {
const trimmed = value.trim()
if (trimmed !== (zone?.name ?? '')) {
updateNode(zoneId, { name: trimmed || undefined })
}
setEditing(false)
}, [value, zone?.name, updateNode, zoneId])
const cancel = useCallback(() => {
setValue(zone?.name ?? '')
setEditing(false)
}, [zone?.name])
if (!labelEl) return null
const shadowColor = zone?.color ?? '#6366f1'
const textShadow = [
`-1px -1px 0 ${shadowColor}`,
` 1px -1px 0 ${shadowColor}`,
`-1px 1px 0 ${shadowColor}`,
` 1px 1px 0 ${shadowColor}`,
].join(',')
// order: -1 puts this flex item before children[0] (hidden) and children[1] (pin)
const sharedStyle: React.CSSProperties = {
order: -1,
color: 'white',
textShadow,
fontSize: 14,
fontFamily: 'sans-serif',
userSelect: 'none',
pointerEvents: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
whiteSpace: 'nowrap',
}
return createPortal(
editing ? (
<div
style={sharedStyle}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') { e.preventDefault(); save() }
if (e.key === 'Escape') { e.preventDefault(); cancel() }
}}
onBlur={save}
onClick={(e) => e.stopPropagation()}
style={{
width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,
border: 'none',
borderBottom: `1px solid ${shadowColor}`,
background: 'transparent',
color: 'white',
textShadow,
outline: 'none',
padding: 0,
margin: 0,
fontSize: 'inherit',
lineHeight: 'inherit',
fontFamily: 'inherit',
textAlign: 'center',
}}
/>
<button
type="button"
onClick={(e) => { e.stopPropagation(); save() }}
onMouseDown={(e) => e.stopPropagation()}
style={{
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
}}
>
<Check size={12} />
</button>
</div>
) : (
<button
type="button"
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
onClick={(e) => {
e.stopPropagation()
setSelection({ zoneId })
setValue(zoneNameRef.current)
setEditing(true)
}}
onMouseDown={(e) => e.stopPropagation()}
>
<span>{zone?.name}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
<Pencil size={10} />
</span>
</button>
),
labelEl,
)
}
// ─── System: rendered in the main React tree (outside Canvas) ─────────────────
export function ZoneLabelEditorSystem() {
const zoneIds = useScene(
useShallow((s) =>
Object.values(s.nodes)
.filter((n) => n.type === 'zone')
.map((n) => n.id as ZoneNode['id']),
),
)
const structureLayer = useEditor((s) => s.structureLayer)
const mode = useEditor((s) => s.mode)
if (structureLayer !== 'zones' || mode !== 'select') return null
return (
<>
{zoneIds.map((id) => (
<ZoneLabelEditor key={id} zoneId={id} />
))}
</>
)
}
@@ -0,0 +1,41 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import useEditor from '../../../store/use-editor'
export const ZoneSystem = () => {
useFrame(() => {
const structureLayer = useEditor.getState().structureLayer
const levelMode = useViewer.getState().levelMode
const selectedLevelId = useViewer.getState().selection.levelId
const visible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes
zones.forEach((zoneId) => {
const obj = sceneRegistry.nodes.get(zoneId)
if (!obj) return
const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined
// In solo mode, hide labels for zones not on the current level
const isOnSelectedLevel = zone?.parentId === selectedLevelId
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel
if (obj.visible !== visible) {
obj.visible = visible
}
// Hide label if zone layer is off OR if in solo mode on a different level
const showLabel = visible && !hideInSoloMode
const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
labelEl.style.opacity = targetOpacity
}
})
})
return null
}
@@ -0,0 +1,42 @@
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingBoundaryEditorProps {
ceilingId: CeilingNode['id']
}
/**
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(ceilingId, { polygon: newPolygon })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, updateNode, setSelection],
)
if (!ceiling || !ceiling.polygon || ceiling.polygon.length < 3) return null
return (
<PolygonEditor
polygon={ceiling.polygon}
color="#d4d4d4"
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -0,0 +1,47 @@
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingHoleEditorProps {
ceilingId: CeilingNode['id']
holeIndex: number
}
/**
* Ceiling hole editor - allows editing a specific hole polygon within a ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const holes = ceiling?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(ceilingId, { holes: updatedHoles })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, holeIndex, holes, updateNode, setSelection],
)
if (!ceiling || !hole || hole.length < 3) return null
return (
<PolygonEditor
polygon={hole}
color="#ef4444" // red for holes
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -0,0 +1,401 @@
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
import { mix, positionLocal } from 'three/tsl'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/
const calculateSnapPoint = (
lastPoint: [number, number],
currentPoint: [number, number],
): [number, number] => {
const [x1, y1] = lastPoint
const [x, y] = currentPoint
const dx = x - x1
const dy = y - y1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy
const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) {
// Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy)
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
} else if (minDist === horizontalDist) {
// Snap to horizontal
return [x, y1]
} else {
// Snap to vertical
return [x1, y]
}
}
/**
* Creates a ceiling with the given polygon points and returns its ID
*/
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
const { createNode, nodes } = useScene.getState()
// Count existing ceilings for naming
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
const name = `Ceiling ${ceilingCount + 1}`
const ceiling = CeilingNode.parse({
name,
polygon: points,
})
createNode(ceiling, levelId)
sfxEmitter.emit('sfx:structure-build')
return ceiling.id
}
export const CeilingTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const gridCursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const groundMainLineRef = useRef<Line>(null!)
const groundClosingLineRef = useRef<Line>(null!)
const verticalLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
const verticalGeo = useMemo(
() => new BufferGeometry().setFromPoints([new Vector3(0, 0, 0), new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0)]),
[],
)
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
const gradientOpacityNode = useMemo(
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
[],
)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current || !gridCursorRef.current) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.position[1])
const ceilingY = event.position[1] + CEILING_HEIGHT
const gridY = event.position[1] + GRID_OFFSET
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
const displayPoint =
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
if (
points.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
if (verticalLineRef.current) {
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
}
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the ceiling and select it
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
}
}
const onCancel = () => {
setPoints([])
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!mainLineRef.current || !closingLineRef.current) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const ceilingY = levelY + CEILING_HEIGHT
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
const gridY = levelY + GRID_OFFSET
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
groundMainLineRef.current.geometry.dispose()
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
groundMainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
groundMainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
const groundClosingPoints = [
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
new Vector3(firstPoint[0], gridY, firstPoint[1]),
]
groundClosingLineRef.current.geometry.dispose()
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(groundClosingPoints)
groundClosingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
groundClosingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]
if (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor at ceiling height */}
<CursorSphere ref={cursorRef} />
{/* Grid-level cursor indicator */}
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2} layers={EDITOR_LAYER}>
<ringGeometry args={[0.15, 0.2, 32]} />
<meshBasicMaterial color="#818cf8" side={DoubleSide} depthTest={false} depthWrite={true} opacity={0.5} transparent />
</mesh>
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
{/* @ts-ignore */}
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1} layers={EDITOR_LAYER}>
<lineBasicNodeMaterial color="#818cf8" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
</line>
{/* Preview fill (Top) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + CEILING_HEIGHT, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Preview fill (Ground) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + GRID_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.1}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.5}
transparent
/>
</line>
{/* Ground main line */}
{/* @ts-ignore */}
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} opacity={0.3} transparent />
</line>
{/* Ground closing line */}
{/* @ts-ignore */}
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.15}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere
key={index}
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
color="#818cf8"
showTooltip={false}
/>
))}
</group>
)
}
@@ -0,0 +1,102 @@
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
/**
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
*/
export function wallLocalToWorld(
wallNode: WallNode,
localX: number,
localY: number,
levelYOffset = 0,
slabElevation = 0,
): [number, number, number] {
const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0],
)
return [
wallNode.start[0] + localX * Math.cos(wallAngle),
slabElevation + localY + levelYOffset,
wallNode.start[1] + localX * Math.sin(wallAngle),
]
}
/**
* Clamps door center X so it stays fully within wall bounds.
* Y is always height/2 — doors sit at floor level.
*/
export function clampToWall(
wallNode: WallNode,
localX: number,
width: number,
height: number,
): { clampedX: number; clampedY: number } {
const dx = wallNode.end[0] - wallNode.start[0]
const dz = wallNode.end[1] - wallNode.start[1]
const wallLength = Math.sqrt(dx * dx + dz * dz)
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
const clampedY = height / 2 // Doors always sit at floor level
return { clampedX, clampedY }
}
/**
* Checks if a proposed door position overlaps any existing wall children.
* Handles item, window, and door types.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of wallNode.children) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number, childRight: number, childBottom: number, childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1]
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
@@ -0,0 +1,267 @@
import {
type AnyNodeId,
DoorNode,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Door tool — places DoorNodes on walls only.
* Doors always sit at floor level (clampedY = height/2).
*/
export const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
useEffect(() => {
useScene.temporal.getState().pause()
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const destroyDraft = () => {
if (!draftRef.current) return
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
if (wallId) markWallDirty(wallId)
}
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
const levelId = getLevelId()
if (!levelId) return
if (event.node.parentId !== levelId) return
destroyDraft()
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const width = 0.9
const height = 2.1
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
const node = DoorNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
}
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY, width, height,
draftRef.current?.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
draftRef.current.width, draftRef.current.height,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
draftRef.current.width, draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
const draft = draftRef.current
draftRef.current = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'door') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
}).length
const name = `Door ${doorCount + 1}`
const node = DoorNode.parse({
name,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: draft.width,
height: draft.height,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
threshold: draft.threshold,
thresholdHeight: draft.thresholdHeight,
hingesSide: draft.hingesSide,
swingDirection: draft.swingDirection,
segments: draft.segments,
handle: draft.handle,
handleHeight: draft.handleHeight,
handleSide: draft.handleSide,
doorCloser: draft.doorCloser,
panicBar: draft.panicBar,
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
event.stopPropagation()
}
const onWallLeave = () => {
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
destroyDraft()
hideCursor()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -0,0 +1,343 @@
import {
type AnyNodeId,
DoorNode,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!)
const exitMoveMode = () => {
useEditor.getState().setMovingNode(null)
}
useEffect(() => {
useScene.temporal.getState().pause()
const meta = (typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null)
? movingDoorNode.metadata as Record<string, unknown>
: {}
const isNew = !!meta.isNew
const original = {
position: [...movingDoorNode.position] as [number, number, number],
rotation: [...movingDoorNode.rotation] as [number, number, number],
side: movingDoorNode.side,
parentId: movingDoorNode.parentId,
wallId: movingDoorNode.wallId,
metadata: movingDoorNode.metadata,
}
if (!isNew) {
useScene.getState().updateNode(movingDoorNode.id, {
metadata: { ...meta, isTransient: true },
})
}
let currentWallId: string | null = movingDoorNode.parentId
const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
movingDoorNode.width, movingDoorNode.height,
)
const prevWallId = currentWallId
currentWallId = event.node.id
useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingDoorNode.width, movingDoorNode.height,
movingDoorNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
movingDoorNode.width, movingDoorNode.height,
)
useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (currentWallId !== event.node.id) {
markWallDirty(currentWallId)
currentWallId = event.node.id
}
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingDoorNode.width, movingDoorNode.height,
movingDoorNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
movingDoorNode.width, movingDoorNode.height,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingDoorNode.width, movingDoorNode.height,
movingDoorNode.id,
)
if (!valid) return
let placedId: string
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
useScene.temporal.getState().resume()
const cloned = structuredClone(movingDoorNode) as any
delete cloned.id
const node = DoorNode.parse({
...cloned,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
placedId = node.id
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
metadata: {},
})
if (original.parentId && original.parentId !== event.node.id) {
markWallDirty(original.parentId)
}
placedId = movingDoorNode.id
}
markWallDirty(event.node.id)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onWallLeave = () => {
hideCursor()
if (isNew) return
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
}
currentWallId = original.parentId
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
})
if (original.parentId) markWallDirty(original.parentId)
}
const onCancel = () => {
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
useScene.temporal.getState().resume()
hideCursor()
exitMoveMode()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
const currentMeta = current?.metadata as Record<string, unknown> | undefined
if (currentMeta?.isTransient) {
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
}
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [movingDoorNode])
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.frameDepth ?? 0.07,
)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [movingDoorNode])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -0,0 +1,26 @@
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
export const ItemTool: React.FC = () => {
const selectedItem = useEditor((state) => state.selectedItem)
const draftNode = useDraftNode()
const cursor = usePlacementCoordinator({
asset: selectedItem!,
draftNode,
initDraft: (gridPosition) => {
if (!selectedItem?.attachTo) {
draftNode.create(gridPosition, selectedItem!)
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
return true
},
})
if (!selectedItem) return null
return <>{cursor}</>
}
@@ -0,0 +1,74 @@
import type { DoorNode, ItemNode, 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 { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function getInitialState(node: {
asset: { attachTo?: string }
parentId: string | null
}): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
}
if (attachTo === 'ceiling') {
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
}
return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
}
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
const draftNode = useDraftNode()
const meta = (typeof movingNode.metadata === 'object' && movingNode.metadata !== null)
? movingNode.metadata as Record<string, unknown>
: {}
const isNew = !!meta.isNew
const cursor = usePlacementCoordinator({
asset: movingNode.asset,
draftNode,
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
initialState: isNew ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } : getInitialState(movingNode),
// Preserve the original item's scale so Y-position calculations use the correct height
defaultScale: isNew ? movingNode.scale : undefined,
initDraft: (gridPosition) => {
if (isNew) {
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
gridPosition.copy(new Vector3(...movingNode.position))
if (!movingNode.asset.attachTo) {
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
}
} else {
draftNode.adopt(movingNode)
gridPosition.copy(new Vector3(...movingNode.position))
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
useEditor.getState().setMovingNode(null)
return false
},
onCancel: () => {
draftNode.destroy()
useEditor.getState().setMovingNode(null)
},
})
return <>{cursor}</>
}
export const MoveTool: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
}
@@ -0,0 +1,86 @@
import { isObject } from '@pascal-app/core'
/**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
*/
export function snapToGrid(position: number, dimension: number): number {
const halfDim = dimension / 2
const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
const offset = needsOffset ? 0.25 : 0
return Math.round((position - offset) * 2) / 2 + offset
}
/**
* Snap a value to 0.5 increments (used for wall-local positions).
*/
export function snapToHalf(value: number): number {
return Math.round(value * 2) / 2
}
/**
* Calculate cursor rotation in WORLD space from wall normal and orientation.
*/
export function calculateCursorRotation(
normal: [number, number, number] | undefined,
wallStart: [number, number],
wallEnd: [number, number],
): number {
if (!normal) return 0
// Wall direction angle in world XZ plane
const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])
// In local wall space, front face has normal.z < 0, back face has normal.z > 0
if (normal[2] < 0) {
return -wallAngle
} else {
return Math.PI - wallAngle
}
}
/**
* Calculate item rotation in WALL-LOCAL space from normal.
* Items are children of the wall mesh, so their rotation is relative to wall's local space.
*/
export function calculateItemRotation(normal: [number, number, number] | undefined): number {
if (!normal) return 0
return normal[2] > 0 ? 0 : Math.PI
}
/**
* Determine which side of the wall based on the normal vector.
* In wall-local space, the wall runs along X-axis, so the normal points along Z-axis.
* Positive Z normal = 'front', Negative Z normal = 'back'
*/
export function getSideFromNormal(normal: [number, number, number] | undefined): 'front' | 'back' {
if (!normal) return 'front'
return normal[2] >= 0 ? 'front' : 'back'
}
/**
* Check if the normal indicates a valid wall side face (front or back).
* Filters out top face and thickness edges.
*
* In wall-local geometry space (after ExtrudeGeometry + rotateX):
* - X axis: along wall direction
* - Y axis: up (height)
* - Z axis: perpendicular to wall (thickness direction)
*
* So valid side faces have normals pointing in ±Z direction (local space).
*/
export function isValidWallSideFace(normal: [number, number, number] | undefined): boolean {
if (!normal) return false
return Math.abs(normal[2]) > 0.7
}
/**
* Strip the `isTransient` flag from node metadata before committing.
*/
export function stripTransient(meta: any): any {
if (!isObject(meta)) return meta
const { isTransient, ...rest } = meta as Record<string, any>
return rest
}
@@ -0,0 +1,532 @@
import type {
AnyNode,
AnyNodeId,
CeilingEvent,
CeilingNode,
GridEvent,
ItemEvent,
ItemNode,
WallEvent,
WallNode,
} from '@pascal-app/core'
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
import { Vector3 } from 'three'
import type {
CommitResult,
LevelResolver,
PlacementContext,
PlacementResult,
SpatialValidators,
TransitionResult,
} from './placement-types'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToGrid,
snapToHalf,
stripTransient,
} from './placement-math'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
// ============================================================================
// FLOOR STRATEGY
// ============================================================================
export const floorStrategy = {
/**
* Handle grid:move — update position when on floor surface.
* Returns null if currently on wall/ceiling.
*/
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
if (ctx.state.surface !== 'floor') return null
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const [dimX, , dimZ] = dims
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
return {
gridPosition: [x, 0, z],
cursorPosition: [x, event.position[1], z],
cursorRotationY: 0,
nodeUpdate: { position: [x, 0, z] },
stopPropagation: false,
dirtyNodeId: null,
}
},
/**
* Handle grid:click — commit placement on floor.
* Returns null if on wall/ceiling or validation fails.
*/
click(ctx: PlacementContext, _event: GridEvent, validators: SpatialValidators): CommitResult | null {
if (ctx.state.surface !== 'floor') return null
if (!ctx.levelId || !ctx.draftItem) return null
const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
const valid = validators.canPlaceOnFloor(
ctx.levelId,
pos,
getScaledDimensions(ctx.draftItem),
[0, 0, 0],
[ctx.draftItem.id],
).valid
if (!valid) return null
return {
nodeUpdate: {
position: pos,
parentId: ctx.levelId,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: false,
dirtyNodeId: null,
}
},
}
// ============================================================================
// WALL STRATEGY
// ============================================================================
export const wallStrategy = {
/**
* Handle wall:enter — transition from floor to wall surface.
* Returns null if item doesn't attach to walls, face is invalid, or wrong level.
* Auto-adjusts Y position to fit within wall bounds.
*/
enter(
ctx: PlacementContext,
event: WallEvent,
resolveLevelId: LevelResolver,
nodes: Record<string, AnyNode>,
validators: SpatialValidators,
): TransitionResult | null {
const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
if (!isValidWallSideFace(event.normal)) return null
// Level guard
const wallLevelId = resolveLevelId(event.node, nodes)
if (ctx.levelId !== wallLevelId) return null
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const x = snapToHalf(event.localPosition[0])
const y = snapToHalf(event.localPosition[1])
const z = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
ctx.levelId,
event.node.id,
x,
y,
ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
attachTo,
side,
[],
)
const adjustedY = validation.adjustedY ?? y
return {
stateUpdate: { surface: 'wall', wallId: event.node.id },
nodeUpdate: {
position: [x, adjustedY, z],
parentId: event.node.id,
side,
rotation: [0, itemRotation, 0],
},
cursorRotationY: cursorRotation,
gridPosition: [x, adjustedY, z],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
stopPropagation: true,
}
},
/**
* Handle wall:move — update position while on wall.
* Returns null if not on a wall or face is invalid.
* Auto-adjusts Y position to fit within wall bounds.
*/
move(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): PlacementResult | null {
if (ctx.state.surface !== 'wall') return null
if (!ctx.draftItem || !ctx.levelId) return null
if (!isValidWallSideFace(event.normal)) return null
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const snappedX = snapToHalf(event.localPosition[0])
const snappedY = snapToHalf(event.localPosition[1])
const snappedZ = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
ctx.levelId,
event.node.id,
snappedX,
snappedY,
getScaledDimensions(ctx.draftItem),
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
side,
[ctx.draftItem.id],
)
const adjustedY = validation.adjustedY ?? snappedY
return {
gridPosition: [snappedX, adjustedY, snappedZ],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
cursorRotationY: cursorRotation,
nodeUpdate: {
position: [snappedX, adjustedY, snappedZ],
side,
rotation: [0, itemRotation, 0],
},
stopPropagation: true,
dirtyNodeId: event.node.id,
}
},
/**
* Handle wall:click — commit placement on wall.
* Returns null if not on wall, face invalid, or validation fails.
*/
click(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): CommitResult | null {
if (ctx.state.surface !== 'wall') return null
if (!isValidWallSideFace(event.normal)) return null
if (!ctx.levelId || !ctx.draftItem) return null
const valid = validators.canPlaceOnWall(
ctx.levelId,
ctx.state.wallId as WallNode['id'],
ctx.gridPosition.x,
ctx.gridPosition.y,
getScaledDimensions(ctx.draftItem),
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
ctx.draftItem.side,
[ctx.draftItem.id],
).valid
if (!valid) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: event.node.id,
side: ctx.draftItem.side,
rotation: ctx.draftItem.rotation,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: event.node.id,
}
},
/**
* Handle wall:leave — transition back to floor surface.
*/
leave(ctx: PlacementContext): TransitionResult | null {
if (ctx.state.surface !== 'wall') return null
return {
stateUpdate: { surface: 'floor', wallId: null },
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.levelId,
},
cursorRotationY: 0,
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
stopPropagation: true,
}
},
}
// ============================================================================
// CEILING STRATEGY
// ============================================================================
export const ceilingStrategy = {
/**
* Handle ceiling:enter — transition from floor to ceiling surface.
* Returns null if item doesn't attach to ceilings or wrong level.
*/
enter(
ctx: PlacementContext,
event: CeilingEvent,
resolveLevelId: LevelResolver,
nodes: Record<string, AnyNode>,
): TransitionResult | null {
if (ctx.asset.attachTo !== 'ceiling') return null
// Level guard
const ceilingLevelId = resolveLevelId(event.node, nodes)
if (ctx.levelId !== ceilingLevelId) return null
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const [dimX, , dimZ] = dims
const itemHeight = dims[1]
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
return {
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
nodeUpdate: {
position: [x, -itemHeight, z],
parentId: event.node.id,
},
cursorRotationY: 0,
gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z],
stopPropagation: true,
}
},
/**
* Handle ceiling:move — update position while on ceiling.
*/
move(ctx: PlacementContext, event: CeilingEvent): PlacementResult | null {
if (ctx.state.surface !== 'ceiling') return null
if (!ctx.draftItem) return null
const dims = getScaledDimensions(ctx.draftItem)
const [dimX, , dimZ] = dims
const itemHeight = dims[1]
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
return {
gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z],
cursorRotationY: 0,
nodeUpdate: null,
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle ceiling:click — commit placement on ceiling.
*/
click(ctx: PlacementContext, event: CeilingEvent, validators: SpatialValidators): CommitResult | null {
if (ctx.state.surface !== 'ceiling') return null
if (!ctx.draftItem) return null
const pos: [number, number, number] = [
ctx.gridPosition.x,
ctx.gridPosition.y,
ctx.gridPosition.z,
]
const valid = validators.canPlaceOnCeiling(
ctx.state.ceilingId as CeilingNode['id'],
pos,
getScaledDimensions(ctx.draftItem),
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
if (!valid) return null
return {
nodeUpdate: {
position: pos,
parentId: event.node.id,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle ceiling:leave — transition back to floor surface.
*/
leave(ctx: PlacementContext): TransitionResult | null {
if (ctx.state.surface !== 'ceiling') return null
return {
stateUpdate: { surface: 'floor', ceilingId: null },
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.levelId,
},
cursorRotationY: 0,
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
stopPropagation: true,
}
},
}
// ============================================================================
// ITEM SURFACE STRATEGY
// ============================================================================
export const itemSurfaceStrategy = {
/**
* Handle item:enter — transition from floor to an item surface.
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
*/
enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
// Only floor items can be placed on surfaces
if (ctx.asset.attachTo) return null
const surfaceItem = event.node as ItemNode
// Don't surface-place on the draft itself
if (surfaceItem.id === ctx.draftItem?.id) return null
// Surface item must declare a surface
if (!surfaceItem.asset.surface) return null
// Size check: our footprint must fit on surface item's footprint
const ourDims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const surfDims = getScaledDimensions(surfaceItem)
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
if (!surfaceMesh) return null
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = surfaceMesh.worldToLocal(worldPos)
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
return {
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
cursorRotationY: 0,
gridPosition: [x, y, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
}
},
/**
* Handle item:move — update position while on an item surface.
*/
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
if (ctx.state.surface !== 'item-surface') return null
if (!ctx.state.surfaceItemId || !ctx.draftItem) return null
const nodes = useScene.getState().nodes
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
if (!surfaceItem?.asset.surface) return null
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
if (!surfaceMesh) return null
const ourDims = getScaledDimensions(ctx.draftItem)
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = surfaceMesh.worldToLocal(worldPos)
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
return {
gridPosition: [x, y, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: 0,
nodeUpdate: { position: [x, y, z] },
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle item:click — commit placement on item surface.
*/
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
if (ctx.state.surface !== 'item-surface') return null
if (!ctx.draftItem || !ctx.state.surfaceItemId) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.state.surfaceItemId,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
}
// ============================================================================
// VALIDATION
// ============================================================================
/**
* Unified validation: check if the current draft item can be placed at its current position.
* Switches on the active surface type and calls the appropriate spatial validator.
*/
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
if (!ctx.levelId || !ctx.draftItem) return false
// Item surface: valid if we entered (size check was in enter)
if (ctx.state.surface === 'item-surface') {
return ctx.state.surfaceItemId !== null
}
const attachTo = ctx.draftItem.asset.attachTo
if (attachTo === 'ceiling') {
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
return validators.canPlaceOnCeiling(
ctx.state.ceilingId as CeilingNode['id'],
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
getScaledDimensions(ctx.draftItem),
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
}
if (attachTo === 'wall' || attachTo === 'wall-side') {
if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
return validators.canPlaceOnWall(
ctx.levelId,
ctx.state.wallId as WallNode['id'],
ctx.gridPosition.x,
ctx.gridPosition.y,
getScaledDimensions(ctx.draftItem),
attachTo,
ctx.draftItem.side,
[ctx.draftItem.id],
).valid
}
// Floor (no attachTo)
return validators.canPlaceOnFloor(
ctx.levelId,
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
getScaledDimensions(ctx.draftItem),
[0, 0, 0],
[ctx.draftItem.id],
).valid
}
@@ -0,0 +1,110 @@
import type { AnyNode, AssetInput, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core'
import type { Vector3 } from 'three'
// ============================================================================
// PLACEMENT STATE
// ============================================================================
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
/**
* Tracks which surface the draft item is currently on.
* Replaces the scattered isOnWall, isOnCeiling refs and currentWallId, currentCeilingId variables.
*/
export interface PlacementState {
surface: SurfaceType
wallId: string | null
ceilingId: string | null
surfaceItemId: string | null
}
// ============================================================================
// STRATEGY CONTEXT
// ============================================================================
/**
* Read-only snapshot passed to every strategy call.
*/
export interface PlacementContext {
asset: AssetInput
levelId: LevelNode['id'] | null
draftItem: ItemNode | null
gridPosition: Vector3
state: PlacementState
}
// ============================================================================
// STRATEGY RESULTS
// ============================================================================
/**
* Returned by strategy move handlers.
*/
export interface PlacementResult {
gridPosition: [number, number, number]
cursorPosition: [number, number, number]
cursorRotationY: number
nodeUpdate: Partial<ItemNode> | null
stopPropagation: boolean
dirtyNodeId: AnyNode['id'] | null
}
/**
* Returned by enter/leave handlers (surface transitions).
*/
export interface TransitionResult {
stateUpdate: Partial<PlacementState>
nodeUpdate: Partial<ItemNode>
gridPosition: [number, number, number]
cursorPosition: [number, number, number]
cursorRotationY: number
stopPropagation: boolean
}
/**
* Returned by click handlers (commit placement).
*/
export interface CommitResult {
nodeUpdate: Partial<ItemNode>
stopPropagation: boolean
dirtyNodeId: AnyNode['id'] | null
}
// ============================================================================
// SPATIAL VALIDATORS
// ============================================================================
/**
* Type for the useSpatialQuery() return value.
*/
export interface SpatialValidators {
canPlaceOnFloor: (
levelId: LevelNode['id'],
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds?: string[],
) => { valid: boolean }
canPlaceOnWall: (
levelId: LevelNode['id'],
wallId: WallNode['id'],
localX: number,
localY: number,
dimensions: [number, number, number],
attachType: 'wall' | 'wall-side',
side?: 'front' | 'back',
ignoreIds?: string[],
) => { valid: boolean; adjustedY?: number; wasAdjusted?: boolean }
canPlaceOnCeiling: (
ceilingId: CeilingNode['id'],
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds?: string[],
) => { valid: boolean }
}
/**
* Resolver function type for finding a node's level.
*/
export type LevelResolver = (node: AnyNode, nodes: Record<string, AnyNode>) => string
@@ -0,0 +1,206 @@
import { type AnyNodeId, type AssetInput, ItemNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useMemo, useRef } from 'react'
import type { Vector3 } from 'three'
import { stripTransient } from './placement-math'
interface OriginalState {
position: [number, number, number]
rotation: [number, number, number]
side: ItemNode['side']
parentId: string | null
metadata: ItemNode['metadata']
}
export interface DraftNodeHandle {
/** Current draft item, or null */
readonly current: ItemNode | null
/** Whether the current draft was adopted (move mode) vs created (create mode) */
readonly isAdopted: boolean
/** Create a new draft item at the given position. Returns the created node or null. */
create: (gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]) => ItemNode | null
/** Take ownership of an existing scene node as the draft (for move mode). */
adopt: (node: ItemNode) => void
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
commit: (finalUpdate: Partial<ItemNode>) => string | null
/** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
destroy: () => void
}
/**
* Hook that manages the lifecycle of a transient (draft) item node.
* Handles temporal pause/resume for undo/redo isolation.
*
* Supports two modes:
* - Create mode (via `create()`): draft is a new transient node. Commit = delete+recreate (undo removes node).
* - Move mode (via `adopt()`): draft is an existing node. Commit = update in place (undo reverts position).
*/
export function useDraftNode(): DraftNodeHandle {
const draftRef = useRef<ItemNode | null>(null)
const adoptedRef = useRef(false)
const originalStateRef = useRef<OriginalState | null>(null)
const create = useCallback((gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]): ItemNode | null => {
const currentLevelId = useViewer.getState().selection.levelId
if (!currentLevelId) return null
const node = ItemNode.parse({
position: [gridPosition.x, gridPosition.y, gridPosition.z],
rotation: rotation ?? [0, 0, 0],
scale: scale ?? [1, 1, 1],
name: asset.name,
asset,
parentId: currentLevelId,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, currentLevelId)
draftRef.current = node
adoptedRef.current = false
originalStateRef.current = null
return node
}, [])
const adopt = useCallback((node: ItemNode): void => {
// Save original state so destroy() can restore it
const meta = (typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata))
? node.metadata as Record<string, unknown>
: {}
originalStateRef.current = {
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
side: node.side,
parentId: node.parentId,
metadata: node.metadata,
}
draftRef.current = node
adoptedRef.current = true
// Mark as transient so it renders as a draft
useScene.getState().updateNode(node.id, {
metadata: { ...meta, isTransient: true },
})
}, [])
const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
const draft = draftRef.current
if (!draft) return null
if (adoptedRef.current) {
// Move mode: update in place (single undoable action)
const { parentId: newParentId, ...updateProps } = finalUpdate
const parentId = newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
const original = originalStateRef.current!
// Restore original state while paused — so the undo baseline is clean
useScene.getState().updateNode(draft.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
// Resume → tracked update (undo reverts to original)
useScene.temporal.getState().resume()
useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string,
})
useScene.temporal.getState().pause()
const id = draft.id
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
return id
}
// Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
const { parentId: newParentId, ...updateProps } = finalUpdate
const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
if (!parentId) return null
// Delete draft while paused (invisible to undo)
useScene.getState().deleteNode(draft.id)
draftRef.current = null
// Briefly resume → create fresh node (the single undoable action)
useScene.temporal.getState().resume()
const finalNode = ItemNode.parse({
name: draft.name,
asset: draft.asset,
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
})
useScene.getState().createNode(finalNode, parentId)
// Re-pause for next draft cycle
useScene.temporal.getState().pause()
adoptedRef.current = false
originalStateRef.current = null
return finalNode.id
}, [])
const destroy = useCallback(() => {
if (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting
const original = originalStateRef.current
const id = draftRef.current.id
useScene.getState().updateNode(id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
// Also reset the Three.js mesh directly — the store update triggers a React
// re-render but the mesh position was mutated by useFrame and may not reset
// until the next render cycle, leaving a visual glitch.
const mesh = sceneRegistry.nodes.get(id as AnyNodeId)
if (mesh) {
mesh.position.set(original.position[0], original.position[1], original.position[2])
mesh.rotation.y = original.rotation[1] ?? 0
mesh.visible = true
}
} else {
// Create mode: delete the transient node
useScene.getState().deleteNode(draftRef.current.id)
}
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
}, [])
return useMemo(
() => ({
get current() {
return draftRef.current
},
get isAdopted() {
return adoptedRef.current
},
create,
adopt,
commit,
destroy,
}),
[create, adopt, commit, destroy],
)
}
@@ -0,0 +1,769 @@
import type { AssetInput } from '@pascal-app/core'
import {
type AnyNodeId,
type CeilingEvent,
emitter,
getScaledDimensions,
type GridEvent,
type ItemEvent,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
useSpatialQuery,
type WallEvent,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import {
BoxGeometry,
EdgesGeometry,
Euler,
type Group,
type LineSegments,
type Mesh,
PlaneGeometry,
Quaternion,
Vector3,
} from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { ceilingStrategy, checkCanPlace, floorStrategy, itemSurfaceStrategy, wallStrategy } from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types'
import type { DraftNodeHandle } from './use-draft-node'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
// Shared materials for placement cursor - we just change colors, not swap materials
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444, // red-500 (invalid)
linewidth: 3,
depthTest: false,
depthWrite: false,
})
const basePlaneMaterial = new MeshBasicNodeMaterial({
color: 0xef4444, // red-500 (invalid)
transparent: true,
depthTest: false,
depthWrite: false,
})
// Create radial opacity: transparent in center, opaque at edges
const center = vec2(0.5, 0.5)
const dist = distance(uv(), center)
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
basePlaneMaterial.opacityNode = radialOpacity
export interface PlacementCoordinatorConfig {
asset: AssetInput
draftNode: DraftNodeHandle
initDraft: (gridPosition: Vector3) => void
onCommitted: () => boolean
onCancel?: () => void
initialState?: PlacementState
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
defaultScale?: [number, number, number]
}
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
const basePlaneRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
)
const shiftFreeRef = useRef(false)
// Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config)
configRef.current = config
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
const { asset, draftNode } = config
useEffect(() => {
useScene.temporal.getState().pause()
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
// Reset placement state
placementState.current = configRef.current.initialState ?? {
surface: 'floor',
wallId: null,
ceilingId: null,
surfaceItemId: null,
}
// ---- Helpers ----
const getContext = () => ({
asset,
levelId: useViewer.getState().selection.levelId,
draftItem: draftNode.current,
gridPosition: gridPosition.current,
state: { ...placementState.current },
})
const getActiveValidators = () => shiftFreeRef.current
? { canPlaceOnFloor: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }), canPlaceOnCeiling: () => ({ valid: true }) }
: validators
const revalidate = (): boolean => {
const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500
edgeMaterial.color.setHex(color)
basePlaneMaterial.color.setHex(color)
return placeable
}
const applyTransition = (result: TransitionResult) => {
Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) {
// Update ref for validation — no store update during drag
Object.assign(draft, result.nodeUpdate)
}
revalidate()
}
const ensureDraft = (result: TransitionResult) => {
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0], configRef.current.defaultScale)
const draft = draftNode.current
if (draft) {
Object.assign(draft, result.nodeUpdate)
// One-time setup: put node in the right parent so it renders correctly
useScene.getState().updateNode(draft.id, result.nodeUpdate)
}
if (!revalidate()) {
draftNode.destroy()
}
}
// ---- Init draft ----
configRef.current.initDraft(gridPosition.current)
// Sync cursor to the draft mesh's world position and rotation
if (draftNode.current) {
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) {
mesh.getWorldPosition(cursorGroupRef.current.position)
// Extract world Y rotation (handles wall-parented items correctly)
const q = new Quaternion()
mesh.getWorldQuaternion(q)
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
} else {
cursorGroupRef.current.position.copy(gridPosition.current)
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
}
}
revalidate()
// ---- Floor Handlers ----
let previousGridPos: [number, number, number] | null = null
const onGridMove = (event: GridEvent) => {
const result = floorStrategy.move(getContext(), event)
if (!result) return
// Play snap sound when grid position changes
if (
previousGridPos &&
(result.gridPosition[0] !== previousGridPos[0] ||
result.gridPosition[2] !== previousGridPos[2])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPos = [...result.gridPosition]
gridPosition.current.set(...result.gridPosition)
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
cursorGroupRef.current.position.x = result.cursorPosition[0]
cursorGroupRef.current.position.z = result.cursorPosition[2]
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
revalidate()
}
const onGridClick = (event: GridEvent) => {
const result = floorStrategy.click(getContext(), event, getActiveValidators())
if (!result) return
// Preserve cursor rotation for the next draft
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
draftNode.create(gridPosition.current, asset, currentRotation)
revalidate()
}
}
// ---- Wall Handlers ----
const onWallEnter = (event: WallEvent) => {
const nodes = useScene.getState().nodes
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to new wall
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
if (result.stateUpdate.wallId) {
useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
}
}
}
const onWallMove = (event: WallEvent) => {
const ctx = getContext()
if (ctx.state.surface !== 'wall') {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, getActiveValidators())
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
if (draftNode.current && enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
if (enterResult.stateUpdate.wallId) {
useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
}
}
return
}
if (!draftNode.current) {
const nodes = useScene.getState().nodes
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = wallStrategy.move(ctx, event, getActiveValidators())
if (!result) return
event.stopPropagation()
const posChanged =
gridPosition.current.x !== result.gridPosition[0] ||
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft && result.nodeUpdate) {
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
if ('rotation' in result.nodeUpdate)
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
}
const placeable = revalidate()
if (draft && placeable) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) {
mesh.position.copy(gridPosition.current)
const rot = result.nodeUpdate?.rotation
if (rot) mesh.rotation.y = rot[1]
// Push wall-side items out by half the parent wall's thickness
if (asset.attachTo === 'wall-side' && placementState.current.wallId) {
const parentWall = useScene.getState().nodes[placementState.current.wallId as AnyNodeId]
if (parentWall?.type === 'wall') {
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
mesh.position.z = (wallThickness / 2) * (draft.side === 'front' ? 1 : -1)
}
}
}
// Mark parent wall dirty so it rebuilds geometry — only when position changed
if (result.dirtyNodeId && posChanged) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
}
}
}
const onWallClick = (event: WallEvent) => {
const result = wallStrategy.click(getContext(), event, getActiveValidators())
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
if (result.dirtyNodeId) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
}
if (configRef.current.onCommitted()) {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onWallLeave = (event: WallEvent) => {
const result = wallStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (asset.attachTo) {
if (draftNode.isAdopted) {
// Move mode: keep draft alive, reparent to level
const oldWallId = placementState.current.wallId
applyTransition(result)
const draft = draftNode.current
if (draft) {
useScene
.getState()
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
}
if (oldWallId) {
useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId)
}
} else {
// Create mode: destroy transient and reset state
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
}
} else {
applyTransition(result)
}
}
// ---- Item Surface Handlers ----
const onItemEnter = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
const result = itemSurfaceStrategy.enter(getContext(), event)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to surface item
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
}
}
const onItemMove = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
const ctx = getContext()
if (ctx.state.surface !== 'item-surface') {
// Try entering surface mode
const enterResult = itemSurfaceStrategy.enter(ctx, event)
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
if (draftNode.current && enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
}
return
}
if (!draftNode.current) {
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (!enterResult) return
event.stopPropagation()
ensureDraft(enterResult)
return
}
const result = itemSurfaceStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.set(...result.gridPosition)
}
revalidate()
}
const onItemLeave = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
if (placementState.current.surface !== 'item-surface') return
event.stopPropagation()
// Transition back to floor using event world position
const wx = Math.round(event.position[0] * 2) / 2
const wz = Math.round(event.position[2] * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
gridPosition.current.set(wx, 0, wz)
cursorGroupRef.current.position.set(wx, event.position[1], wz)
const draft = draftNode.current
if (draft) {
draft.position = floorPos
useScene.getState().updateNode(draft.id, {
parentId: useViewer.getState().selection.levelId as string,
position: floorPos,
})
}
revalidate()
}
const onItemClick = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
const result = itemSurfaceStrategy.click(getContext(), event)
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
// Try to set up next draft on the same surface
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
// ---- Ceiling Handlers ----
const onCeilingEnter = (event: CeilingEvent) => {
const nodes = useScene.getState().nodes
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to new ceiling
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
if (result.stateUpdate.ceilingId) {
useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
}
}
}
const onCeilingMove = (event: CeilingEvent) => {
if (!draftNode.current && placementState.current.surface === 'ceiling') {
const nodes = useScene.getState().nodes
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = ceilingStrategy.move(getContext(), event)
if (!result) return
event.stopPropagation()
// Play snap sound when grid position changes
const posChanged =
gridPosition.current.x !== result.gridPosition[0] ||
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
revalidate()
const draft = draftNode.current
if (draft) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.copy(gridPosition.current)
}
}
const onCeilingClick = (event: CeilingEvent) => {
const result = ceilingStrategy.click(getContext(), event, getActiveValidators())
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onCeilingLeave = (event: CeilingEvent) => {
const result = ceilingStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (asset.attachTo) {
if (draftNode.isAdopted) {
// Move mode: keep draft alive, reparent to level
const oldCeilingId = placementState.current.ceilingId
applyTransition(result)
const draft = draftNode.current
if (draft) {
useScene
.getState()
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
}
if (oldCeilingId) {
useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId)
}
} else {
// Create mode: destroy transient and reset state
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
}
} else {
applyTransition(result)
}
}
// ---- Keyboard rotation ----
const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = true
revalidate()
return
}
const draft = draftNode.current
if (!draft) return
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')
const currentRotation = draft.rotation
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
// Ref + cursor mesh + item mesh — no store update during drag
cursorGroupRef.current.rotation.y = newRotationY
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.rotation.y = newRotationY
revalidate()
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = false
revalidate()
}
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
// ---- tool:cancel (Escape / programmatic) ----
const onCancel = () => {
if (configRef.current.onCancel) {
configRef.current.onCancel()
}
}
emitter.on('tool:cancel', onCancel)
// ---- Right-click cancel ----
const onContextMenu = (event: MouseEvent) => {
if (configRef.current.onCancel) {
event.preventDefault()
configRef.current.onCancel()
}
}
window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ----
const draft = draftNode.current
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
boxGeometry.translate(0, dims[1] / 2, 0)
const edgesGeometry = new EdgesGeometry(boxGeometry)
edgesRef.current.geometry = edgesGeometry
// ---- Subscribe ----
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('item:enter', onItemEnter)
emitter.on('item:move', onItemMove)
emitter.on('item:leave', onItemLeave)
emitter.on('item:click', onItemClick)
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('ceiling:enter', onCeilingEnter)
emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave)
return () => {
draftNode.destroy()
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('item:enter', onItemEnter)
emitter.off('item:move', onItemMove)
emitter.off('item:leave', onItemLeave)
emitter.off('item:click', onItemClick)
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('ceiling:enter', onCeilingEnter)
emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('contextmenu', onContextMenu)
}
}, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
// Reparent floor draft to the new level when the user switches levels mid-placement.
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
const viewerLevelId = useViewer((s) => s.selection.levelId)
useEffect(() => {
const draft = draftNode.current
if (!draft || !viewerLevelId || asset.attachTo) return
if (draft.parentId === viewerLevelId) return
draft.parentId = viewerLevelId
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
}, [viewerLevelId, draftNode, asset])
useFrame((_, delta) => {
if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
if (asset.attachTo && placementState.current.surface === 'floor') {
mesh.visible = false
return
}
mesh.visible = true
if (placementState.current.surface === 'floor') {
const distance = mesh.position.distanceToSquared(gridPosition.current)
if (distance > 1) {
mesh.position.copy(gridPosition.current)
} else {
mesh.position.lerp(gridPosition.current, delta * 20)
}
// Adjust Y for slab elevation (floor items on top of slabs)
if (!asset.attachTo) {
const nodes = useScene.getState().nodes
const levelId = resolveLevelId(draftNode.current, nodes)
const slabElevation = spatialGridManager.getSlabElevationForItem(
levelId,
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
getScaledDimensions(draftNode.current),
draftNode.current.rotation,
)
mesh.position.y = slabElevation
// Cursor group is at the world root (not inside a level group), so add the
// level group's current world Y to convert from level-local to world space.
const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
}
}
})
const initialDraft = draftNode.current
const dims = initialDraft ? getScaledDimensions(initialDraft) : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
initialBoxGeometry.translate(0, dims[1] / 2, 0)
// Base plane geometry (colored rectangle on the ground)
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting
return (
<group ref={cursorGroupRef}>
<lineSegments ref={edgesRef} material={edgeMaterial} layers={EDITOR_LAYER}>
<edgesGeometry args={[initialBoxGeometry]} />
</lineSegments>
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -0,0 +1,248 @@
import {
type AnyNode,
emitter,
type GridEvent,
type LevelNode,
RoofNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Group, 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 GRID_OFFSET = 0.02
/**
* Creates a roof with the given corners
*/
const commitRoofPlacement = (
levelId: LevelNode['id'],
corner1: [number, number, number],
corner2: [number, number, number],
): RoofNode['id'] => {
const { createNode, 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])
// Split width evenly between left and right slopes
const slopeWidth = Math.max(width / 2, 0.5)
// 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,
})
createNode(roof, levelId)
sfxEmitter.emit('sfx:structure-build')
return roof.id
}
type PreviewState = {
corner1: [number, number, number] | null
cursorPosition: [number, number, number]
levelY: number
}
export const RoofTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const outlineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const setTool = useEditor((state) => state.setTool)
const setMode = useEditor((state) => state.setMode)
const corner1Ref = useRef<[number, number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const [preview, setPreview] = useState<PreviewState>({
corner1: null,
cursorPosition: [0, 0, 0],
levelY: 0,
})
useEffect(() => {
if (!currentLevelId) return
// Initialize outline geometry
outlineRef.current.geometry = new BufferGeometry()
const updateOutline = (
corner1: [number, number, number],
corner2: [number, number, number],
) => {
const gridY = corner1[1] + GRID_OFFSET
const groundPoints = [
new Vector3(corner1[0], gridY, corner1[2]),
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
]
outlineRef.current.geometry.dispose()
outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints)
outlineRef.current.visible = true
}
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 &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
setPreview({
corner1: corner1Ref.current,
cursorPosition,
levelY: y,
})
// Update outline if we have first corner
if (corner1Ref.current) {
updateOutline(corner1Ref.current, cursorPosition)
}
}
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
if (!corner1Ref.current) {
// First click - set corner 1
corner1Ref.current = [gridX, y, gridZ]
setPreview((prev) => ({
...prev,
corner1: corner1Ref.current,
}))
} else {
// Second click - create the roof
const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
// Auto-select the newly created roof
setSelection({ selectedIds: [roofId as AnyNode['id']] })
// Reset state
corner1Ref.current = null
outlineRef.current.visible = false
}
}
const onCancel = () => {
if (corner1Ref.current) {
corner1Ref.current = null
outlineRef.current.visible = false
setPreview((prev) => ({ ...prev, corner1: null }))
}
}
// Subscribe to events
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
// Reset state on unmount
corner1Ref.current = null
}
}, [currentLevelId, setTool, setSelection, setMode])
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])
const width = Math.abs(cursorPosition[2] - corner1[2])
const centerX = (corner1[0] + cursorPosition[0]) / 2
const centerZ = (corner1[2] + cursorPosition[2]) / 2
return { length, width, centerX, centerZ }
}, [corner1, cursorPosition])
return (
<group>
{/* Cursor at ground height */}
<CursorSphere ref={cursorRef} />
{/* Outline showing rectangle being drawn (Ground) */}
{/* @ts-ignore */}
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={2} depthTest={false} depthWrite={false} opacity={0.3} transparent />
</line>
{/* First corner marker */}
{corner1 && (
<CursorSphere
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
color="#818cf8"
showTooltip={false}
/>
)}
{/* Thin preview fill when drawing (Ground) */}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
<mesh
layers={EDITOR_LAYER}
position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
<meshBasicMaterial
color="#818cf8"
opacity={0.1}
transparent
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh>
)}
</group>
)
}
@@ -0,0 +1,94 @@
import type { ThreeElements } from '@react-three/fiber'
import { forwardRef } from 'react'
import type { Group } from 'three'
import { Html } from '@react-three/drei'
import { EDITOR_LAYER } from '../../../lib/constants'
import useEditor from '../../../store/use-editor'
import { tools } from '../../../components/ui/action-menu/structure-tools'
import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
color?: string
depthWrite?: boolean
showTooltip?: boolean
height?: number
}
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
{ color = '#818cf8', showTooltip = true, height = 2.5, ...props },
ref,
) {
const tool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const catalogCategory = useEditor((s) => s.catalogCategory)
// Find the icon for the current tool
let activeToolConfig = null
if (mode === 'build' && tool) {
if (tool === 'item' && catalogCategory) {
activeToolConfig = furnishTools.find((t) => t.catalogCategory === catalogCategory)
} else {
activeToolConfig = tools.find((t) => t.id === tool)
}
}
return (
<group ref={ref} {...props}>
{/* Flat marker on the ground */}
<group rotation={[-Math.PI / 2, 0, 0]}>
{/* Center dot */}
<mesh renderOrder={2} layers={EDITOR_LAYER}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.9} />
</mesh>
{/* Outer ring / glow */}
<mesh renderOrder={2} layers={EDITOR_LAYER}>
<circleGeometry args={[0.2, 32]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.25} />
</mesh>
</group>
{/* Vertical line */}
{height > 0 && (
<mesh position={[0, height / 2, 0]} renderOrder={2} layers={EDITOR_LAYER}>
<cylinderGeometry args={[0.01, 0.01, height, 8]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.7} />
</mesh>
)}
{/* Tool Icon Tooltip at the top of the line */}
{showTooltip && activeToolConfig && (
<Html
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
center
style={{
pointerEvents: 'none',
background: '#18181b', // zinc-900
padding: '6px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.05)',
boxShadow: '0 8px 16px -4px rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activeToolConfig.iconSrc}
alt={activeToolConfig.label}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))'
}}
/>
</Html>
)}
</group>
)
})
@@ -0,0 +1,361 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
const Y_OFFSET = 0.02
type DragState = {
isDragging: boolean
vertexIndex: number
initialPosition: [number, number]
pointerId: number
}
export interface PolygonEditorProps {
polygon: Array<[number, number]>
color?: string
onPolygonChange: (polygon: Array<[number, number]>) => void
minVertices?: number
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
levelId?: string
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
surfaceHeight?: number
}
/**
* Generic polygon editor component for editing polygon vertices
* Used by zone and site boundary editors
*/
const MIN_HANDLE_HEIGHT = 0.15
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon,
color = '#3b82f6',
onPolygonChange,
minVertices = 3,
levelId,
surfaceHeight = 0,
}) => {
// Get level node from registry if levelId is provided
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
// When using portal, edit at Y_OFFSET (local to level)
// When not using portal, edit at world origin
const editY = levelNode ? Y_OFFSET : 0
// Local state for dragging
const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
// Keep ref in sync
useEffect(() => {
previewPolygonRef.current = previewPolygon
}, [previewPolygon])
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
// Track the last polygon prop to detect external changes (undo/redo)
const lastPolygonRef = useRef(polygon)
if (polygon !== lastPolygonRef.current) {
lastPolygonRef.current = polygon
// External change (e.g. undo/redo) — clear any stale preview/drag state
if (previewPolygon) setPreviewPolygon(null)
if (dragState) setDragState(null)
}
// The polygon to display (preview during drag, or actual polygon)
const displayPolygon = previewPolygon ?? polygon
// Calculate midpoints for adding new vertices
const midpoints = useMemo(() => {
if (displayPolygon.length < 2) return []
return displayPolygon.map(([x1, z1], index) => {
const nextIndex = (index + 1) % displayPolygon.length
const [x2, z2] = displayPolygon[nextIndex]!
return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number]
})
}, [displayPolygon])
// Update vertex position using grid cursor position
const handleVertexDrag = useCallback(
(vertexIndex: number, position: [number, number]) => {
setPreviewPolygon((prev) => {
const basePolygon = prev ?? polygon
const newPolygon = [...basePolygon]
newPolygon[vertexIndex] = position
return newPolygon
})
},
[polygon],
)
// Commit polygon changes
const commitPolygonChange = useCallback(() => {
if (previewPolygonRef.current) {
onPolygonChange(previewPolygonRef.current)
}
setPreviewPolygon(null)
setDragState(null)
}, [onPolygonChange])
// Handle adding a new vertex at midpoint
const handleAddVertex = useCallback(
(afterIndex: number, position: [number, number]) => {
const basePolygon = previewPolygon ?? polygon
const newPolygon = [
...basePolygon.slice(0, afterIndex + 1),
position,
...basePolygon.slice(afterIndex + 1),
]
setPreviewPolygon(newPolygon)
return afterIndex + 1 // Return new vertex index
},
[polygon, previewPolygon],
)
// Handle deleting a vertex
const handleDeleteVertex = useCallback(
(index: number) => {
const basePolygon = previewPolygon ?? polygon
if (basePolygon.length <= minVertices) return // Need at least minVertices points
const newPolygon = basePolygon.filter((_, i) => i !== index)
onPolygonChange(newPolygon)
setPreviewPolygon(null)
},
[polygon, previewPolygon, onPolygonChange, minVertices],
)
// Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag
if (
dragState?.isDragging &&
previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] ||
newPosition[1] !== previousPositionRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousPositionRef.current = newPosition
setCursorPosition(newPosition)
// Update vertex position during drag
if (dragState?.isDragging) {
handleVertexDrag(dragState.vertexIndex, newPosition)
}
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [dragState, handleVertexDrag])
// Set up pointer up listener for ending drag
useEffect(() => {
if (!dragState?.isDragging) return
const handlePointerUp = (e: PointerEvent | MouseEvent) => {
// Only handle the specific pointer that started the drag, if it's a PointerEvent
if (
'pointerId' in e &&
dragState.pointerId !== undefined &&
e.pointerId !== dragState.pointerId
)
return
// Stop the event from propagating to prevent grid click
e.stopImmediatePropagation()
e.preventDefault()
// Suppress the follow-up click event that browsers fire after pointerup
const suppressClick = (ce: MouseEvent) => {
ce.stopImmediatePropagation()
ce.preventDefault()
window.removeEventListener('click', suppressClick, true)
}
window.addEventListener('click', suppressClick, true)
// Safety cleanup in case no click fires
requestAnimationFrame(() => {
window.removeEventListener('click', suppressClick, true)
})
commitPolygonChange()
}
window.addEventListener('pointerup', handlePointerUp as EventListener, true)
window.addEventListener('pointercancel', handlePointerUp as EventListener, true)
return () => {
window.removeEventListener('pointerup', handlePointerUp as EventListener, true)
window.removeEventListener('pointercancel', handlePointerUp as EventListener, true)
}
}, [dragState, commitPolygonChange])
// Update line geometry when polygon changes
useEffect(() => {
if (!lineRef.current || displayPolygon.length < 2) return
const positions: number[] = []
for (const [x, z] of displayPolygon) {
positions.push(x!, editY + 0.01, z!)
}
// Close the loop
const first = displayPolygon[0]!
positions.push(first[0]!, editY + 0.01, first[1]!)
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
lineRef.current.geometry.dispose()
lineRef.current.geometry = geometry
}, [displayPolygon, editY])
if (displayPolygon.length < minVertices) return null
const canDelete = displayPolygon.length > minVertices
const editorContent = (
<group>
{/* Border line */}
<line
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
ref={lineRef}
frustumCulled={false}
renderOrder={10}
raycast={() => {}}
layers={EDITOR_LAYER}
>
<bufferGeometry />
<lineBasicNodeMaterial
color={color}
linewidth={2}
depthTest={false}
depthWrite={false}
transparent
opacity={0.8}
/>
</line>
{/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index
const isDragging = dragState?.vertexIndex === index
const radius = 0.1
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
return (
<mesh
layers={EDITOR_LAYER}
key={`vertex-${index}`}
position={[x!, editY + height / 2, z!]}
castShadow
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredVertex(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredVertex(null)
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
setDragState({
isDragging: true,
vertexIndex: index,
initialPosition: [x!, z!],
pointerId: e.pointerId,
})
}}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onDoubleClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
if (canDelete) {
handleDeleteVertex(index)
}
}}
>
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
/>
</mesh>
)
})}
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState &&
midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index
const radius = 0.06
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
return (
<mesh
layers={EDITOR_LAYER}
key={`midpoint-${index}`}
position={[x!, editY + height / 2, z!]}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredMidpoint(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredMidpoint(null)
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
const newVertexIndex = handleAddVertex(index, [x!, z!])
if (newVertexIndex >= 0) {
setDragState({
isDragging: true,
vertexIndex: newVertexIndex,
initialPosition: [x!, z!],
pointerId: e.pointerId,
})
setHoveredMidpoint(null)
}
}}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
>
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial
color={isHovered ? '#4ade80' : '#22c55e'}
transparent
opacity={isHovered ? 1 : 0.7}
/>
</mesh>
)
})}
</group>
)
// Mount to level node if available, otherwise render at world origin
return levelNode ? createPortal(editorContent, levelNode) : editorContent
}
@@ -0,0 +1,42 @@
import { type SiteNode, useScene } from '@pascal-app/core'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
/**
* Site boundary editor - allows editing site polygon when in site phase
* Uses the generic PolygonEditor component
*/
export const SiteBoundaryEditor: React.FC = () => {
const nodes = useScene((state) => state.nodes)
const rootNodeIds = useScene((state) => state.rootNodeIds)
const updateNode = useScene((state) => state.updateNode)
// Get the site node (first root node)
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
if (site) {
updateNode(site.id, {
polygon: {
type: 'polygon',
points: newPolygon,
},
})
}
},
[site, updateNode],
)
if (!site || !site.polygon?.points || site.polygon.points.length < 3) return null
return (
<PolygonEditor
polygon={site.polygon.points}
color="#10b981"
onPolygonChange={handlePolygonChange}
minVertices={3}
/>
)
}
@@ -0,0 +1,42 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabBoundaryEditorProps {
slabId: SlabNode['id']
}
/**
* Slab boundary editor - allows editing slab polygon vertices for a specific slab
* Uses the generic PolygonEditor component
*/
export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(slabId, { polygon: newPolygon })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, updateNode, setSelection],
)
if (!slab || !slab.polygon || slab.polygon.length < 3) return null
return (
<PolygonEditor
polygon={slab.polygon}
color="#a3a3a3"
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(slab, useScene.getState().nodes)}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -0,0 +1,47 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabHoleEditorProps {
slabId: SlabNode['id']
holeIndex: number
}
/**
* Slab hole editor - allows editing a specific hole polygon within a slab
* Uses the generic PolygonEditor component
*/
export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const holes = slab?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(slabId, { holes: updatedHoles })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, holeIndex, holes, updateNode, setSelection],
)
if (!slab || !hole || hole.length < 3) return null
return (
<PolygonEditor
polygon={hole}
color="#ef4444" // red for holes
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(slab, useScene.getState().nodes)}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -0,0 +1,289 @@
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const Y_OFFSET = 0.02
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/
const calculateSnapPoint = (
lastPoint: [number, number],
currentPoint: [number, number],
): [number, number] => {
const [x1, y1] = lastPoint
const [x, y] = currentPoint
const dx = x - x1
const dy = y - y1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy
const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) {
// Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy)
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
} else if (minDist === horizontalDist) {
// Snap to horizontal
return [x, y1]
} else {
// Snap to vertical
return [x1, y]
}
}
/**
* Creates a slab with the given polygon points and returns its ID
*/
const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
const { createNode, nodes } = useScene.getState()
// Count existing slabs for naming
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
const name = `Slab ${slabCount + 1}`
const slab = SlabNode.parse({
name,
polygon: points,
})
createNode(slab, levelId)
sfxEmitter.emit('sfx:structure-build')
return slab.id
}
export const SlabTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.position[1])
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
if (points.length > 0 && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the slab and select it
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
}
}
const onCancel = () => {
setPoints([])
}
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!mainLineRef.current || !closingLineRef.current) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const y = levelY + Y_OFFSET
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], y, snappedCursor[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]
if (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor */}
<CursorSphere ref={cursorRef} />
{/* Preview fill */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.5}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
))}
</group>
)
}
@@ -0,0 +1,112 @@
import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { DoorTool } from './door/door-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool'
import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool'
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: {
'property-line': SiteBoundaryEditor,
},
structure: {
wall: WallTool,
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
window: WindowTool,
},
furnish: {
item: ItemTool,
},
}
export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
// Check if a slab is selected
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
| SlabNode['id']
| undefined
// Check if a ceiling is selected
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
| CeilingNode['id']
| undefined
// Show site boundary editor when in site phase and edit mode
const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
const showSlabBoundaryEditor =
phase === 'structure' && mode === 'select' && selectedSlabId !== undefined &&
(!editingHole || editingHole.nodeId !== selectedSlabId)
// Show slab hole editor when editing a hole on the selected slab
const showSlabHoleEditor =
selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
const showCeilingBoundaryEditor =
phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined &&
(!editingHole || editingHole.nodeId !== selectedCeilingId)
// Show ceiling hole editor when editing a hole on the selected ceiling
const showCeilingHoleEditor =
selectedCeilingId !== undefined && editingHole !== null && editingHole.nodeId === selectedCeilingId
// Show zone boundary editor when in structure/select mode with a zone selected
// Hide when editing a slab or ceiling to avoid overlapping handles
const showZoneBoundaryEditor =
phase === 'structure' &&
mode === 'select' &&
selectedZoneId !== null &&
!showSlabBoundaryEditor &&
!showCeilingBoundaryEditor
// Show build tools when in build mode
const showBuildTool = mode === 'build' && tool !== null
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showSlabHoleEditor && selectedSlabId && editingHole && (
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingHole.holeIndex} />
)}
{showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)}
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</>
)
}
@@ -0,0 +1,215 @@
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Mesh, type Group, Shape, ShapeGeometry, Vector3 } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
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
*/
const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
// Calculate direction and perpendicular for wall thickness
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
mesh.visible = false
return
}
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)
shape.lineTo(length, 0)
shape.lineTo(length, WALL_HEIGHT)
shape.lineTo(0, WALL_HEIGHT)
shape.closePath()
// Create geometry
const geometry = new ShapeGeometry(shape)
// Calculate rotation angle
// Negate the angle to fix the opposite direction issue
const angle = -Math.atan2(direction.z, direction.x)
// Position at start point and rotate
mesh.position.set(start.x, start.y, start.z)
mesh.rotation.y = angle
// Dispose old geometry and assign new one
if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry
}
const commitWallDrawing = (start: [number, number], end: [number, number]) => {
const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState()
if (!currentLevelId) return
const wallCount = Object.values(nodes).filter((n) => n.type === 'wall').length
const name = `Wall ${wallCount + 1}`
const wall = WallNode.parse({ name, start, end })
createNode(wall, currentLevelId)
sfxEmitter.emit('sfx:structure-build')
}
export const WallTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
useEffect(() => {
let gridPosition: [number, number] = [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])
if (buildingState.current === 1) {
// Snap to 45° angles only if shift is not pressed
const snapped = shiftPressed.current
? cursorPosition
: snapTo45Degrees(startingPoint.current, cursorPosition)
endingPoint.current.copy(snapped)
// Position the cursor at the end of the wall being drawn
cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
// Play snap sound only when the actual wall end position changes
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
if (previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousWallEnd = currentWallEnd
// Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
} else {
// Not drawing a wall, just follow the grid position
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
}
}
const onGridClick = (event: GridEvent) => {
if (buildingState.current === 0) {
startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1])
buildingState.current = 1
wallPreviewRef.current.visible = true
} else if (buildingState.current === 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(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
)
wallPreviewRef.current.visible = false
buildingState.current = 0
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = false
}
}
const onCancel = () => {
if (buildingState.current === 1) {
buildingState.current = 0
wallPreviewRef.current.visible = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [])
return (
<group>
{/* Cursor indicator */}
<CursorSphere ref={cursorRef} />
{/* Wall preview */}
<mesh ref={wallPreviewRef} visible={false} renderOrder={1} layers={EDITOR_LAYER}>
<shapeGeometry />
<meshBasicMaterial
color="#818cf8"
transparent
opacity={0.5}
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh>
</group>
)
}
@@ -0,0 +1,377 @@
import {
type AnyNodeId,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
*
* Move mode (metadata.isNew falsy):
* Adopts the existing window, pauses temporal. On commit: restores original state
* (clean undo baseline) then resumes + updateNode (undo reverts to original position).
* On cancel: restores original state.
*
* Duplicate mode (metadata.isNew = true):
* The node is a freshly created transient copy. On commit: deletes transient + resumes
* + createNode (undo removes the new window entirely). On cancel: deletes the node.
*/
export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
const cursorGroupRef = useRef<Group>(null!)
const exitMoveMode = () => {
useEditor.getState().setMovingNode(null)
}
useEffect(() => {
useScene.temporal.getState().pause()
const meta = (typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null)
? movingWindowNode.metadata as Record<string, unknown>
: {}
const isNew = !!meta.isNew
// Save original state (only used in move mode)
const original = {
position: [...movingWindowNode.position] as [number, number, number],
rotation: [...movingWindowNode.rotation] as [number, number, number],
side: movingWindowNode.side,
parentId: movingWindowNode.parentId,
wallId: movingWindowNode.wallId,
metadata: movingWindowNode.metadata,
}
if (!isNew) {
// Move mode: mark the existing window as transient so it hides while being repositioned
useScene.getState().updateNode(movingWindowNode.id, {
metadata: { ...meta, isTransient: true },
})
}
let currentWallId: string | null = movingWindowNode.parentId
const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node, localX, localY,
movingWindowNode.width, movingWindowNode.height,
)
const prevWallId = currentWallId
currentWallId = event.node.id
useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingWindowNode.width, movingWindowNode.height,
movingWindowNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node, localX, localY,
movingWindowNode.width, movingWindowNode.height,
)
useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (currentWallId !== event.node.id) {
markWallDirty(currentWallId)
currentWallId = event.node.id
}
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingWindowNode.width, movingWindowNode.height,
movingWindowNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node, localX, localY,
movingWindowNode.width, movingWindowNode.height,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingWindowNode.width, movingWindowNode.height,
movingWindowNode.id,
)
if (!valid) return
let placedId: string
if (isNew) {
// Duplicate mode: delete transient + resume + createNode
// Undo will remove the newly created node entirely
useScene.getState().deleteNode(movingWindowNode.id)
useScene.temporal.getState().resume()
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: movingWindowNode.width,
height: movingWindowNode.height,
frameThickness: movingWindowNode.frameThickness,
frameDepth: movingWindowNode.frameDepth,
columnRatios: movingWindowNode.columnRatios,
rowRatios: movingWindowNode.rowRatios,
columnDividerThickness: movingWindowNode.columnDividerThickness,
rowDividerThickness: movingWindowNode.rowDividerThickness,
sill: movingWindowNode.sill,
sillDepth: movingWindowNode.sillDepth,
sillThickness: movingWindowNode.sillThickness,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
placedId = node.id
} else {
// Move mode: restore original (clean baseline) + resume + updateNode
// Undo will revert to the original position
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
metadata: {},
})
if (original.parentId && original.parentId !== event.node.id) {
markWallDirty(original.parentId)
}
placedId = movingWindowNode.id
}
markWallDirty(event.node.id)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onWallLeave = () => {
hideCursor()
if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
}
currentWallId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
})
if (original.parentId) markWallDirty(original.parentId)
}
const onCancel = () => {
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
useScene.temporal.getState().resume()
hideCursor()
exitMoveMode()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as WindowNode | undefined
const currentMeta = current?.metadata as Record<string, unknown> | undefined
if (currentMeta?.isTransient) {
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
}
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [movingWindowNode])
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(
movingWindowNode.width,
movingWindowNode.height,
movingWindowNode.frameDepth ?? 0.07,
)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [movingWindowNode])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -0,0 +1,109 @@
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
/**
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
* Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
* Pass levelYOffset (the level group's current world Y) and slabElevation (the
* wall mesh's Y within the level group) so the cursor lands at the correct world
* height — matching how WallSystem positions the wall mesh at slabElevation.
*/
export function wallLocalToWorld(
wallNode: WallNode,
localX: number,
localY: number,
levelYOffset = 0,
slabElevation = 0,
): [number, number, number] {
const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0],
)
return [
wallNode.start[0] + localX * Math.cos(wallAngle),
slabElevation + localY + levelYOffset,
wallNode.start[1] + localX * Math.sin(wallAngle),
]
}
/**
* Clamps window center position so it stays fully within wall bounds.
*/
export function clampToWall(
wallNode: WallNode,
localX: number,
localY: number,
width: number,
height: number,
): { clampedX: number; clampedY: number } {
const dx = wallNode.end[0] - wallNode.start[0]
const dz = wallNode.end[1] - wallNode.start[1]
const wallLength = Math.sqrt(dx * dx + dz * dz)
const wallHeight = wallNode.height ?? 2.5
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))
return { clampedX, clampedY }
}
/**
* Directly checks the wall's children for bounding-box overlap with a proposed window.
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
* The spatial grid only tracks `item` nodes, so windows must be checked this way.
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true // Block if wall not found
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of wallNode.children) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number, childRight: number, childBottom: number, childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1] // items store bottom Y
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2 // windows store center Y
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2 // doors store center Y
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
@@ -0,0 +1,276 @@
import {
type AnyNodeId,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
// Shared edge material — reuse across renders, just toggle color
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444, // red-500 default (invalid)
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Window tool — places WindowNodes on walls only.
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
*/
export const WindowTool: React.FC = () => {
const draftRef = useRef<WindowNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
useEffect(() => {
useScene.temporal.getState().pause()
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const destroyDraft = () => {
if (!draftRef.current) return
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
// Rebuild wall so it removes the cutout from the deleted draft
if (wallId) markWallDirty(wallId)
}
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
const levelId = getLevelId()
if (!levelId) return
// Only interact with walls on the current level
if (event.node.parentId !== levelId) return
destroyDraft()
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const width = 1.5
const height = 1.5
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
}
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY, width, height,
draftRef.current?.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node, localX, localY,
draftRef.current.width, draftRef.current.height,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
draftRef.current.width, draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
const draft = draftRef.current
draftRef.current = null
// Delete transient draft (paused, invisible to undo)
useScene.getState().deleteNode(draft.id)
// Resume → create permanent node (single undoable action)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const windowCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'window') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
}).length
const name = `Window ${windowCount + 1}`
const node = WindowNode.parse({
name,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: draft.width,
height: draft.height,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
columnRatios: draft.columnRatios,
rowRatios: draft.rowRatios,
columnDividerThickness: draft.columnDividerThickness,
rowDividerThickness: draft.rowDividerThickness,
sill: draft.sill,
sillDepth: draft.sillDepth,
sillThickness: draft.sillThickness,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
event.stopPropagation()
}
const onWallLeave = () => {
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
destroyDraft()
hideCursor()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: window outline rectangle (width × height × frameDepth)
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -0,0 +1,39 @@
import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface ZoneBoundaryEditorProps {
zoneId: ZoneNode['id']
}
/**
* Zone boundary editor - allows editing zone polygon vertices for a specific zone
* Uses the generic PolygonEditor component
*/
export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }) => {
const zoneNode = useScene((state) => state.nodes[zoneId])
const updateNode = useScene((state) => state.updateNode)
const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(zoneId, { polygon: newPolygon })
},
[zoneId, updateNode],
)
if (!zone || !zone.polygon || zone.polygon.length < 3) return null
const zoneColor = zone.color || '#3b82f6'
return (
<PolygonEditor
polygon={zone.polygon}
color={zoneColor}
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(zone, useScene.getState().nodes)}
/>
)
}
@@ -0,0 +1,360 @@
import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from "three";
import { EDITOR_LAYER } from "./../../../lib/constants";
import useEditor from "./../../../store/use-editor";
import { CursorSphere } from "../shared/cursor-sphere";
import { PALETTE_COLORS } from "./../../../components/ui/primitives/color-dot";
const Y_OFFSET = 0.02;
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/
const calculateSnapPoint = (
lastPoint: [number, number],
currentPoint: [number, number]
): [number, number] => {
const [x1, y1] = lastPoint;
const [x, y] = currentPoint;
const dx = x - x1;
const dy = y - y1;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
// Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy;
const verticalDist = absDx;
const diagonalDist = Math.abs(absDx - absDy);
// Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist);
if (minDist === diagonalDist) {
// Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy);
return [
x1 + Math.sign(dx) * diagonalLength,
y1 + Math.sign(dy) * diagonalLength,
];
} else if (minDist === horizontalDist) {
// Snap to horizontal
return [x, y1];
} else {
// Snap to vertical
return [x1, y];
}
};
/**
* Creates a zone with the given polygon points
*/
const commitZoneDrawing = (
levelId: LevelNode["id"],
points: Array<[number, number]>
) => {
const { createNode, nodes } = useScene.getState();
// Count existing zones for naming and color cycling
const zoneCount = Object.values(nodes).filter((n) => n.type === "zone").length;
const name = `Zone ${zoneCount + 1}`;
// Cycle through colors
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
const zone = ZoneNode.parse({
name,
polygon: points,
color,
});
createNode(zone, levelId);
// Select the newly created zone
useViewer.getState().setSelection({ zoneId: zone.id });
};
type PreviewState = {
points: Array<[number, number]>;
cursorPoint: [number, number] | null;
levelY: number;
};
// Helper to validate point values (no NaN or Infinity)
const isValidPoint = (
pt: [number, number] | null | undefined
): pt is [number, number] => {
if (!pt) return false;
return Number.isFinite(pt[0]) && Number.isFinite(pt[1]);
};
export const ZoneTool: React.FC = () => {
const cursorRef = useRef<Group>(null);
const mainLineRef = useRef<Line>(null!);
const closingLineRef = useRef<Line>(null!);
const pointsRef = useRef<Array<[number, number]>>([]);
const levelYRef = useRef(0); // Track current level Y position
const currentLevelId = useViewer((state) => state.selection.levelId);
const setTool = useEditor((state) => state.setTool);
// Preview state for reactive rendering (for shape and point markers)
const [preview, setPreview] = useState<PreviewState>({
points: [],
cursorPoint: null,
levelY: 0,
});
useEffect(() => {
if (!currentLevelId) return;
let cursorPosition: [number, number] = [0, 0];
// Initialize line geometries
mainLineRef.current.geometry = new BufferGeometry();
closingLineRef.current.geometry = new BufferGeometry();
const updateLines = () => {
const points = pointsRef.current;
const y = levelYRef.current + Y_OFFSET;
if (points.length === 0) {
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
return;
}
// Build main line points
const linePoints: Vector3[] = points.map(
([x, z]) => new Vector3(x, y, z)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
}
}
// Update main line geometry
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose();
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
mainLineRef.current.visible = true;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], y, snapped[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
];
closingLineRef.current.geometry.dispose();
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
closingLineRef.current.visible = true;
}
} else {
closingLineRef.current.visible = false;
}
};
const updatePreview = () => {
const points = pointsRef.current;
const lastPoint = points[points.length - 1];
let cursorPt: [number, number] | null = null;
if (lastPoint) {
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
} else if (points.length === 0) {
cursorPt = cursorPosition;
}
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
updateLines();
};
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;
cursorPosition = [gridX, gridZ];
levelYRef.current = event.position[1];
// If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
} else {
cursorRef.current.position.set(gridX, event.position[1], gridZ);
}
updatePreview();
};
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return;
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
let clickPoint: [number, number] = [gridX, gridZ];
// Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
if (lastPoint) {
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
}
// Check if clicking on the first point to close the shape
const firstPoint = pointsRef.current[0];
if (
pointsRef.current.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the zone
commitZoneDrawing(currentLevelId, pointsRef.current);
// Reset state
pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
} else {
// Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint];
updatePreview();
}
};
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return;
// Need at least 3 points to form a polygon
if (pointsRef.current.length >= 3) {
commitZoneDrawing(currentLevelId, pointsRef.current);
// Reset state
pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
}
};
// Subscribe to events
emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick);
emitter.on("grid:double-click", onGridDoubleClick);
return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick);
emitter.off("grid:double-click", onGridDoubleClick);
// Reset state on unmount
pointsRef.current = [];
};
}, [currentLevelId, setTool]);
const { points, cursorPoint, levelY } = preview;
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null;
const allPoints = [...points];
if (isValidPoint(cursorPoint)) {
allPoints.push(cursorPoint);
}
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0];
if (!isValidPoint(firstPt)) return null;
const shape = new Shape();
shape.moveTo(firstPt[0], -firstPt[1]);
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i];
if (isValidPoint(pt)) {
shape.lineTo(pt[0], -pt[1]);
}
}
shape.closePath();
return shape;
}, [points, cursorPoint]);
return (
<group>
{/* Cursor */}
<CursorSphere ref={cursorRef} />
{/* Preview fill */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line - uses native line element with TSL-compatible material */}
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={3}
depthTest={false}
depthWrite={false}
/>
</line>
{/* Closing line - uses native line element with TSL-compatible material */}
{/* @ts-ignore */}
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.5}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) =>
isValidPoint([x, z]) ? (
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
) : null
)}
</group>
);
};
@@ -0,0 +1,62 @@
import * as React from "react";
import { Button } from "./../../../components/ui/primitives/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "./../../../components/ui/primitives/tooltip";
import { cn } from "./../../../lib/utils";
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
label: string;
shortcut?: string;
isActive?: boolean;
tooltipContent?: React.ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
}
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
(
{ className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
ref
) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={ref}
className={cn(
"relative h-11 w-11 transition-all",
className
)}
{...props}
>
<div
className={cn(
"flex h-full w-full items-center justify-center transition-transform",
shortcut && "-translate-x-0.5 -translate-y-0.5"
)}
>
{children}
</div>
{shortcut && (
<div className="absolute bottom-1 right-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
{shortcut}
</span>
</div>
)}
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
{tooltipContent || (
<p>
{label} {shortcut && `(${shortcut})`}
</p>
)}
</TooltipContent>
</Tooltip>
);
}
);
ActionButton.displayName = "ActionButton";
@@ -0,0 +1,74 @@
'use client'
import { emitter } from '@pascal-app/core'
import Image from 'next/image'
import { ActionButton } from "./action-button";
export function CameraActions() {
const goToTopView = () => {
emitter.emit('camera-controls:top-view')
}
const orbitCW = () => {
emitter.emit('camera-controls:orbit-cw')
}
const orbitCCW = () => {
emitter.emit('camera-controls:orbit-ccw')
}
return (
<div className="flex items-center gap-1">
{/* Orbit CCW */}
<ActionButton
label="Orbit Left"
className="group hover:bg-white/5"
onClick={orbitCCW}
size="icon"
variant="ghost"
>
<Image
alt="Orbit Left"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
height={28}
src="/icons/rotate.png"
width={28}
/>
</ActionButton>
{/* Orbit CW */}
<ActionButton
label="Orbit Right"
className="group hover:bg-white/5"
onClick={orbitCW}
size="icon"
variant="ghost"
>
<Image
alt="Orbit Right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/rotate.png"
width={28}
/>
</ActionButton>
{/* Top View */}
<ActionButton
label="Top View"
className="group hover:bg-white/5"
onClick={goToTopView}
size="icon"
variant="ghost"
>
<Image
alt="Top View"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/topview.png"
width={28}
/>
</ActionButton>
</div>
)
}
@@ -0,0 +1,136 @@
"use client";
import Image from "next/image";
import { ActionButton } from "./action-button";
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
import { cn } from "./../../../lib/utils";
import useEditor, { Mode, Phase } from "./../../../store/use-editor";
type ModeConfig = {
id: Mode;
icon?: LucideIcon;
imageSrc?: string;
label: string;
shortcut: string;
color: string;
activeColor: string;
};
// All available control modes
const allModes: ModeConfig[] = [
{
id: "select",
imageSrc: "/icons/select.png",
label: "Select",
shortcut: "V",
color: "hover:bg-blue-500/20 hover:text-blue-400",
activeColor: "bg-blue-500/20 text-blue-400",
},
{
id: "edit",
icon: Pencil,
label: "Edit",
shortcut: "E",
color: "hover:bg-orange-500/20 hover:text-orange-400",
activeColor: "bg-orange-500/20 text-orange-400",
},
{
id: "build",
imageSrc: "/icons/build.png",
label: "Build",
shortcut: "B",
color: "hover:bg-green-500/20 hover:text-green-400",
activeColor: "bg-green-500/20 text-green-400",
},
{
id: "delete",
icon: Trash2,
label: "Delete",
shortcut: "D",
color: "hover:bg-red-500/20 hover:text-red-400",
activeColor: "bg-red-500/20 text-red-400",
},
// {
// id: 'painting',
// icon: Paintbrush,
// label: 'Painting',
// shortcut: 'P',
// color: 'hover:bg-cyan-500/20 hover:text-cyan-400',
// activeColor: 'bg-cyan-500/20 text-cyan-400',
// },
// {
// id: 'guide',
// icon: Image,
// label: 'Guide',
// shortcut: 'G',
// color: 'hover:bg-purple-500/20 hover:text-purple-400',
// activeColor: 'bg-purple-500/20 text-purple-400',
// },
];
// Define which modes are available in each editor mode
const modesByPhase: Record<Phase, Mode[]> = {
site: ["select", "edit"],
structure: ["select", "delete", "build"],
furnish: ["select", "delete", "build"],
};
export function ControlModes() {
const mode = useEditor((state) => state.mode);
const phase = useEditor((state) => state.phase);
const setMode = useEditor((state) => state.setMode);
const availableModeIds = modesByPhase[phase];
const availableModes = allModes.filter((m) =>
availableModeIds.includes(m.id)
);
const handleModeClick = (mode: Mode) => {
setMode(mode);
};
return (
<div className="flex items-center gap-1">
{availableModes.map((m) => {
const Icon = m.icon;
const isActive = mode === m.id;
const isImageMode = Boolean(m.imageSrc);
return (
<ActionButton
key={m.id}
label={m.label}
shortcut={m.shortcut}
className={cn(
"text-muted-foreground",
!isImageMode && !isActive && m.color,
!isImageMode && isActive && m.activeColor,
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
isImageMode && !isActive && "hover:bg-white/5"
)}
onClick={() => handleModeClick(m.id)}
size="icon"
variant="ghost"
>
{m.imageSrc ? (
<Image
alt={m.label}
className={cn(
"h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200",
!isActive && "opacity-60 grayscale",
isActive && "opacity-100 grayscale-0"
)}
height={28}
src={m.imageSrc}
width={28}
/>
) : (
Icon && <Icon className="h-5 w-5" />
)}
</ActionButton>
);
})}
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import NextImage from "next/image";
import { ActionButton } from "./action-button";
import { cn } from "./../../../lib/utils";
import useEditor, { CatalogCategory } from "./../../../store/use-editor";
export type FurnishToolConfig = {
id: "item";
iconSrc: string;
label: string;
catalogCategory: CatalogCategory;
};
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
export const furnishTools: FurnishToolConfig[] = [
{
id: "item",
iconSrc: "/icons/couch.png",
label: "Furniture",
catalogCategory: "furniture",
},
{
id: "item",
iconSrc: "/icons/appliance.png",
label: "Appliance",
catalogCategory: "appliance",
},
{
id: "item",
iconSrc: "/icons/kitchen.png",
label: "Kitchen",
catalogCategory: "kitchen",
},
{
id: "item",
iconSrc: "/icons/bathroom.png",
label: "Bathroom",
catalogCategory: "bathroom",
},
{
id: "item",
iconSrc: "/icons/tree.png",
label: "Outdoor",
catalogCategory: "outdoor",
},
];
export function FurnishTools() {
const mode = useEditor((state) => state.mode);
const activeTool = useEditor((state) => state.tool);
const setActiveTool = useEditor((state) => state.setTool);
const setMode = useEditor((state) => state.setMode);
const catalogCategory = useEditor((state) => state.catalogCategory);
const setCatalogCategory = useEditor((state) => state.setCatalogCategory);
const hasActiveTool = furnishTools.some((tool) =>
mode === "build" &&
activeTool === "item" &&
catalogCategory === tool.catalogCategory
);
return (
<div className="flex items-center gap-1.5 px-1">
{furnishTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match
const isActive =
mode === "build" &&
activeTool === "item" &&
catalogCategory === tool.catalogCategory;
return (
<ActionButton
key={`${tool.id}-${tool.catalogCategory ?? index}`}
label={tool.label}
className={cn(
"rounded-lg duration-300",
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
)}
onClick={() => {
if (!isActive) {
setCatalogCategory(tool.catalogCategory);
setActiveTool("item");
if (mode !== "build") {
setMode("build");
}
}
}}
size="icon"
variant="ghost"
>
<NextImage
alt={tool.label}
className="size-full object-contain"
height={28}
src={tool.iconSrc}
width={28}
/>
</ActionButton>
);
})}
</div>
);
}
@@ -0,0 +1,157 @@
"use client";
import { TooltipProvider } from "./../../../components/ui/primitives/tooltip";
import { cn } from "./../../../lib/utils";
import { CameraActions } from "./camera-actions";
import { ControlModes } from "./control-modes";
import { StructureTools } from "./structure-tools";
import useEditor from "./../../../store/use-editor";
import { useReducedMotion } from "./../../../hooks/use-reduced-motion";
import { AnimatePresence, motion } from "motion/react";
import { ItemCatalog } from "../item-catalog/item-catalog";
import { FurnishTools } from "./furnish-tools";
import { ViewToggles } from "./view-toggles";
export function ActionMenu({ className }: { className?: string }) {
const phase = useEditor((state) => state.phase);
const mode = useEditor((state) => state.mode);
const tool = useEditor((state) => state.tool);
const catalogCategory = useEditor((state) => state.catalogCategory);
const reducedMotion = useReducedMotion();
const transition = reducedMotion
? { duration: 0 }
: { type: "spring" as const, bounce: 0.2, duration: 0.4 };
return (
<TooltipProvider>
<motion.div
layout
transition={transition}
className={cn(
"-translate-x-1/2 fixed bottom-6 left-1/2 z-50",
"rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md",
"transition-colors duration-200 ease-out",
className,
)}
>
{/* Item Catalog Row - Only show when in build mode with item tool */}
<AnimatePresence>
{mode === "build" && tool === "item" && catalogCategory && (
<motion.div
className={cn(
"overflow-hidden border-border border-b px-2 py-2",
)}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
animate={{
opacity: 1,
maxHeight: 160,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<ItemCatalog key={catalogCategory} category={catalogCategory} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{phase === "furnish" && mode === "build" && (
<motion.div
className={cn(
"overflow-hidden border-border",
"max-h-20 border-b px-2 py-2 opacity-100",
)}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
animate={{
opacity: 1,
maxHeight: 80,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<div className="mx-auto w-max">
<FurnishTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Structure Tools Row - Animated */}
<AnimatePresence>
{phase === "structure" && mode === "build" && (
<motion.div
className={cn(
"overflow-hidden border-border max-h-20 border-b px-2 py-2",
)}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
animate={{
opacity: 1,
maxHeight: 80,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<div className="w-max">
<StructureTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<ViewToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
</motion.div>
</TooltipProvider>
);
}
@@ -0,0 +1,88 @@
'use client'
import NextImage from 'next/image'
import { ActionButton } from "./action-button";
import { cn } from '../../../lib/utils'
import useEditor, { CatalogCategory, StructureTool, Tool } from '../../../store/use-editor'
import { useContextualTools } from '../../../hooks/use-contextual-tools'
export type ToolConfig = {
id: StructureTool; iconSrc: string; label: string; catalogCategory?: CatalogCategory }
export const tools: ToolConfig[] = [
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
// { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
]
export function StructureTools() {
const activeTool = useEditor((state) => state.tool)
const catalogCategory = useEditor((state) => state.catalogCategory)
const structureLayer = useEditor((state) => state.structureLayer)
const setTool = useEditor((state) => state.setTool)
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
const contextualTools = useContextualTools()
// Filter tools based on structureLayer
const visibleTools = structureLayer === 'zones'
? tools.filter((t) => t.id === 'zone')
: tools.filter((t) => t.id !== 'zone')
const hasActiveTool = visibleTools.some((t) =>
activeTool === t.id &&
(t.catalogCategory ? catalogCategory === t.catalogCategory : true)
)
return (
<div className="flex items-center gap-1.5 px-1">
{visibleTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match
const isActive =
activeTool === tool.id &&
(tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
const isContextual = contextualTools.includes(tool.id)
return (
<ActionButton
key={`${tool.id}-${tool.catalogCategory ?? index}`}
label={tool.label}
className={cn(
'rounded-lg duration-300',
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
)}
onClick={() => {
if (!isActive) {
setTool(tool.id)
setCatalogCategory(tool.catalogCategory ?? null)
// Automatically switch to build mode if we select a tool
if (useEditor.getState().mode !== 'build') {
useEditor.getState().setMode('build')
}
}
}}
size="icon"
variant="ghost"
>
<NextImage
alt={tool.label}
className="size-full object-contain"
height={28}
src={tool.iconSrc}
width={28}
/>
</ActionButton>
)
})}
</div>
)
}
@@ -0,0 +1,162 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import { Box, Camera, Diamond, Image, Layers, Layers2 } from 'lucide-react'
import { ActionButton } from "./action-button";
import { cn } from '../../../lib/utils'
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
solo: 'Solo',
}
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
type WallMode = 'up' | 'cutaway' | 'down'
const wallModeConfig: Record<
WallMode,
{ icon: React.FC<React.ComponentProps<'img'>>; label: string }
> = {
up: {
icon: (props) => (
<img alt="Full Height" height={20} src="/icons/room.png" width={20} {...props} />
),
label: 'Full Height',
},
cutaway: {
icon: (props) => (
<img alt="Cutaway" height={20} src="/icons/wallcut.png" width={20} {...props} />
),
label: 'Cutaway',
},
down: {
icon: (props) => <img alt="Low" height={20} src="/icons/walllow.png" width={20} {...props} />,
label: 'Low',
},
}
const wallModeOrder: WallMode[] = ['cutaway', 'up', 'down']
export function ViewToggles() {
const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode)
const levelMode = useViewer((state) => state.levelMode)
const setLevelMode = useViewer((state) => state.setLevelMode)
const wallMode = useViewer((state) => state.wallMode)
const setWallMode = useViewer((state) => state.setWallMode)
const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans)
const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides)
const toggleCameraMode = () => {
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
}
const cycleLevelMode = () => {
if (levelMode === 'manual') {
setLevelMode('stacked')
return
}
const currentIndex = levelModeOrder.indexOf(levelMode as 'stacked' | 'exploded' | 'solo')
const nextIndex = (currentIndex + 1) % levelModeOrder.length
const nextMode = levelModeOrder[nextIndex]
if (nextMode) setLevelMode(nextMode)
}
const cycleWallMode = () => {
const currentIndex = wallModeOrder.indexOf(wallMode)
const nextIndex = (currentIndex + 1) % wallModeOrder.length
const nextMode = wallModeOrder[nextIndex]
if (nextMode) setWallMode(nextMode)
}
return (
<div className="flex items-center gap-1">
{/* Camera Mode */}
<ActionButton
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
className={cn(
cameraMode === 'orthographic'
? 'bg-violet-500/20 text-violet-400'
: 'hover:text-violet-400',
)}
onClick={toggleCameraMode}
size="icon"
variant="ghost"
>
<Camera className="h-6 w-6" />
</ActionButton>
{/* Level Mode */}
<ActionButton
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
className={cn(
levelMode !== 'stacked'
? 'bg-amber-500/20 text-amber-400'
: 'hover:text-amber-400',
)}
onClick={cycleLevelMode}
size="icon"
variant="ghost"
>
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
</ActionButton>
{/* Wall Mode */}
<ActionButton
label={`Walls: ${wallModeConfig[wallMode].label}`}
className={cn(
'p-0',
wallMode !== 'cutaway'
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)}
onClick={cycleWallMode}
size="icon"
variant="ghost"
>
{(() => {
const Icon = wallModeConfig[wallMode].icon
return <Icon className="h-[28px] w-[28px]" />
})()}
</ActionButton>
{/* Show Scans */}
<ActionButton
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
className={cn(
'p-0',
showScans
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)}
onClick={() => setShowScans(!showScans)}
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
</ActionButton>
{/* Show Guides */}
<ActionButton
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
className={cn(
'p-0',
showGuides
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)}
onClick={() => setShowGuides(!showGuides)}
size="icon"
variant="ghost"
>
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
</ActionButton>
</div>
)
}
@@ -0,0 +1,772 @@
"use client";
import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { create } from "zustand";
import {
AppWindow,
ArrowRight,
Building2,
Camera,
ChevronRight,
Copy,
DoorOpen,
Eye,
EyeOff,
FileJson,
Hexagon,
Layers,
Map,
Maximize2,
Minimize2,
Moon,
MousePointer2,
Package,
PencilLine,
Plus,
Redo2,
Search,
Square,
SquareStack,
Sun,
Trash2,
Undo2,
Video,
Box,
Grid3X3,
} from "lucide-react";
import { Dialog, DialogContent } from "./../../../components/ui/primitives/dialog";
import useEditor from "./../../../store/use-editor";
import type { StructureTool } from "./../../../store/use-editor";
import { useViewer } from "@pascal-app/viewer";
import { emitter, LevelNode, useScene } from "@pascal-app/core";
import type { AnyNodeId } from "@pascal-app/core";
import { useShallow } from "zustand/shallow";
// ---------------------------------------------------------------------------
// Open-state store — imported by icon-rail to trigger the palette
// ---------------------------------------------------------------------------
interface CommandPaletteStore {
open: boolean;
setOpen: (open: boolean) => void;
}
export const useCommandPalette = create<CommandPaletteStore>((set) => ({
open: false,
setOpen: (open) => set({ open }),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function Shortcut({ keys }: { keys: string[] }) {
return (
<span className="ml-auto flex items-center gap-0.5 shrink-0">
{keys.map((k) => (
<kbd
key={k}
className="flex items-center justify-center rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] leading-none text-muted-foreground min-w-4.5"
>
{k}
</kbd>
))}
</span>
);
}
function Item({
icon,
label,
onSelect,
shortcut,
disabled = false,
keywords = [],
badge,
navigate = false,
}: {
icon: React.ReactNode;
label: string;
onSelect: () => void;
shortcut?: string[];
disabled?: boolean;
keywords?: string[];
badge?: string;
navigate?: boolean;
}) {
return (
<Command.Item
value={label}
keywords={keywords}
onSelect={onSelect}
disabled={disabled}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
<span className="flex-1 truncate">{label}</span>
{badge && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{badge}
</span>
)}
{shortcut && <Shortcut keys={shortcut} />}
{(badge || navigate) && <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
</Command.Item>
);
}
function OptionItem({
label,
isActive = false,
onSelect,
icon,
disabled = false,
}: {
label: string;
isActive?: boolean;
onSelect: () => void;
icon?: React.ReactNode;
disabled?: boolean;
}) {
return (
<Command.Item
value={label}
onSelect={onSelect}
disabled={disabled}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{isActive
? <div className="h-1.5 w-1.5 rounded-full bg-primary" />
: icon
}
</span>
<span className="flex-1 truncate">{label}</span>
</Command.Item>
);
}
// ---------------------------------------------------------------------------
// Sub-page label map
// ---------------------------------------------------------------------------
const PAGE_LABEL: Record<string, string> = {
"wall-mode": "Wall Mode",
"level-mode": "Level Mode",
"rename-level": "Rename Level",
"goto-level": "Go to Level",
"camera-view": "Camera Snapshot",
"camera-scope": "", // dynamic — overridden in breadcrumb
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function CommandPalette() {
const { open, setOpen } = useCommandPalette();
const [meta, setMeta] = useState("⌘");
const [isFullscreen, setIsFullscreen] = useState(false);
const [pages, setPages] = useState<string[]>([]);
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 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 allLevels = useScene(
useShallow((s) =>
(Object.values(s.nodes).filter((n) => n.type === "level") as LevelNode[]).sort(
(a, b) => a.level - b.level
)
)
);
const hasSelection = selection.selectedIds.length > 0;
// Platform detection
useEffect(() => {
setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘" : "Ctrl");
}, []);
// Fullscreen tracking
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
// Cmd/Ctrl+K global shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(true);
}
};
window.addEventListener("keydown", handler);
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", down: "Down" };
const levelModeLabel: Record<"manual" | "stacked" | "exploded" | "solo", string> = {
manual: "Manual",
stacked: "Stacked",
exploded: "Exploded",
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}` });
});
};
const confirmRename = () => {
if (!activeLevelId || !inputValue.trim()) return;
run(() => {
useScene.getState().updateNode(activeLevelId as AnyNodeId, { name: inputValue.trim() } as any);
});
};
// 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 }));
};
const viewSnapshot = () => {
if (!cameraScope || !hasScopeSnapshot) return;
run(() => emitter.emit("camera-controls:view", { nodeId: cameraScope.nodeId as AnyNodeId }));
};
const clearSnapshot = () => {
if (!cameraScope || !hasScopeSnapshot) return;
run(() => {
useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any);
});
};
// 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);
});
const copyShareLink = () =>
run(() => {
navigator.clipboard.writeText(window.location.href);
});
const takeScreenshot = () =>
run(() => {
const canvas = document.querySelector("canvas");
if (!canvas) return;
const a = Object.assign(document.createElement("a"), {
href: canvas.toDataURL("image/png"),
download: `screenshot_${new Date().toISOString().split("T")[0]}.png`,
});
a.click();
});
const toggleFullscreen = () =>
run(() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
});
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
showCloseButton={false}
className="p-0 gap-0 max-w-lg overflow-hidden"
>
<Command
shouldFilter={page !== "rename-level"}
className="**:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-3 **:[[cmdk-group-heading]]:text-[10px] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === "Backspace" && !inputValue && pages.length > 0) {
e.preventDefault();
goBack();
}
}}
>
{/* Search bar */}
<div className="flex items-center border-b border-border/50 px-3">
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
{page && (
<button
type="button"
onClick={goBack}
className="mr-2 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/70 transition-colors"
>
{page === "camera-scope"
? (cameraScope?.label ?? "Snapshot")
: (PAGE_LABEL[page] ?? page)}
</button>
)}
<Command.Input
value={inputValue}
onValueChange={setInputValue}
className="flex h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder={
page === "rename-level"
? "Type a new name…"
: page
? "Filter options…"
: "Search actions…"
}
/>
</div>
<Command.List className="max-h-100 overflow-y-auto p-1.5">
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
No commands found.
</Command.Empty>
{/* ── Root view ─────────────────────────────────────────────── */}
{!page && (
<>
{/* Scene / Tools */}
<Command.Group heading="Scene">
<Item icon={<Square className="h-4 w-4" />} label="Wall Tool" onSelect={() => activateTool("wall")} keywords={["draw", "build", "structure"]} />
<Item icon={<Layers className="h-4 w-4" />} label="Slab Tool" onSelect={() => activateTool("slab")} keywords={["floor", "build"]} />
<Item icon={<Grid3X3 className="h-4 w-4" />} label="Ceiling Tool" onSelect={() => activateTool("ceiling")} keywords={["top", "build"]} />
<Item icon={<DoorOpen className="h-4 w-4" />} label="Door Tool" onSelect={() => activateTool("door")} keywords={["opening", "entrance"]} />
<Item icon={<AppWindow className="h-4 w-4" />} label="Window Tool" onSelect={() => activateTool("window")} keywords={["opening", "glass"]} />
<Item icon={<Package className="h-4 w-4" />} label="Item Tool" onSelect={() => activateTool("item")} keywords={["furniture", "object", "asset", "furnish"]} />
<Item icon={<Hexagon className="h-4 w-4" />} label="Zone Tool" onSelect={() => activateTool("zone")} keywords={["area", "room", "space"]} />
<Item
icon={<Trash2 className="h-4 w-4" />}
label="Delete Selection"
onSelect={deleteSelection}
disabled={!hasSelection}
shortcut={["⌫"]}
keywords={["remove", "erase"]}
/>
</Command.Group>
{/* Levels */}
<Command.Group heading="Levels">
<Item
icon={<ArrowRight className="h-4 w-4" />}
label="Go to Level"
navigate
onSelect={() => navigateTo("goto-level")}
disabled={allLevels.length === 0}
keywords={["level", "floor", "go", "navigate", "switch", "select"]}
/>
<Item
icon={<Plus className="h-4 w-4" />}
label="Add Level"
onSelect={addLevel}
keywords={["level", "floor", "add", "create", "new"]}
/>
<Item
icon={<PencilLine className="h-4 w-4" />}
label="Rename Level"
navigate
onSelect={() => navigateTo("rename-level")}
disabled={!activeLevelId}
keywords={["level", "floor", "rename", "name"]}
/>
<Item
icon={<Trash2 className="h-4 w-4" />}
label="Delete Level"
onSelect={deleteActiveLevel}
disabled={!activeLevelId || isLevelZero}
keywords={["level", "floor", "delete", "remove"]}
/>
</Command.Group>
{/* Viewer Controls */}
<Command.Group heading="Viewer Controls">
<Item
icon={<Layers className="h-4 w-4" />}
label="Wall Mode"
badge={wallModeLabel[wallMode]}
onSelect={() => navigateTo("wall-mode")}
keywords={["wall", "cutaway", "up", "down", "view"]}
/>
<Item
icon={<SquareStack className="h-4 w-4" />}
label="Level Mode"
badge={levelModeLabel[levelMode]}
onSelect={() => navigateTo("level-mode")}
keywords={["level", "floor", "exploded", "stacked", "solo"]}
/>
<Item
icon={<Video className="h-4 w-4" />}
label={`Camera: Switch to ${cameraMode === "perspective" ? "Orthographic" : "Perspective"}`}
onSelect={() =>
run(() =>
setCameraMode(
cameraMode === "perspective" ? "orthographic" : "perspective"
)
)
}
keywords={["camera", "ortho", "perspective", "2d", "3d", "view"]}
/>
<Item
icon={theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
label={theme === "dark" ? "Switch to Light Theme" : "Switch to Dark Theme"}
onSelect={() => run(() => setTheme(theme === "dark" ? "light" : "dark"))}
keywords={["theme", "dark", "light", "appearance", "color"]}
/>
<Item
icon={<Camera className="h-4 w-4" />}
label="Camera Snapshot"
navigate
onSelect={() => navigateTo("camera-view")}
keywords={["camera", "snapshot", "capture", "save", "view", "bookmark"]}
/>
</Command.Group>
{/* View / Mode */}
<Command.Group heading="View">
<Item
icon={isPreviewMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
label={isPreviewMode ? "Exit Preview" : "Enter Preview"}
onSelect={() => run(() => setPreviewMode(!isPreviewMode))}
keywords={["preview", "view", "read-only", "present"]}
/>
<Item
icon={
isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)
}
label={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
onSelect={toggleFullscreen}
keywords={["fullscreen", "maximize", "expand", "window"]}
/>
</Command.Group>
{/* History */}
<Command.Group heading="History">
<Item
icon={<Undo2 className="h-4 w-4" />}
label="Undo"
onSelect={() => run(() => useScene.temporal.getState().undo())}
shortcut={[meta, "Z"]}
keywords={["undo", "revert", "back"]}
/>
<Item
icon={<Redo2 className="h-4 w-4" />}
label="Redo"
onSelect={() => run(() => useScene.temporal.getState().redo())}
shortcut={[meta, "⇧", "Z"]}
keywords={["redo", "forward", "repeat"]}
/>
</Command.Group>
{/* Export / Share */}
<Command.Group heading="Export & Share">
<Item
icon={<FileJson className="h-4 w-4" />}
label="Export Scene (JSON)"
onSelect={exportJson}
keywords={["export", "download", "json", "save", "data"]}
/>
{exportScene && (
<Item
icon={<Box className="h-4 w-4" />}
label="Export 3D Model (GLB)"
onSelect={() => run(() => exportScene())}
keywords={["export", "glb", "gltf", "3d", "model", "download"]}
/>
)}
<Item
icon={<Copy className="h-4 w-4" />}
label="Copy Share Link"
onSelect={copyShareLink}
keywords={["share", "copy", "url", "link"]}
/>
<Item
icon={<Camera className="h-4 w-4" />}
label="Take Screenshot"
onSelect={takeScreenshot}
keywords={["screenshot", "capture", "image", "photo", "png"]}
/>
</Command.Group>
</>
)}
{/* ── Wall Mode sub-page ────────────────────────────────────── */}
{page === "wall-mode" && (
<Command.Group heading="Wall Mode">
{(["cutaway", "up", "down"] as const).map((mode) => (
<OptionItem
key={mode}
label={wallModeLabel[mode]}
isActive={wallMode === mode}
onSelect={() => run(() => setWallMode(mode))}
/>
))}
</Command.Group>
)}
{/* ── Level Mode sub-page ───────────────────────────────────── */}
{page === "level-mode" && (
<Command.Group heading="Level Mode">
{(["stacked", "exploded", "solo"] as const).map((mode) => (
<OptionItem
key={mode}
label={levelModeLabel[mode]}
isActive={levelMode === mode}
onSelect={() => run(() => setLevelMode(mode))}
/>
))}
</Command.Group>
)}
{/* ── Go to Level sub-page ──────────────────────────────────── */}
{page === "goto-level" && (
<Command.Group heading="Go to Level">
{allLevels.map((level) => (
<OptionItem
key={level.id}
label={level.name ?? `Level ${level.level}`}
isActive={level.id === activeLevelId}
onSelect={() =>
run(() => useViewer.getState().setSelection({ levelId: level.id }))
}
/>
))}
</Command.Group>
)}
{/* ── Rename Level sub-page ─────────────────────────────────── */}
{page === "rename-level" && (
<Command.Group heading="Rename Level">
<Command.Item
value="confirm-rename"
onSelect={confirmRename}
disabled={!inputValue.trim()}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
<PencilLine className="h-4 w-4" />
</span>
<span className="flex-1 truncate">
{inputValue.trim() ? (
<>Rename to <span className="font-medium">"{inputValue.trim()}"</span></>
) : (
<span className="text-muted-foreground">Type a new name above</span>
)}
</span>
</Command.Item>
</Command.Group>
)}
{/* ── Camera Snapshot: scope picker ─────────────────────────── */}
{page === "camera-view" && (
<Command.Group heading="Camera Snapshot — Select Scope">
<OptionItem
label="Site"
icon={<Map className="h-4 w-4" />}
onSelect={() => {
const { rootNodeIds } = useScene.getState();
const siteId = rootNodeIds[0];
if (siteId) navigateToCameraScope(siteId, "Site");
}}
/>
<OptionItem
label="Building"
icon={<Building2 className="h-4 w-4" />}
onSelect={() => {
const building = Object.values(useScene.getState().nodes).find(
(n) => n.type === "building"
);
if (building) navigateToCameraScope(building.id, "Building");
}}
/>
<OptionItem
label="Level"
icon={<Layers className="h-4 w-4" />}
disabled={!activeLevelId}
onSelect={() => {
if (activeLevelId) navigateToCameraScope(activeLevelId, "Level");
}}
/>
<OptionItem
label="Selection"
icon={<MousePointer2 className="h-4 w-4" />}
disabled={!hasSelection}
onSelect={() => {
const firstId = selection.selectedIds[0];
if (firstId) navigateToCameraScope(firstId, "Selection");
}}
/>
</Command.Group>
)}
{/* ── Camera Snapshot: actions for selected scope ───────────── */}
{page === "camera-scope" && cameraScope && (
<Command.Group heading={`${cameraScope.label} Snapshot`}>
<OptionItem
label={hasScopeSnapshot ? "Update Snapshot" : "Take Snapshot"}
icon={<Camera className="h-4 w-4" />}
onSelect={takeSnapshot}
/>
{hasScopeSnapshot && (
<OptionItem
label="View Snapshot"
icon={<Eye className="h-4 w-4" />}
onSelect={viewSnapshot}
/>
)}
{hasScopeSnapshot && (
<OptionItem
label="Clear Snapshot"
icon={<Trash2 className="h-4 w-4" />}
onSelect={clearSnapshot}
/>
)}
</Command.Group>
)}
</Command.List>
{/* Footer hint */}
<div className="flex items-center justify-between border-t border-border/50 px-3 py-2">
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["↑", "↓"]} /> navigate
</span>
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["↵"]} /> select
</span>
{page ? (
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["⌫"]} /> back
</span>
) : (
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["Esc"]} /> close
</span>
)}
</div>
</Command>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,31 @@
'use client'
import { cn } from '../../../lib/utils'
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon?: React.ReactNode
label: string
}
export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
return (
<button
{...props}
className={cn(
"flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-xs font-medium text-foreground transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]",
className
)}
>
{icon}
<span>{label}</span>
</button>
)
}
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex gap-1.5", className)}>
{children}
</div>
)
}
@@ -0,0 +1,246 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '../../../lib/utils'
interface MetricControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
}
export function MetricControl({
label,
value,
onChange,
min = -Infinity,
max = Infinity,
precision = 2,
step = 1,
className,
unit = '',
}: MetricControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const startXRef = useRef(0)
const startValueRef = useRef(0)
const containerRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
}
}, [value, precision, isEditing])
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleWheel = (e: WheelEvent) => {
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
let direction = 0
if (e.key === 'ArrowUp') direction = 1
else if (e.key === 'ArrowDown') direction = -1
if (direction !== 0) {
e.preventDefault()
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isEditing) return
e.preventDefault()
setIsDragging(true)
startXRef.current = e.clientX
startValueRef.current = value
useScene.temporal.getState().pause()
let finalValue = value
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startXRef.current
let dragStep = step
if (moveEvent.shiftKey) dragStep = step * 10
else if (moveEvent.altKey) dragStep = step * 0.1
const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
if (newFinalValue !== finalValue) {
finalValue = newFinalValue
onChange(finalValue)
}
}
const handlePointerUp = () => {
setIsDragging(false)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (finalValue !== startValueRef.current) {
onChange(startValueRef.current)
useScene.temporal.getState().resume()
onChange(finalValue)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, value, onChange, clamp, precision, step]
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
} else {
setInputValue(value.toFixed(precision))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
},
[submitValue, value, precision, step, clamp, onChange],
)
return (
<div
ref={containerRef}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn("group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
>
<div
className={cn(
"text-muted-foreground select-none truncate transition-colors",
isDragging ? "cursor-ew-resize text-foreground" : "hover:text-foreground hover:cursor-ew-resize"
)}
onPointerDown={handlePointerDown}
>
{label}
</div>
<div className="flex shrink-0 justify-end">
{isEditing ? (
<div className="flex items-center">
<input
autoFocus
className="w-full bg-transparent p-0 text-right text-foreground font-mono outline-none selection:bg-primary/30"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground hover:text-primary transition-colors"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,67 @@
'use client'
import { cn } from '../../../lib/utils'
import { ChevronDown } from 'lucide-react'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
interface PanelSectionProps {
title: string
children: React.ReactNode
defaultExpanded?: boolean
className?: string
}
export function PanelSection({
title,
children,
defaultExpanded = true,
className,
}: PanelSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
return (
<motion.div
layout
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
>
<motion.button
layout="position"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"group/section flex items-center justify-between h-10 px-3 transition-all duration-200 shrink-0",
isExpanded
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<span className="font-medium text-sm truncate">{title}</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
isExpanded ? "rotate-180" : "rotate-0",
isExpanded ? "text-foreground" : "opacity-0 group-hover/section:opacity-100"
)}
/>
</motion.button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-1.5 p-3 pt-2">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -0,0 +1,40 @@
'use client'
import { cn } from '../../../lib/utils'
interface SegmentedControlProps<T extends string> {
value: T
onChange: (value: T) => void
options: { label: React.ReactNode; value: T }[]
className?: string
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
className,
}: SegmentedControlProps<T>) {
return (
<div className={cn("flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]", className)}>
{options.map((option) => {
const isSelected = value === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"relative flex h-full flex-1 items-center justify-center rounded-md text-xs font-medium transition-all duration-200",
isSelected
? "bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
)}
>
<span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
</button>
)
})}
</div>
)
}
@@ -0,0 +1,319 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '../../../lib/utils'
interface SliderControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
}
export function SliderControl({
label,
value,
onChange,
min = 0,
max = 100,
precision = 0,
step = 1,
className,
unit = '',
}: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
// Track the original value and bounds when dragging starts
const [dragStartValue, setDragStartValue] = useState<number | null>(null)
const [dragMin, setDragMin] = useState<number | null>(null)
const [dragMax, setDragMax] = useState<number | null>(null)
const trackRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
}
}, [value, precision, isEditing])
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleWheel = (e: WheelEvent) => {
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
let direction = 0
if (e.key === 'ArrowUp') direction = 1
else if (e.key === 'ArrowDown') direction = -1
if (direction !== 0) {
e.preventDefault()
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isEditing) return
e.preventDefault()
const track = trackRef.current
if (!track) return
setIsDragging(true)
setDragStartValue(value)
setDragMin(min)
setDragMax(max)
useScene.temporal.getState().pause()
const rect = track.getBoundingClientRect()
const updateValueFromEvent = (clientX: number) => {
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
const rawValue = min + percent * (max - min)
// snap to step
const snapped = Math.round(rawValue / step) * step
const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision))
onChange(finalValue)
}
updateValueFromEvent(e.clientX)
const handlePointerMove = (moveEvent: PointerEvent) => {
updateValueFromEvent(moveEvent.clientX)
}
const handlePointerUp = (e: PointerEvent) => {
// Only stop dragging if we didn't release on the reset button
// Let the reset button's onPointerDown handle its own cleanup
if ((e.target as HTMLElement).closest('button')) {
return
}
setIsDragging(false)
const startVal = dragStartValue
const finalVal = valueRef.current
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (startVal !== null && startVal !== finalVal) {
// Revert to start value while paused so the undo baseline is clean
onChange(startVal)
useScene.temporal.getState().resume()
// Apply final value while recording
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, min, max, step, precision, clamp, onChange]
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
} else {
setInputValue(value.toFixed(precision))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
},
[submitValue, value, precision, step, clamp, onChange],
)
const currentMin = isDragging && dragMin !== null ? dragMin : min
const currentMax = isDragging && dragMax !== null ? dragMax : max
const percent = Math.max(0, Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100))
const startPercent = dragStartValue !== null ? Math.max(0, Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100)) : null
return (
<div
ref={containerRef}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn("group flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors relative", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
>
{/* Reset button that appears when dragged away from start */}
{isDragging && dragStartValue !== null && dragStartValue !== value && (
<button
className="absolute -top-10 right-0 rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] font-medium text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground z-50 pointer-events-auto cursor-pointer"
onPointerDown={(e) => {
e.stopPropagation()
onChange(dragStartValue)
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
setIsDragging(false)
useScene.temporal.getState().resume()
}}
>
Reset
</button>
)}
<div className="w-[80px] shrink-0 text-muted-foreground select-none truncate">
{label}
</div>
<div
ref={trackRef}
className={cn(
"relative flex h-full flex-1 items-center justify-center touch-none mx-2",
isDragging ? "cursor-grabbing" : "cursor-grab"
)}
onPointerDown={handlePointerDown}
>
{/* Track dots background */}
<div className="absolute inset-x-0 flex items-center justify-between opacity-30 px-1 pointer-events-none">
{[...Array(9)].map((_, i) => (
<div key={i} className="h-[3px] w-[3px] rounded-full bg-current" />
))}
</div>
{/* Original Thumb Ghost */}
{isDragging && startPercent !== null && (
<div
className="absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm bg-foreground/20 pointer-events-none"
style={{ left: `${startPercent}%` }}
/>
)}
{/* Active Thumb */}
<div
className={cn(
"absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition pointer-events-none",
isDragging ? "bg-foreground scale-y-110" : "bg-foreground/60 group-hover:bg-foreground/80"
)}
style={{ left: `${percent}%` }}
/>
</div>
<div className="flex w-[50px] shrink-0 justify-end">
{isEditing ? (
<div className="flex items-center">
<input
autoFocus
className="w-full bg-transparent p-0 text-right text-foreground font-mono outline-none selection:bg-primary/30"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground/60 hover:text-foreground transition-colors"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,40 @@
'use client'
import { cn } from '../../../lib/utils'
import { Check } from 'lucide-react'
interface ToggleControlProps {
label: string
checked: boolean
onChange: (checked: boolean) => void
className?: string
}
export function ToggleControl({
label,
checked,
onChange,
className,
}: ToggleControlProps) {
return (
<div
className={cn("group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]", className)}
onClick={() => onChange(!checked)}
>
<div className="text-muted-foreground transition-colors group-hover:text-foreground select-none">
{label}
</div>
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200",
checked
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-black/20 text-transparent group-hover:border-muted-foreground"
)}
>
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</div>
</div>
)
}
@@ -0,0 +1,14 @@
export function CeilingHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -0,0 +1,33 @@
'use client'
import useEditor from '../../../store/use-editor'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper'
import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() {
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
if (movingNode) {
return <ItemHelper showEsc />
}
// Show appropriate helper based on current tool
switch (tool) {
case 'wall':
return <WallHelper />
case 'item':
return <ItemHelper />
case 'slab':
return <SlabHelper />
case 'ceiling':
return <CeilingHelper />
case 'roof':
return <RoofHelper />
default:
return null
}
}
@@ -0,0 +1,28 @@
interface ItemHelperProps {
showEsc?: boolean
}
export function ItemHelper({ showEsc }: ItemHelperProps) {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">R</kbd>
<span className="text-muted-foreground">Rotate counterclockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd>
<span className="text-muted-foreground">Rotate clockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Free place</span>
</div>
{showEsc && (
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,10 @@
export function RoofHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -0,0 +1,14 @@
export function SlabHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -0,0 +1,14 @@
export function WallHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
"use client";
import { AssetInput } from "@pascal-app/core";
import { resolveCdnUrl } from "@pascal-app/viewer";
import Image from "next/image";
import { useEffect, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "./../../../components/ui/primitives/tooltip";
import { cn } from "./../../../lib/utils";
import useEditor, { CatalogCategory } from "./../../../store/use-editor";
import { CATALOG_ITEMS } from "./catalog-items";
const PLACEMENT_TAGS = new Set(["floor", "wall", "ceiling", "countertop"]);
export function ItemCatalog({ category }: { category: CatalogCategory }) {
const selectedItem = useEditor((state) => state.selectedItem);
const setSelectedItem = useEditor((state) => state.setSelectedItem);
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null);
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null);
const categoryItems = CATALOG_ITEMS.filter(
(item) => item.category === category,
);
// Collect tags available in this category
const allTags = Array.from(
new Set(categoryItems.flatMap((item) => item.tags ?? [])),
);
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t));
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t));
const hasFilters = allTags.length > 1;
// Count items for a placement tag given the current functional filter
const placementCount = (tag: string | null) =>
categoryItems.filter((item) => {
const tags = item.tags ?? [];
if (tag !== null && !tags.includes(tag)) return false;
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false;
return true;
}).length;
// Count items for a functional tag given the current placement filter
const functionalCount = (tag: string) =>
categoryItems.filter((item) => {
const tags = item.tags ?? [];
if (!tags.includes(tag)) return false;
if (activePlacementTag && !tags.includes(activePlacementTag)) return false;
return true;
}).length;
const filteredItems = categoryItems.filter((item) => {
const tags = item.tags ?? [];
if (activePlacementTag && !tags.includes(activePlacementTag)) return false;
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false;
return true;
});
// Auto-select first item if current selection is not in the filtered list
useEffect(() => {
const isCurrentItemInCategory = filteredItems.some(
(item) => item.src === selectedItem?.src,
);
if (!isCurrentItemInCategory && filteredItems.length > 0) {
setSelectedItem(filteredItems[0] as AssetInput);
}
}, [filteredItems, selectedItem?.src, setSelectedItem]);
// Get attachment icon based on attachTo type
const getAttachmentIcon = (attachTo: AssetInput["attachTo"]) => {
if (attachTo === "wall" || attachTo === "wall-side") {
return "/icons/wall.png";
}
if (attachTo === "ceiling") {
return "/icons/ceiling.png";
}
return null;
};
return (
<div className="flex flex-col gap-2">
{/* Filter chips */}
{hasFilters && (
<div className="flex flex-col gap-1.5">
{/* Placement row */}
{placementTags.length > 0 && (
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => setActivePlacementTag(null)}
className={cn(
"cursor-pointer rounded-md px-2 py-0.5 text-xs font-medium transition-colors",
activePlacementTag === null
? "bg-blue-500 text-white"
: "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
)}
>
All
</button>
{placementTags.map((tag) => {
const count = placementCount(tag);
const isActive = activePlacementTag === tag;
const isEmpty = count === 0 && !isActive;
return (
<button
key={tag}
type="button"
disabled={isEmpty}
onClick={() => setActivePlacementTag(isActive ? null : tag)}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
isActive
? "bg-blue-500 text-white"
: isEmpty
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
: "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
)}
>
{tag}
<span className={cn("text-[10px]", isActive ? "text-blue-200" : isEmpty ? "text-zinc-600" : "text-blue-500/70")}>
{count}
</span>
</button>
);
})}
</div>
)}
{/* Functional row */}
{functionalTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{functionalTags.map((tag) => {
const count = functionalCount(tag);
const isActive = activeFunctionalTag === tag;
const isEmpty = count === 0 && !isActive;
return (
<button
key={tag}
type="button"
disabled={isEmpty}
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
isActive
? "bg-violet-500 text-white"
: isEmpty
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
{tag}
<span className={cn("text-[10px]", isActive ? "text-violet-200" : isEmpty ? "text-zinc-600" : "text-zinc-500/70")}>
{count}
</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Items */}
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src;
const attachmentIcon = getAttachmentIcon(item?.attachTo);
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
className={cn(
"relative aspect-square min-w-14 min-h-14 h-14 w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer",
isSelected && "ring-2 ring-primary-foreground",
)}
onClick={() => setSelectedItem(item)}
type="button"
>
<Image
alt={item.name}
className="rounded-lg object-cover"
fill
src={resolveCdnUrl(item.thumbnail) || ""}
/>
{attachmentIcon && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<Image
alt={
item.attachTo === "ceiling"
? "Ceiling attachment"
: "Wall attachment"
}
className="h-4 w-4"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs" side="top">
{item.name}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,218 @@
'use client'
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import useEditor from '../../../store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton } from '../controls/action-button'
export function CeilingPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<CeilingNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
useEffect(() => {
return () => {
setEditingHole(null)
}
}, [setEditingHole])
const handleAddHole = useCallback(() => {
if (!node || !selectedId) return
const polygon = node.polygon
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
},
[selectedId, setEditingHole],
)
const handleDeleteHole = useCallback(
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]![0] * polygon[j]![1]
area -= polygon[j]![0] * polygon[i]![1]
}
return Math.abs(area) / 2
}
const area = calculateArea(node.polygon)
return (
<PanelWrapper
title={node.name || "Ceiling"}
icon="/icons/ceiling.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Height">
<SliderControl
label="Height"
value={Math.round(node.height * 1000) / 1000}
onChange={(v) => handleUpdate({ height: v })}
min={0}
max={6}
precision={3}
step={0.01}
unit="m"
/>
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
</div>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
key={index}
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes
</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,295 @@
'use client'
import type { AnyNodeId, Collection, CollectionId } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react'
import { useState } from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../../../components/ui/primitives/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '../../../../components/ui/primitives/popover'
import { ColorDot } from '../../../../components/ui/primitives/color-dot'
import { cn } from '../../../../lib/utils'
interface CollectionsPopoverProps {
nodeId: AnyNodeId
collectionIds?: CollectionId[]
children: React.ReactNode
}
export function CollectionsPopover({ nodeId, collectionIds, children }: CollectionsPopoverProps) {
const collections = useScene((s) => s.collections)
const nodes = useScene((s) => s.nodes)
const createCollection = useScene((s) => s.createCollection)
const deleteCollection = useScene((s) => s.deleteCollection)
const updateCollection = useScene((s) => s.updateCollection)
const addToCollection = useScene((s) => s.addToCollection)
const removeFromCollection = useScene((s) => s.removeFromCollection)
const [open, setOpen] = useState(false)
const [showCreateInput, setShowCreateInput] = useState(false)
const [createName, setCreateName] = useState('')
const [renamingId, setRenamingId] = useState<CollectionId | null>(null)
const [renameValue, setRenameValue] = useState('')
const [renameColor, setRenameColor] = useState('')
const [deletingId, setDeletingId] = useState<CollectionId | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<CollectionId>>(new Set())
const memberIds = collectionIds ?? []
const allCollections = Object.values(collections)
const handleCreate = () => {
if (!createName.trim()) return
createCollection(createName.trim(), [nodeId])
setCreateName('')
setShowCreateInput(false)
}
const handleRenameConfirm = (id: CollectionId) => {
if (!renameValue.trim()) return
updateCollection(id, { name: renameValue.trim(), color: renameColor || undefined })
setRenamingId(null)
}
const toggleMembership = (collectionId: CollectionId) => {
if (memberIds.includes(collectionId)) {
removeFromCollection(collectionId, nodeId)
} else {
addToCollection(collectionId, nodeId)
}
}
const toggleExpand = (collectionId: CollectionId) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(collectionId)) next.delete(collectionId)
else next.add(collectionId)
return next
})
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="left"
align="start"
sideOffset={8}
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground tracking-tight">Collections</span>
</div>
<button
type="button"
onClick={() => { setShowCreateInput((v) => !v); setCreateName('') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
>
<Plus className="h-3 w-3" />
New
</button>
</div>
{/* Create input */}
{showCreateInput && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
<input
autoFocus
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
if (e.key === 'Escape') { setShowCreateInput(false); setCreateName('') }
}}
placeholder="Collection name…"
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
type="button"
disabled={!createName.trim()}
onClick={handleCreate}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { setShowCreateInput(false); setCreateName('') }}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Collections list */}
<div className="max-h-72 overflow-y-auto no-scrollbar">
{allCollections.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
<Layers className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">
No collections yet. Create one to group items together.
</p>
</div>
) : (
<ul className="divide-y divide-border/30">
{allCollections.map((collection) => {
const isIn = memberIds.includes(collection.id)
const isExpanded = expandedIds.has(collection.id)
const isRenaming = renamingId === collection.id
const isDeleting = deletingId === collection.id
if (isDeleting) {
return (
<li key={collection.id} className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
<span className="text-xs text-foreground/80 truncate">Delete "{collection.name}"?</span>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => { deleteCollection(collection.id); setDeletingId(null) }}
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
Delete
</button>
<button
type="button"
onClick={() => setDeletingId(null)}
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
>
Cancel
</button>
</div>
</li>
)
}
if (isRenaming) {
return (
<li key={collection.id} className="flex items-center gap-1.5 px-3 py-2">
<ColorDot color={renameColor || '#6366f1'} onChange={setRenameColor} />
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameConfirm(collection.id)
if (e.key === 'Escape') setRenamingId(null)
}}
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
type="button"
onClick={() => handleRenameConfirm(collection.id)}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setRenamingId(null)}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
)
}
return (
<li key={collection.id}>
<div className="group flex items-center gap-2 px-3 py-2 hover:bg-white/5 transition-colors">
{/* Color dot — click to pick color */}
<ColorDot
color={collection.color ?? '#6366f1'}
onChange={(c) => updateCollection(collection.id, { color: c })}
/>
{/* Name + count — clicking toggles membership */}
<button
type="button"
onClick={() => toggleMembership(collection.id)}
className="flex-1 min-w-0 flex items-center gap-1.5 text-left"
>
<span className={cn('truncate text-xs font-medium', isIn ? 'text-foreground' : 'text-muted-foreground')}>
{collection.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground/60">
{collection.nodeIds.length}
</span>
</button>
{/* Membership check */}
<div
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors pointer-events-none',
isIn ? 'border-primary bg-primary/20 text-primary' : 'border-border/50',
)}
>
{isIn && <Check className="h-2.5 w-2.5" />}
</div>
{/* Expand toggle (only if has members) */}
{collection.nodeIds.length > 0 && (
<button
type="button"
onClick={() => toggleExpand(collection.id)}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded
? <ChevronDown className="h-3 w-3" />
: <ChevronRight className="h-3 w-3" />}
</button>
)}
{/* More dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="left" align="start" className="min-w-40">
<DropdownMenuItem onClick={() => { setRenamingId(collection.id); setRenameValue(collection.name); setRenameColor(collection.color ?? '') }}>
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => setDeletingId(collection.id)}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Expanded member list */}
{isExpanded && (
<ul className="pb-1 pl-6 pr-3 flex flex-col gap-0.5">
{collection.nodeIds.map((nid) => {
const n = nodes[nid]
return (
<li key={nid} className="flex items-center gap-1.5 py-0.5">
<span className="h-1 w-1 rounded-full bg-muted-foreground/40 shrink-0" />
<span className={cn('truncate text-[11px]', nid === nodeId ? 'text-foreground font-medium' : 'text-muted-foreground')}>
{n?.name ?? nid}
</span>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ul>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,550 @@
'use client'
import { type AnyNode, type AnyNodeId, DoorNode, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ToggleControl } from '../controls/toggle-control'
import { SegmentedControl } from '../controls/segmented-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PresetsPopover } from './presets/presets-popover'
export function DoorPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleFlip = useCallback(() => {
if (!node) return
handleUpdate({
side: node.side === 'front' ? 'back' : 'front',
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
})
}, [node, handleUpdate])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [selectedId, node, deleteNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const cloned = structuredClone(node) as any
delete cloned.id
cloned.metadata = { ...cloned.metadata, isNew: true }
const duplicate = DoorNode.parse(cloned)
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
const numSegs = node!.segments.length
const totalH = node!.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node!.segments.map(s => s.heightRatio / totalH)
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]!
const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta)
const newRatios = normH.map((v, i) => {
if (i === segIdx) return clamped
if (i === neighborIdx) return neighborVal
return v
})
const updated = node!.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
handleUpdate({ segments: updated })
}
const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {
const seg = node!.segments[segIdx]!
const normRatios = (() => {
const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
return seg.columnRatios.map(r => r / sum)
})()
const numCols = normRatios.length
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1
const delta = clamped - normRatios[colIdx]!
const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta)
const newRatios = normRatios.map((v, i) => {
if (i === colIdx) return clamped
if (i === neighborIdx) return neighborVal
return v
})
const updated = node!.segments.map((s, idx) =>
idx === segIdx ? { ...s, columnRatios: newRatios } : s,
)
handleUpdate({ segments: updated })
}
const getDoorPresetData = useCallback(() => {
if (!node) return null
return {
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
contentPadding: node.contentPadding,
hingesSide: node.hingesSide,
swingDirection: node.swingDirection,
threshold: node.threshold,
thresholdHeight: node.thresholdHeight,
handle: node.handle,
handleHeight: node.handleHeight,
handleSide: node.handleSide,
doorCloser: node.doorCloser,
panicBar: node.panicBar,
panicBarHeight: node.panicBarHeight,
segments: node.segments,
}
}, [node])
const handleSavePreset = useCallback(async (name: string) => {
const data = getDoorPresetData()
if (!data || !selectedId) return
const res = await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'door', name, data }),
})
if (res.ok) {
const json = await res.json()
const presetId = json.preset?.id
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
}
}, [getDoorPresetData, selectedId])
const handleOverwritePreset = useCallback(async (id: string) => {
const data = getDoorPresetData()
if (!data || !selectedId) return
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
}, [getDoorPresetData, selectedId])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<DoorNode>)
}, [handleUpdate])
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
return (
<PanelWrapper
title={node.name || "Door"}
icon="/icons/door.png"
onClose={handleClose}
width={320}
>
{/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="door" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
<BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span>
</button>
</PresetsPopover>
</div>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<div className="pt-2 pb-1 px-1">
<ActionButton
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
className="w-full"
/>
</div>
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.5}
max={3}
precision={2}
step={0.05}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
min={1.0}
max={4}
precision={2}
step={0.05}
unit="m"
/>
</PanelSection>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
max={0.3}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Content Padding">
<SliderControl
label="Horizontal"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
min={0}
max={0.2}
precision={3}
step={0.005}
unit="m"
/>
<SliderControl
label="Vertical"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
min={0}
max={0.2}
precision={3}
step={0.005}
unit="m"
/>
</PanelSection>
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Hinges Side</span>
<SegmentedControl
value={node.hingesSide}
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
/>
</div>
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Direction</span>
<SegmentedControl
value={node.swingDirection}
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
/>
</div>
</div>
</PanelSection>
<PanelSection title="Threshold">
<ToggleControl
label="Enable Threshold"
checked={node.threshold}
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
value={Math.round(node.thresholdHeight * 1000) / 1000}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
min={0.005}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
</PanelSection>
<PanelSection title="Handle">
<ToggleControl
label="Enable Handle"
checked={node.handle}
onChange={(checked) => handleUpdate({ handle: checked })}
/>
{node.handle && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
value={Math.round(node.handleHeight * 100) / 100}
onChange={(v) => handleUpdate({ handleHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
unit="m"
/>
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Handle Side</span>
<SegmentedControl
value={node.handleSide}
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
/>
</div>
</div>
)}
</PanelSection>
<PanelSection title="Hardware">
<ToggleControl
label="Door Closer"
checked={node.doorCloser}
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
label="Panic Bar"
checked={node.panicBar}
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
value={Math.round(node.panicBarHeight * 100) / 100}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
unit="m"
/>
</div>
)}
</PanelSection>
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map(r => r / colSum)
return (
<div key={i} className="mb-2 flex flex-col gap-1">
<div className="flex items-center justify-between pb-1">
<span className="text-xs font-medium text-white/80">Segment {i + 1}</span>
</div>
<SegmentedControl
value={seg.type}
onChange={(t) => {
const updated = node.segments.map((s, idx) => idx === i ? { ...s, type: t } : s)
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
/>
<SliderControl
label="Height"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
<SliderControl
label="Columns"
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
min={1}
max={8}
precision={0}
step={1}
/>
{numCols > 1 && (
<div className="mt-1 border-t border-border/50 pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
<SliderControl
label="Divider"
value={Math.round(seg.dividerThickness * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0.005}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Inset"
value={Math.round(seg.panelInset * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(seg.panelDepth * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
className="text-white/60 hover:text-white"
/>
)}
</div>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,257 @@
'use client'
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import useEditor from '../../../store/use-editor'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { CollectionsPopover } from './collections/collections-popover'
export function ItemPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
: undefined
const [uniformScale, setUniformScale] = useState(true)
const handleUpdate = useCallback(
(updates: Partial<ItemNode>) => {
if (!selectedId || !node) return
updateNode(selectedId as AnyNode['id'], updates)
if (node.asset.attachTo === 'wall' && node.parentId) {
requestAnimationFrame(() => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
})
}
},
[selectedId, node, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
const proto = ItemNode.parse({
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
name: node.name,
asset: node.asset,
parentId: node.parentId,
side: node.side,
metadata: { isNew: true },
})
setMovingNode(proto)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [selectedId, deleteNode, setSelection])
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
return (
<PanelWrapper
title={node.name || node.asset.name}
icon={node.asset.thumbnail || '/icons/furniture.png'}
onClose={handleClose}
width={300}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
min={node.position[0] - 2}
max={node.position[0] + 2}
precision={2}
step={0.01}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
min={node.position[1] - 2}
max={node.position[1] + 2}
precision={2}
step={0.01}
unit="m"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
min={node.position[2] - 2}
max={node.position[2] + 2}
precision={2}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
precision={0}
step={1}
unit="°"
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees - 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees + 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
</div>
</PanelSection>
<PanelSection title="Scale">
<div className="flex items-center justify-between px-2 pb-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Uniform Scale</span>
<button
type="button"
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md transition-colors text-muted-foreground hover:text-foreground",
uniformScale ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]"
)}
onClick={() => setUniformScale((v) => !v)}
>
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
</button>
</div>
{uniformScale ? (
<SliderControl
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => {
const v = Math.max(0.01, value)
handleUpdate({ scale: [v, v, v] })
}}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
) : (
<>
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[1] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[2] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
</>
)}
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Dimensions</span>
{(() => {
const [w, h, d] = getScaledDimensions(node)
return (
<span className="font-mono text-white">
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
</span>
)
})()}
</div>
</PanelSection>
<PanelSection title="Collections">
<ActionGroup>
<CollectionsPopover nodeId={selectedId as AnyNode['id']} collectionIds={node.collectionIds}>
<ActionButton label="Manage collections…" />
</CollectionsPopover>
</ActionGroup>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,50 @@
'use client'
import { AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ItemPanel } from './item-panel'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { SlabPanel } from './slab-panel'
import { WallPanel } from './wall-panel'
import { DoorPanel } from './door-panel'
import { WindowPanel } from './window-panel'
export function PanelManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const nodes = useScene((s) => s.nodes)
// Show reference panel if a reference is selected
if (selectedReferenceId) {
return <ReferencePanel />
}
// Show appropriate panel based on selected node type
if (selectedIds.length === 1) {
const selectedNode = selectedIds[0]
const node = nodes[selectedNode as AnyNodeId]
if (node) {
switch (node.type) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'slab':
return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
case 'wall':
return <WallPanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
}
}
return null
}
@@ -0,0 +1,79 @@
'use client'
import { cn } from '../../../lib/utils'
import { X, RotateCcw, Moon } from 'lucide-react'
import Image from 'next/image'
interface PanelWrapperProps {
title: string
icon?: string
onClose?: () => void
onReset?: () => void
children: React.ReactNode
className?: string
width?: number | string
}
export function PanelWrapper({
title,
icon,
onClose,
onReset,
children,
className,
width = 320, // default width
}: PanelWrapperProps) {
return (
<div
className={cn(
"pointer-events-auto fixed right-4 top-20 z-50 flex flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground max-h-[calc(100dvh-100px)]",
className
)}
style={{ width }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-3 border-b border-border/50">
<div className="flex items-center gap-2">
{icon && (
<Image
src={icon}
alt=""
width={16}
height={16}
className="shrink-0 object-contain"
/>
)}
<h2 className="font-semibold text-foreground text-sm truncate tracking-tight">
{title}
</h2>
</div>
<div className="flex items-center gap-1">
{onReset && (
<button
type="button"
onClick={onReset}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
>
<RotateCcw className="h-4 w-4" />
</button>
)}
{onClose && (
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0 no-scrollbar flex flex-col">
{children}
</div>
</div>
)
}
@@ -0,0 +1,457 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { BookMarked, Check, Globe, GlobeLock, MoreHorizontal, Pencil, Plus, Save, Trash2, Users, X } from 'lucide-react'
import { emitter } from '@pascal-app/core'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../primitives/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
import { cn } from '../../../../lib/utils'
export type PresetType = 'door' | 'window'
export interface PresetData {
id: string
type: string
name: string
data: Record<string, unknown>
thumbnail_url: string | null
user_id: string | null
is_community: boolean
created_at: string
}
type Tab = 'community' | 'mine'
interface PresetsPopoverProps {
type: PresetType
/** Apply preset data to the current node */
onApply: (data: Record<string, unknown>) => void
/** Save current node state as a new preset with the given name */
onSave: (name: string) => Promise<void>
/** Overwrite an existing preset's data with the current node state */
onOverwrite: (id: string) => Promise<void>
children: React.ReactNode
isAuthenticated?: boolean
}
export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, isAuthenticated = false }: PresetsPopoverProps) {
const [open, setOpen] = useState(false)
const [tab, setTab] = useState<Tab>('community')
const [presets, setPresets] = useState<PresetData[]>([])
const [loading, setLoading] = useState(false)
// New preset save state
const [showSaveInput, setShowSaveInput] = useState(false)
const [saveName, setSaveName] = useState('')
const [saving, setSaving] = useState(false)
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
// Delete confirmation
const [deletingId, setDeletingId] = useState<string | null>(null)
// Overwrite feedback (shows check icon briefly after overwrite)
const [overwrittenId, setOverwrittenId] = useState<string | null>(null)
const fetchPresets = useCallback(async () => {
setLoading(true)
try {
const res = await fetch(`/api/presets?type=${type}&tab=${tab}`)
if (res.ok) {
const json = await res.json()
setPresets(json.presets ?? [])
}
} finally {
setLoading(false)
}
}, [type, tab])
useEffect(() => {
if (open) fetchPresets()
}, [open, fetchPresets])
useEffect(() => {
if (!isAuthenticated && tab === 'mine') setTab('community')
}, [isAuthenticated, tab])
useEffect(() => {
const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => {
setPresets((prev) =>
prev.map((p) => (p.id === presetId ? { ...p, thumbnail_url: thumbnailUrl } : p)),
)
}
emitter.on('preset:thumbnail-updated', handler)
return () => emitter.off('preset:thumbnail-updated', handler)
}, [])
const handleSaveNew = async () => {
if (!saveName.trim()) return
setSaving(true)
try {
await onSave(saveName.trim())
setSaveName('')
setShowSaveInput(false)
if (tab === 'mine') fetchPresets()
else setTab('mine')
} finally {
setSaving(false)
}
}
const handleRename = async (id: string) => {
if (!renameValue.trim()) return
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameValue.trim() }),
})
if (res.ok) {
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p)))
setRenamingId(null)
}
}
const handleDelete = async (id: string) => {
const res = await fetch(`/api/presets/${id}`, { method: 'DELETE' })
if (res.ok) {
setPresets((prev) => prev.filter((p) => p.id !== id))
setDeletingId(null)
}
}
const handleOverwrite = async (id: string) => {
await onOverwrite(id)
setOverwrittenId(id)
setTimeout(() => setOverwrittenId(null), 1500)
}
const handleToggleCommunity = async (id: string, current: boolean) => {
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_community: !current }),
})
if (res.ok) {
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="left"
align="start"
sideOffset={8}
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
<div className="flex items-center gap-1.5">
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground tracking-tight">
{type === 'door' ? 'Door' : 'Window'} Presets
</span>
</div>
{isAuthenticated && (
<button
onClick={() => { setShowSaveInput((v) => !v); setSaveName('') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
>
<Plus className="h-3 w-3" />
Save new
</button>
)}
</div>
{/* New preset name input */}
{showSaveInput && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
<input
autoFocus
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveNew()
if (e.key === 'Escape') { setShowSaveInput(false); setSaveName('') }
}}
placeholder="Preset name…"
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
disabled={!saveName.trim() || saving}
onClick={handleSaveNew}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { setShowSaveInput(false); setSaveName('') }}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-border/50">
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
<Users className="h-3 w-3" />
Community
</TabButton>
<TabButton
active={tab === 'mine'}
onClick={() => { if (isAuthenticated) setTab('mine') }}
disabled={!isAuthenticated}
>
<BookMarked className="h-3 w-3" />
My presets
</TabButton>
</div>
{/* Content */}
<div className="max-h-72 overflow-y-auto no-scrollbar">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
</div>
) : presets.length === 0 ? (
<EmptyState tab={tab} isAuthenticated={isAuthenticated} />
) : (
<ul className="divide-y divide-border/30">
{presets.map((preset) => (
<PresetRow
key={preset.id}
preset={preset}
isMine={tab === 'mine'}
renamingId={renamingId}
renameValue={renameValue}
deletingId={deletingId}
overwrittenId={overwrittenId}
onApply={() => { onApply(preset.data); setOpen(false) }}
onOverwrite={() => handleOverwrite(preset.id)}
onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}
onStartRename={() => { setRenamingId(preset.id); setRenameValue(preset.name) }}
onRenameChange={setRenameValue}
onRenameConfirm={() => handleRename(preset.id)}
onRenameCancel={() => setRenamingId(null)}
onDeleteRequest={() => setDeletingId(preset.id)}
onDeleteConfirm={() => handleDelete(preset.id)}
onDeleteCancel={() => setDeletingId(null)}
/>
))}
</ul>
)}
</div>
</PopoverContent>
</Popover>
)
}
function TabButton({
active,
onClick,
disabled,
children,
}: {
active: boolean
onClick: () => void
disabled?: boolean
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 py-2 text-[11px] font-medium transition-colors',
active
? 'text-foreground border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground',
disabled && 'opacity-40 cursor-not-allowed',
)}
>
{children}
</button>
)
}
function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boolean }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
<BookMarked className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">
{tab === 'community'
? 'No community presets yet.'
: isAuthenticated
? 'No presets saved yet. Use "Save new" to save the current configuration.'
: 'Sign in to save and view your presets.'}
</p>
</div>
)
}
interface PresetRowProps {
preset: PresetData
isMine: boolean
renamingId: string | null
renameValue: string
deletingId: string | null
overwrittenId: string | null
onApply: () => void
onOverwrite: () => void
onToggleCommunity: () => void
onStartRename: () => void
onRenameChange: (v: string) => void
onRenameConfirm: () => void
onRenameCancel: () => void
onDeleteRequest: () => void
onDeleteConfirm: () => void
onDeleteCancel: () => void
}
function PresetRow({
preset,
isMine,
renamingId,
renameValue,
deletingId,
overwrittenId,
onApply,
onOverwrite,
onToggleCommunity,
onStartRename,
onRenameChange,
onRenameConfirm,
onRenameCancel,
onDeleteRequest,
onDeleteConfirm,
onDeleteCancel,
}: PresetRowProps) {
const isRenaming = renamingId === preset.id
const isDeleting = deletingId === preset.id
const justOverwritten = overwrittenId === preset.id
if (isDeleting) {
return (
<li className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
<span className="text-xs text-foreground/80 truncate">Delete "{preset.name}"?</span>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onDeleteConfirm}
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
Delete
</button>
<button
onClick={onDeleteCancel}
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
>
Cancel
</button>
</div>
</li>
)
}
if (isRenaming) {
return (
<li className="flex items-center gap-1.5 px-3 py-2">
<input
autoFocus
value={renameValue}
onChange={(e) => onRenameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onRenameConfirm()
if (e.key === 'Escape') onRenameCancel()
}}
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
onClick={onRenameConfirm}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={onRenameCancel}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
)
}
return (
<li className="group flex items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors">
{/* Thumbnail */}
<div className="h-12 w-12 shrink-0 rounded-md border border-border/40 bg-white/5 overflow-hidden">
{preset.thumbnail_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preset.thumbnail_url} alt={preset.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="h-3 w-5 rounded-sm border border-muted-foreground/30" />
</div>
)}
</div>
{/* Name + date — clicking applies */}
<button onClick={onApply} className="flex-1 min-w-0 text-left">
<span className="flex items-center gap-1.5">
<span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90">
{preset.name}
</span>
{/* Only show globe in "My presets" — in community tab it's redundant */}
{isMine && preset.is_community && (
<Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
)}
</span>
<span className="block text-[10px] text-muted-foreground/60">
{new Date(preset.created_at).toLocaleDateString()}
</span>
</button>
{/* Actions — 3-dot dropdown, only in "My presets" */}
{isMine && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors opacity-0 group-hover:opacity-100',
justOverwritten
? 'text-green-400 bg-green-500/10 opacity-100'
: 'text-muted-foreground hover:text-foreground hover:bg-white/10',
)}
>
{justOverwritten ? <Check className="h-3 w-3" /> : <MoreHorizontal className="h-3.5 w-3.5" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="left" align="start" className="min-w-44">
<DropdownMenuItem onClick={onOverwrite}>
<Save className="h-3.5 w-3.5" />
Update with current
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleCommunity}>
{preset.is_community
? <><GlobeLock className="h-3.5 w-3.5" />Remove from community</>
: <><Globe className="h-3.5 w-3.5" />Share with community</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onStartRename}>
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={onDeleteRequest}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</li>
)
}
@@ -0,0 +1,150 @@
'use client'
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
import { Box, Image as ImageIcon } from 'lucide-react'
import { useCallback } from 'react'
import useEditor from '../../../store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
type ReferenceNode = ScanNode | GuideNode
export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const node = selectedReferenceId
? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<ReferenceNode>) => {
if (!selectedReferenceId) return
updateNode(selectedReferenceId as AnyNode['id'], updates)
},
[selectedReferenceId, updateNode],
)
const handleClose = useCallback(() => {
setSelectedReferenceId(null)
}, [setSelectedReferenceId])
if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
const isScan = node.type === 'scan'
return (
<PanelWrapper
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
icon={isScan ? undefined : undefined}
onClose={handleClose}
width={300}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => {
const pos = [...node.position] as [number, number, number]
pos[0] = value
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => {
const pos = [...node.position] as [number, number, number]
pos[1] = value
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
onChange={(value) => {
const pos = [...node.position] as [number, number, number]
pos[2] = value
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({
rotation: [node.rotation[0], radians, node.rotation[2]],
})
}}
min={-180}
max={180}
precision={0}
step={1}
unit="°"
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]] })}
/>
<ActionButton
label="+45°"
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]] })}
/>
</div>
</PanelSection>
<PanelSection title="Scale & Opacity">
<SliderControl
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale * 100) / 100}
onChange={(value) => {
if (value > 0) {
handleUpdate({ scale: value })
}
}}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label="Opacity"
value={node.opacity}
onChange={(v) => handleUpdate({ opacity: v })}
min={0}
max={100}
precision={0}
step={1}
unit="%"
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,169 @@
'use client'
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ActionButton } from '../controls/action-button'
export function RoofPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<RoofNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
const totalWidth = node.leftWidth + node.rightWidth
return (
<PanelWrapper
title={node.name || "Roof"}
icon="/icons/roof.png"
onClose={handleClose}
width={300}
>
<PanelSection title="Dimensions">
<SliderControl
label="Length"
value={Math.round(node.length * 100) / 100}
onChange={(v) => handleUpdate({ length: v })}
min={0.5}
max={20}
precision={2}
step={0.5}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Slope Widths">
<div className="flex items-center justify-between px-2 pb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
<span>Widths</span>
<span>Total: {totalWidth.toFixed(1)}m</span>
</div>
<SliderControl
label="Left"
value={Math.round(node.leftWidth * 100) / 100}
onChange={(v) => handleUpdate({ leftWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Right"
value={Math.round(node.rightWidth * 100) / 100}
onChange={(v) => handleUpdate({ rightWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={<>R<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: radians })
}}
min={-180}
max={180}
precision={0}
step={1}
unit="°"
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-90°"
onClick={() => handleUpdate({ rotation: node.rotation - Math.PI / 2 })}
/>
<ActionButton
label="+90°"
onClick={() => handleUpdate({ rotation: node.rotation + Math.PI / 2 })}
/>
</div>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,219 @@
'use client'
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import useEditor from '../../../store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
export function SlabPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<SlabNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
useEffect(() => {
return () => {
setEditingHole(null)
}
}, [setEditingHole])
const handleAddHole = useCallback(() => {
if (!node || !selectedId) return
const polygon = node.polygon
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
},
[selectedId, setEditingHole],
)
const handleDeleteHole = useCallback(
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]![0] * polygon[j]![1]
area -= polygon[j]![0] * polygon[i]![1]
}
return Math.abs(area) / 2
}
const area = calculateArea(node.polygon)
return (
<PanelWrapper
title={node.name || "Slab"}
icon="/icons/floor.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Elevation">
<SliderControl
label="Height"
value={Math.round(node.elevation * 1000) / 1000}
onChange={(v) => handleUpdate({ elevation: v })}
min={-1}
max={1}
precision={3}
step={0.01}
unit="m"
/>
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
</div>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
key={index}
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes
</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,82 @@
'use client'
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
export function WallPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as WallNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<WallNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
const height = node.height ?? 2.5
const thickness = node.thickness ?? 0.1
return (
<PanelWrapper
title={node.name || "Wall"}
icon="/icons/wall.png"
onClose={handleClose}
width={280}
>
<PanelSection title="Dimensions">
<SliderControl
label="Height"
value={Math.round(height * 100) / 100}
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
min={0.1}
max={6}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Thickness"
value={Math.round(thickness * 1000) / 1000}
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
min={0.05}
max={1}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Length</span>
<span className="font-mono text-white">{length.toFixed(2)} m</span>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,407 @@
'use client'
import { type AnyNode, type AnyNodeId, WindowNode, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ToggleControl } from '../controls/toggle-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PresetsPopover } from './presets/presets-popover'
export function WindowPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleFlip = useCallback(() => {
if (!node) return
handleUpdate({
side: node.side === 'front' ? 'back' : 'front',
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
})
}, [node, handleUpdate])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [selectedId, node, deleteNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const duplicate = WindowNode.parse({
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
side: node.side,
wallId: node.wallId,
parentId: node.parentId,
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
columnRatios: [...node.columnRatios],
rowRatios: [...node.rowRatios],
columnDividerThickness: node.columnDividerThickness,
rowDividerThickness: node.rowDividerThickness,
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
metadata: { isNew: true },
})
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const getWindowPresetData = useCallback(() => {
if (!node) return null
return {
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
columnRatios: node.columnRatios,
rowRatios: node.rowRatios,
columnDividerThickness: node.columnDividerThickness,
rowDividerThickness: node.rowDividerThickness,
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
}
}, [node])
const handleSavePreset = useCallback(async (name: string) => {
const data = getWindowPresetData()
if (!data || !selectedId) return
const res = await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'window', name, data }),
})
if (res.ok) {
const json = await res.json()
const presetId = json.preset?.id
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
}
}, [getWindowPresetData, selectedId])
const handleOverwritePreset = useCallback(async (id: string) => {
const data = getWindowPresetData()
if (!data || !selectedId) return
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
}, [getWindowPresetData, selectedId])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>)
}, [handleUpdate])
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
const numCols = node.columnRatios.length
const numRows = node.rowRatios.length
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
const normCols = node.columnRatios.map(r => r / colSum)
const normRows = node.rowRatios.map(r => r / rowSum)
const setColumnRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
const delta = clamped - normCols[index]!
const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
const newRatios = normCols.map((v, i) => {
if (i === index) return clamped
if (i === neighborIdx) return neighborVal
return v
})
handleUpdate({ columnRatios: newRatios })
}
const setRowRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
const delta = clamped - normRows[index]!
const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
const newRatios = normRows.map((v, i) => {
if (i === index) return clamped
if (i === neighborIdx) return neighborVal
return v
})
handleUpdate({ rowRatios: newRatios })
}
return (
<PanelWrapper
title={node.name || "Window"}
icon="/icons/window.png"
onClose={handleClose}
width={320}
>
{/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="window" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
<BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span>
</button>
</PresetsPopover>
</div>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
min={-10}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<div className="pt-2 pb-1 px-1">
<ActionButton
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
className="w-full"
/>
</div>
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.2}
max={5}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.2}
max={5}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
max={0.3}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Grid">
<SliderControl
label="Columns"
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
/>
<SliderControl
label="Rows"
value={numRows}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
/>
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Col Widths</div>
{normCols.map((ratio, i) => (
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setColumnRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Divider"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
min={0.005}
max={0.1}
precision={3}
step={0.01}
unit="m"
/>
</div>
</div>
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Row Heights</div>
{normRows.map((ratio, i) => (
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setRowRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Divider"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
min={0.005}
max={0.1}
precision={3}
step={0.01}
unit="m"
/>
</div>
</div>
)}
</PanelSection>
<PanelSection title="Sill">
<ToggleControl
label="Enable Sill"
checked={node.sill}
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
value={Math.round(node.sillDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ sillDepth: v })}
min={0.01}
max={0.5}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Thickness"
value={Math.round(node.sillThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ sillThickness: v })}
min={0.005}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
</div>
)}
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,69 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-barlow font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
ref,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
if (asChild) {
return (
<Slot
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
ref={ref as never}
{...props}
/>
)
}
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
ref={ref}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,75 @@
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
className,
)}
data-slot="card"
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
data-slot="card-header"
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('font-semibold leading-none', className)}
data-slot="card-title"
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('text-muted-foreground text-sm', className)}
data-slot="card-description"
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
data-slot="card-action"
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('px-6', className)} data-slot="card-content" {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
data-slot="card-footer"
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
@@ -0,0 +1,58 @@
'use client'
import { useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from './popover'
import { cn } from '../../../lib/utils'
export const PALETTE_COLORS = [
'#ef4444', // Red 0°
'#f97316', // Orange 30°
'#f59e0b', // Amber 45°
'#84cc16', // Lime 85°
'#22c55e', // Green 142°
'#10b981', // Emerald 160°
'#06b6d4', // Cyan 190°
'#3b82f6', // Blue 217°
'#6366f1', // Indigo 239°
'#a855f7', // Violet 270°
'#64748b', // Dark gray
'#cccccc', // Light gray
]
interface ColorDotProps {
color: string
onChange: (color: string) => void
}
export function ColorDot({ color, onChange }: ColorDotProps) {
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="relative shrink-0 h-3 w-3 rounded-sm border border-border/50 cursor-pointer hover:ring-1 hover:ring-ring/50 transition-all"
style={{ backgroundColor: color }}
onClick={(e) => e.stopPropagation()}
/>
</PopoverTrigger>
<PopoverContent side="left" align="center" sideOffset={6} className="w-auto p-1.5">
<div className="grid grid-cols-4 gap-1">
{PALETTE_COLORS.map((c) => (
<button
key={c}
type="button"
className={cn(
'h-5 w-5 rounded-sm border transition-transform hover:scale-110',
c === color ? 'border-foreground/50 ring-1 ring-ring/50' : 'border-border/30',
)}
style={{ backgroundColor: c }}
onClick={() => { onChange(c); setOpen(false) }}
/>
))}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,224 @@
'use client'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-inset={inset}
data-slot="context-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
data-slot="context-menu-sub-content"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
data-slot="context-menu-content"
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-inset={inset}
data-slot="context-menu-item"
data-variant={variant}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="context-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="context-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
className={cn('px-2 py-1.5 font-medium font-barlow text-foreground text-sm data-[inset]:pl-8', className)}
data-inset={inset}
data-slot="context-menu-label"
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border', className)}
data-slot="context-menu-separator"
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}
data-slot="context-menu-shortcut"
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
@@ -0,0 +1,129 @@
'use client'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
data-slot="dialog-overlay"
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg',
className,
)}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
data-slot="dialog-close"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
data-slot="dialog-header"
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
data-slot="dialog-footer"
{...props}
/>
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn('font-semibold text-lg leading-none', className)}
data-slot="dialog-title"
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
data-slot="dialog-description"
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,228 @@
'use client'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
data-inset={inset}
data-slot="dropdown-menu-item"
data-variant={variant}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
className={cn('px-2 py-1.5 font-medium font-barlow text-sm data-inset:pl-8', className)}
data-inset={inset}
data-slot="dropdown-menu-label"
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border', className)}
data-slot="dropdown-menu-separator"
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}
data-slot="dropdown-menu-shortcut"
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-inset:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-inset={inset}
data-slot="dropdown-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
data-slot="dropdown-menu-sub-content"
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,53 @@
'use client'
import React, { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children?: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
}
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo)
}
public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-[#1b1c1f] p-4 text-white">
<h2 className="mb-4 text-xl font-bold text-red-400">Something went wrong</h2>
<pre className="max-w-full overflow-auto rounded bg-black/30 p-4 text-sm text-gray-300">
{this.state.error?.message}
</pre>
<button
className="mt-4 rounded bg-blue-600 px-4 py-2 hover:bg-blue-700"
onClick={() => this.setState({ hasError: false, error: null })}
>
Try again
</button>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,21 @@
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
className={cn(
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className,
)}
data-slot="input"
type={type}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,181 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useRef, useState } from 'react'
import NumberFlow from '@number-flow/react'
interface NumberInputProps {
label: string
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
}
export function NumberInput({
label,
value,
onChange,
min,
max,
precision = 2,
step = 0.1,
className = '',
}: NumberInputProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const startXRef = useRef(0)
const startValueRef = useRef(0)
const labelRef = useRef<HTMLDivElement>(null)
const clamp = useCallback(
(val: number) => {
if (min !== undefined && val < min) return min
if (max !== undefined && val > max) return max
return val
},
[min, max],
)
const handleLabelMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isEditing) return
e.preventDefault()
setIsDragging(true)
startXRef.current = e.clientX
startValueRef.current = value
// Pause history tracking during drag
useScene.temporal.getState().pause()
let finalValue = value
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startXRef.current
// Determine step size based on modifier keys
let dragStep = step // Default from prop
if (moveEvent.shiftKey) {
dragStep = step * 10 // Coarse
} else if (moveEvent.altKey) {
dragStep = step * 0.1 // Fine
}
const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
// Only call onChange if value actually changed (avoid extra processing on tiny moves)
if (newFinalValue !== finalValue) {
finalValue = newFinalValue
onChange(finalValue)
}
}
const handleMouseUp = () => {
setIsDragging(false)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// Reset to initial value while still paused (no history entry)
// Then resume and apply final value (creates single history entry)
if (finalValue !== startValueRef.current) {
onChange(startValueRef.current)
useScene.temporal.getState().resume()
onChange(finalValue)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[isEditing, value, onChange, clamp, precision],
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const handleInputBlur = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
}
setIsEditing(false)
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
}
},
[inputValue, onChange, value, clamp, precision],
)
return (
<div className={`${className} relative group/input`}>
<div
className={`absolute inset-y-0 left-0 bg-primary/10 dark:bg-primary/20 pointer-events-none transition-all duration-75 ${isDragging ? 'opacity-100' : 'opacity-0'}`}
style={{
width: `${Math.min(100, Math.max(0, ((value - (min ?? Math.min(0, value))) / ((max ?? Math.max(10, value)) - (min ?? Math.min(0, value)))) * 100))}%`,
borderTopRightRadius: value >= (max ?? Math.max(10, value)) ? '0.5rem' : '0',
borderBottomRightRadius: value >= (max ?? Math.max(10, value)) ? '0.5rem' : '0',
borderTopLeftRadius: '0.5rem',
borderBottomLeftRadius: '0.5rem',
}}
/>
<div className={`flex items-center rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] overflow-hidden transition-all focus-within:ring-1 focus-within:ring-primary focus-within:border-primary relative z-10 ${isDragging ? 'bg-transparent border-neutral-300 dark:border-border ring-1 ring-neutral-200/60 dark:ring-border/50' : 'bg-white dark:bg-accent/30 border-neutral-200/60 dark:border-border/50 hover:border-neutral-300 dark:hover:border-border/80'}`}>
<div
ref={labelRef}
className={`pl-2 pr-1 py-1.5 text-muted-foreground text-xs select-none font-barlow font-medium truncate z-10 ${
isDragging ? 'cursor-ew-resize text-foreground' : 'hover:cursor-ew-resize hover:text-foreground'
} transition-colors`}
onMouseDown={handleLabelMouseDown}
>
{label}
</div>
{isEditing ? (
<input
autoFocus
size={1}
className="flex-1 min-w-0 bg-transparent px-2 py-1.5 text-foreground text-sm font-mono font-medium outline-none text-right placeholder:text-muted-foreground/50 z-10"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
) : (
<div
className={`flex-1 px-2 py-1.5 text-sm font-mono font-medium cursor-text hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-right truncate z-10 text-foreground tabular-nums tracking-tight min-w-0`}
onClick={handleValueClick}
>
<NumberFlow
value={Number(value.toFixed(precision))}
format={{ minimumFractionDigits: precision, maximumFractionDigits: precision }}
/>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,79 @@
'use client'
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { Button } from '../../../components/ui/primitives/button'
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/primitives/popover'
import { Slider } from '../../../components/ui/primitives/slider'
import { cn } from '../../../lib/utils'
interface OpacityControlProps {
visible?: boolean
opacity?: number
onVisibilityToggle: () => void
onOpacityChange: (opacity: number) => void
className?: string
}
export function OpacityControl({
visible = true,
opacity = 100,
onVisibilityToggle,
onOpacityChange,
className,
}: OpacityControlProps) {
const [isOpen, setIsOpen] = useState(false)
const actualOpacity = opacity ?? 100
const isHidden = visible === false || actualOpacity === 0
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className={cn('flex items-center gap-1', className)}>
{!isHidden && actualOpacity < 100 && (
<span className="text-muted-foreground text-xs">{actualOpacity}%</span>
)}
<PopoverTrigger asChild>
<Button
className={cn(
'h-5 w-5 p-0 transition-opacity',
isHidden ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100',
)}
onClick={(e) => {
e.stopPropagation()
// If clicking the button (not opening popover), toggle visibility
if (!isOpen) {
onVisibilityToggle()
}
}}
size="sm"
variant="ghost"
>
{isHidden ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-48 p-3"
onClick={(e) => e.stopPropagation()}
side="right"
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Opacity</span>
<span className="text-muted-foreground text-xs">{actualOpacity}%</span>
</div>
<Slider
max={100}
min={0}
onValueChange={(values: number[]) => {
if (values[0] !== undefined) onOpacityChange(values[0])
}}
step={1}
value={[actualOpacity]}
/>
</div>
</PopoverContent>
</div>
</Popover>
)
}
@@ -0,0 +1,42 @@
'use client'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
@@ -0,0 +1,28 @@
'use client'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
className={cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
className,
)}
data-slot="separator"
decorative={decorative}
orientation={orientation}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,130 @@
'use client'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
data-slot="sheet-overlay"
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
className={cn(
'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
data-slot="sheet-content"
{...props}
>
{children}
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-1.5 p-4', className)}
data-slot="sheet-header"
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
data-slot="sheet-footer"
{...props}
/>
)
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
className={cn('font-semibold text-foreground', className)}
data-slot="sheet-title"
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
data-slot="sheet-description"
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,874 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { Button } from "./../../../components/ui/primitives/button";
import { Input } from "./../../../components/ui/primitives/input";
import { Separator } from "./../../../components/ui/primitives/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./../../../components/ui/primitives/sheet";
import { Skeleton } from "./../../../components/ui/primitives/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./../../../components/ui/primitives/tooltip";
import { useIsMobile } from "./../../../hooks/use-mobile";
import { cn } from "./../../../lib/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "18rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarStore = {
width: number;
setWidth: (width: number) => void;
isDragging: boolean;
setIsDragging: (isDragging: boolean) => void;
};
export const useSidebarStore = create<SidebarStore>()(
persist(
(set) => ({
width: 288, // 18rem = 288px
setWidth: (width) => set({ width: Math.max(288, Math.min(width, 800)) }),
isDragging: false,
setIsDragging: (isDragging) => set({ isDragging }),
}),
{
name: "sidebar-preferences",
partialize: (state) => ({ width: state.width }), // Only persist width
}
)
);
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const sidebarWidth = useSidebarStore((state) => state.width);
const isDragging = useSidebarStore((state) => state.isDragging);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(
() =>
isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open),
[isMobile, setOpen],
);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar pointer-events-none",
className,
)}
data-slot="sidebar-wrapper"
data-dragging={isDragging}
style={
{
"--sidebar-width": `${sidebarWidth}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function SidebarResizer({ side }: { side: "left" | "right" }) {
const setWidth = useSidebarStore((state) => state.setWidth);
const setIsDragging = useSidebarStore((state) => state.setIsDragging);
const isResizing = React.useRef(false);
const handlePointerDown = (e: React.PointerEvent) => {
isResizing.current = true;
setIsDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
};
React.useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
if (!isResizing.current) return;
const newWidth = side === "left" ? e.clientX : window.innerWidth - e.clientX;
setWidth(Math.max(288, Math.min(newWidth, 800)));
};
const handlePointerUp = () => {
isResizing.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, side]);
return (
<div
onPointerDown={handlePointerDown}
className={cn(
"absolute top-0 bottom-0 w-2 cursor-col-resize z-50 hover:bg-primary/50 transition-colors",
side === "left" ? "-right-1" : "-left-1"
)}
/>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
data-slot="sidebar"
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
<SheetContent
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
data-mobile="true"
data-sidebar="sidebar"
data-slot="sidebar"
side={side}
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block "
data-collapsible={state === "collapsed" ? collapsible : ""}
data-side={side}
data-slot="sidebar"
data-state={state}
data-variant={variant}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[dragging=true]/sidebar-wrapper:transition-none",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
data-slot="sidebar-gap"
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex pointer-events-auto",
"group-data-[dragging=true]/sidebar-wrapper:transition-none",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
data-slot="sidebar-container"
{...props}
>
<div
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm pointer-events-auto relative"
data-sidebar="sidebar"
data-slot="sidebar-inner"
>
{children}
<SidebarResizer side={side} />
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
className={cn("size-7", className)}
data-sidebar="trigger"
data-slot="sidebar-trigger"
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
size="icon"
variant="ghost"
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
aria-label="Toggle Sidebar"
className={cn(
"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
data-sidebar="rail"
data-slot="sidebar-rail"
onClick={toggleSidebar}
tabIndex={-1}
title="Toggle Sidebar"
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className,
)}
data-slot="sidebar-inset"
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
className={cn("h-8 w-full bg-background shadow-none", className)}
data-sidebar="input"
data-slot="sidebar-input"
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 p-2", className)}
data-sidebar="header"
data-slot="sidebar-header"
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 p-2", className)}
data-sidebar="footer"
data-slot="sidebar-footer"
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn("mx-2 w-auto bg-sidebar-border", className)}
data-sidebar="separator"
data-slot="sidebar-separator"
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
data-sidebar="content"
data-slot="sidebar-content"
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
data-sidebar="group"
data-slot="sidebar-group"
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
ref,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
if (asChild) {
return (
<Slot
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium font-barlow text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
data-sidebar="group-label"
data-slot="sidebar-group-label"
ref={ref as never}
{...props}
/>
);
}
return (
<div
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium font-barlow text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
data-sidebar="group-label"
data-slot="sidebar-group-label"
ref={ref}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
ref,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const classes = cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"after:-inset-2 after:absolute md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
);
if (asChild) {
return (
<Slot
className={classes}
data-sidebar="group-action"
data-slot="sidebar-group-action"
ref={ref as never}
{...props}
/>
);
}
return (
<button
className={classes}
data-sidebar="group-action"
data-slot="sidebar-group-action"
ref={ref}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("w-full text-sm", className)}
data-sidebar="group-content"
data-slot="sidebar-group-content"
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
data-sidebar="menu"
data-slot="sidebar-menu"
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
className={cn("group/menu-item relative", className)}
data-sidebar="menu-item"
data-slot="sidebar-menu-item"
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left font-barlow text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
ref,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar();
const classes = cn(sidebarMenuButtonVariants({ variant, size }), className);
const button = asChild ? (
<Slot
className={classes}
data-active={isActive}
data-sidebar="menu-button"
data-size={size}
data-slot="sidebar-menu-button"
ref={ref as never}
{...props}
/>
) : (
<button
className={classes}
data-active={isActive}
data-sidebar="menu-button"
data-size={size}
data-slot="sidebar-menu-button"
ref={ref}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
align="center"
hidden={state !== "collapsed" || isMobile}
side="right"
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
ref,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const classes = cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
"after:-inset-2 after:absolute md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
);
if (asChild) {
return (
<Slot
className={classes}
data-sidebar="menu-action"
data-slot="sidebar-menu-action"
ref={ref as never}
{...props}
/>
);
}
return (
<button
className={classes}
data-sidebar="menu-action"
data-slot="sidebar-menu-action"
ref={ref}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
data-sidebar="menu-badge"
data-slot="sidebar-menu-badge"
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(
() => `${Math.floor(Math.random() * 40) + 50}%`,
[],
);
return (
<div
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
data-sidebar="menu-skeleton"
data-slot="sidebar-menu-skeleton"
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
data-sidebar="menu-sub"
data-slot="sidebar-menu-sub"
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
className={cn("group/menu-sub-item relative", className)}
data-sidebar="menu-sub-item"
data-slot="sidebar-menu-sub-item"
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
ref,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const classes = cn(
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 font-barlow text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
);
if (asChild) {
return (
<Slot
className={classes}
data-active={isActive}
data-sidebar="menu-sub-button"
data-size={size}
data-slot="sidebar-menu-sub-button"
ref={ref as never}
{...props}
/>
);
}
return (
<a
className={classes}
data-active={isActive}
data-sidebar="menu-sub-button"
data-size={size}
data-slot="sidebar-menu-sub-button"
ref={ref}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -0,0 +1,13 @@
import { cn } from '../../../lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('animate-pulse rounded-md bg-accent', className)}
data-slot="skeleton"
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,58 @@
'use client'
import * as SliderPrimitive from '@radix-ui/react-slider'
import * as React from 'react'
import { cn } from '../../../lib/utils'
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
)
return (
<SliderPrimitive.Root
className={cn(
'relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50',
className,
)}
data-slot="slider"
defaultValue={defaultValue}
max={max}
min={min}
value={value}
{...props}
>
<SliderPrimitive.Track
className={cn(
'relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5',
)}
data-slot="slider-track"
>
<SliderPrimitive.Range
className={cn(
'absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
)}
data-slot="slider-range"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
data-slot="slider-thumb"
key={index}
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "./../../../lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -0,0 +1,57 @@
'use client'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import type * as React from 'react'
import { cn } from '../../../lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background font-barlow text-xs data-[state=closed]:animate-out',
className,
)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,40 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
const LOADERS = [
'pascal-loader-1',
'pascal-loader-2',
'pascal-loader-3',
'pascal-loader-4',
'pascal-loader-5',
]
interface SceneLoaderProps {
className?: string
fullScreen?: boolean
}
export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {
const [loaderClass, setLoaderClass] = useState<string | null>(null)
useEffect(() => {
// Pick a random loader on mount
setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!)
}, [])
if (!loaderClass) return null
return (
<div
className={cn(
"z-100 flex items-center justify-center bg-background/80 backdrop-blur-md transition-opacity duration-300",
fullScreen ? "fixed inset-0" : "absolute inset-0",
className
)}
>
<div className={cn(loaderClass, "text-foreground opacity-80")} />
</div>
)
}
@@ -0,0 +1,73 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
import { CommandPalette } from "./../../../components/ui/command-palette";
import {
Sidebar,
SidebarContent,
SidebarHeader,
useSidebarStore,
} from "./../../../components/ui/primitives/sidebar";
import { cn } from "./../../../lib/utils";
import { IconRail, type PanelId } from "./icon-rail";
import { SettingsPanel, type SettingsPanelProps } from "./panels/settings-panel";
import { SitePanel, type SitePanelProps } from "./panels/site-panel";
interface AppSidebarProps {
appMenuButton?: ReactNode;
sidebarTop?: ReactNode;
settingsPanelProps?: SettingsPanelProps;
sitePanelProps?: SitePanelProps;
}
export function AppSidebar({ appMenuButton, sidebarTop, settingsPanelProps, sitePanelProps }: AppSidebarProps) {
const [activePanel, setActivePanel] = useState<PanelId>("site");
useEffect(() => {
// Widen default sidebar (288px → 432px) for better project title visibility
const store = useSidebarStore.getState();
if (store.width <= 288) {
store.setWidth(432);
}
}, []);
const renderPanelContent = () => {
switch (activePanel) {
case "site":
return <SitePanel {...sitePanelProps} />;
case "settings":
return <SettingsPanel {...settingsPanelProps} />;
default:
return null;
}
};
return (
<>
<Sidebar className={cn("dark text-white")} variant="floating">
<div className="flex h-full">
{/* Icon Rail */}
<IconRail
activePanel={activePanel}
onPanelChange={setActivePanel}
appMenuButton={appMenuButton}
/>
{/* Panel Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{sidebarTop && (
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50 relative">
{sidebarTop}
</SidebarHeader>
)}
<SidebarContent className={cn("no-scrollbar flex flex-1 flex-col overflow-hidden")}>
{renderPanelContent()}
</SidebarContent>
</div>
</div>
</Sidebar>
<CommandPalette />
</>
);
}
@@ -0,0 +1,112 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { motion } from "framer-motion";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "./../../../components/ui/primitives/tooltip";
import { cn } from "./../../../lib/utils";
import { useViewer } from "@pascal-app/viewer";
export type PanelId = "site" | "settings";
interface IconRailProps {
activePanel: PanelId;
onPanelChange: (panel: PanelId) => void;
appMenuButton?: ReactNode;
className?: string;
}
const panels: { id: PanelId; iconSrc: string; label: string }[] = [
{ id: "site", iconSrc: "/icons/level.png", label: "Site" },
{ id: "settings", iconSrc: "/icons/settings.png", label: "Settings" },
];
export function IconRail({
activePanel,
onPanelChange,
appMenuButton,
className,
}: IconRailProps) {
const theme = useViewer((state) => state.theme);
const setTheme = useViewer((state) => state.setTheme);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<div
className={cn(
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
className,
)}
>
{/* App menu slot */}
{appMenuButton}
{/* Divider */}
<div className="w-8 h-px bg-border/50 mb-1" />
{panels.map((panel) => {
const isActive = activePanel === panel.id;
return (
<Tooltip key={panel.id}>
<TooltipTrigger asChild>
<button
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
isActive ? "bg-accent" : "hover:bg-accent",
)}
onClick={() => onPanelChange(panel.id)}
type="button"
>
<img
src={panel.iconSrc}
alt={panel.label}
className={cn(
"h-6 w-6 transition-all object-contain",
!isActive && "opacity-50 saturate-0"
)}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip>
);
})}
{/* Spacer */}
<div className="flex-1" />
{/* Theme Toggle */}
{mounted && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 transition-all text-foreground hover:bg-accent mb-2"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
type="button"
>
<motion.div
key={theme}
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</motion.div>
</button>
</TooltipTrigger>
<TooltipContent side="right">Toggle theme</TooltipContent>
</Tooltip>
)}
</div>
);
}
export { panels };
@@ -0,0 +1,89 @@
import { Volume2, VolumeX } from 'lucide-react'
import { Button } from '../../../../../components/ui/primitives/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../../../../../components/ui/primitives/dialog'
import { Slider } from '../../../../../components/ui/slider'
import useAudio from '../../../../../store/use-audio'
export function AudioSettingsDialog() {
const { masterVolume, sfxVolume, radioVolume, muted, setMasterVolume, setSfxVolume, setRadioVolume, toggleMute } = useAudio()
return (
<Dialog>
<DialogTrigger asChild>
<Button
className="w-full justify-start gap-2"
variant="outline"
>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
Audio Settings
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Audio Settings</DialogTitle>
<DialogDescription>
Adjust volume levels and mute settings
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Master Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Master Volume</label>
<span className="text-sm text-muted-foreground">{masterVolume}%</span>
</div>
<Slider
value={[masterVolume]}
onValueChange={(value) => value[0] !== undefined && setMasterVolume(value[0])}
max={100}
step={1}
disabled={muted}
/>
</div>
{/* Radio Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Radio Volume</label>
<span className="text-sm text-muted-foreground">{radioVolume}%</span>
</div>
<Slider
value={[radioVolume]}
onValueChange={(value) => value[0] !== undefined && setRadioVolume(value[0])}
max={100}
step={1}
disabled={muted}
/>
</div>
{/* SFX Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Sound Effects</label>
<span className="text-sm text-muted-foreground">{sfxVolume}%</span>
</div>
<Slider
value={[sfxVolume]}
onValueChange={(value) => value[0] !== undefined && setSfxVolume(value[0])}
max={100}
step={1}
disabled={muted}
/>
</div>
{/* Mute Toggle */}
<div className="pt-4 border-t">
<Button
onClick={toggleMute}
variant={muted ? 'default' : 'outline'}
className="w-full justify-start gap-2"
>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
{muted ? 'Unmute All Sounds' : 'Mute All Sounds'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,448 @@
import { emitter, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { VisualJson, TreeView } from "@visual-json/react";
import { Camera, Download, Save, Trash2, Upload } from "lucide-react";
import {
type KeyboardEvent,
type SyntheticEvent,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "./../../../../../components/ui/primitives/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "./../../../../../components/ui/primitives/dialog";
import { Switch } from "./../../../../../components/ui/primitives/switch";
import useEditor from "./../../../../../store/use-editor";
import { AudioSettingsDialog } from "./audio-settings-dialog";
import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog";
type SceneNode = Record<string, unknown> & {
id?: unknown;
type?: unknown;
name?: unknown;
parentId?: unknown;
children?: unknown;
};
type SceneGraphNode = {
id: string;
type: string;
name: string | null;
parentId: string | null;
children: SceneGraphNode[];
missing?: true;
cycle?: true;
};
type SceneGraphValue = {
roots: SceneGraphNode[];
detachedNodes?: SceneGraphNode[];
};
const isSceneNode = (value: unknown): value is SceneNode => {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as { id: unknown }).id === "string"
);
};
const getChildIdsFromNode = (node: SceneNode): string[] => {
if (!Array.isArray(node.children)) {
return [];
}
const childIds = new Set<string>();
for (const child of node.children) {
if (typeof child === "string") {
childIds.add(child);
continue;
}
if (isSceneNode(child)) {
childIds.add(child.id as string);
}
}
return Array.from(childIds);
};
const buildSceneGraphValue = (
nodes: Record<string, SceneNode>,
rootNodeIds: string[],
): SceneGraphValue => {
const childIdsByParent = new Map<string, Set<string>>();
for (const [id, node] of Object.entries(nodes)) {
const childIds = getChildIdsFromNode(node);
if (childIds.length > 0) {
childIdsByParent.set(id, new Set(childIds));
}
}
for (const [id, node] of Object.entries(nodes)) {
if (typeof node.parentId !== "string") {
continue;
}
const siblings = childIdsByParent.get(node.parentId) ?? new Set<string>();
siblings.add(id);
childIdsByParent.set(node.parentId, siblings);
}
const visited = new Set<string>();
const buildNode = (id: string, path: Set<string>): SceneGraphNode => {
const node = nodes[id];
if (!node) {
return {
id,
type: "missing",
name: null,
parentId: null,
missing: true,
children: [],
};
}
const nodeType = typeof node.type === "string" ? node.type : "unknown";
const nodeName = typeof node.name === "string" ? node.name : null;
const parentId = typeof node.parentId === "string" ? node.parentId : null;
if (path.has(id)) {
return {
id,
type: nodeType,
name: nodeName,
parentId,
cycle: true,
children: [],
};
}
visited.add(id);
const nextPath = new Set(path);
nextPath.add(id);
const childIds = Array.from(childIdsByParent.get(id) ?? []);
return {
id,
type: nodeType,
name: nodeName,
parentId,
children: childIds.map((childId) => buildNode(childId, nextPath)),
};
};
const roots = rootNodeIds.map((id) => buildNode(id, new Set()));
const detachedNodeIds = Object.keys(nodes).filter((id) => !visited.has(id));
if (detachedNodeIds.length === 0) {
return { roots };
}
return {
roots,
detachedNodes: detachedNodeIds.map((id) => buildNode(id, new Set())),
};
};
export interface ProjectVisibility {
isPrivate: boolean
showScansPublic: boolean
showGuidesPublic: boolean
}
export interface SettingsPanelProps {
projectId?: string
projectVisibility?: ProjectVisibility
onVisibilityChange?: (field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic', value: boolean) => Promise<void>
}
export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange }: SettingsPanelProps = {}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const nodes = useScene((state) => state.nodes);
const rootNodeIds = useScene((state) => state.rootNodeIds);
const setScene = useScene((state) => state.setScene);
const clearScene = useScene((state) => state.clearScene);
const resetSelection = useViewer((state) => state.resetSelection);
const exportScene = useViewer((state) => state.exportScene);
const setPhase = useEditor((state) => state.setPhase);
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false);
const sceneGraphValue = useMemo(
() => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
[nodes, rootNodeIds],
);
const blockSceneGraphMutations = useCallback((event: SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
const blockSceneGraphDeletion = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
event.stopPropagation();
}
},
[],
);
const isLocalProject = false; // Props-based; only show cloud sections when projectId provided
const handleExport = async () => {
if (exportScene) {
await exportScene();
}
};
const handleSaveBuild = () => {
const sceneData = { nodes, rootNodeIds };
const json = JSON.stringify(sceneData, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const date = new Date().toISOString().split("T")[0];
link.download = `layout_${date}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handleFileLoad = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (data.nodes && data.rootNodeIds) {
setScene(data.nodes, data.rootNodeIds);
resetSelection();
setPhase("site");
}
} catch (err) {
console.error("Failed to load build:", err);
}
};
reader.readAsText(file);
// Reset input so the same file can be loaded again
e.target.value = "";
};
const handleResetToDefault = () => {
clearScene();
resetSelection();
setPhase("site");
};
const handleGenerateThumbnail = () => {
if (!projectId) return;
setIsGeneratingThumbnail(true);
emitter.emit('camera-controls:generate-thumbnail', { projectId });
setTimeout(() => setIsGeneratingThumbnail(false), 3000);
};
const handleVisibilityChange = async (
field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
value: boolean,
) => {
await onVisibilityChange?.(field, value);
};
return (
<div className="flex flex-col gap-6 p-3">
{/* Visibility Section (only for cloud projects) */}
{projectId && !isLocalProject && (
<div className="space-y-3">
<label className="font-medium text-muted-foreground text-xs uppercase">
Visibility
</label>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Public</div>
<div className="text-xs text-muted-foreground">
{projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view
</div>
</div>
<Switch
checked={!(projectVisibility?.isPrivate ?? false)}
onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Show 3D Scans</div>
<div className="text-xs text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch
checked={projectVisibility?.showScansPublic ?? true}
onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Show Floorplans</div>
<div className="text-xs text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch
checked={projectVisibility?.showGuidesPublic ?? true}
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Show Grid</div>
<div className="text-xs text-muted-foreground">
Visible only in the editor
</div>
</div>
<Switch
checked={useViewer((state) => state.showGrid)}
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
/>
</div>
</div>
)}
{/* Export Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Export
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleExport}
variant="outline"
>
<Download className="size-4" />
Export 3D Model
</Button>
</div>
{/* Thumbnail Section (only for cloud projects) */}
{projectId && !isLocalProject && (
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Thumbnail
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleGenerateThumbnail}
variant="outline"
disabled={isGeneratingThumbnail}
>
<Camera className="size-4" />
{isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}
</Button>
</div>
)}
{/* Save/Load Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Save & Load
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleSaveBuild}
variant="outline"
>
<Save className="size-4" />
Save Build
</Button>
<Button
className="w-full justify-start gap-2"
onClick={() => fileInputRef.current?.click()}
variant="outline"
>
<Upload className="size-4" />
Load Build
</Button>
<input
accept="application/json"
className="hidden"
onChange={handleFileLoad}
ref={fileInputRef}
type="file"
/>
</div>
{/* Audio Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Audio
</label>
<AudioSettingsDialog />
</div>
{/* Keyboard Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Keyboard
</label>
<KeyboardShortcutsDialog />
</div>
{/* Scene Graph */}
<div className="space-y-1">
<label className="font-medium text-muted-foreground text-xs uppercase">
Scene Graph
</label>
<Dialog>
<DialogTrigger asChild>
<Button className="h-auto justify-start p-0 text-sm" variant="link">
Explore scene graph
</Button>
</DialogTrigger>
<DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
<DialogTitle className="sr-only">Scene Graph</DialogTitle>
<div
className="flex h-full w-full min-h-0 min-w-0 *:h-full *:w-full *:overflow-y-auto"
onContextMenuCapture={blockSceneGraphMutations}
onDragStartCapture={blockSceneGraphMutations}
onDropCapture={blockSceneGraphMutations}
onKeyDownCapture={blockSceneGraphDeletion}
>
<VisualJson value={sceneGraphValue}>
<TreeView showCounts />
</VisualJson>
</div>
</DialogContent>
</Dialog>
</div>
{/* Danger Zone */}
<div className="space-y-2">
<label className="font-medium text-destructive text-xs uppercase">
Danger Zone
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleResetToDefault}
variant="destructive"
>
<Trash2 className="size-4" />
Clear & Start New
</Button>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More