excellent selection
This commit is contained in:
@@ -5,6 +5,7 @@ import { Viewer } from '@pascal-app/viewer'
|
|||||||
import { OrbitControls } from '@react-three/drei'
|
import { OrbitControls } from '@react-three/drei'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ViewerOverlay } from './viewer-overlay'
|
||||||
|
|
||||||
export default function ViewerPage() {
|
export default function ViewerPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -52,7 +53,8 @@ export default function ViewerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full">
|
<div className="relative h-screen w-full">
|
||||||
|
<ViewerOverlay />
|
||||||
<Viewer>
|
<Viewer>
|
||||||
<OrbitControls makeDefault />
|
<OrbitControls makeDefault />
|
||||||
</Viewer>
|
</Viewer>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
const getNodeName = (node: AnyNode): string => {
|
||||||
|
if ('name' in node && node.name) return node.name
|
||||||
|
if (node.type === 'wall') return 'Wall'
|
||||||
|
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
||||||
|
if (node.type === 'slab') return 'Slab'
|
||||||
|
if (node.type === 'ceiling') return 'Ceiling'
|
||||||
|
if (node.type === 'roof') return 'Roof'
|
||||||
|
return node.type
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewerOverlay = () => {
|
||||||
|
const selection = useViewer((s) => s.selection)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
|
||||||
|
const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null
|
||||||
|
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
||||||
|
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
|
||||||
|
|
||||||
|
// Get the first selected item (if any)
|
||||||
|
const selectedNode = selection.selectedIds.length > 0
|
||||||
|
? (nodes[selection.selectedIds[0]!] as AnyNode | undefined)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Get all levels for the selected building
|
||||||
|
const levels = building?.children
|
||||||
|
.map((id) => nodes[id] as LevelNode | undefined)
|
||||||
|
.filter((n): n is LevelNode => n?.type === 'level')
|
||||||
|
.sort((a, b) => a.level - b.level) ?? []
|
||||||
|
|
||||||
|
const handleLevelClick = (levelId: string) => {
|
||||||
|
// When switching levels, deselect zone and items
|
||||||
|
useViewer.getState().setSelection({ levelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {
|
||||||
|
switch (depth) {
|
||||||
|
case 'root':
|
||||||
|
useViewer.getState().resetSelection()
|
||||||
|
break
|
||||||
|
case 'building':
|
||||||
|
useViewer.getState().setSelection({ levelId: null })
|
||||||
|
break
|
||||||
|
case 'level':
|
||||||
|
useViewer.getState().setSelection({ zoneId: null })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => handleBreadcrumbClick('root')}
|
||||||
|
className="text-neutral-500 hover:text-neutral-800 transition-colors"
|
||||||
|
>
|
||||||
|
Site
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{building && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
||||||
|
<button
|
||||||
|
onClick={() => handleBreadcrumbClick('building')}
|
||||||
|
className={`transition-colors ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
||||||
|
>
|
||||||
|
{building.name || 'Building'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{level && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
||||||
|
<button
|
||||||
|
onClick={() => handleBreadcrumbClick('level')}
|
||||||
|
className={`transition-colors ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
||||||
|
>
|
||||||
|
{level.name || `Level ${level.level}`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zone && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
||||||
|
<span className={`transition-colors ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}>
|
||||||
|
{zone.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedNode && zone && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
||||||
|
<span className="text-neutral-800 font-medium">{getNodeName(selectedNode)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level List (only when building is selected) */}
|
||||||
|
{building && levels.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200 w-40">
|
||||||
|
<span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
|
||||||
|
{levels.map((lvl) => (
|
||||||
|
<button
|
||||||
|
key={lvl.id}
|
||||||
|
onClick={() => handleLevelClick(lvl.id)}
|
||||||
|
className={`text-left px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
lvl.id === selection.levelId
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lvl.name || `Level ${lvl.level}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,38 +26,38 @@ interface ViewerProps {
|
|||||||
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => {
|
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => {
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
className={'bg-[#303035]'}
|
className={'bg-[#303035]'}
|
||||||
gl={async (props) => {
|
gl={async (props) => {
|
||||||
const renderer = new THREE.WebGPURenderer(props as any)
|
const renderer = new THREE.WebGPURenderer(props as any)
|
||||||
await renderer.init()
|
await renderer.init()
|
||||||
return renderer
|
return renderer
|
||||||
}}
|
}}
|
||||||
shadows
|
shadows
|
||||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||||
>
|
>
|
||||||
<color attach="background" args={['#ececec']} />
|
<color attach="background" args={['#ececec']} />
|
||||||
<ViewerCamera />
|
<ViewerCamera />
|
||||||
|
|
||||||
<directionalLight position={[10, 10, 5]} intensity={0.5} castShadow />
|
<directionalLight position={[10, 10, 5]} intensity={0.5} castShadow />
|
||||||
<Environment preset="sunset" environmentIntensity={0.3} />
|
<Environment preset="sunset" environmentIntensity={0.3} />
|
||||||
<Bvh>
|
<Bvh>
|
||||||
<SceneRenderer />
|
<SceneRenderer />
|
||||||
</Bvh>
|
</Bvh>
|
||||||
|
|
||||||
{/* Default Systems */}
|
{/* Default Systems */}
|
||||||
<LevelSystem />
|
<LevelSystem />
|
||||||
<GuideSystem />
|
<GuideSystem />
|
||||||
<ScanSystem />
|
<ScanSystem />
|
||||||
{/* Core systems */}
|
{/* Core systems */}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<ItemSystem />
|
<ItemSystem />
|
||||||
<RoofSystem />
|
<RoofSystem />
|
||||||
<SlabSystem />
|
<SlabSystem />
|
||||||
<WallSystem />
|
<WallSystem />
|
||||||
<PostProcessing />
|
<PostProcessing />
|
||||||
|
|
||||||
{selectionManager === 'default' && <SelectionManager />}
|
{selectionManager === 'default' && <SelectionManager />}
|
||||||
{children}
|
{children}
|
||||||
</Canvas>
|
</Canvas>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,14 +111,26 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zone selected -> can hover contents (walls, items, slabs, ceilings, roofs)
|
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs)
|
||||||
return {
|
return {
|
||||||
types: ['wall', 'item', 'slab', 'ceiling', 'roof'],
|
types: ['wall', 'item', 'slab', 'ceiling', 'roof'],
|
||||||
handleClick: () => {
|
handleClick: (node) => {
|
||||||
// No action on click at zone content level
|
const { selectedIds } = useViewer.getState().selection
|
||||||
|
// Toggle selection - if already selected, deselect; otherwise select
|
||||||
|
if (selectedIds.includes(node.id)) {
|
||||||
|
useViewer.getState().setSelection({ selectedIds: selectedIds.filter((id) => id !== node.id) })
|
||||||
|
} else {
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
handleDeselect: () => {
|
handleDeselect: () => {
|
||||||
useViewer.getState().setSelection({ zoneId: null })
|
const { selectedIds } = useViewer.getState().selection
|
||||||
|
// If items are selected, deselect them first; otherwise go back to level
|
||||||
|
if (selectedIds.length > 0) {
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [] })
|
||||||
|
} else {
|
||||||
|
useViewer.getState().setSelection({ zoneId: null })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
isValid: (node) => {
|
isValid: (node) => {
|
||||||
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof']
|
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof']
|
||||||
@@ -222,12 +234,17 @@ const PointerMissedHandler = ({ clickHandledRef }: { clickHandledRef: React.Muta
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OutlinerSync = () => {
|
const OutlinerSync = () => {
|
||||||
|
const selection = useViewer((s) => s.selection)
|
||||||
const hoveredId = useViewer((s) => s.hoveredId)
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
const outliner = useViewer((s) => s.outliner)
|
const outliner = useViewer((s) => s.outliner)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear selected objects - we only show hover in viewer
|
// Sync selected objects
|
||||||
outliner.selectedObjects.length = 0
|
outliner.selectedObjects.length = 0
|
||||||
|
for (const id of selection.selectedIds) {
|
||||||
|
const obj = sceneRegistry.nodes.get(id)
|
||||||
|
if (obj) outliner.selectedObjects.push(obj)
|
||||||
|
}
|
||||||
|
|
||||||
// Sync hovered objects
|
// Sync hovered objects
|
||||||
outliner.hoveredObjects.length = 0
|
outliner.hoveredObjects.length = 0
|
||||||
@@ -235,7 +252,7 @@ const OutlinerSync = () => {
|
|||||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||||
if (obj) outliner.hoveredObjects.push(obj)
|
if (obj) outliner.hoveredObjects.push(obj)
|
||||||
}
|
}
|
||||||
}, [hoveredId, outliner])
|
}, [selection, hoveredId, outliner])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user