@@ -28,7 +28,7 @@ export default function Editor() {
|
|||||||
<ActionMenu />
|
<ActionMenu />
|
||||||
<PanelManager />
|
<PanelManager />
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-10">
|
<SidebarProvider className="fixed z-20">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<Viewer selectionManager="custom">
|
<Viewer selectionManager="custom">
|
||||||
|
|||||||
@@ -46,11 +46,6 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
structure: {
|
structure: {
|
||||||
types: ["wall", "item", "zone", "slab", "ceiling", "roof"],
|
types: ["wall", "item", "zone", "slab", "ceiling", "roof"],
|
||||||
handleSelect: (node, isShift) => {
|
handleSelect: (node, isShift) => {
|
||||||
// Single click on item (door/window) → enter move mode
|
|
||||||
if (!isShift && node.type === 'item') {
|
|
||||||
useEditor.getState().setMovingNode(node as ItemNode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { selection, setSelection } = useViewer.getState();
|
const { selection, setSelection } = useViewer.getState();
|
||||||
if (node.type === 'zone') {
|
if (node.type === 'zone') {
|
||||||
setSelection({ zoneId: node.id });
|
setSelection({ zoneId: node.id });
|
||||||
@@ -93,11 +88,6 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
furnish: {
|
furnish: {
|
||||||
types: ["item"],
|
types: ["item"],
|
||||||
handleSelect: (node, isShift) => {
|
handleSelect: (node, isShift) => {
|
||||||
// Single click on item → enter move mode
|
|
||||||
if (!isShift && node.type === 'item') {
|
|
||||||
useEditor.getState().setMovingNode(node as ItemNode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { selection, setSelection } = useViewer.getState();
|
const { selection, setSelection } = useViewer.getState();
|
||||||
const nextIds = isShift
|
const nextIds = isShift
|
||||||
? selection.selectedIds.includes(node.id)
|
? selection.selectedIds.includes(node.id)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
rotation: rotation ?? [0, 0, 0],
|
rotation: rotation ?? [0, 0, 0],
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
asset,
|
asset,
|
||||||
|
parentId: currentLevelId,
|
||||||
metadata: { isTransient: true },
|
metadata: { isTransient: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type CeilingEvent,
|
type CeilingEvent,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
|
resolveLevelId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
useSpatialQuery,
|
useSpatialQuery,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
@@ -12,18 +15,17 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { BoxGeometry, Euler, type Mesh, type MeshStandardMaterial, Quaternion, Vector3 } from 'three'
|
|
||||||
import { spatialGridManager } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-manager'
|
|
||||||
import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync'
|
|
||||||
import {
|
import {
|
||||||
ceilingStrategy,
|
BoxGeometry,
|
||||||
checkCanPlace,
|
Euler,
|
||||||
floorStrategy,
|
type Mesh,
|
||||||
wallStrategy,
|
type MeshStandardMaterial,
|
||||||
} from './placement-strategies'
|
Quaternion,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
|
||||||
import type { PlacementState, TransitionResult } from './placement-types'
|
import type { PlacementState, TransitionResult } from './placement-types'
|
||||||
import type { DraftNodeHandle } from './use-draft-node'
|
import type { DraftNodeHandle } from './use-draft-node'
|
||||||
import type { AssetInput } from '@pascal-app/core'
|
|
||||||
|
|
||||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||||
|
|
||||||
@@ -43,6 +45,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store config callbacks in refs to avoid re-running effect when they change
|
||||||
|
const configRef = useRef(config)
|
||||||
|
configRef.current = config
|
||||||
|
|
||||||
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
||||||
const { asset, draftNode } = config
|
const { asset, draftNode } = config
|
||||||
|
|
||||||
@@ -52,7 +58,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
||||||
|
|
||||||
// Reset placement state
|
// Reset placement state
|
||||||
placementState.current = config.initialState ?? {
|
placementState.current = configRef.current.initialState ?? {
|
||||||
surface: 'floor',
|
surface: 'floor',
|
||||||
wallId: null,
|
wallId: null,
|
||||||
ceilingId: null,
|
ceilingId: null,
|
||||||
@@ -70,9 +76,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const revalidate = (): boolean => {
|
const revalidate = (): boolean => {
|
||||||
const placeable = checkCanPlace(getContext(), validators)
|
const placeable = checkCanPlace(getContext(), validators)
|
||||||
;(cursorRef.current.material as MeshStandardMaterial).color.set(
|
;(cursorRef.current.material as MeshStandardMaterial).color.set(placeable ? 'green' : 'red')
|
||||||
placeable ? 'green' : 'red',
|
|
||||||
)
|
|
||||||
return placeable
|
return placeable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +114,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Init draft ----
|
// ---- Init draft ----
|
||||||
config.initDraft(gridPosition.current)
|
configRef.current.initDraft(gridPosition.current)
|
||||||
|
|
||||||
// Sync cursor to the draft mesh's world position and rotation
|
// Sync cursor to the draft mesh's world position and rotation
|
||||||
if (draftNode.current) {
|
if (draftNode.current) {
|
||||||
@@ -136,7 +140,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
||||||
|
cursorRef.current.position.x = result.cursorPosition[0]
|
||||||
|
cursorRef.current.position.z = result.cursorPosition[2]
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) draft.position = result.gridPosition
|
if (draft) draft.position = result.gridPosition
|
||||||
@@ -152,7 +158,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const currentRotation: [number, number, number] = [0, cursorRef.current.rotation.y, 0]
|
const currentRotation: [number, number, number] = [0, cursorRef.current.rotation.y, 0]
|
||||||
|
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
if (config.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
draftNode.create(gridPosition.current, asset, currentRotation)
|
draftNode.create(gridPosition.current, asset, currentRotation)
|
||||||
revalidate()
|
revalidate()
|
||||||
}
|
}
|
||||||
@@ -265,7 +271,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||||
if (enterResult) {
|
if (enterResult) {
|
||||||
@@ -289,7 +295,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
applyTransition(result)
|
applyTransition(result)
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
useScene.getState().updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
|
||||||
}
|
}
|
||||||
if (oldWallId) {
|
if (oldWallId) {
|
||||||
useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId)
|
||||||
@@ -360,7 +368,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
|
|
||||||
if (config.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||||
if (enterResult) {
|
if (enterResult) {
|
||||||
@@ -384,7 +392,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
applyTransition(result)
|
applyTransition(result)
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
useScene.getState().updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
|
||||||
}
|
}
|
||||||
if (oldCeilingId) {
|
if (oldCeilingId) {
|
||||||
useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId)
|
||||||
@@ -404,9 +414,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const ROTATION_STEP = Math.PI / 2
|
const ROTATION_STEP = Math.PI / 2
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
// Escape / right-click → cancel
|
// Escape / right-click → cancel
|
||||||
if (event.key === 'Escape' && config.onCancel) {
|
if (event.key === 'Escape' && configRef.current.onCancel) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
config.onCancel()
|
configRef.current.onCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,9 +444,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
// ---- Right-click cancel ----
|
// ---- Right-click cancel ----
|
||||||
const onContextMenu = (event: MouseEvent) => {
|
const onContextMenu = (event: MouseEvent) => {
|
||||||
if (config.onCancel) {
|
if (configRef.current.onCancel) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
config.onCancel()
|
configRef.current.onCancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('contextmenu', onContextMenu)
|
window.addEventListener('contextmenu', onContextMenu)
|
||||||
@@ -501,17 +511,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
// Adjust Y for slab elevation (floor items on top of slabs)
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
||||||
if (!asset.attachTo) {
|
if (!asset.attachTo) {
|
||||||
const levelId = useViewer.getState().selection.levelId
|
const nodes = useScene.getState().nodes
|
||||||
if (levelId) {
|
const levelId = resolveLevelId(draftNode.current, nodes)
|
||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||||
levelId,
|
levelId,
|
||||||
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||||
asset.dimensions ?? DEFAULT_DIMENSIONS,
|
asset.dimensions ?? DEFAULT_DIMENSIONS,
|
||||||
draftNode.current.rotation,
|
draftNode.current.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation
|
mesh.position.y = slabElevation
|
||||||
cursorRef.current.position.y = slabElevation
|
cursorRef.current.position.y = slabElevation
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type ItemNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Move, Trash2, X } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
|
||||||
|
export function ItemPanel() {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
|
// Get the first selected node if it's an item
|
||||||
|
const selectedId = selectedIds[0]
|
||||||
|
const node = selectedId
|
||||||
|
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<ItemNode>) => {
|
||||||
|
if (!selectedId || !node) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
|
||||||
|
// Mark parent wall as dirty if item is attached to wall
|
||||||
|
if (node.asset.attachTo === 'wall' && node.parentId) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedId, node, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (node) {
|
||||||
|
setMovingNode(node)
|
||||||
|
// Deselect so the panel closes
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!selectedId) return
|
||||||
|
deleteNode(selectedId as AnyNode['id'])
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [selectedId, deleteNode, setSelection])
|
||||||
|
|
||||||
|
// Only show if exactly one item is selected
|
||||||
|
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Image
|
||||||
|
src={node.asset.thumbnail || '/icons/furniture.png'}
|
||||||
|
alt=""
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
<h2 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{node.name || node.asset.name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Position */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Position
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<NumberInput
|
||||||
|
label="X"
|
||||||
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ position: [value, node.position[1], node.position[2]] })
|
||||||
|
}}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Y"
|
||||||
|
value={Math.round(node.position[1] * 100) / 100}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ position: [node.position[0], value, node.position[2]] })
|
||||||
|
}}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Z"
|
||||||
|
value={Math.round(node.position[2] * 100) / 100}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ position: [node.position[0], node.position[1], value] })
|
||||||
|
}}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rotation */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Rotation
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NumberInput
|
||||||
|
label="Y"
|
||||||
|
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||||
|
onChange={(degrees) => {
|
||||||
|
const radians = (degrees * Math.PI) / 180
|
||||||
|
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||||
|
}}
|
||||||
|
precision={0}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
|
const newDegrees = currentDegrees - 90
|
||||||
|
const radians = (newDegrees * Math.PI) / 180
|
||||||
|
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
-90°
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
|
const newDegrees = currentDegrees + 90
|
||||||
|
const radians = (newDegrees * Math.PI) / 180
|
||||||
|
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+90°
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dimensions (read-only) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Dimensions
|
||||||
|
</label>
|
||||||
|
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{node.asset.dimensions[0]}m × {node.asset.dimensions[1]}m × {node.asset.dimensions[2]}m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleMove}
|
||||||
|
>
|
||||||
|
<Move className="h-3.5 w-3.5" />
|
||||||
|
<span>Move</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { AnyNodeId, useScene } from '@pascal-app/core'
|
import { AnyNodeId, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { ItemPanel } from './item-panel'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
import { SlabPanel } from './slab-panel'
|
import { SlabPanel } from './slab-panel'
|
||||||
@@ -23,6 +24,8 @@ export function PanelManager() {
|
|||||||
const node = nodes[selectedNode as AnyNodeId]
|
const node = nodes[selectedNode as AnyNodeId]
|
||||||
if (node) {
|
if (node) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
|
case 'item':
|
||||||
|
return <ItemPanel />
|
||||||
case 'roof':
|
case 'roof':
|
||||||
return <RoofPanel />
|
return <RoofPanel />
|
||||||
case 'slab':
|
case 'slab':
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-a
|
|||||||
import { Box, Image, X } from 'lucide-react'
|
import { Box, Image, X } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
|
||||||
type ReferenceNode = ScanNode | GuideNode
|
type ReferenceNode = ScanNode | GuideNode
|
||||||
|
|
||||||
@@ -65,23 +66,17 @@ export function ReferencePanel() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{([0, 1, 2] as const).map((i) => (
|
{([0, 1, 2] as const).map((i) => (
|
||||||
<div key={i} className="space-y-1">
|
<NumberInput
|
||||||
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label>
|
key={i}
|
||||||
<input
|
label={['X', 'Y', 'Z'][i]!}
|
||||||
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
value={Math.round(node.position[i] * 100) / 100}
|
||||||
onChange={(e) => {
|
onChange={(value) => {
|
||||||
const value = Number.parseFloat(e.target.value)
|
const pos = [...node.position] as [number, number, number]
|
||||||
if (!Number.isNaN(value)) {
|
pos[i] = value
|
||||||
const pos = [...node.position] as [number, number, number]
|
handleUpdate({ position: pos })
|
||||||
pos[i] = value
|
}}
|
||||||
handleUpdate({ position: pos })
|
precision={2}
|
||||||
}
|
/>
|
||||||
}}
|
|
||||||
step="0.1"
|
|
||||||
type="number"
|
|
||||||
value={Math.round(node.position[i] * 100) / 100}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,20 +87,17 @@ export function ReferencePanel() {
|
|||||||
Rotation
|
Rotation
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<NumberInput
|
||||||
className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
label="Y"
|
||||||
onChange={(e) => {
|
|
||||||
const degrees = Number.parseFloat(e.target.value)
|
|
||||||
if (!Number.isNaN(degrees)) {
|
|
||||||
const radians = (degrees * Math.PI) / 180
|
|
||||||
handleUpdate({
|
|
||||||
rotation: [node.rotation[0], radians, node.rotation[2]],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
step="1"
|
|
||||||
type="number"
|
|
||||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||||
|
onChange={(degrees) => {
|
||||||
|
const radians = (degrees * Math.PI) / 180
|
||||||
|
handleUpdate({
|
||||||
|
rotation: [node.rotation[0], radians, node.rotation[2]],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
precision={0}
|
||||||
|
className="min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
||||||
<button
|
<button
|
||||||
@@ -136,18 +128,16 @@ export function ReferencePanel() {
|
|||||||
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
Scale
|
Scale
|
||||||
</label>
|
</label>
|
||||||
<input
|
<NumberInput
|
||||||
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
label="Scale"
|
||||||
min="0.01"
|
value={Math.round(node.scale * 100) / 100}
|
||||||
onChange={(e) => {
|
onChange={(value) => {
|
||||||
const value = Number.parseFloat(e.target.value)
|
if (value > 0) {
|
||||||
if (!Number.isNaN(value) && value > 0) {
|
|
||||||
handleUpdate({ scale: value })
|
handleUpdate({ scale: value })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
step="0.1"
|
min={0.01}
|
||||||
type="number"
|
precision={2}
|
||||||
value={Math.round(node.scale * 100) / 100}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
|
||||||
export function SlabPanel() {
|
export function SlabPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -76,17 +77,14 @@ export function SlabPanel() {
|
|||||||
Elevation
|
Elevation
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<NumberInput
|
||||||
className="flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
label="Elevation"
|
||||||
onChange={(e) => {
|
|
||||||
const value = Number.parseFloat(e.target.value)
|
|
||||||
if (!Number.isNaN(value)) {
|
|
||||||
handleUpdate({ elevation: value })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
step="0.05"
|
|
||||||
type="number"
|
|
||||||
value={Math.round(node.elevation * 1000) / 1000}
|
value={Math.round(node.elevation * 1000) / 1000}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ elevation: value })
|
||||||
|
}}
|
||||||
|
precision={3}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface NumberInputProps {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
precision?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumberInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
precision = 2,
|
||||||
|
className = '',
|
||||||
|
}: NumberInputProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||||
|
const startXRef = useRef(0)
|
||||||
|
const startValueRef = useRef(0)
|
||||||
|
const labelRef = useRef<HTMLLabelElement>(null)
|
||||||
|
|
||||||
|
const clamp = useCallback(
|
||||||
|
(val: number) => {
|
||||||
|
if (min !== undefined && val < min) return min
|
||||||
|
if (max !== undefined && val > max) return max
|
||||||
|
return val
|
||||||
|
},
|
||||||
|
[min, max],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleLabelMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (isEditing) return
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(true)
|
||||||
|
startXRef.current = e.clientX
|
||||||
|
startValueRef.current = value
|
||||||
|
|
||||||
|
// Pause history tracking during drag
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
let finalValue = value
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
const deltaX = moveEvent.clientX - startXRef.current
|
||||||
|
|
||||||
|
// Determine step size based on modifier keys
|
||||||
|
let step = 0.1 // Default
|
||||||
|
if (moveEvent.shiftKey) {
|
||||||
|
step = 1.0 // Coarse
|
||||||
|
} else if (moveEvent.altKey) {
|
||||||
|
step = 0.01 // Fine
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaValue = deltaX * step
|
||||||
|
const newValue = clamp(startValueRef.current + deltaValue)
|
||||||
|
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||||
|
|
||||||
|
// Only call onChange if value actually changed (avoid extra processing on tiny moves)
|
||||||
|
if (newFinalValue !== finalValue) {
|
||||||
|
finalValue = newFinalValue
|
||||||
|
onChange(finalValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
|
||||||
|
// Reset to initial value while still paused (no history entry)
|
||||||
|
// Then resume and apply final value (creates single history entry)
|
||||||
|
if (finalValue !== startValueRef.current) {
|
||||||
|
onChange(startValueRef.current)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
onChange(finalValue)
|
||||||
|
} else {
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
},
|
||||||
|
[isEditing, value, onChange, clamp, precision],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleValueClick = useCallback(() => {
|
||||||
|
setIsEditing(true)
|
||||||
|
setInputValue(value.toFixed(precision))
|
||||||
|
}, [value, precision])
|
||||||
|
|
||||||
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(e.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleInputBlur = useCallback(() => {
|
||||||
|
const numValue = Number.parseFloat(inputValue)
|
||||||
|
if (!Number.isNaN(numValue)) {
|
||||||
|
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [inputValue, onChange, clamp, precision])
|
||||||
|
|
||||||
|
const handleInputKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const numValue = Number.parseFloat(inputValue)
|
||||||
|
if (!Number.isNaN(numValue)) {
|
||||||
|
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setInputValue(value.toFixed(precision))
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputValue, onChange, value, clamp, precision],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className}`}>
|
||||||
|
<div className="flex items-center rounded border border-input bg-muted/30 overflow-hidden">
|
||||||
|
<label
|
||||||
|
ref={labelRef}
|
||||||
|
className={`px-2 py-1 text-muted-foreground text-xs select-none ${
|
||||||
|
isDragging ? 'cursor-ew-resize' : 'hover:cursor-ew-resize hover:text-foreground'
|
||||||
|
} transition-colors`}
|
||||||
|
onMouseDown={handleLabelMouseDown}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 bg-transparent px-2 py-1 text-foreground text-sm outline-none text-right"
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex-1 px-2 py-1 text-foreground text-sm cursor-text hover:bg-muted/50 transition-colors text-right"
|
||||||
|
onClick={handleValueClick}
|
||||||
|
>
|
||||||
|
{value.toFixed(precision)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -345,6 +345,7 @@ function LevelsSection() {
|
|||||||
const nodes = useScene((state) => state.nodes);
|
const nodes = useScene((state) => state.nodes);
|
||||||
const createNode = useScene((state) => state.createNode);
|
const createNode = useScene((state) => state.createNode);
|
||||||
const updateNode = useScene((state) => state.updateNode);
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
|
const deleteNode = useScene((state) => state.deleteNode);
|
||||||
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
||||||
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
@@ -493,6 +494,15 @@ function LevelsSection() {
|
|||||||
>
|
>
|
||||||
References
|
References
|
||||||
</button>
|
</button>
|
||||||
|
{level.level !== 0 && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 rounded text-sm hover:bg-accent hover:text-red-600 cursor-pointer"
|
||||||
|
onClick={() => deleteNode(level.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "editor",
|
"name": "editor",
|
||||||
@@ -63,13 +62,14 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@pascal-app/core",
|
"name": "@pascal-app/core",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"three-bvh-csg": "^0.0.17",
|
"three-bvh-csg": "^0.0.17",
|
||||||
|
"three-mesh-bvh": "^0.9.8",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
"zundo": "^2.3.0",
|
"zundo": "^2.3.0",
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
},
|
},
|
||||||
"packages/viewer": {
|
"packages/viewer": {
|
||||||
"name": "@pascal-app/viewer",
|
"name": "@pascal-app/viewer",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"three-bvh-csg": "^0.0.17",
|
"three-bvh-csg": "^0.0.17",
|
||||||
|
"three-mesh-bvh": "^0.9.8",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
"zundo": "^2.3.0",
|
"zundo": "^2.3.0",
|
||||||
"zustand": "^5"
|
"zustand": "^5"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export {
|
|||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||||
export { pointInPolygon } from './hooks/spatial-grid/spatial-grid-manager'
|
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||||
// Schema
|
// Schema
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
export { default as useScene } from './store/use-scene'
|
export { default as useScene } from './store/use-scene'
|
||||||
|
|||||||
@@ -215,13 +215,11 @@ useScene.temporal.subscribe((state) => {
|
|||||||
const currentPastLength = state.pastStates.length
|
const currentPastLength = state.pastStates.length
|
||||||
const currentFutureLength = state.futureStates.length
|
const currentFutureLength = state.futureStates.length
|
||||||
|
|
||||||
|
|
||||||
// Undo: futureStates increases (state moved from past to future)
|
// Undo: futureStates increases (state moved from past to future)
|
||||||
// Redo: pastStates increases while futureStates decreases (state moved from future to past)
|
// Redo: pastStates increases while futureStates decreases (state moved from future to past)
|
||||||
const didUndo = currentFutureLength > prevFutureLength
|
const didUndo = currentFutureLength > prevFutureLength
|
||||||
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
|
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
|
||||||
|
|
||||||
|
|
||||||
if (didUndo || didRedo) {
|
if (didUndo || didRedo) {
|
||||||
// Use RAF to ensure all middleware and store updates are complete
|
// Use RAF to ensure all middleware and store updates are complete
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||||
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||||
@@ -21,6 +22,7 @@ const csgEvaluator = new Evaluator()
|
|||||||
// WALL SYSTEM
|
// WALL SYSTEM
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
let useFrameNb = 0;
|
||||||
export const WallSystem = () => {
|
export const WallSystem = () => {
|
||||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
const clearDirty = useScene((state) => state.clearDirty)
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
@@ -33,7 +35,7 @@ export const WallSystem = () => {
|
|||||||
// Collect dirty walls and their levels
|
// Collect dirty walls and their levels
|
||||||
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
useFrameNb += 1;
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
@@ -106,6 +108,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
|||||||
const node = nodes[wallId as WallNode['id']]
|
const node = nodes[wallId as WallNode['id']]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
|
|
||||||
|
|
||||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||||
if (!mesh) return
|
if (!mesh) return
|
||||||
|
|
||||||
@@ -258,6 +261,10 @@ export function generateExtrudedWall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create wall brush from geometry
|
// Create wall brush from geometry
|
||||||
|
// Pre-compute BVH with new API to avoid deprecation warning
|
||||||
|
geometry.computeBoundsTree = computeBoundsTree
|
||||||
|
geometry.computeBoundsTree({ maxLeafSize: 10 })
|
||||||
|
|
||||||
const wallBrush = new Brush(geometry)
|
const wallBrush = new Brush(geometry)
|
||||||
wallBrush.updateMatrixWorld()
|
wallBrush.updateMatrixWorld()
|
||||||
|
|
||||||
@@ -348,6 +355,10 @@ function collectCutoutBrushes(
|
|||||||
0, // Center on Z axis (wall thickness direction)
|
0, // Center on Z axis (wall thickness direction)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pre-compute BVH with new API to avoid deprecation warning
|
||||||
|
boxGeo.computeBoundsTree = computeBoundsTree
|
||||||
|
boxGeo.computeBoundsTree({ maxLeafSize: 10 })
|
||||||
|
|
||||||
const brush = new Brush(boxGeo)
|
const brush = new Brush(boxGeo)
|
||||||
brushes.push(brush)
|
brushes.push(brush)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
import { type CeilingNode, useRegistry } from '@pascal-app/core'
|
import { type CeilingNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { faceDirection, float, mix } from 'three/tsl'
|
import { faceDirection, float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||||
import { DoubleSide, type Mesh, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
// TSL material that renders differently based on face direction:
|
// TSL material that renders differently based on face direction:
|
||||||
// - Back face (looking up at ceiling from below): solid
|
// - Back face (looking up at ceiling from below): solid
|
||||||
// - Front face (looking down at ceiling from above): 30% opacity
|
// - Front face (looking down at ceiling from above): 30% opacity
|
||||||
const ceilingMaterial = new MeshStandardNodeMaterial({
|
const ceilingMaterial = new MeshBasicNodeMaterial({
|
||||||
color: 0xffffff,
|
color: 0x999999,
|
||||||
side: DoubleSide,
|
side: DoubleSide,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Create grid pattern based on local position
|
||||||
|
const gridScale = 5 // Grid cells per meter (1 = 1m grid)
|
||||||
|
const gridX = positionWorld.x.mul(gridScale).fract()
|
||||||
|
const gridY = positionWorld.z.mul(gridScale).fract()
|
||||||
|
|
||||||
|
// Create grid lines - they are at 0 and 1
|
||||||
|
const lineWidth = 0.05 // Width of grid lines (0-1 range within cell)
|
||||||
|
|
||||||
|
// Create visible lines at edges (near 0 and near 1)
|
||||||
|
const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
|
||||||
|
const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
|
||||||
|
|
||||||
|
// Combine: if either X or Y is a line, show the line
|
||||||
|
const gridPattern = lineX.max(lineY)
|
||||||
|
|
||||||
|
// Grid lines at 0.8 opacity, spaces at 0.1 opacity
|
||||||
|
const gridOpacity = mix(float(0.1), float(0.8), gridPattern)
|
||||||
|
|
||||||
// faceDirection is 1.0 for front face, -1.0 for back face
|
// faceDirection is 1.0 for front face, -1.0 for back face
|
||||||
// We want: front face (top, looking down) = 0.3 opacity, back face (bottom, looking up) = 1.0 opacity
|
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
|
||||||
ceilingMaterial.opacityNode = mix(float(1.0), float(0.3), faceDirection.greaterThan(0.0))
|
ceilingMaterial.opacityNode = mix(float(1.0), gridOpacity, faceDirection.greaterThan(0.0))
|
||||||
|
|
||||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Clone } from '@react-three/drei/core/Clone'
|
|||||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||||
import type { Group, Material, Mesh } from 'three'
|
import type { Group, Material, Mesh } from 'three'
|
||||||
|
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||||
|
|
||||||
// Shared materials to avoid creating new instances for every mesh
|
// Shared materials to avoid creating new instances for every mesh
|
||||||
const defaultMaterial = new MeshStandardNodeMaterial({
|
const defaultMaterial = new MeshStandardNodeMaterial({
|
||||||
@@ -39,13 +40,35 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={node.position} rotation={node.rotation} ref={ref} visible={node.visible}>
|
<group position={node.position} rotation={node.rotation} ref={ref} visible={node.visible}>
|
||||||
<Suspense>
|
<Suspense fallback={<PreviewModel node={node} />}>
|
||||||
<ModelRenderer node={node} />
|
<ModelRenderer node={node} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewMaterial = new MeshStandardNodeMaterial({
|
||||||
|
color: '#cccccc',
|
||||||
|
roughness: 1,
|
||||||
|
metalness: 0,
|
||||||
|
depthTest: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
|
||||||
|
|
||||||
|
previewMaterial.opacityNode = previewOpacity
|
||||||
|
previewMaterial.transparent = true
|
||||||
|
|
||||||
|
const PreviewModel = ({ node }: { node: ItemNode }) => {
|
||||||
|
return (
|
||||||
|
<mesh position-y={node.asset.dimensions[1] / 2} material={previewMaterial}>
|
||||||
|
<boxGeometry
|
||||||
|
args={[node.asset.dimensions[0], node.asset.dimensions[1], node.asset.dimensions[2]]}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||||
const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
position={[edge.midX, 0.5, edge.midZ]}
|
position={[edge.midX, 0.5, edge.midZ]}
|
||||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
zIndexRange={[10, 0]}
|
zIndexRange={[10, 0]}
|
||||||
|
occlude
|
||||||
>
|
>
|
||||||
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
|
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
|
||||||
{edge.dist.toFixed(2)}m
|
{edge.dist.toFixed(2)}m
|
||||||
|
|||||||
Reference in New Issue
Block a user