splitting editor and community
This commit is contained in:
@@ -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'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 };
|
||||
+89
@@ -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
Reference in New Issue
Block a user