sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)

Squash merge of 3 commits:
1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes
2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels)
3. Missing files fix (materials.ts, merged-outline-node.ts, type fix)

75 files changed, ~6K additions.
This commit is contained in:
Pascal
2026-04-07 19:21:10 -04:00
committed by GitHub
parent e8ad92592d
commit 0a46a9deb4
77 changed files with 6890 additions and 2062 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "Pascal building editor component",
"type": "module",
"exports": {
".": "./src/index.tsx"
".": "./src/index.tsx",
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
},
"scripts": {
"check-types": "tsc --noEmit"
@@ -1,387 +1,387 @@
'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer, ZONE_LAYER } 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 tempDelta = new Vector3()
const tempPosition = new Vector3()
const tempSize = new Vector3()
const tempTarget = new Vector3()
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const selection = useViewer((s) => s.selection)
const currentLevelId = selection.levelId
const firstLoad = useRef(true)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
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(ZONE_LAYER)
}, [camera, raycaster])
useEffect(() => {
if (isPreviewMode || isFirstPersonMode) return
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, isFirstPersonMode])
useEffect(() => {
if (!controls.current || isFirstPersonMode) return
controls.current.maxPolarAngle = maxPolarAngle
controls.current.minPolarAngle = 0
if (controls.current.polarAngle > maxPolarAngle) {
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
}
}, [maxPolarAngle, isFirstPersonMode])
const focusNode = useCallback(
(nodeId: string) => {
if (isPreviewMode || !controls.current) return
const object3D = sceneRegistry.nodes.get(nodeId)
if (!object3D) return
tempBox.setFromObject(object3D)
if (tempBox.isEmpty()) return
tempBox.getCenter(tempCenter)
controls.current.getPosition(tempPosition)
controls.current.getTarget(tempTarget)
tempDelta.copy(tempCenter).sub(tempTarget)
controls.current.setLookAt(
tempPosition.x + tempDelta.x,
tempPosition.y + tempDelta.y,
tempPosition.z + tempDelta.z,
tempCenter.x,
tempCenter.y,
tempCenter.z,
true,
)
},
[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(() => {
if (isFirstPersonMode) return
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, isFirstPersonMode])
// 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(() => {
if (isFirstPersonMode) return
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?.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)
}
const handleNodeFocus = ({ nodeId }: CameraControlEvent) => {
focusNode(nodeId)
}
emitter.on('camera-controls:capture', handleNodeCapture)
emitter.on('camera-controls:focus', handleNodeFocus)
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:focus', handleNodeFocus)
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)
}
}, [focusNode, isFirstPersonMode])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
// In first-person mode, don't render orbit controls — FirstPersonControls takes over
if (isFirstPersonMode) {
return null
}
return (
<CameraControls
makeDefault
maxDistance={100}
maxPolarAngle={maxPolarAngle}
minDistance={10}
minPolarAngle={0}
mouseButtons={mouseButtons}
onRest={onRest}
onSleep={onRest}
onTransitionStart={onTransitionStart}
ref={controls}
restThreshold={0.01}
/>
)
}
'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer, WalkthroughControls, ZONE_LAYER } 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 tempDelta = new Vector3()
const tempPosition = new Vector3()
const tempSize = new Vector3()
const tempTarget = new Vector3()
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const walkthroughMode = useViewer((s) => s.walkthroughMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const selection = useViewer((s) => s.selection)
const currentLevelId = selection.levelId
const firstLoad = useRef(true)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
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(ZONE_LAYER)
}, [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 (!controls.current) return
if (firstLoad.current) {
firstLoad.current = false
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
}
controls.current.getTarget(currentTarget)
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
}, [currentLevelId, isPreviewMode])
useEffect(() => {
if (!controls.current) return
controls.current.maxPolarAngle = maxPolarAngle
controls.current.minPolarAngle = 0
if (controls.current.polarAngle > maxPolarAngle) {
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
}
}, [maxPolarAngle])
const focusNode = useCallback(
(nodeId: string) => {
if (isPreviewMode || !controls.current) return
const object3D = sceneRegistry.nodes.get(nodeId)
if (!object3D) return
tempBox.setFromObject(object3D)
if (tempBox.isEmpty()) return
tempBox.getCenter(tempCenter)
controls.current.getPosition(tempPosition)
controls.current.getTarget(tempTarget)
tempDelta.copy(tempCenter).sub(tempTarget)
controls.current.setLookAt(
tempPosition.x + tempDelta.x,
tempPosition.y + tempDelta.y,
tempPosition.z + tempDelta.z,
tempCenter.x,
tempCenter.y,
tempCenter.z,
true,
)
},
[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
if (
position &&
target &&
position.length >= 3 &&
target.length >= 3 &&
position.every((v) => v !== null && v !== undefined) &&
target.every((v) => v !== null && v !== undefined)
) {
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?.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)
}
const handleNodeFocus = ({ nodeId }: CameraControlEvent) => {
focusNode(nodeId)
}
emitter.on('camera-controls:capture', handleNodeCapture)
emitter.on('camera-controls:focus', handleNodeFocus)
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:focus', handleNodeFocus)
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)
}
}, [focusNode])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
if (walkthroughMode) {
return <WalkthroughControls />
}
return (
<CameraControls
makeDefault
maxDistance={100}
maxPolarAngle={maxPolarAngle}
minDistance={10}
minPolarAngle={0}
mouseButtons={mouseButtons}
onRest={onRest}
onSleep={onRest}
onTransitionStart={onTransitionStart}
ref={controls}
restThreshold={0.01}
/>
)
}
+13 -2
View File
@@ -14,9 +14,11 @@ const SIDEBAR_COLLAPSE_THRESHOLD = 220
function LeftColumn({
tabs,
renderTabContent,
sidebarOverlay,
}: {
tabs: SidebarTab[]
renderTabContent: (tabId: string) => ReactNode
sidebarOverlay?: ReactNode
}) {
const width = useSidebarStore((s) => s.width)
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -108,7 +110,10 @@ function LeftColumn({
}}
>
<TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
<div className="flex flex-1 flex-col overflow-hidden">{renderTabContent(activePanel)}</div>
<div className="relative flex flex-1 flex-col overflow-hidden">
{renderTabContent(activePanel)}
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
</div>
{/* Resize handle + hit area */}
<div
@@ -171,6 +176,7 @@ export interface EditorLayoutV2Props {
navbarSlot?: ReactNode
sidebarTabs?: SidebarTab[]
renderTabContent: (tabId: string) => ReactNode
sidebarOverlay?: ReactNode
viewerToolbarLeft?: ReactNode
viewerToolbarRight?: ReactNode
viewerContent: ReactNode
@@ -181,6 +187,7 @@ export function EditorLayoutV2({
navbarSlot,
sidebarTabs = [],
renderTabContent,
sidebarOverlay,
viewerToolbarLeft,
viewerToolbarRight,
viewerContent,
@@ -194,7 +201,11 @@ export function EditorLayoutV2({
{/* Main content: left column + right column */}
<div className="flex min-h-0 flex-1">
{sidebarTabs.length > 0 && (
<LeftColumn renderTabContent={renderTabContent} tabs={sidebarTabs} />
<LeftColumn
renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay}
tabs={sidebarTabs}
/>
)}
<RightColumn
overlays={overlays}
+48 -4
View File
@@ -7,6 +7,8 @@ import {
ItemNode,
RoofNode,
RoofSegmentNode,
StairNode,
StairSegmentNode,
sceneRegistry,
useScene,
WindowNode,
@@ -20,7 +22,17 @@ import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment', 'wall', 'slab']
const ALLOWED_TYPES = [
'item',
'door',
'window',
'roof',
'roof-segment',
'stair',
'stair-segment',
'wall',
'slab',
]
const DELETE_ONLY_TYPES = ['wall', 'slab']
export function FloatingActionMenu() {
@@ -66,7 +78,9 @@ export function FloatingActionMenu() {
node.type === 'window' ||
node.type === 'door' ||
node.type === 'roof' ||
node.type === 'roof-segment'
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment'
) {
setMovingNode(node as any)
}
@@ -98,6 +112,10 @@ export function FloatingActionMenu() {
duplicate = RoofNode.parse(duplicateInfo)
} else if (node.type === 'roof-segment') {
duplicate = RoofSegmentNode.parse(duplicateInfo)
} else if (node.type === 'stair') {
duplicate = StairNode.parse(duplicateInfo)
} else if (node.type === 'stair-segment') {
duplicate = StairSegmentNode.parse(duplicateInfo)
}
} catch (error) {
console.error('Failed to parse duplicate', error)
@@ -107,7 +125,12 @@ export function FloatingActionMenu() {
if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (duplicate.type === 'roof' || duplicate.type === 'roof-segment') {
} else if (
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment'
) {
// Add small offset to make it visible
if ('position' in duplicate) {
duplicate.position = [
@@ -136,13 +159,34 @@ export function FloatingActionMenu() {
}
}
}
// Duplicate children for stair nodes
if (node.type === 'stair' && node.children) {
const nodesState = useScene.getState().nodes
for (const childId of node.children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'stair-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
try {
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
} catch (e) {
console.error('Failed to duplicate stair segment', e)
}
}
}
}
}
if (
duplicate.type === 'item' ||
duplicate.type === 'window' ||
duplicate.type === 'door' ||
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment'
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment'
) {
setMovingNode(duplicate as any)
}
File diff suppressed because it is too large Load Diff
+130 -40
View File
@@ -8,7 +8,14 @@ import {
useScene,
} from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -47,7 +54,6 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
import { CustomCameraControls } from './custom-camera-controls'
import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
import { FloatingActionMenu } from './floating-action-menu'
import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid'
@@ -56,17 +62,19 @@ import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels'
import { ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
const DELETE_CURSOR_BADGE_OFFSET_X = 14
const DELETE_CURSOR_BADGE_OFFSET_Y = 14
/**
* Wire up module-level singletons (spatial grid, space detection, SFX) for
* an Editor mount. Returns a teardown function that detaches the scene-store
* subscriptions and resets the shared singletons so a subsequent remount —
* including hot navigation back to the editor in the same tab — starts from
* a clean slate. Without this, the spatial-grid manager and viewer outliner
* accumulate stale references from the previous Editor instance and can
* freeze the app on re-entry.
* a clean slate.
*/
function initializeEditorRuntime(): () => void {
const unsubscribeSpatialGrid = initSpatialGridSync()
@@ -77,15 +85,8 @@ function initializeEditorRuntime(): () => void {
unsubscribeSpatialGrid()
unsubscribeSpaceDetection?.()
// Drop all entries the spatial-grid singleton accumulated for the
// previous scene so the next mount re-syncs from current state instead
// of layering on top of stale data.
spatialGridManager.clear()
// The viewer outliner holds direct Object3D references used by the
// post-processing selection pass. Clearing the underlying arrays (we
// intentionally mutate in place — there is no setter by design) releases
// those refs so the disposed Three.js scene graph can be GC'd.
const outliner = useViewer.getState().outliner
outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0
@@ -123,6 +124,10 @@ export interface EditorProps {
// Thumbnail
onThumbnailCapture?: (blob: Blob) => void
// Version preview overlays (rendered by host app)
sidebarOverlay?: ReactNode
viewerBanner?: ReactNode
// Panel config (passed through to sidebar panels — v1 only)
settingsPanelProps?: SettingsPanelProps
sitePanelProps?: SitePanelProps
@@ -472,6 +477,35 @@ function ViewerCanvasControlsHint({
)
}
function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40"
style={{
left: position.x + DELETE_CURSOR_BADGE_OFFSET_X,
top: position.y + DELETE_CURSOR_BADGE_OFFSET_Y,
}}
>
<div
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${DELETE_CURSOR_BADGE_COLOR}22`,
}}
>
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color={DELETE_CURSOR_BADGE_COLOR}
height={18}
icon="mdi:trash-can-outline"
width={18}
/>
</div>
</div>
)
}
export default function Editor({
layoutVersion = 'v1',
appMenuButton,
@@ -489,13 +523,15 @@ export default function Editor({
isVersionPreviewMode = false,
isLoading = false,
onThumbnailCapture,
sidebarOverlay,
viewerBanner,
settingsPanelProps,
sitePanelProps,
extraSidebarPanels,
presetsAdapter,
commandPaletteEmptyAction,
}: EditorProps) {
useKeyboard()
useKeyboard({ isVersionPreviewMode })
const { isLoadingSceneRef } = useAutoSave({
onSave,
@@ -510,10 +546,14 @@ export default function Editor({
null,
)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const mode = useEditor((s) => s.mode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
const [viewerCursorPosition, setViewerCursorPosition] = useState<{ x: number; y: number } | null>(
null,
)
const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -602,6 +642,17 @@ export default function Editor({
}
}, [isVersionPreviewMode, previewScene])
// Lock scene graph and reset to select mode when entering version preview
useEffect(() => {
useScene.getState().setReadOnly(isVersionPreviewMode)
if (isVersionPreviewMode) {
useEditor.getState().setMode('select')
}
return () => {
useScene.getState().setReadOnly(false)
}
}, [isVersionPreviewMode])
useEffect(() => {
document.body.classList.add('dark')
return () => {
@@ -623,8 +674,8 @@ export default function Editor({
const viewerSceneContent = (
<>
{!isFirstPersonMode && <SelectionManager />}
{!isFirstPersonMode && <BoxSelectTool />}
{!isFirstPersonMode && <FloatingActionMenu />}
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
@@ -632,9 +683,9 @@ export default function Editor({
<RoofEditSystem />
<StairEditSystem />
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
{!isLoading && !isFirstPersonMode && <ToolManager />}
<CustomCameraControls />
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
{isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<PresetThumbnailGenerator />
{!isFirstPersonMode && <SiteEdgeLabels />}
@@ -661,6 +712,33 @@ export default function Editor({
const show2d = viewMode === '2d' || viewMode === 'split'
const show3d = viewMode === '3d' || viewMode === 'split'
const showDeleteCursorBadge = mode === 'delete' && !isVersionPreviewMode
useEffect(() => {
if (!(showDeleteCursorBadge && show3d)) {
setViewerCursorPosition(null)
}
}, [show3d, showDeleteCursorBadge])
const handleViewerPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!showDeleteCursorBadge) {
setViewerCursorPosition(null)
return
}
const rect = event.currentTarget.getBoundingClientRect()
setViewerCursorPosition({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
})
},
[showDeleteCursorBadge],
)
const handleViewerPointerLeave = useCallback(() => {
setViewerCursorPosition(null)
}, [])
const viewerCanvas = (
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
@@ -689,8 +767,14 @@ export default function Editor({
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
<div
className="relative min-w-0 flex-1 overflow-hidden"
onPointerEnter={handleViewerPointerMove}
onPointerLeave={handleViewerPointerLeave}
onPointerMove={handleViewerPointerMove}
style={{ display: show3d ? undefined : 'none' }}
>
{showDeleteCursorBadge && viewerCursorPosition ? (
<DeleteCursorBadge position={viewerCursorPosition} />
) : null}
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
<ViewerCanvasControlsHint
isPreviewMode={isPreviewMode}
@@ -701,7 +785,7 @@ export default function Editor({
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
</div>
</div>
{!isLoading && <ZoneLabelEditorSystem />}
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
</ErrorBoundary>
)
@@ -741,6 +825,34 @@ export default function Editor({
</div>
) : (
<>
<EditorLayoutV2
navbarSlot={navbarSlot}
overlays={
<>
<FloatingLevelSelector />
{!isVersionPreviewMode && (
<div className="pointer-events-auto">
<ActionMenu />
</div>
)}
{!isVersionPreviewMode && (
<div className="pointer-events-auto">
<PanelManager />
</div>
)}
<div className="pointer-events-auto">
<HelperManager />
</div>
{viewerBanner}
</>
}
renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay}
sidebarTabs={tabBarTabs}
viewerContent={viewerCanvas}
viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight}
/>
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="fixed inset-0 z-50 pointer-events-none">
@@ -749,28 +861,6 @@ export default function Editor({
/>
</div>
)}
<EditorLayoutV2
navbarSlot={navbarSlot}
overlays={
<>
<FloatingLevelSelector />
<div className="pointer-events-auto">
<ActionMenu />
</div>
<div className="pointer-events-auto">
<PanelManager />
</div>
<div className="pointer-events-auto">
<HelperManager />
</div>
</>
}
renderTabContent={renderTabContent}
sidebarTabs={tabBarTabs}
viewerContent={viewerCanvas}
viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight}
/>
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
</>
+265 -10
View File
@@ -11,7 +11,8 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
@@ -66,6 +67,88 @@ export const resolveBuildingId = (
return null
}
const HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42,
},
} as const
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
type HighlightableMaterial = Material & {
color?: Color
emissive?: Color
emissiveIntensity?: number
opacity?: number
transparent?: boolean
needsUpdate?: boolean
}
function isHighlightableMesh(object: Object3D): object is Mesh {
return Boolean(
(object as Mesh).isMesh &&
(object as Mesh).material &&
object.visible &&
object.name !== 'collision-mesh',
)
}
function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
const highlightedMaterial = material.clone() as HighlightableMaterial
const profile = HIGHLIGHT_PROFILES[kind]
if (highlightedMaterial.color instanceof Color) {
highlightedMaterial.color = highlightedMaterial.color.clone().lerp(profile.color, profile.blend)
}
if (highlightedMaterial.emissive instanceof Color) {
highlightedMaterial.emissive = highlightedMaterial.emissive
.clone()
.lerp(profile.color, profile.emissiveBlend)
highlightedMaterial.emissiveIntensity = Math.max(
highlightedMaterial.emissiveIntensity ?? 0,
profile.emissiveIntensity,
)
}
if (typeof highlightedMaterial.opacity === 'number' && highlightedMaterial.opacity < 1) {
highlightedMaterial.transparent = true
highlightedMaterial.opacity = Math.min(1, highlightedMaterial.opacity + 0.08)
}
highlightedMaterial.needsUpdate = true
return highlightedMaterial
}
function createHighlightedMaterials(
material: Material | Material[],
kind: HighlightKind,
): Material | Material[] {
if (Array.isArray(material)) {
return material.map((entry) => createHighlightedMaterial(entry, kind))
}
return createHighlightedMaterial(material, kind)
}
function disposeHighlightedMaterials(material: Material | Material[]) {
if (Array.isArray(material)) {
material.forEach((entry) => entry.dispose())
return
}
material.dispose()
}
const computeNextIds = (
node: AnyNode,
selectedIds: string[],
@@ -99,7 +182,19 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
},
structure: {
types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
types: [
'wall',
'item',
'zone',
'slab',
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
],
handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes
@@ -144,7 +239,9 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
node.type === 'roof-segment'
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment'
)
return true
if (node.type === 'item') {
@@ -204,6 +301,8 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
node.type === 'ceiling' ||
node.type === 'roof' ||
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment' ||
node.type === 'window' ||
node.type === 'door'
) {
@@ -233,6 +332,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
export const SelectionManager = () => {
const phase = useEditor((s) => s.phase)
const mode = useEditor((s) => s.mode)
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
const modifierKeysRef = useRef<ModifierKeys>({
meta: false,
ctrl: false,
@@ -241,6 +341,14 @@ export const SelectionManager = () => {
const movingNode = useEditor((s) => s.movingNode)
useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
return () => {
setHoverHighlightMode('default')
}
}, [mode, setHoverHighlightMode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = true
@@ -314,6 +422,12 @@ export const SelectionManager = () => {
nodeToSelect = parentNode
}
}
if (node.type === 'stair-segment' && node.parentId) {
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
if (parentNode && parentNode.type === 'stair') {
nodeToSelect = parentNode
}
}
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
@@ -333,6 +447,8 @@ export const SelectionManager = () => {
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
]
@@ -343,8 +459,15 @@ export const SelectionManager = () => {
const onGridClick = () => {
if (clickHandledRef.current) return
if (boxSelectHandled) return
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
// When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') {
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('select')
}
}
emitter.on('grid:click', onGridClick)
@@ -415,6 +538,8 @@ export const SelectionManager = () => {
node.type === 'ceiling' ||
node.type === 'roof' ||
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment' ||
node.type === 'window' ||
node.type === 'door'
) {
@@ -422,6 +547,9 @@ export const SelectionManager = () => {
if (node.type === 'roof-segment' && currentPhase === 'structure') {
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
}
if (node.type === 'stair-segment' && currentPhase === 'structure') {
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
}
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') {
@@ -461,6 +589,8 @@ export const SelectionManager = () => {
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
'zone',
@@ -529,6 +659,8 @@ export const SelectionManager = () => {
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
'zone',
@@ -553,6 +685,7 @@ export const SelectionManager = () => {
return (
<>
<SelectionStateSync />
<SelectionMaterialSync />
<EditorOutlinerSync />
</>
)
@@ -590,9 +723,127 @@ const SelectionStateSync = () => {
return null
}
const SelectionMaterialSync = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
const activeHighlightKindsRef = useRef(new Map<string, HighlightKind>())
const highlightedMaterialsRef = useRef(
new Map<
Mesh,
{
originalMaterial: Material | Material[]
highlightedMaterial: Material | Material[]
kind: HighlightKind
}
>(),
)
const syncSelectionMaterials = useCallback(() => {
const activeMeshes = new Set<Mesh>()
for (const [id, kind] of activeHighlightKindsRef.current.entries()) {
const node = useScene.getState().nodes[id as AnyNodeId]
if (node?.type === 'wall') {
continue
}
const rootObject = sceneRegistry.nodes.get(id)
if (!rootObject) {
continue
}
rootObject.traverse((child) => {
if (!isHighlightableMesh(child)) {
return
}
activeMeshes.add(child)
const existingEntry = highlightedMaterialsRef.current.get(child)
if (existingEntry) {
const materialWasOverwritten = child.material !== existingEntry.highlightedMaterial
if (materialWasOverwritten || existingEntry.kind !== kind) {
disposeHighlightedMaterials(existingEntry.highlightedMaterial)
const originalMaterial = materialWasOverwritten
? child.material
: existingEntry.originalMaterial
const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind)
child.material = highlightedMaterial
highlightedMaterialsRef.current.set(child, {
originalMaterial,
highlightedMaterial,
kind,
})
}
return
}
const originalMaterial = child.material
const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind)
child.material = highlightedMaterial
highlightedMaterialsRef.current.set(child, {
originalMaterial,
highlightedMaterial,
kind,
})
})
}
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
if (activeMeshes.has(mesh)) {
continue
}
if (mesh.material === entry.highlightedMaterial) {
mesh.material = entry.originalMaterial
}
disposeHighlightedMaterials(entry.highlightedMaterial)
highlightedMaterialsRef.current.delete(mesh)
}
}, [])
useEffect(() => {
const nextHighlightKinds = new Map<string, HighlightKind>()
for (const id of new Set([...selectedIds, ...previewSelectedIds])) {
nextHighlightKinds.set(id, 'selection')
}
if (hoverHighlightMode === 'delete' && hoveredId) {
nextHighlightKinds.set(hoveredId, 'delete')
}
activeHighlightKindsRef.current = nextHighlightKinds
syncSelectionMaterials()
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
useEffect(() => {
return useScene.subscribe(() => {
syncSelectionMaterials()
})
}, [syncSelectionMaterials])
useEffect(() => {
return () => {
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
if (mesh.material === entry.highlightedMaterial) {
mesh.material = entry.originalMaterial
}
disposeHighlightedMaterials(entry.highlightedMaterial)
}
highlightedMaterialsRef.current.clear()
}
}, [])
return null
}
const EditorOutlinerSync = () => {
const phase = useEditor((s) => s.phase)
const selection = useViewer((s) => s.selection)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
const outliner = useViewer((s) => s.outliner)
@@ -609,19 +860,23 @@ const EditorOutlinerSync = () => {
case 'structure':
// Highlight selected items (walls/slabs)
// We IGNORE buildingId even if it's set in the store
idsToHighlight = selection.selectedIds
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
break
case 'furnish':
// Highlight selected furniture/items
idsToHighlight = selection.selectedIds
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
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]
if (selection.selectedIds.length > 0 || previewSelectedIds.length > 0) {
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
} 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)
@@ -636,7 +891,7 @@ const EditorOutlinerSync = () => {
const obj = sceneRegistry.nodes.get(hoveredId)
if (obj) outliner.hoveredObjects.push(obj)
}
}, [phase, selection, hoveredId, outliner])
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
return null
}
-1
View File
@@ -58,7 +58,6 @@ export function WallMeasurementLabel() {
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: reset cached object when selection changes
useEffect(() => {
setWallObject(null)
}, [selectedId])
+144 -18
View File
@@ -1,11 +1,12 @@
'use client'
import { useScene, type ZoneNode } from '@pascal-app/core'
import { type AnyNodeId, emitter, 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 { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
// ─── Per-zone label editor ────────────────────────────────────────────────────
@@ -13,7 +14,13 @@ import useEditor from '../../../store/use-editor'
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setSelection = useViewer((s) => s.setSelection)
const selectedZoneId = useViewer((s) => s.selection.zoneId)
const hoveredId = useViewer((s) => s.hoveredId)
const mode = useEditor((s) => s.mode)
const isSelected = selectedZoneId === zoneId
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
@@ -27,15 +34,26 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
// Setup: find the label element, enable pointer events, and hide the
// zone-renderer's own text node (children[0]) — we replace it via portal.
// Retries via rAF because the <Html> element from drei may not exist yet at mount time.
useEffect(() => {
const el = document.getElementById(`${zoneId}-label`)
if (!el) return
setLabelEl(el)
let cancelled = false
let textEl: HTMLElement | undefined
const textEl = el.children[0] as HTMLElement | undefined
if (textEl) textEl.style.display = 'none'
const tryFind = () => {
const el = document.getElementById(`${zoneId}-label`)
if (!el) {
if (!cancelled) requestAnimationFrame(tryFind)
return
}
setLabelEl(el)
textEl = el.children[0] as HTMLElement | undefined
if (textEl) textEl.style.display = 'none'
}
tryFind()
return () => {
cancelled = true
if (textEl) textEl.style.display = ''
}
}, [zoneId])
@@ -48,6 +66,29 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
}
}, [editing])
// Tint the label pin red when delete-hovered
useEffect(() => {
if (!labelEl) return
const pin = labelEl.querySelector('.label-pin') as HTMLElement | null
if (!pin) return
const line = pin.children[0] as HTMLElement | undefined
const circle = pin.children[1] as HTMLElement | undefined
const color = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1')
if (line) line.style.backgroundColor = color
if (circle) {
circle.style.backgroundColor = color
}
if (isDeleteHovered) {
pin.style.opacity = '1'
}
return () => {
// Restore zone color
const originalColor = zone?.color ?? '#6366f1'
if (line) line.style.backgroundColor = originalColor
if (circle) circle.style.backgroundColor = originalColor
}
}, [isDeleteHovered, labelEl, zone?.color])
const save = useCallback(() => {
const trimmed = value.trim()
if (trimmed !== (zone?.name ?? '')) {
@@ -61,9 +102,38 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
setEditing(false)
}, [zone?.name])
// Select zone + switch to zone mode from any mode
const selectZone = useCallback(() => {
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones')
useEditor.getState().setMode('select')
setSelection({ zoneId })
}, [zoneId, setSelection])
// Enter text editing
const enterTextEditing = useCallback(() => {
selectZone()
setValue(zoneNameRef.current)
setEditing(true)
}, [selectZone])
// Listen for edit-label events from the 2D floorplan (double-click on zone label)
useEffect(() => {
const handler = (event: { zoneId: string }) => {
if (event.zoneId === zoneId) {
setValue(zoneNameRef.current)
setEditing(true)
}
}
emitter.on('zone:edit-label' as any, handler as any)
return () => {
emitter.off('zone:edit-label' as any, handler as any)
}
}, [zoneId])
if (!labelEl) return null
const shadowColor = zone?.color ?? '#6366f1'
const shadowColor = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1')
const textShadow = [
`-1px -1px 0 ${shadowColor}`,
` 1px -1px 0 ${shadowColor}`,
@@ -151,18 +221,78 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
<button
onClick={(e) => {
e.stopPropagation()
setSelection({ zoneId })
setValue(zoneNameRef.current)
setEditing(true)
if (mode === 'delete') {
sfxEmitter.emit('sfx:structure-delete')
deleteNode(zoneId as AnyNodeId)
setSelection({ zoneId: null })
return
}
if (isSelected) {
// Already selected → enter text editing
enterTextEditing()
} else {
// Not selected → select zone + switch to zone mode
selectZone()
}
}}
onMouseDown={(e) => e.stopPropagation()}
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
onPointerEnter={(e) => {
if (mode === 'delete') {
useViewer.setState({ hoveredId: zoneId })
}
}}
onPointerLeave={() => {
if (mode === 'delete' && useViewer.getState().hoveredId === zoneId) {
useViewer.setState({ hoveredId: null })
}
}}
onPointerMove={
mode === 'delete'
? (e) => {
// Re-dispatch pointermove to the viewer container so DeleteCursorBadge tracks the cursor.
const viewerDiv = (e.currentTarget as HTMLElement).closest(
'.relative.overflow-hidden',
)
if (viewerDiv) {
viewerDiv.dispatchEvent(
new PointerEvent('pointermove', {
clientX: e.clientX,
clientY: e.clientY,
bubbles: true,
}),
)
}
}
: undefined
}
style={{
...sharedStyle,
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
type="button"
>
<span>{zone?.name}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
<Pencil size={10} />
</span>
{isSelected && (
<span
onClick={(e) => {
e.stopPropagation()
enterTextEditing()
}}
role="button"
style={{
display: 'inline-flex',
alignItems: 'center',
cursor: 'text',
filter: `drop-shadow(0 0 2px ${shadowColor})`,
}}
tabIndex={0}
>
<Pencil size={12} />
</span>
)}
</button>
),
labelEl,
@@ -179,10 +309,6 @@ export function ZoneLabelEditorSystem() {
.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 (
<>
+61 -15
View File
@@ -1,17 +1,26 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu'
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
// Disable raycasting on zone geometry so clicks pass through to items underneath.
// Zone selection in the editor is handled exclusively via the HTML label overlay.
const noopRaycast = () => {}
const visible = structureLayer === 'zones'
export const ZoneSystem = () => {
useFrame((_, delta) => {
const structureLayer = useEditor.getState().structureLayer
const editorMode = useEditor.getState().mode
const selectedLevelId = useViewer.getState().selection.levelId
const selectedZoneId = useViewer.getState().selection.zoneId
const hoveredId = useViewer.getState().hoveredId
const zoneGeometryVisible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes
const lerpSpeed = 10 * delta
zones.forEach((zoneId) => {
const obj = sceneRegistry.nodes.get(zoneId)
@@ -19,20 +28,57 @@ export const ZoneSystem = () => {
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
const isSelected = zoneId === selectedZoneId
const isDeleteHovered = editorMode === 'delete' && hoveredId === zoneId
if (obj.visible !== visible) {
obj.visible = visible
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
// Show meshes when: in zone mode, selected, or delete-hovered.
if (!obj.visible) obj.visible = true
const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered
const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
if (walls) {
walls.visible = meshVisible
const material = walls.material as MeshBasicNodeMaterial
if (material?.userData?.uOpacity) {
material.userData.uOpacity.value = MathUtils.lerp(
material.userData.uOpacity.value,
targetOpacity,
lerpSpeed,
)
}
}
// 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 floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
if (floor) {
floor.visible = meshVisible
const material = floor.material as MeshBasicNodeMaterial
if (material?.userData?.uOpacity) {
material.userData.uOpacity.value = MathUtils.lerp(
material.userData.uOpacity.value,
targetOpacity,
lerpSpeed,
)
}
}
// Disable raycasting once per zone object so geometry never intercepts clicks
if (!obj.userData.__raycastDisabled) {
obj.raycast = noopRaycast
obj.traverse((child) => {
child.raycast = noopRaycast
})
obj.userData.__raycastDisabled = true
}
// Labels: always visible on the current level (regardless of mode)
const showLabel = !!selectedLevelId && isOnSelectedLevel
const labelOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
labelEl.style.opacity = targetOpacity
if (labelEl && labelEl.style.opacity !== labelOpacity) {
labelEl.style.opacity = labelOpacity
}
})
})
@@ -1,4 +1,12 @@
import type { DoorNode, ItemNode, RoofNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
import type {
DoorNode,
ItemNode,
RoofNode,
RoofSegmentNode,
StairNode,
StairSegmentNode,
WindowNode,
} from '@pascal-app/core'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -76,5 +84,7 @@ export const MoveTool: React.FC = () => {
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
}
@@ -9,6 +9,7 @@ import {
resolveLevelId,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
useScene,
useSpatialQuery,
type WallEvent,
@@ -219,6 +220,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
// Publish live transform for 2D floorplan
if (draft) {
useLiveTransforms.getState().set(draft.id, {
position: result.gridPosition,
rotation: cursorGroupRef.current.rotation.y,
})
}
revalidate()
}
@@ -229,6 +238,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Preserve cursor rotation for the next draft
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
// Clear live transform before commit
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
draftNode.create(gridPosition.current, asset, currentRotation)
@@ -353,6 +367,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (result.dirtyNodeId && posChanged) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
}
// Publish live transform for 2D floorplan
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: result.cursorRotationY,
})
}
}
@@ -361,6 +381,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!result) return
event.stopPropagation()
// Clear live transform before commit
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (result.dirtyNodeId) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
@@ -470,6 +494,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.set(...result.gridPosition)
// Publish live transform for 2D floorplan
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: result.cursorRotationY,
})
}
revalidate()
@@ -508,6 +538,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!result) return
event.stopPropagation()
// Clear live transform before commit
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
@@ -578,6 +612,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.copy(gridPosition.current)
// Publish live transform for 2D floorplan
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: cursorGroupRef.current.rotation.y,
})
}
}
@@ -586,6 +626,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!result) return
event.stopPropagation()
// Clear live transform before commit
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
@@ -657,6 +701,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
cursorGroupRef.current.rotation.y = newRotationY
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.rotation.y = newRotationY
// Update live transform rotation for 2D floorplan
const currentLive = useLiveTransforms.getState().get(draft.id)
if (currentLive) {
useLiveTransforms.getState().set(draft.id, {
...currentLive,
rotation: newRotationY,
})
}
revalidate()
}
}
@@ -693,7 +747,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
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 wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
const edgesGeometry = new EdgesGeometry(boxGeometry)
edgesRef.current.geometry = edgesGeometry
@@ -715,6 +770,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('ceiling:leave', onCeilingLeave)
return () => {
// Clear live transform for any remaining draft
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.destroy()
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
@@ -793,16 +852,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
? getScaledDimensions(initialDraft)
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
initialBoxGeometry.translate(0, dims[1] / 2, 0)
const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
// 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
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
return (
<group ref={cursorGroupRef}>
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef}>
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
<edgesGeometry args={[initialBoxGeometry]} />
</lineSegments>
<mesh
@@ -810,6 +870,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
layers={EDITOR_LAYER}
material={basePlaneMaterial}
ref={basePlaneRef}
renderOrder={999}
/>
</group>
)
@@ -4,7 +4,10 @@ import {
type GridEvent,
type RoofNode,
type RoofSegmentNode,
type StairNode,
type StairSegmentNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -14,9 +17,9 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
node: movingNode,
}) => {
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => {
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
@@ -31,7 +34,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
return [pos.x, pos.y, pos.z]
}
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
if (movingNode.type === 'roof-segment' && movingNode.parentId) {
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentAngle = parentNode.rotation as number
@@ -95,13 +101,14 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
// user sees the individual segment tracking the cursor.
let segmentWrapperGroup: THREE.Object3D | null = null
let mergedRoofMesh: THREE.Object3D | null = null
if (movingNode.type === 'roof-segment') {
if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
if (segmentMesh?.parent) {
// segmentMesh.parent = <group visible={isSelected}> wrapper in RoofRenderer
// segmentMesh.parent.parent = the registered roof group
// segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
// segmentMesh.parent.parent = the registered roof/stair group
segmentWrapperGroup = segmentMesh.parent
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName('merged-roof') ?? null
const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
segmentWrapperGroup.visible = true
if (mergedRoofMesh) mergedRoofMesh.visible = false
}
@@ -111,7 +118,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
let localX = gridX
let localZ = gridZ
if (movingNode.type === 'roof-segment' && movingNode.parentId) {
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
@@ -156,6 +166,12 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
mesh.position.x = localX
mesh.position.z = localZ
}
// Publish world-space position so the 2D floorplan can track the drag
useLiveTransforms.getState().set(movingNode.id, {
position: [gridX, y, gridZ],
rotation: pendingRotation,
})
}
const onGridClick = (event: GridEvent) => {
@@ -181,11 +197,13 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
useLiveTransforms.getState().clear(movingNode.id)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
useLiveTransforms.getState().clear(movingNode.id)
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
} else {
@@ -218,6 +236,15 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) mesh.rotation.y = pendingRotation
// Update live transform rotation for 2D floorplan
const currentLive = useLiveTransforms.getState().get(movingNode.id)
if (currentLive) {
useLiveTransforms.getState().set(movingNode.id, {
...currentLive,
rotation: pendingRotation,
})
}
}
}
@@ -231,6 +258,9 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
if (mergedRoofMesh) mergedRoofMesh.visible = true
// Clear ephemeral live transform
useLiveTransforms.getState().clear(movingNode.id)
if (!wasCommitted) {
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
@@ -16,6 +16,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import {
Box3,
BufferAttribute,
BufferGeometry,
DoubleSide,
@@ -151,6 +152,7 @@ function pointInPolygon(x: number, z: number, polygon: [number, number][]): bool
// ── Node-in-bounds checks ───────────────────────────────────────────────────
const _tempVec = new Vector3()
const _tempBox = new Box3()
function getNodeWorldXZ(nodeId: string): [number, number] | null {
const obj = sceneRegistry.nodes.get(nodeId)
@@ -159,6 +161,26 @@ function getNodeWorldXZ(nodeId: string): [number, number] | null {
return [_tempVec.x, _tempVec.z]
}
function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean {
const obj = sceneRegistry.nodes.get(nodeId)
if (!obj) return false
obj.updateWorldMatrix(true, true)
_tempBox.setFromObject(obj)
if (_tempBox.isEmpty()) {
const xz = getNodeWorldXZ(nodeId)
return Boolean(xz && pointInBounds(xz[0], xz[1], bounds))
}
return !(
_tempBox.max.x < bounds.minX ||
_tempBox.min.x > bounds.maxX ||
_tempBox.max.z < bounds.minZ ||
_tempBox.min.z > bounds.maxZ
)
}
function collectNodeIdsInBounds(bounds: Bounds): string[] {
const { levelId } = useViewer.getState().selection
const { nodes } = useScene.getState()
@@ -214,6 +236,10 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
result.push(node.id)
}
} else if (node.type === 'stair') {
if (objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.id)
}
}
}
} else if (phase === 'structure' && structureLayer === 'zones') {
@@ -243,6 +269,13 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
return result
}
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
return (
currentIds.length === nextIds.length &&
currentIds.every((currentId, index) => currentId === nextIds[index])
)
}
// ── Visual helpers ──────────────────────────────────────────────────────────
function updateRectVisuals(
@@ -300,11 +333,11 @@ function createOutlineSegments(): LineSegments {
geo.setAttribute('position', new BufferAttribute(positions, 3))
const mat = new LineBasicMaterial({
color: '#818cf8',
color: BOX_SELECT_ACCENT_COLOR,
depthTest: false,
depthWrite: false,
transparent: true,
opacity: 0.6,
opacity: 0.85,
})
const segments = new LineSegments(geo, mat)
@@ -318,8 +351,18 @@ function createOutlineSegments(): LineSegments {
// ── Drag threshold (pixels) ─────────────────────────────────────────────────
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
const DRAG_THRESHOLD_PX = 4
function getSnappedGridPosition(x: number, z: number): [number, number] {
return [Math.round(x * 2) / 2, Math.round(z * 2) / 2]
}
function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
target.set(snappedX, y, snappedZ)
}
// ── Component ───────────────────────────────────────────────────────────────
export const BoxSelectTool: React.FC = () => {
@@ -344,6 +387,7 @@ const BOX_SELECT_TOOLTIP = (
const BoxSelectToolInner: React.FC = () => {
const { camera, gl } = useThree()
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const cursorRef = useRef<Group>(null)
const rectFillRef = useRef<Mesh>(null!)
const outlineRef = useRef(createOutlineSegments())
@@ -354,7 +398,8 @@ const BoxSelectToolInner: React.FC = () => {
const startClientX = useRef(0)
const startClientY = useRef(0)
const gridY = useRef(0)
const prevHitCount = useRef(0)
const previousGridPosition = useRef<[number, number] | null>(null)
const previewSelectedIdsRef = useRef<string[]>([])
// Raycasting helpers (same technique as useGridEvents)
const raycasterRef = useRef(new Raycaster())
@@ -366,10 +411,21 @@ const BoxSelectToolInner: React.FC = () => {
useEffect(() => {
const outline = outlineRef.current
return () => {
previewSelectedIdsRef.current = []
setPreviewSelectedIds([])
outline.geometry.dispose()
;(outline.material as LineBasicMaterial).dispose()
}
}, [])
}, [setPreviewSelectedIds])
const syncPreviewSelectedIds = (nextIds: string[]) => {
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) {
return
}
previewSelectedIdsRef.current = nextIds
setPreviewSelectedIds(nextIds)
}
// Sync ground plane Y with the current level
useEffect(() => {
@@ -409,14 +465,15 @@ const BoxSelectToolInner: React.FC = () => {
const point = raycastToGround(e)
if (!point) return
startPoint.current.copy(point)
currentPoint.current.copy(point)
setSnappedPoint(startPoint.current, point.x, point.y, point.z)
setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
gridY.current = point.y
pointerDown.current = true
isDragging.current = false
prevHitCount.current = 0
previousGridPosition.current = getSnappedGridPosition(point.x, point.z)
startClientX.current = e.clientX
startClientY.current = e.clientY
syncPreviewSelectedIds([])
}
const onCanvasPointerUp = (e: PointerEvent) => {
@@ -425,7 +482,7 @@ const BoxSelectToolInner: React.FC = () => {
if (isDragging.current) {
const point = raycastToGround(e)
if (point) currentPoint.current.copy(point)
if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
const bounds: Bounds = {
minX: Math.min(startPoint.current.x, currentPoint.current.x),
@@ -465,6 +522,7 @@ const BoxSelectToolInner: React.FC = () => {
// Hide visuals
if (rectFillRef.current) rectFillRef.current.visible = false
if (outlineRef.current) outlineRef.current.visible = false
syncPreviewSelectedIds([])
// Reset
pointerDown.current = false
@@ -483,14 +541,16 @@ const BoxSelectToolInner: React.FC = () => {
// grid:move for cursor tracking + rectangle update during drag
useEffect(() => {
const onMove = (event: GridEvent) => {
const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
// Always update cursor position
if (cursorRef.current) {
cursorRef.current.position.set(event.position[0], event.position[1], event.position[2])
cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
}
if (!pointerDown.current) return
currentPoint.current.set(event.position[0], event.position[1], event.position[2])
currentPoint.current.set(snappedX, event.position[1], snappedZ)
// Check drag threshold (screen pixels)
const nativeEvent = event.nativeEvent as unknown as PointerEvent
@@ -509,18 +569,23 @@ const BoxSelectToolInner: React.FC = () => {
gridY.current,
)
// Play snap sound when the set of captured nodes changes
const nextGridPosition: [number, number] = [snappedX, snappedZ]
if (
previousGridPosition.current &&
(nextGridPosition[0] !== previousGridPosition.current[0] ||
nextGridPosition[1] !== previousGridPosition.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosition.current = nextGridPosition
const bounds: Bounds = {
minX: Math.min(startPoint.current.x, currentPoint.current.x),
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
}
const hitCount = collectNodeIdsInBounds(bounds).length
if (hitCount !== prevHitCount.current) {
sfxEmitter.emit('sfx:grid-snap')
prevHitCount.current = hitCount
}
syncPreviewSelectedIds(collectNodeIdsInBounds(bounds))
}
}
@@ -545,10 +610,10 @@ const BoxSelectToolInner: React.FC = () => {
>
<planeGeometry args={[1, 1]} />
<meshBasicMaterial
color="#818cf8"
color={BOX_SELECT_ACCENT_COLOR}
depthTest={false}
depthWrite={false}
opacity={0.12}
opacity={0.14}
side={DoubleSide}
transparent
/>
@@ -0,0 +1,7 @@
export const DEFAULT_STAIR_WIDTH = 1.0
export const DEFAULT_STAIR_LENGTH = 3.0
export const DEFAULT_STAIR_HEIGHT = 2.5
export const DEFAULT_STAIR_STEP_COUNT = 10
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
export const DEFAULT_STAIR_THICKNESS = 0.25
@@ -12,45 +12,48 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
DEFAULT_STAIR_ATTACHMENT_SIDE,
DEFAULT_STAIR_FILL_TO_FLOOR,
DEFAULT_STAIR_HEIGHT,
DEFAULT_STAIR_LENGTH,
DEFAULT_STAIR_STEP_COUNT,
DEFAULT_STAIR_THICKNESS,
DEFAULT_STAIR_WIDTH,
} from './stair-defaults'
const GRID_OFFSET = 0.02
// Default stair segment dimensions
const DEFAULT_WIDTH = 1.0
const DEFAULT_LENGTH = 3.0
const DEFAULT_HEIGHT = 2.5
const DEFAULT_STEP_COUNT = 10
/**
* Generates the step-profile geometry for the ghost preview.
* Same algorithm as StairSystem's generateStairSegmentGeometry.
*/
function createStairPreviewGeometry(): THREE.BufferGeometry {
const riserHeight = DEFAULT_HEIGHT / DEFAULT_STEP_COUNT
const treadDepth = DEFAULT_LENGTH / DEFAULT_STEP_COUNT
const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT
const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT
const shape = new THREE.Shape()
shape.moveTo(0, 0)
for (let i = 0; i < DEFAULT_STEP_COUNT; i++) {
for (let i = 0; i < DEFAULT_STAIR_STEP_COUNT; i++) {
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
}
// Fill to floor (absoluteHeight = 0)
shape.lineTo(DEFAULT_LENGTH, 0)
shape.lineTo(DEFAULT_STAIR_LENGTH, 0)
shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, {
steps: 1,
depth: DEFAULT_WIDTH,
depth: DEFAULT_STAIR_WIDTH,
bevelEnabled: false,
})
// Rotate so extrusion is along X (width), shape profile in XZ plane
const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(DEFAULT_WIDTH / 2, 0, 0)
matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0)
geometry.applyMatrix4(matrix)
return geometry
@@ -71,12 +74,13 @@ function commitStairPlacement(
const segment = StairSegmentNode.parse({
segmentType: 'stair',
width: DEFAULT_WIDTH,
length: DEFAULT_LENGTH,
height: DEFAULT_HEIGHT,
stepCount: DEFAULT_STEP_COUNT,
attachmentSide: 'front',
fillToFloor: true,
width: DEFAULT_STAIR_WIDTH,
length: DEFAULT_STAIR_LENGTH,
height: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STAIR_STEP_COUNT,
attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
thickness: DEFAULT_STAIR_THICKNESS,
position: [0, 0, 0],
})
+25 -1
View File
@@ -1,32 +1,40 @@
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
export type WallPlanPoint = [number, number]
export const WALL_GRID_STEP = 0.5
export const WALL_JOIN_SNAP_RADIUS = 0.35
export const WALL_MIN_LENGTH = 0.5
export const WALL_MIN_LENGTH = 0.01
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
return Math.round(value / step) * step
}
export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
}
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
const dx = cursor[0] - start[0]
const dz = cursor[1] - start[1]
const angle = Math.atan2(dz, dx)
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
const distance = Math.sqrt(dx * dx + dz * dz)
return snapPointToGrid([
start[0] + Math.cos(snappedAngle) * distance,
start[1] + Math.sin(snappedAngle) * distance,
])
}
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
const [x1, z1] = wall.start
const [x2, z2] = wall.end
@@ -36,12 +44,15 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
if (lengthSquared < 1e-9) {
return null
}
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
if (t <= 0 || t >= 1) {
return null
}
return [x1 + dx * t, z1 + dz * t]
}
export function findWallSnapTarget(
point: WallPlanPoint,
walls: WallNode[],
@@ -51,10 +62,12 @@ export function findWallSnapTarget(
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
let bestTarget: WallPlanPoint | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY
for (const wall of walls) {
if (ignoreWallIds.has(wall.id)) {
continue
}
const candidates: Array<WallPlanPoint | null> = [
wall.start,
wall.end,
@@ -64,6 +77,7 @@ export function findWallSnapTarget(
if (!candidate) {
continue
}
const candidateDistanceSquared = distanceSquared(point, candidate)
if (
candidateDistanceSquared > radiusSquared ||
@@ -71,12 +85,15 @@ export function findWallSnapTarget(
) {
continue
}
bestTarget = candidate
bestDistanceSquared = candidateDistanceSquared
}
}
return bestTarget
}
export function snapWallDraftPoint(args: {
point: WallPlanPoint
walls: WallNode[]
@@ -86,31 +103,38 @@ export function snapWallDraftPoint(args: {
}): WallPlanPoint {
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
return (
findWallSnapTarget(basePoint, walls, {
ignoreWallIds,
}) ?? basePoint
)
}
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
}
export function createWallOnCurrentLevel(
start: WallPlanPoint,
end: WallPlanPoint,
): WallNode | null {
const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState()
if (!(currentLevelId && isWallLongEnough(start, end))) {
return null
}
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
const wall = WallSchema.parse({
name: `Wall ${wallCount + 1}`,
start,
end,
})
createNode(wall, currentLevelId)
sfxEmitter.emit('sfx:structure-build')
return wall
}
+3 -8
View File
@@ -6,12 +6,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
createWallOnCurrentLevel,
snapWallDraftPoint,
WALL_MIN_LENGTH,
type WallPlanPoint,
} from './wall-drafting'
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
const WALL_HEIGHT = 2.5
@@ -23,7 +18,7 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < WALL_MIN_LENGTH) {
if (length < 0.01) {
mesh.visible = false
return
}
@@ -148,7 +143,7 @@ export const WallTool: React.FC = () => {
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z
if (dx * dx + dz * dz < WALL_MIN_LENGTH * WALL_MIN_LENGTH) return
if (dx * dx + dz * dz < 0.01 * 0.01) return
createWallOnCurrentLevel(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
+2 -3
View File
@@ -2,7 +2,6 @@ import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pa
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot'
import { EDITOR_LAYER } from './../../../lib/constants'
import useEditor from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -55,8 +54,8 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
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]
// Default to blue, cycle through palette for subsequent zones
const color = '#3b82f6'
const zone = ZoneNode.parse({
name,
+74 -97
View File
@@ -1,97 +1,74 @@
'use client'
import { Icon } from '@iconify/react'
import { emitter } from '@pascal-app/core'
import Image from 'next/image'
import useEditor from '../../../store/use-editor'
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')
}
const enterStreetView = () => {
useEditor.getState().setFirstPersonMode(true)
}
return (
<div className="flex items-center gap-1">
{/* Orbit CCW */}
<ActionButton
className="group hover:bg-white/5"
label="Orbit Left"
onClick={orbitCCW}
size="icon"
variant="ghost"
>
<Image
alt="Orbit Left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/rotate.png"
width={28}
/>
</ActionButton>
{/* Orbit CW */}
<ActionButton
className="group hover:bg-white/5"
label="Orbit Right"
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
className="group hover:bg-white/5"
label="Top View"
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>
{/* Street View */}
<ActionButton
className="group hover:bg-white/5"
label="Street View"
onClick={enterStreetView}
size="icon"
variant="ghost"
>
<Icon
className="opacity-70 transition-opacity group-hover:opacity-100"
color="currentColor"
height={22}
icon="mdi:walk"
width={22}
/>
</ActionButton>
</div>
)
}
'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
className="group hover:bg-white/5"
label="Orbit Left"
onClick={orbitCCW}
size="icon"
variant="ghost"
>
<Image
alt="Orbit Left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/rotate.png"
width={28}
/>
</ActionButton>
{/* Orbit CW */}
<ActionButton
className="group hover:bg-white/5"
label="Orbit Right"
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
className="group hover:bg-white/5"
label="Top View"
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>
)
}
+48 -1
View File
@@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button'
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'delete'
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete'
type ControlConfig = {
id: ControlId
@@ -54,6 +54,22 @@ const controls: ControlConfig[] = [
color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 text-green-400',
},
{
id: 'furnish',
imageSrc: '/icons/couch.png',
label: 'Furnish',
shortcut: 'F',
color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 text-green-400',
},
{
id: 'zone',
imageSrc: '/icons/zone.png',
label: 'Zone',
shortcut: 'Z',
color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 text-green-400',
},
{
id: 'delete',
icon: Trash2,
@@ -82,11 +98,18 @@ export function ControlModes() {
const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
const canEnterSiteEdit = isGroundFloor || isSiteEditing
const structureLayer = useEditor((state) => state.structureLayer)
const getIsActive = (id: ControlId): boolean => {
if (isSiteEditing) return id === 'site-edit'
if (id === 'select') return mode === 'select' && selectionTool === 'click'
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
if (id === 'site-edit') return false
if (id === 'build')
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
if (id === 'zone')
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
return mode === id
}
@@ -118,6 +141,30 @@ export function ControlModes() {
} else if (id === 'box-select') {
setMode('select')
setSelectionTool('marquee')
} else if (id === 'build') {
// Toggle: if already in structure build, go back to select
if (getIsActive('build')) {
setMode('select')
} else {
setPhase('structure')
setStructureLayer('elements')
setMode('build')
}
} else if (id === 'furnish') {
if (getIsActive('furnish')) {
setMode('select')
} else {
setPhase('furnish')
setMode('build')
}
} else if (id === 'zone') {
if (getIsActive('zone')) {
setMode('select')
} else {
setPhase('structure')
setStructureLayer('zones')
setMode('build')
}
} else {
setMode(id)
}
+116 -16
View File
@@ -8,14 +8,18 @@ import {
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { ChevronDown } from 'lucide-react'
import { useCallback, useState } from 'react'
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '../../../lib/utils'
import { useUploadStore } from '../../../store/use-upload'
import { SliderControl } from '../controls/slider-control'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button'
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
// ── Helper: get guide images for the current level ──────────────────────────
function useLevelGuides(): GuideNode[] {
@@ -48,12 +52,67 @@ function useLevelScans(): ScanNode[] {
)
}
// ── Shared upload button for dropdowns ──────────────────────────────────────
function UploadButton() {
const fileInputRef = useRef<HTMLInputElement>(null)
const levelId = useViewer((s) => s.selection.levelId)
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!(file && levelId)) return
e.target.value = ''
const { uploadHandler } = useUploadStore.getState()
if (!uploadHandler) return
if (file.size > MAX_FILE_SIZE) return
const isScan =
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/')
if (!(isScan || isImage)) return
const type = isScan ? 'scan' : 'guide'
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
if (!projectId) return
useUploadStore.getState().clearUpload(levelId)
uploadHandler(projectId, levelId, file, type)
},
[levelId],
)
return (
<>
<button
aria-label="Upload scan or guide image"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<Plus className="h-3 w-3" />
</button>
<input
accept={ACCEPTED_FILE_TYPES}
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
</>
)
}
// ── Guides toggle + dropdown ────────────────────────────────────────────────
function GuidesControl() {
const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides)
const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const [isOpen, setIsOpen] = useState(false)
const guides = useLevelGuides()
@@ -74,7 +133,7 @@ function GuidesControl() {
className={cn(
'rounded-r-none p-0',
showGuides
? 'bg-white/10'
? 'bg-white/15'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
@@ -82,11 +141,16 @@ function GuidesControl() {
size="icon"
variant="ghost"
>
<img
alt="Guides"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png"
/>
<div className="relative">
<img
alt="Guides"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png"
/>
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{guides.length}
</span>
</div>
</ActionButton>
{/* Dropdown chevron */}
@@ -96,7 +160,13 @@ function GuidesControl() {
aria-label="Guide image settings"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',
showGuides
? isOpen
? 'bg-white/10'
: 'bg-white/5 hover:bg-white/8'
: isOpen
? 'bg-white/8'
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
)}
type="button"
>
@@ -116,7 +186,7 @@ function GuidesControl() {
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
</span>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Guide images</p>
{hasGuides && (
<p className="text-muted-foreground text-xs">
@@ -124,13 +194,14 @@ function GuidesControl() {
</p>
)}
</div>
<UploadButton />
</div>
{hasGuides ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{guides.map((guide, index) => (
<div
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
key={guide.id}
>
<div className="flex min-w-0 items-center gap-2">
@@ -142,6 +213,14 @@ function GuidesControl() {
<p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`}
</p>
<button
aria-label="Delete guide image"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(guide.id)}
type="button"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
<SliderControl
label="Opacity"
@@ -173,6 +252,7 @@ function ScansControl() {
const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans)
const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const [isOpen, setIsOpen] = useState(false)
const scans = useLevelScans()
@@ -193,7 +273,7 @@ function ScansControl() {
className={cn(
'rounded-r-none p-0',
showScans
? 'bg-white/10'
? 'bg-white/15'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
@@ -201,7 +281,12 @@ function ScansControl() {
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
<div className="relative">
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{scans.length}
</span>
</div>
</ActionButton>
{/* Dropdown chevron */}
@@ -211,7 +296,13 @@ function ScansControl() {
aria-label="Scan settings"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',
showScans
? isOpen
? 'bg-white/10'
: 'bg-white/5 hover:bg-white/8'
: isOpen
? 'bg-white/8'
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
)}
type="button"
>
@@ -231,7 +322,7 @@ function ScansControl() {
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
</span>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Scans</p>
{hasScans && (
<p className="text-muted-foreground text-xs">
@@ -239,13 +330,14 @@ function ScansControl() {
</p>
)}
</div>
<UploadButton />
</div>
{hasScans ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{scans.map((scan, index) => (
<div
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
key={scan.id}
>
<div className="flex min-w-0 items-center gap-2">
@@ -257,6 +349,14 @@ function ScansControl() {
<p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`}
</p>
<button
aria-label="Delete scan"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(scan.id)}
type="button"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
<SliderControl
label="Opacity"
+40 -5
View File
@@ -3,8 +3,9 @@
import type { AnyNodeId, LevelNode } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Command } from 'cmdk'
import { Command, useCommandState } from 'cmdk'
import { ChevronRight, Search } from 'lucide-react'
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { create } from 'zustand'
import { useShallow } from 'zustand/shallow'
@@ -160,10 +161,41 @@ const PAGE_LABEL: Record<string, string> = {
'camera-scope': '',
}
// ---------------------------------------------------------------------------
// Empty state fallback (force-mounted, visible only when no results)
// ---------------------------------------------------------------------------
export interface CommandPaletteEmptyAction {
icon: ReactNode
label: (query: string) => string
onSelect: (query: string) => void
}
function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
const count = useCommandState((s) => s.filtered.count)
const search = useCommandState((s) => s.search)
if (count > 0) return null
// No Command.Group wrapper — groups hide themselves when not in filtered.groups (which is
// empty when nothing matches), swallowing the force-mounted item even with forceMount on
// the item itself.
return (
<Command.Item
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[selected=true]:bg-accent"
forceMount
onSelect={() => action.onSelect(search)}
value="__empty_action__"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{action.icon}
</span>
<span className="flex-1 truncate">{action.label(search)}</span>
</Command.Item>
)
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function CommandPalette() {
export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
const {
open,
setOpen,
@@ -353,9 +385,12 @@ export function CommandPalette() {
</div>
<Command.List className="max-h-100 overflow-y-auto p-1.5">
<Command.Empty className="py-8 text-center text-muted-foreground text-sm">
No commands found.
</Command.Empty>
{(!emptyAction || page) && (
<Command.Empty className="py-8 text-center text-muted-foreground text-sm">
No commands found.
</Command.Empty>
)}
{emptyAction && !page && <EmptyActionItem action={emptyAction} />}
{/* ── Registered page view (e.g. 'ai') ─────────────────────── */}
{page &&
+37 -21
View File
@@ -35,7 +35,9 @@ type MaterialPickerProps = {
}
export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(value?.preset === 'custom' || !!value?.properties)
const [showCustom, setShowCustom] = useState<boolean>(
value?.preset === 'custom' || !!value?.properties,
)
const currentPreset = value?.preset || 'white'
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]
@@ -60,7 +62,10 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
}
}
const handlePropertyChange = (prop: keyof typeof currentProps, val: typeof currentProps[keyof typeof currentProps]) => {
const handlePropertyChange = (
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
onChange({
preset: showCustom ? 'custom' : currentPreset,
properties: {
@@ -84,7 +89,10 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
onClick={() => handlePresetChange(preset)}
style={{
backgroundColor: PRESET_COLORS[preset],
backgroundImage: preset === 'glass' ? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)' : undefined,
backgroundImage:
preset === 'glass'
? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)'
: undefined,
backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
}}
title={PRESET_LABELS[preset]}
@@ -96,15 +104,15 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
{showCustom && (
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Color</label>
<label className="w-16 text-gray-500 text-xs">Color</label>
<input
className="h-7 w-12 rounded border border-gray-300 cursor-pointer"
className="h-7 w-12 cursor-pointer rounded border border-gray-300"
onChange={(e) => handlePropertyChange('color', e.target.value)}
type="color"
value={currentProps.color}
/>
<input
className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded"
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) => handlePropertyChange('color', e.target.value)}
type="text"
value={currentProps.color}
@@ -112,41 +120,45 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Roughness</label>
<label className="w-16 text-gray-500 text-xs">Roughness</label>
<input
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer"
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1}
min={0}
onChange={(e) => handlePropertyChange('roughness', parseFloat(e.target.value))}
onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
step={0.01}
type="range"
value={currentProps.roughness}
/>
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.roughness.toFixed(2)}</span>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.roughness.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Metalness</label>
<label className="w-16 text-gray-500 text-xs">Metalness</label>
<input
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer"
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1}
min={0}
onChange={(e) => handlePropertyChange('metalness', parseFloat(e.target.value))}
onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
step={0.01}
type="range"
value={currentProps.metalness}
/>
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.metalness.toFixed(2)}</span>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Opacity</label>
<label className="w-16 text-gray-500 text-xs">Opacity</label>
<input
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer"
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1}
min={0}
onChange={(e) => {
const opacity = parseFloat(e.target.value)
const opacity = Number.parseFloat(e.target.value)
handlePropertyChange('opacity', opacity)
if (opacity < 1 && !currentProps.transparent) {
handlePropertyChange('transparent', true)
@@ -156,14 +168,18 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
type="range"
value={currentProps.opacity}
/>
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.opacity.toFixed(2)}</span>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Side</label>
<label className="w-16 text-gray-500 text-xs">Side</label>
<select
className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded"
onChange={(e) => handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')}
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side}
>
<option value="front">Front</option>
@@ -16,6 +16,11 @@ interface SliderControlProps {
unit?: string
}
function stepPrecision(s: number): number {
if (s <= 0) return 0
return Math.max(0, Math.ceil(-Math.log10(s)))
}
export function SliderControl({
label,
value,
@@ -32,7 +37,7 @@ export function SliderControl({
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const dragRef = useRef<{ accumulatedDx: number; startValue: number } | null>(null)
const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
const labelRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
@@ -57,7 +62,7 @@ export function SliderControl({
if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision))
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
}
el.addEventListener('wheel', handleWheel, { passive: false })
@@ -77,7 +82,7 @@ export function SliderControl({
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision))
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
}
}
@@ -89,15 +94,8 @@ export function SliderControl({
(e: React.PointerEvent<HTMLDivElement>) => {
if (isEditing) return
e.preventDefault()
// Use PointerLock for infinite dragging (Unity3D-style).
// Falls back to pointer capture if lock is denied.
const el = e.currentTarget
if (el.requestPointerLock) {
el.requestPointerLock()
} else {
el.setPointerCapture(e.pointerId)
}
dragRef.current = { accumulatedDx: 0, startValue: valueRef.current }
e.currentTarget.setPointerCapture(e.pointerId)
dragRef.current = { startX: e.clientX, startValue: valueRef.current }
setIsDragging(true)
useScene.temporal.getState().pause()
},
@@ -107,15 +105,15 @@ export function SliderControl({
const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
// Accumulate movementX for infinite dragging. movementX gives the
// delta since the last event, independent of screen bounds.
dragRef.current.accumulatedDx += e.movementX
const { accumulatedDx, startValue } = dragRef.current
const { startX, startValue } = dragRef.current
const dx = e.clientX - startX
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
// 4 px per step at default sensitivity
const newValue = clamp(Number.parseFloat((startValue + (accumulatedDx / 4) * s).toFixed(precision)))
const newValue = clamp(
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
)
onChange(newValue)
},
[step, precision, clamp, onChange],
@@ -128,12 +126,7 @@ export function SliderControl({
const finalVal = valueRef.current
dragRef.current = null
setIsDragging(false)
if (document.pointerLockElement) {
document.exitPointerLock()
} else {
e.currentTarget.releasePointerCapture(e.pointerId)
}
e.currentTarget.releasePointerCapture(e.pointerId)
if (startValue !== finalVal) {
onChange(startValue)
@@ -146,28 +139,6 @@ export function SliderControl({
[onChange],
)
// Clean up drag state if pointer lock is lost unexpectedly (e.g. Escape key)
useEffect(() => {
const handlePointerLockChange = () => {
if (!document.pointerLockElement && dragRef.current) {
const { startValue } = dragRef.current
const finalVal = valueRef.current
dragRef.current = null
setIsDragging(false)
if (startValue !== finalVal) {
onChange(startValue)
useScene.temporal.getState().resume()
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
}
}
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => document.removeEventListener('pointerlockchange', handlePointerLockChange)
}, [onChange])
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
@@ -262,7 +233,7 @@ export function SliderControl({
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
+310 -31
View File
@@ -1,20 +1,178 @@
'use client'
import { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
LevelNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { MoreVertical, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import { cn } from '../../lib/utils'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './primitives/dialog'
import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
function getLevelDisplayLabel(level: LevelNode) {
return level.name || `Level ${level.level}`
}
// ── Inline rename input for a level row ─────────────────────────────────────
function LevelInlineRename({
level,
isEditing,
onStopEditing,
}: {
level: LevelNode
isEditing: boolean
onStopEditing: () => void
}) {
const updateNode = useScene((s) => s.updateNode)
const defaultName = `Level ${level.level}`
const [value, setValue] = useState(level.name || '')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isEditing) {
setValue(level.name || '')
setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 0)
}
}, [isEditing, level.name])
const handleSave = useCallback(() => {
const trimmed = value.trim()
if (trimmed !== level.name) {
updateNode(level.id, { name: trimmed || undefined })
}
onStopEditing()
}, [value, level.id, level.name, updateNode, onStopEditing])
if (!isEditing) return null
return (
<input
className="m-0 h-full w-full min-w-0 rounded-lg bg-transparent px-2.5 py-1.5 font-medium text-foreground text-xs outline-none ring-1 ring-primary/50"
onBlur={handleSave}
onChange={(e) => setValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
e.preventDefault()
onStopEditing()
}
}}
placeholder={defaultName}
ref={inputRef}
type="text"
value={value}
/>
)
}
// ── Level row with three-dot menu ───────────────────────────────────────────
function LevelRow({
level,
isSelected,
onSelect,
onRequestDelete,
}: {
level: LevelNode
isSelected: boolean
onSelect: () => void
onRequestDelete: () => void
}) {
const [isEditing, setIsEditing] = useState(false)
return (
<div className="group/level">
{isEditing ? (
<LevelInlineRename
isEditing={isEditing}
level={level}
onStopEditing={() => setIsEditing(false)}
/>
) : (
<div
className={cn(
'flex items-center rounded-lg transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
)}
>
<button
className="flex min-w-0 flex-1 items-center justify-start px-2.5 py-1.5 font-medium text-xs"
onClick={onSelect}
onDoubleClick={(e) => {
e.stopPropagation()
setIsEditing(true)
}}
title={getLevelDisplayLabel(level)}
type="button"
>
<span className="truncate">{getLevelDisplayLabel(level)}</span>
</button>
{/* Vertical three-dot menu — inside the pill */}
<Popover>
<PopoverTrigger asChild>
<button
className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground/40 opacity-0 transition-all hover:text-foreground group-hover/level:opacity-100"
onClick={(e) => e.stopPropagation()}
type="button"
>
<MoreVertical className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-36 p-1" side="right" sideOffset={8}>
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
onRequestDelete()
}}
type="button"
>
<Trash2 className="h-3 w-3" />
Delete level
</button>
</PopoverContent>
</Popover>
</div>
)}
</div>
)
}
// ── Main component ──────────────────────────────────────────────────────────
export function FloatingLevelSelector() {
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const levelId = useViewer((s) => s.selection.levelId)
const setSelection = useViewer((s) => s.setSelection)
const createNode = useScene((s) => s.createNode)
const updateNodes = useScene((s) => s.updateNodes)
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
// Resolve the effective building ID — selected or first in scene (scalar, stable reference)
const resolvedBuildingId = useScene((state) => {
if (selectedBuildingId) return selectedBuildingId
const first = Object.values(state.nodes).find((n) => n?.type === 'building') as
@@ -23,7 +181,6 @@ export function FloatingLevelSelector() {
return first?.id ?? null
})
// Get levels for the resolved building (array, useShallow for stable reference)
const levels = useScene(
useShallow((state) => {
if (!resolvedBuildingId) return [] as LevelNode[]
@@ -36,41 +193,163 @@ export function FloatingLevelSelector() {
}),
)
if (levels.length <= 1) return null
const handleAddAbove = useCallback(() => {
if (!resolvedBuildingId) return
const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1
const newLevel = LevelNode.parse({
level: maxLevel + 1,
children: [],
parentId: resolvedBuildingId,
})
createNode(newLevel, resolvedBuildingId)
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
}, [resolvedBuildingId, levels, createNode, setSelection])
const handleAddBelow = useCallback(() => {
if (!resolvedBuildingId) return
const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1
const newLevel = LevelNode.parse({
level: minLevel - 1,
children: [],
parentId: resolvedBuildingId,
})
createNode(newLevel, resolvedBuildingId)
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
}, [resolvedBuildingId, levels, createNode, setSelection])
const handleInsertBetween = useCallback(
(lowerIndex: number) => {
if (!resolvedBuildingId) return
const lower = levels[lowerIndex]
if (!lower) return
const newLevelNumber = lower.level + 1
const toShift = levels.filter((l) => l.level >= newLevelNumber)
if (toShift.length > 0) {
updateNodes(
toShift.map((l) => ({
id: l.id as AnyNodeId,
data: { level: l.level + 1 } as Partial<AnyNode>,
})),
)
}
const newLevel = LevelNode.parse({
level: newLevelNumber,
children: [],
parentId: resolvedBuildingId,
})
createNode(newLevel, resolvedBuildingId)
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
},
[resolvedBuildingId, levels, createNode, updateNodes, setSelection],
)
const handleConfirmDelete = useCallback(() => {
if (!deletingLevel) return
deleteLevelWithFallbackSelection(deletingLevel.id)
setDeletingLevel(null)
}, [deletingLevel])
if (levels.length === 0) return null
// Display highest level at top, ground at bottom
const reversedLevels = [...levels].reverse()
const addButtonClass =
'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground'
return (
<div className="pointer-events-auto absolute top-14 left-3 z-20">
{/* Outer: rounded-xl (12px) with p-1 (4px) → inner: rounded-lg (8px) for concentric radii */}
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
{reversedLevels.map((level) => {
const isSelected = level.id === levelId
return (
<>
<div className="pointer-events-auto absolute top-14 left-3 z-20">
<div className="relative">
{/* Floating + at top edge */}
<button
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
onClick={handleAddAbove}
title="Add level above"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
{/* Floating + at bottom edge */}
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={handleAddBelow}
title="Add level below"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
{/* Level list */}
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
{reversedLevels.map((level, i) => {
const isSelected = level.id === levelId
const sortedIndex = levels.indexOf(level)
const showGapBelow = i < reversedLevels.length - 1
return (
<div className="relative" key={level.id}>
<LevelRow
isSelected={isSelected}
level={level}
onRequestDelete={() => setDeletingLevel(level)}
onSelect={() =>
setSelection(
resolvedBuildingId
? { buildingId: resolvedBuildingId, levelId: level.id }
: { levelId: level.id },
)
}
/>
{showGapBelow && (
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={() => handleInsertBetween(sortedIndex - 1)}
title="Insert level here"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
</div>
)
})}
</div>
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog onOpenChange={(open) => !open && setDeletingLevel(null)} open={!!deletingLevel}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Delete level</DialogTitle>
<DialogDescription>
Are you sure you want to delete{' '}
<strong>{deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}</strong>? All
walls, floors, and objects on this level will be permanently removed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
className={cn(
'flex min-w-[80px] items-center justify-start rounded-lg px-2.5 py-1.5 font-medium text-xs transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
)}
key={level.id}
onClick={() =>
setSelection(
resolvedBuildingId
? { buildingId: resolvedBuildingId, levelId: level.id }
: { levelId: level.id },
)
}
title={getLevelDisplayLabel(level)}
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
onClick={() => setDeletingLevel(null)}
type="button"
>
<span className="truncate">{getLevelDisplayLabel(level)}</span>
Cancel
</button>
)
})}
</div>
</div>
<button
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
onClick={handleConfirmDelete}
type="button"
>
Delete
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type AssetInput, ItemNode } from '@pascal-app/core'
import type { AssetInput } from '@pascal-app/core'
export const CATALOG_ITEMS: AssetInput[] = [
{
id: 'tesla',
+10 -14
View File
@@ -32,6 +32,13 @@ export function CeilingPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
@@ -95,10 +102,6 @@ export function CeilingPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -107,12 +110,8 @@ export function CeilingPanel() {
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const pi = polygon[i]
const pj = polygon[j]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
}
return Math.abs(area) / 2
}
@@ -224,10 +223,7 @@ export function CeilingPanel() {
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
+21 -12
View File
@@ -1,6 +1,13 @@
'use client'
import { type AnyNode, type AnyNodeId, type MaterialSchema, DoorNode, emitter, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
DoorNode,
emitter,
type MaterialSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
@@ -39,6 +46,13 @@ export function DoorPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -80,10 +94,9 @@ export function DoorPanel() {
}, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
if (!node) return
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 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]!
@@ -563,13 +576,6 @@ export function DoorPanel() {
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={(material) => handleUpdate({ material })}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -586,6 +592,9 @@ export function DoorPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
+2 -2
View File
@@ -37,12 +37,12 @@ export function PanelManager() {
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'slab':
return <SlabPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
case 'wall':
+10 -11
View File
@@ -41,6 +41,13 @@ export function RoofPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -124,10 +131,6 @@ export function RoofPanel() {
setSelection({ selectedIds: [] })
}, [selectedId, node, setSelection])
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
const segments = (node.children ?? [])
@@ -235,13 +238,6 @@ export function RoofPanel() {
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -258,6 +254,9 @@ export function RoofPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
+10 -11
View File
@@ -55,6 +55,13 @@ export function RoofSegmentPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -110,10 +117,6 @@ export function RoofSegmentPanel() {
}
}, [selectedId, node, setSelection])
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
return (
@@ -299,13 +302,6 @@ export function RoofSegmentPanel() {
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -322,6 +318,9 @@ export function RoofSegmentPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
+9 -14
View File
@@ -30,9 +30,12 @@ export function SlabPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
@@ -105,12 +108,8 @@ export function SlabPanel() {
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const pi = polygon[i]
const pj = polygon[j]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
}
return Math.abs(area) / 2
}
@@ -221,12 +220,8 @@ export function SlabPanel() {
/>
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
+31 -22
View File
@@ -1,6 +1,12 @@
'use client'
import { type AnyNode, type AnyNodeId, type MaterialSchema, useScene, type WallNode } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type MaterialSchema,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { MaterialPicker } from '../controls/material-picker'
@@ -26,29 +32,35 @@ export function WallPanel() {
[selectedId, updateNode],
)
const handleUpdateLength = useCallback((newLength: number) => {
if (!node || newLength <= 0) return
const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const currentLength = Math.sqrt(dx * dx + dz * dz)
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const currentLength = Math.sqrt(dx * dx + dz * dz)
if (currentLength === 0) return
if (currentLength === 0) return
const dirX = dx / currentLength
const dirZ = dz / currentLength
const dirX = dx / currentLength
const dirZ = dz / currentLength
const newEnd: [number, number] = [
node.start[0] + dirX * newLength,
node.start[1] + dirZ * newLength
]
const newEnd: [number, number] = [
node.start[0] + dirX * newLength,
node.start[1] + dirZ * newLength,
]
handleUpdate({ end: newEnd })
}, [node, handleUpdate])
handleUpdate({ end: newEnd })
},
[node, handleUpdate],
)
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
@@ -104,10 +116,7 @@ export function WallPanel() {
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
+18 -12
View File
@@ -1,6 +1,13 @@
'use client'
import { type AnyNode, type AnyNodeId, emitter, type MaterialSchema, useScene, WindowNode } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
emitter,
type MaterialSchema,
useScene,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
@@ -40,6 +47,13 @@ export function WindowPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -139,10 +153,6 @@ export function WindowPanel() {
[handleUpdate],
)
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
const numCols = node.columnRatios.length
@@ -407,13 +417,6 @@ export function WindowPanel() {
)}
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -430,6 +433,9 @@ export function WindowPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
-1
View File
@@ -32,7 +32,6 @@ const SIDEBAR_WIDTH = '18rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
const SIDEBAR_COLLAPSE_THRESHOLD = 220
const SIDEBAR_MAX_WIDTH = 800
+51 -28
View File
@@ -1,16 +1,19 @@
'use client'
import { type ReactNode, useEffect, useState } from 'react'
import { CommandPalette } from './../../../components/ui/command-palette'
import { type ReactNode, useEffect } from 'react'
import {
CommandPalette,
type CommandPaletteEmptyAction,
} from './../../../components/ui/command-palette'
import { EditorCommands } from './../../../components/ui/command-palette/editor-commands'
import {
Sidebar,
SidebarContent,
SidebarHeader,
useSidebarStore,
} from './../../../components/ui/primitives/sidebar'
import { cn } from './../../../lib/utils'
import { IconRail, type PanelId } from './icon-rail'
import useEditor from './../../../store/use-editor'
import { type ExtraPanel, IconRail } from './icon-rail'
import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'
import { SitePanel, type SitePanelProps } from './panels/site-panel'
@@ -19,6 +22,8 @@ interface AppSidebarProps {
sidebarTop?: ReactNode
settingsPanelProps?: SettingsPanelProps
sitePanelProps?: SitePanelProps
extraPanels?: ExtraPanel[]
commandPaletteEmptyAction?: CommandPaletteEmptyAction
}
export function AppSidebar({
@@ -26,8 +31,15 @@ export function AppSidebar({
sidebarTop,
settingsPanelProps,
sitePanelProps,
extraPanels,
commandPaletteEmptyAction,
}: AppSidebarProps) {
const [activePanel, setActivePanel] = useState<PanelId>('site')
const activePanel = useEditor((s) => s.activeSidebarPanel)
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
const hasActivePanel =
activePanel === 'site' ||
activePanel === 'settings' ||
Boolean(extraPanels?.some((panel) => panel.id === activePanel))
useEffect(() => {
// Widen default sidebar (288px → 432px) for better project title visibility
@@ -37,44 +49,55 @@ export function AppSidebar({
}
}, [])
useEffect(() => {
if (!hasActivePanel) {
setActivePanel('site')
}
}, [hasActivePanel, setActivePanel])
const renderPanelContent = () => {
switch (activePanel) {
case 'site':
return <SitePanel {...sitePanelProps} />
case 'settings':
return <SettingsPanel {...settingsPanelProps} />
default:
return null
default: {
const extra = extraPanels?.find((p) => p.id === activePanel)
if (extra) {
const Component = extra.component
return <Component />
}
return <SitePanel {...sitePanelProps} />
}
}
}
return (
<>
<Sidebar className={cn('dark text-white')} variant="floating">
<div className="flex h-full">
{/* Icon Rail */}
<IconRail
activePanel={activePanel}
appMenuButton={appMenuButton}
onPanelChange={setActivePanel}
/>
<div className={cn('dark flex h-full w-full bg-sidebar text-sidebar-foreground')}>
{/* Icon Rail */}
<IconRail
activePanel={activePanel}
appMenuButton={appMenuButton}
extraPanels={extraPanels}
onPanelChange={setActivePanel}
/>
{/* Panel Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{sidebarTop && (
<SidebarHeader className="relative flex-col items-start justify-center gap-1 border-border/50 border-b px-3 py-3">
{sidebarTop}
</SidebarHeader>
)}
{/* Panel Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{sidebarTop && (
<SidebarHeader className="relative flex-col items-start justify-center gap-1 border-border/50 border-b px-3 py-3">
{sidebarTop}
</SidebarHeader>
)}
<SidebarContent className={cn('no-scrollbar flex flex-1 flex-col overflow-hidden')}>
{renderPanelContent()}
</SidebarContent>
</div>
<SidebarContent className={cn('no-scrollbar flex flex-1 flex-col overflow-hidden')}>
{renderPanelContent()}
</SidebarContent>
</div>
</Sidebar>
</div>
<EditorCommands />
<CommandPalette />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
</>
)
}
+81 -61
View File
@@ -1,9 +1,6 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import { Moon, Ruler, Sun } from 'lucide-react'
import { motion } from 'motion/react'
import { type ReactNode, useEffect, useState } from 'react'
import type { ComponentType, ReactNode } from 'react'
import {
Tooltip,
TooltipContent,
@@ -11,31 +8,39 @@ import {
} from './../../../components/ui/primitives/tooltip'
import { cn } from './../../../lib/utils'
export type PanelId = 'site' | 'settings'
export type PanelId = string
export type ExtraPanel = { id: string; icon: ReactNode; label: string; component: ComponentType }
interface IconRailProps {
activePanel: PanelId
onPanelChange: (panel: PanelId) => void
appMenuButton?: ReactNode
extraPanels?: ExtraPanel[]
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' },
]
const sitePanel: { id: PanelId; iconSrc: string; label: string } = {
id: 'site',
iconSrc: '/icons/level.png',
label: 'Site',
}
export function IconRail({ activePanel, onPanelChange, appMenuButton, className }: IconRailProps) {
const theme = useViewer((state) => state.theme)
const setTheme = useViewer((state) => state.setTheme)
const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit)
const [mounted, setMounted] = useState(false)
const settingsPanel: { id: PanelId; iconSrc: string; label: string } = {
id: 'settings',
iconSrc: '/icons/settings.png',
label: 'Settings',
}
useEffect(() => {
setMounted(true)
}, [])
const panels: { id: PanelId; iconSrc: string; label: string }[] = [sitePanel, settingsPanel]
export function IconRail({
activePanel,
onPanelChange,
appMenuButton,
extraPanels,
className,
}: IconRailProps) {
return (
<div
className={cn(
@@ -49,7 +54,8 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className
{/* Divider */}
<div className="mb-1 h-px w-8 bg-border/50" />
{panels.map((panel) => {
{/* Site panel */}
{[sitePanel].map((panel) => {
const isActive = activePanel === panel.id
return (
<Tooltip key={panel.id}>
@@ -77,49 +83,63 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className
)
})}
{/* Spacer */}
<div className="flex-1" />
{/* Unit Toggle */}
{mounted && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="mb-1 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent"
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
type="button"
>
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 font-medium text-[10px] leading-none">
{unit === 'metric' ? 'm' : 'ft'}
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right">Toggle units (metric/imperial)</TooltipContent>
</Tooltip>
)}
{/* Theme Toggle */}
{mounted && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
>
<motion.div
animate={{ rotate: 0, opacity: 1 }}
initial={{ rotate: -90, opacity: 0 }}
key={theme}
transition={{ duration: 0.25, ease: 'easeOut' }}
{/* Extra panels (injected between site and settings) */}
{extraPanels?.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"
>
{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>
)}
<span
className={cn(
'flex h-6 w-6 items-center justify-center transition-all',
!isActive && 'opacity-50',
)}
>
{panel.icon}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip>
)
})}
{/* Settings panel */}
{[settingsPanel].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
alt={panel.label}
className={cn(
'h-6 w-6 object-contain transition-all',
!isActive && 'opacity-50 saturate-0',
)}
src={panel.iconSrc}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip>
)
})}
</div>
)
}
+21 -14
View File
@@ -18,7 +18,7 @@ import {
DialogTrigger,
} from './../../../../../components/ui/primitives/dialog'
import { Switch } from './../../../../../components/ui/primitives/switch'
import useEditor from './../../../../../store/use-editor'
import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
import { AudioSettingsDialog } from './audio-settings-dialog'
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
@@ -202,12 +202,6 @@ export function SettingsPanel({
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
const handleExport = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
if (exportScene) {
await exportScene(format)
}
}
const handleSaveBuild = () => {
const sceneData = { nodes, rootNodeIds }
const json = JSON.stringify(sceneData, null, 2)
@@ -247,7 +241,8 @@ export function SettingsPanel({
const handleResetToDefault = () => {
clearScene()
resetSelection()
setPhase('site')
setPhase('structure')
selectDefaultBuildingAndLevel()
}
const handleGenerateThumbnail = () => {
@@ -318,17 +313,29 @@ export function SettingsPanel({
{/* 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('glb')} variant="outline">
<Button
className="w-full justify-start gap-2"
onClick={() => exportScene?.('glb')}
variant="outline"
>
<Download className="size-4" />
Export as GLB
Export GLB
</Button>
<Button className="w-full justify-start gap-2" onClick={() => handleExport('stl')} variant="outline">
<Button
className="w-full justify-start gap-2"
onClick={() => exportScene?.('stl')}
variant="outline"
>
<Download className="size-4" />
Export as STL
Export STL
</Button>
<Button className="w-full justify-start gap-2" onClick={() => handleExport('obj')} variant="outline">
<Button
className="w-full justify-start gap-2"
onClick={() => exportScene?.('obj')}
variant="outline"
>
<Download className="size-4" />
Export as OBJ
Export OBJ
</Button>
</div>
@@ -118,12 +118,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const pi = polygon[i]
const pj = polygon[j]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
}
return Math.abs(area) / 2
+1 -1
View File
@@ -908,7 +908,7 @@ function LayerToggle() {
</div>
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
S
B
</span>
</div>
</button>
+2 -6
View File
@@ -88,12 +88,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const pi = polygon[i]
const pj = polygon[j]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
}
return Math.abs(area) / 2
+2 -6
View File
@@ -79,12 +79,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const pi = polygon[i]
const pj = polygon[j]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
}
return Math.abs(area) / 2
+499 -514
View File
File diff suppressed because it is too large Load Diff
+18 -7
View File
@@ -3,10 +3,13 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import type { Mesh } from 'three'
import useEditor from '../store/use-editor'
export const ViewerZoneSystem = () => {
useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection
const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => {
@@ -16,16 +19,24 @@ export const ViewerZoneSystem = () => {
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
if (!zone) return
// Hide zones if:
// 1. No level is selected
// 2. Zone is not on the selected level
// 3. A zone is already selected (hide all zones to show zone contents)
const isOnSelectedLevel = zone.parentId === levelId
const shouldShow = !!levelId && isOnSelectedLevel && !zoneId
obj.visible = shouldShow
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
// Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
// The editor ZoneSystem handles the selected zone's opacity animation.
const isSelected = id === zoneId
const shouldShowGeometry =
(structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected
if (!obj.visible) obj.visible = true
obj.traverse((child) => {
if ((child as Mesh).isMesh) {
child.visible = shouldShowGeometry
}
})
const targetOpacity = shouldShow ? '1' : '0'
// Labels: always visible on the current level (regardless of mode or zone selection)
const showLabel = !!levelId && isOnSelectedLevel
const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
labelEl.style.opacity = targetOpacity
+48 -11
View File
@@ -11,7 +11,7 @@ export const markToolCancelConsumed = () => {
_toolCancelConsumed = true
}
export const useKeyboard = () => {
export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts if user is typing in an input
@@ -30,9 +30,20 @@ export const useKeyboard = () => {
// Only switch to select mode if no tool had an active mid-action to cancel.
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
if (!_toolCancelConsumed) {
// Return to the default select tool while keeping the active building/level context.
const currentPhase = useEditor.getState().phase
const currentStructureLayer = useEditor.getState().structureLayer
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
// From zone mode, return to structure select
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('select')
} else {
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setMode('select')
}
useEditor.getState().setFloorplanSelectionTool('click')
// Clear selections to close UI panels, but KEEP the active building and level context.
@@ -51,29 +62,34 @@ export const useKeyboard = () => {
e.preventDefault()
useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('select')
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('build')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones')
useEditor.getState().setMode('build')
}
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('select')
useEditor.getState().setFloorplanSelectionTool('click')
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
useScene.temporal.getState().undo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
useScene.temporal.getState().redo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
@@ -108,7 +124,7 @@ export const useKeyboard = () => {
}
}
}
} else if (e.key === 'r' || e.key === 'R') {
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
@@ -128,7 +144,7 @@ export const useKeyboard = () => {
sfxEmitter.emit('sfx:item-rotate')
}
}
} else if (e.key === 't' || e.key === 'T') {
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
// Rotate selected node counter-clockwise
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
@@ -147,9 +163,30 @@ export const useKeyboard = () => {
sfxEmitter.emit('sfx:item-rotate')
}
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
e.preventDefault()
// Check for a selected reference (guide/scan) first
const selectedRefId = useEditor.getState().selectedReferenceId
if (selectedRefId) {
const refNode = useScene.getState().nodes[selectedRefId as AnyNodeId]
if (refNode && (refNode.type === 'guide' || refNode.type === 'scan')) {
sfxEmitter.emit('sfx:structure-delete')
useScene.getState().deleteNode(selectedRefId as AnyNodeId)
useEditor.getState().setSelectedReferenceId(null)
return
}
}
// Delete selected zone
const selectedZoneId = useViewer.getState().selection.zoneId
if (selectedZoneId) {
sfxEmitter.emit('sfx:structure-delete')
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
useViewer.getState().setSelection({ zoneId: null })
return
}
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length > 0) {
@@ -171,7 +208,7 @@ export const useKeyboard = () => {
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
}, [isVersionPreviewMode])
return null
}
+1 -1
View File
@@ -15,7 +15,6 @@ export {
} from './components/ui/sidebar/panels/settings-panel'
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save'
@@ -31,3 +30,4 @@ export {
usePaletteViewRegistry,
} from './store/use-palette-view-registry'
export { useUploadStore } from './store/use-upload'
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
Regular → Executable
+20 -15
View File
@@ -20,14 +20,6 @@ type PersistedSelectionPath = {
selectedIds: string[]
}
/**
* IDs are stored as plain strings in localStorage. Cast them back to their
* branded template-literal types before passing to the viewer store.
*/
function toViewerSelection(s: PersistedSelectionPath) {
return s as unknown as Parameters<ReturnType<typeof useViewer.getState>['setSelection']>[0]
}
const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = {
buildingId: null,
levelId: null,
@@ -271,9 +263,27 @@ export function syncEditorSelectionFromCurrentScene() {
: null
if (firstBuilding && firstLevel) {
const isEmptyLevel = !firstLevel.children || firstLevel.children.length === 0
// For empty projects (new/blank), always start in structure/build/wall
// regardless of persisted state from a previous project
if (isEmptyLevel) {
useViewer.getState().setSelection({
buildingId: firstBuilding.id,
levelId: firstLevel.id,
selectedIds: [],
zoneId: null,
})
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
return
}
if (shouldRestoreEditorUiState) {
if (restoredSelection) {
useViewer.getState().setSelection(toViewerSelection(restoredSelection))
useViewer.getState().setSelection(restoredSelection)
useEditor.setState(
restoredEditorUiState.phase === 'site'
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
@@ -295,7 +305,7 @@ export function syncEditorSelectionFromCurrentScene() {
}
if (restoredSelection) {
useViewer.getState().setSelection(toViewerSelection(restoredSelection))
useViewer.getState().setSelection(restoredSelection)
if (selectionDrivenEditorUiState) {
useEditor.setState(selectionDrivenEditorUiState)
}
@@ -310,11 +320,6 @@ export function syncEditorSelectionFromCurrentScene() {
})
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
if (!firstLevel.children || firstLevel.children.length === 0) {
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
}
} else {
useEditor.getState().setPhase('site')
useViewer.getState().setSelection({
+31 -9
View File
@@ -81,9 +81,25 @@ type EditorState = {
setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null
setSelectedItem: (item: AssetInput) => void
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null
movingNode:
| ItemNode
| WindowNode
| DoorNode
| RoofNode
| RoofSegmentNode
| StairNode
| StairSegmentNode
| null
setMovingNode: (
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
node:
| ItemNode
| WindowNode
| DoorNode
| RoofNode
| RoofSegmentNode
| StairNode
| StairSegmentNode
| null,
) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
@@ -109,12 +125,13 @@ type EditorState = {
setFloorplanHovered: (hovered: boolean) => void
floorplanSelectionTool: FloorplanSelectionTool
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null
setFirstPersonMode: (enabled: boolean) => void
// Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean
setAllowUndergroundCamera: (enabled: boolean) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
setFirstPersonMode: (enabled: boolean) => void
activeSidebarPanel: string
setActiveSidebarPanel: (id: string) => void
floorplanPaneRatio: number
@@ -403,7 +420,15 @@ const useEditor = create<EditorState>()(
setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }),
movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
movingNode: null as
| ItemNode
| WindowNode
| DoorNode
| RoofNode
| RoofSegmentNode
| StairNode
| StairSegmentNode
| null,
setMovingNode: (node) => set({ movingNode: node }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
@@ -442,9 +467,7 @@ const useEditor = create<EditorState>()(
_viewModeBeforeFirstPerson: null as ViewMode | null,
setFirstPersonMode: (enabled) => {
if (enabled) {
// Save current view mode and force 3D for immersive walkthrough
const currentViewMode = get().viewMode
// Force perspective camera and full-height walls for immersive walkthrough
useViewer.getState().setCameraMode('perspective')
useViewer.getState().setWallMode('up')
set({
@@ -458,7 +481,6 @@ const useEditor = create<EditorState>()(
})
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else {
// Restore previous view mode
const prevMode = get()._viewModeBeforeFirstPerson
set({
isFirstPersonMode: false,