Merge pull request #84 from pascalorg/feat/flood-fill-wall-cut
Feat/flood fill wall cut
This commit is contained in:
@@ -21,6 +21,7 @@ export const ViewerOverlay = () => {
|
|||||||
const showGuides = useViewer((s) => s.showGuides)
|
const showGuides = useViewer((s) => s.showGuides)
|
||||||
const cameraMode = useViewer((s) => s.cameraMode)
|
const cameraMode = useViewer((s) => s.cameraMode)
|
||||||
const levelMode = useViewer((s) => s.levelMode)
|
const levelMode = useViewer((s) => s.levelMode)
|
||||||
|
const wallMode = useViewer((s) => s.wallMode)
|
||||||
|
|
||||||
const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null
|
const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null
|
||||||
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
||||||
@@ -202,6 +203,38 @@ export const ViewerOverlay = () => {
|
|||||||
Solo
|
Solo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Wall Mode */}
|
||||||
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
||||||
|
<span className="text-xs text-neutral-500 px-2 pb-1">Wall Mode</span>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setWallMode('cutaway')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
wallMode === 'cutaway' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img alt="Cutaway" height={16} src="/icons/wallcut.png" width={16} className="w-4 h-4" />
|
||||||
|
Cutaway
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setWallMode('up')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
wallMode === 'up' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img alt="Full Height" height={16} src="/icons/room.png" width={16} className="w-4 h-4" />
|
||||||
|
Full Height
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setWallMode('down')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
wallMode === 'down' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img alt="Low" height={16} src="/icons/walllow.png" width={16} className="w-4 h-4" />
|
||||||
|
Low
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { initSpatialGridSync, sceneRegistry, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
initSpaceDetectionSync,
|
||||||
|
initSpatialGridSync,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
|
import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
|
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
@@ -10,6 +15,7 @@ import { MathUtils, type Mesh } from 'three'
|
|||||||
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
|
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
@@ -23,6 +29,7 @@ import { SelectionManager } from './selection-manager'
|
|||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
console.log('Loaded scene in editor')
|
console.log('Loaded scene in editor')
|
||||||
initSpatialGridSync()
|
initSpatialGridSync()
|
||||||
|
initSpaceDetectionSync(useScene, useEditor)
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
useKeyboard()
|
useKeyboard()
|
||||||
@@ -41,7 +48,7 @@ export default function Editor() {
|
|||||||
{/* Editor only system to toggle zone visibility */}
|
{/* Editor only system to toggle zone visibility */}
|
||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
{/* <Stats /> */}
|
{/* <Stats /> */}
|
||||||
<Grid cellColor="#666" sectionColor="#999" fadeDistance={30} />
|
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={30} />
|
||||||
<ToolManager />
|
<ToolManager />
|
||||||
<CustomCameraControls />
|
<CustomCameraControls />
|
||||||
</Viewer>
|
</Viewer>
|
||||||
@@ -49,7 +56,6 @@ export default function Editor() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const Grid = ({
|
const Grid = ({
|
||||||
cellSize = 0.5,
|
cellSize = 0.5,
|
||||||
cellThickness = 0.5,
|
cellThickness = 0.5,
|
||||||
|
|||||||
@@ -18,11 +18,39 @@ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
|||||||
|
|
||||||
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
||||||
|
|
||||||
|
type WallMode = 'up' | 'cutaway' | 'down'
|
||||||
|
|
||||||
|
const wallModeConfig: Record<
|
||||||
|
WallMode,
|
||||||
|
{ icon: React.FC<React.ComponentProps<'img'>>; label: string }
|
||||||
|
> = {
|
||||||
|
up: {
|
||||||
|
icon: (props) => (
|
||||||
|
<img alt="Full Height" height={20} src="/icons/room.png" width={20} {...props} />
|
||||||
|
),
|
||||||
|
label: 'Full Height',
|
||||||
|
},
|
||||||
|
cutaway: {
|
||||||
|
icon: (props) => (
|
||||||
|
<img alt="Cutaway" height={20} src="/icons/wallcut.png" width={20} {...props} />
|
||||||
|
),
|
||||||
|
label: 'Cutaway',
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
icon: (props) => <img alt="Low" height={20} src="/icons/walllow.png" width={20} {...props} />,
|
||||||
|
label: 'Low',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallModeOrder: WallMode[] = ['cutaway', 'up', 'down']
|
||||||
|
|
||||||
export function ViewToggles() {
|
export function ViewToggles() {
|
||||||
const cameraMode = useViewer((state) => state.cameraMode)
|
const cameraMode = useViewer((state) => state.cameraMode)
|
||||||
const setCameraMode = useViewer((state) => state.setCameraMode)
|
const setCameraMode = useViewer((state) => state.setCameraMode)
|
||||||
const levelMode = useViewer((state) => state.levelMode)
|
const levelMode = useViewer((state) => state.levelMode)
|
||||||
const setLevelMode = useViewer((state) => state.setLevelMode)
|
const setLevelMode = useViewer((state) => state.setLevelMode)
|
||||||
|
const wallMode = useViewer((state) => state.wallMode)
|
||||||
|
const setWallMode = useViewer((state) => state.setWallMode)
|
||||||
const showScans = useViewer((state) => state.showScans)
|
const showScans = useViewer((state) => state.showScans)
|
||||||
const setShowScans = useViewer((state) => state.setShowScans)
|
const setShowScans = useViewer((state) => state.setShowScans)
|
||||||
const showGuides = useViewer((state) => state.showGuides)
|
const showGuides = useViewer((state) => state.showGuides)
|
||||||
@@ -43,6 +71,13 @@ export function ViewToggles() {
|
|||||||
if (nextMode) setLevelMode(nextMode)
|
if (nextMode) setLevelMode(nextMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cycleWallMode = () => {
|
||||||
|
const currentIndex = wallModeOrder.indexOf(wallMode)
|
||||||
|
const nextIndex = (currentIndex + 1) % wallModeOrder.length
|
||||||
|
const nextMode = wallModeOrder[nextIndex]
|
||||||
|
if (nextMode) setWallMode(nextMode)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Camera Mode */}
|
{/* Camera Mode */}
|
||||||
@@ -91,6 +126,31 @@ export function ViewToggles() {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Wall Mode */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 text-zinc-400 transition-all p-0',
|
||||||
|
wallMode !== 'cutaway'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400'
|
||||||
|
: 'hover:bg-zinc-800',
|
||||||
|
)}
|
||||||
|
onClick={cycleWallMode}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const Icon = wallModeConfig[wallMode].icon
|
||||||
|
return <Icon className="h-5 w-5" />
|
||||||
|
})()}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Walls: {wallModeConfig[wallMode].label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Show Scans */}
|
{/* Show Scans */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type BuildingNode, type ItemNode, type LevelNode, useScene } from '@pascal-app/core'
|
import { type BuildingNode, type ItemNode, type LevelNode, type Space, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { AssetInput } from '@pascal-app/core'
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
@@ -50,7 +50,7 @@ type EditorState = {
|
|||||||
setMode: (mode: Mode) => void
|
setMode: (mode: Mode) => void
|
||||||
tool: Tool | null
|
tool: Tool | null
|
||||||
setTool: (tool: Tool | null) => void
|
setTool: (tool: Tool | null) => void
|
||||||
structureLayer: StructureLayer
|
structureLayer: StructureLayer
|
||||||
setStructureLayer: (layer: StructureLayer) => void
|
setStructureLayer: (layer: StructureLayer) => void
|
||||||
catalogCategory: CatalogCategory | null
|
catalogCategory: CatalogCategory | null
|
||||||
setCatalogCategory: (category: CatalogCategory | null) => void
|
setCatalogCategory: (category: CatalogCategory | null) => void
|
||||||
@@ -60,6 +60,9 @@ type EditorState = {
|
|||||||
setMovingNode: (node: ItemNode | null) => void
|
setMovingNode: (node: ItemNode | null) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
|
// Space detection for cutaway mode
|
||||||
|
spaces: Record<string, Space>
|
||||||
|
setSpaces: (spaces: Record<string, Space>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditor = create<EditorState>()((set, get) => ({
|
const useEditor = create<EditorState>()((set, get) => ({
|
||||||
@@ -174,6 +177,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setMovingNode: (node) => set({ movingNode: node }),
|
setMovingNode: (node) => set({ movingNode: node }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
|
spaces: {},
|
||||||
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export default useEditor
|
export default useEditor
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ export { WallSystem } from './systems/wall/wall-system'
|
|||||||
export { isObject } from './utils/types'
|
export { isObject } from './utils/types'
|
||||||
// Asset storage
|
// Asset storage
|
||||||
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
|
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
|
||||||
|
// Space detection
|
||||||
|
export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection'
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import type { WallNode } from '../schema'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Space = {
|
||||||
|
id: string
|
||||||
|
levelId: string
|
||||||
|
polygon: Array<[number, number]>
|
||||||
|
wallIds: string[]
|
||||||
|
isExterior: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYNC INITIALIZATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes space detection sync with scene and editor stores
|
||||||
|
* Call this once during app initialization
|
||||||
|
*/
|
||||||
|
export function initSpaceDetectionSync(
|
||||||
|
sceneStore: any, // useScene store
|
||||||
|
editorStore: any, // useEditor store
|
||||||
|
): () => void {
|
||||||
|
const prevWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
let isProcessing = false // Prevent re-entrant calls
|
||||||
|
|
||||||
|
// Subscribe to scene changes (standard Zustand subscribe, not selector-based)
|
||||||
|
const unsubscribe = sceneStore.subscribe((state: any) => {
|
||||||
|
// Skip if already processing to avoid infinite loops
|
||||||
|
if (isProcessing) return
|
||||||
|
|
||||||
|
const nodes = state.nodes
|
||||||
|
const currentWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
// Group walls by level
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if ((node as any).type === 'wall' && (node as any).parentId) {
|
||||||
|
const levelId = (node as any).parentId
|
||||||
|
if (!currentWallsByLevel.has(levelId)) {
|
||||||
|
currentWallsByLevel.set(levelId, new Set())
|
||||||
|
}
|
||||||
|
currentWallsByLevel.get(levelId)!.add((node as any).id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each level for changes
|
||||||
|
const levelsToUpdate = new Set<string>()
|
||||||
|
|
||||||
|
// Check for new walls (created)
|
||||||
|
for (const [levelId, wallIds] of currentWallsByLevel.entries()) {
|
||||||
|
const prevWallIds = prevWallsByLevel.get(levelId)
|
||||||
|
|
||||||
|
if (!prevWallIds) {
|
||||||
|
// New level with walls - run detection if there are multiple walls
|
||||||
|
if (wallIds.size > 1) {
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find newly added walls
|
||||||
|
for (const wallId of wallIds) {
|
||||||
|
if (!prevWallIds.has(wallId)) {
|
||||||
|
// Wall was added - check if it touches other walls
|
||||||
|
const wall = nodes[wallId as keyof typeof nodes] as WallNode
|
||||||
|
const otherWalls = Array.from(wallIds)
|
||||||
|
.filter((id) => id !== wallId)
|
||||||
|
.map((id) => nodes[id as keyof typeof nodes] as WallNode)
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (wallTouchesOthers(wall, otherWalls)) {
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for deleted walls
|
||||||
|
for (const [levelId, prevWallIds] of prevWallsByLevel.entries()) {
|
||||||
|
const currentWallIds = currentWallsByLevel.get(levelId)
|
||||||
|
|
||||||
|
if (!currentWallIds) {
|
||||||
|
// All walls deleted from level - clear spaces
|
||||||
|
if (prevWallIds.size > 0) {
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any walls were deleted
|
||||||
|
for (const wallId of prevWallIds) {
|
||||||
|
if (!currentWallIds.has(wallId)) {
|
||||||
|
// Wall was deleted - run detection
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run detection for affected levels
|
||||||
|
if (levelsToUpdate.size > 0) {
|
||||||
|
isProcessing = true
|
||||||
|
try {
|
||||||
|
runSpaceDetection(Array.from(levelsToUpdate), sceneStore, editorStore, nodes)
|
||||||
|
} finally {
|
||||||
|
isProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous walls reference
|
||||||
|
prevWallsByLevel.clear()
|
||||||
|
for (const [levelId, wallIds] of currentWallsByLevel.entries()) {
|
||||||
|
prevWallsByLevel.set(levelId, wallIds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs space detection for the given levels
|
||||||
|
* Updates wall nodes and editor spaces
|
||||||
|
*/
|
||||||
|
function runSpaceDetection(
|
||||||
|
levelIds: string[],
|
||||||
|
sceneStore: any,
|
||||||
|
editorStore: any,
|
||||||
|
nodes: any,
|
||||||
|
): void {
|
||||||
|
const { updateNode } = sceneStore.getState()
|
||||||
|
const { setSpaces } = editorStore.getState()
|
||||||
|
|
||||||
|
const allSpaces: Record<string, any> = {}
|
||||||
|
|
||||||
|
for (const levelId of levelIds) {
|
||||||
|
// Get walls for this level
|
||||||
|
const walls = Object.values(nodes).filter(
|
||||||
|
(node: any) => node.type === 'wall' && node.parentId === levelId,
|
||||||
|
) as WallNode[]
|
||||||
|
|
||||||
|
if (walls.length === 0) {
|
||||||
|
// No walls - clear any spaces for this level
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run detection
|
||||||
|
const { wallUpdates, spaces } = detectSpacesForLevel(levelId, walls)
|
||||||
|
|
||||||
|
// Update wall nodes (only if values changed to avoid infinite loop)
|
||||||
|
for (const update of wallUpdates) {
|
||||||
|
const wall = nodes[update.wallId as keyof typeof nodes] as WallNode
|
||||||
|
if (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) {
|
||||||
|
updateNode(update.wallId as any, {
|
||||||
|
frontSide: update.frontSide,
|
||||||
|
backSide: update.backSide,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store spaces
|
||||||
|
for (const space of spaces) {
|
||||||
|
allSpaces[space.id] = space
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update editor spaces
|
||||||
|
setSpaces(allSpaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Grid = {
|
||||||
|
cells: Map<string, 'empty' | 'wall' | 'exterior' | 'interior'>
|
||||||
|
resolution: number
|
||||||
|
minX: number
|
||||||
|
minZ: number
|
||||||
|
maxX: number
|
||||||
|
maxZ: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type WallSideUpdate = {
|
||||||
|
wallId: string
|
||||||
|
frontSide: 'interior' | 'exterior' | 'unknown'
|
||||||
|
backSide: 'interior' | 'exterior' | 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN DETECTION FUNCTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects spaces for a level by flood-filling a grid from the edges
|
||||||
|
* Returns wall side updates and detected spaces
|
||||||
|
*/
|
||||||
|
export function detectSpacesForLevel(
|
||||||
|
levelId: string,
|
||||||
|
walls: WallNode[],
|
||||||
|
gridResolution: number = 0.5, // Match spatial grid cell size
|
||||||
|
): {
|
||||||
|
wallUpdates: WallSideUpdate[]
|
||||||
|
spaces: Space[]
|
||||||
|
} {
|
||||||
|
if (walls.length === 0) {
|
||||||
|
return { wallUpdates: [], spaces: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build grid from walls
|
||||||
|
const grid = buildGrid(walls, gridResolution)
|
||||||
|
|
||||||
|
// Flood fill from edges to mark exterior
|
||||||
|
floodFillFromEdges(grid)
|
||||||
|
|
||||||
|
// Find interior spaces
|
||||||
|
const interiorSpaces = findInteriorSpaces(grid, levelId)
|
||||||
|
|
||||||
|
// Assign wall sides
|
||||||
|
const wallUpdates = assignWallSides(walls, grid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
wallUpdates,
|
||||||
|
spaces: interiorSpaces,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GRID BUILDING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a discrete grid and marks cells occupied by walls
|
||||||
|
*/
|
||||||
|
function buildGrid(walls: WallNode[], resolution: number): Grid {
|
||||||
|
// Find bounds
|
||||||
|
let minX = Number.POSITIVE_INFINITY
|
||||||
|
let minZ = Number.POSITIVE_INFINITY
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY
|
||||||
|
let maxZ = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
for (const wall of walls) {
|
||||||
|
minX = Math.min(minX, wall.start[0], wall.end[0])
|
||||||
|
minZ = Math.min(minZ, wall.start[1], wall.end[1])
|
||||||
|
maxX = Math.max(maxX, wall.start[0], wall.end[0])
|
||||||
|
maxZ = Math.max(maxZ, wall.start[1], wall.end[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add padding around bounds
|
||||||
|
const padding = 2 // meters
|
||||||
|
minX -= padding
|
||||||
|
minZ -= padding
|
||||||
|
maxX += padding
|
||||||
|
maxZ += padding
|
||||||
|
|
||||||
|
const width = Math.ceil((maxX - minX) / resolution)
|
||||||
|
const height = Math.ceil((maxZ - minZ) / resolution)
|
||||||
|
|
||||||
|
const grid: Grid = {
|
||||||
|
cells: new Map(),
|
||||||
|
resolution,
|
||||||
|
minX,
|
||||||
|
minZ,
|
||||||
|
maxX,
|
||||||
|
maxZ,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark wall cells
|
||||||
|
for (const wall of walls) {
|
||||||
|
markWallCells(grid, wall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks all grid cells occupied by a wall using line rasterization
|
||||||
|
* Uses denser sampling to ensure continuous barriers
|
||||||
|
*/
|
||||||
|
function markWallCells(grid: Grid, wall: WallNode): void {
|
||||||
|
const thickness = wall.thickness ?? 0.2
|
||||||
|
const halfThickness = thickness / 2
|
||||||
|
|
||||||
|
const [x1, z1] = wall.start
|
||||||
|
const [x2, z2] = wall.end
|
||||||
|
|
||||||
|
// Wall direction vector
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dz = z2 - z1
|
||||||
|
const len = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
if (len < 0.001) return
|
||||||
|
|
||||||
|
// Normalized direction and perpendicular
|
||||||
|
const dirX = dx / len
|
||||||
|
const dirZ = dz / len
|
||||||
|
const perpX = -dirZ
|
||||||
|
const perpZ = dirX
|
||||||
|
|
||||||
|
// Denser sampling along wall length (at least 2x resolution)
|
||||||
|
const steps = Math.max(Math.ceil(len / (grid.resolution * 0.5)), 2)
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps
|
||||||
|
const x = x1 + dx * t
|
||||||
|
const z = z1 + dz * t
|
||||||
|
|
||||||
|
// Denser sampling across wall thickness
|
||||||
|
const thicknessSteps = Math.max(Math.ceil(thickness / (grid.resolution * 0.5)), 2)
|
||||||
|
for (let j = 0; j <= thicknessSteps; j++) {
|
||||||
|
const offset = (j / thicknessSteps - 0.5) * thickness
|
||||||
|
const wx = x + perpX * offset
|
||||||
|
const wz = z + perpZ * offset
|
||||||
|
|
||||||
|
const key = getCellKey(grid, wx, wz)
|
||||||
|
if (key) {
|
||||||
|
grid.cells.set(key, 'wall')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FLOOD FILL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flood fills from all edge cells to mark exterior space
|
||||||
|
*/
|
||||||
|
function floodFillFromEdges(grid: Grid): void {
|
||||||
|
const queue: string[] = []
|
||||||
|
|
||||||
|
// Add all edge cells to queue
|
||||||
|
for (let x = 0; x < grid.width; x++) {
|
||||||
|
for (let z = 0; z < grid.height; z++) {
|
||||||
|
// Only process edge cells
|
||||||
|
if (x === 0 || x === grid.width - 1 || z === 0 || z === grid.height - 1) {
|
||||||
|
const key = getCellKeyFromIndex(x, z, grid.width)
|
||||||
|
const cell = grid.cells.get(key)
|
||||||
|
if (cell !== 'wall') {
|
||||||
|
grid.cells.set(key, 'exterior')
|
||||||
|
queue.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flood fill
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const key = queue.shift()!
|
||||||
|
const [x, z] = parseCellKey(key)
|
||||||
|
|
||||||
|
// Check 4 neighbors
|
||||||
|
const neighbors: [number, number][] = [
|
||||||
|
[x + 1, z],
|
||||||
|
[x - 1, z],
|
||||||
|
[x, z + 1],
|
||||||
|
[x, z - 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [nx, nz] of neighbors) {
|
||||||
|
if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height) continue
|
||||||
|
|
||||||
|
const nKey = getCellKeyFromIndex(nx, nz, grid.width)
|
||||||
|
const cell = grid.cells.get(nKey)
|
||||||
|
|
||||||
|
if (cell !== 'wall' && cell !== 'exterior') {
|
||||||
|
grid.cells.set(nKey, 'exterior')
|
||||||
|
queue.push(nKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERIOR SPACE DETECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all interior spaces (connected regions not marked as exterior or wall)
|
||||||
|
*/
|
||||||
|
function findInteriorSpaces(grid: Grid, levelId: string): Space[] {
|
||||||
|
const spaces: Space[] = []
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
// Scan grid for interior cells
|
||||||
|
for (let x = 0; x < grid.width; x++) {
|
||||||
|
for (let z = 0; z < grid.height; z++) {
|
||||||
|
const key = getCellKeyFromIndex(x, z, grid.width)
|
||||||
|
if (visited.has(key)) continue
|
||||||
|
|
||||||
|
const cell = grid.cells.get(key)
|
||||||
|
if (cell === 'wall' || cell === 'exterior') {
|
||||||
|
visited.add(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found interior cell - flood fill to find full space
|
||||||
|
const spaceCells = new Set<string>()
|
||||||
|
const queue = [key]
|
||||||
|
visited.add(key)
|
||||||
|
spaceCells.add(key)
|
||||||
|
// Mark the seed cell as interior in the grid
|
||||||
|
grid.cells.set(key, 'interior')
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const curKey = queue.shift()!
|
||||||
|
const [cx, cz] = parseCellKey(curKey)
|
||||||
|
|
||||||
|
const neighbors: [number, number][] = [
|
||||||
|
[cx + 1, cz],
|
||||||
|
[cx - 1, cz],
|
||||||
|
[cx, cz + 1],
|
||||||
|
[cx, cz - 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [nx, nz] of neighbors) {
|
||||||
|
if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height) continue
|
||||||
|
|
||||||
|
const nKey = getCellKeyFromIndex(nx, nz, grid.width)
|
||||||
|
if (visited.has(nKey)) continue
|
||||||
|
|
||||||
|
const nCell = grid.cells.get(nKey)
|
||||||
|
if (nCell === 'wall' || nCell === 'exterior') {
|
||||||
|
visited.add(nKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(nKey)
|
||||||
|
spaceCells.add(nKey)
|
||||||
|
// Mark as interior in grid
|
||||||
|
grid.cells.set(nKey, 'interior')
|
||||||
|
queue.push(nKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create space from cells
|
||||||
|
const polygon = extractPolygonFromCells(spaceCells, grid)
|
||||||
|
spaces.push({
|
||||||
|
id: `space-${spaces.length}`,
|
||||||
|
levelId,
|
||||||
|
polygon,
|
||||||
|
wallIds: [],
|
||||||
|
isExterior: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaces
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a simplified polygon from a set of grid cells
|
||||||
|
* Returns bounding box for now (can be improved to trace actual boundary)
|
||||||
|
*/
|
||||||
|
function extractPolygonFromCells(cells: Set<string>, grid: Grid): Array<[number, number]> {
|
||||||
|
let minX = Number.POSITIVE_INFINITY
|
||||||
|
let minZ = Number.POSITIVE_INFINITY
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY
|
||||||
|
let maxZ = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
for (const key of cells) {
|
||||||
|
const [x, z] = parseCellKey(key)
|
||||||
|
const worldX = grid.minX + x * grid.resolution
|
||||||
|
const worldZ = grid.minZ + z * grid.resolution
|
||||||
|
|
||||||
|
minX = Math.min(minX, worldX)
|
||||||
|
minZ = Math.min(minZ, worldZ)
|
||||||
|
maxX = Math.max(maxX, worldX)
|
||||||
|
maxZ = Math.max(maxZ, worldZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return bounding box as polygon
|
||||||
|
return [
|
||||||
|
[minX, minZ],
|
||||||
|
[maxX, minZ],
|
||||||
|
[maxX, maxZ],
|
||||||
|
[minX, maxZ],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WALL SIDE ASSIGNMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns front/back side classification to each wall based on grid
|
||||||
|
*/
|
||||||
|
function assignWallSides(walls: WallNode[], grid: Grid): WallSideUpdate[] {
|
||||||
|
const updates: WallSideUpdate[] = []
|
||||||
|
|
||||||
|
for (const wall of walls) {
|
||||||
|
const thickness = wall.thickness ?? 0.2
|
||||||
|
const [x1, z1] = wall.start
|
||||||
|
const [x2, z2] = wall.end
|
||||||
|
|
||||||
|
// Wall direction and perpendicular
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dz = z2 - z1
|
||||||
|
const len = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
if (len < 0.001) continue
|
||||||
|
|
||||||
|
const perpX = -dz / len
|
||||||
|
const perpZ = dx / len
|
||||||
|
|
||||||
|
// Sample point on front side (perpendicular direction)
|
||||||
|
const midX = (x1 + x2) / 2
|
||||||
|
const midZ = (z1 + z2) / 2
|
||||||
|
// Sample beyond wall thickness + one full grid cell to ensure we're in the next cell
|
||||||
|
const offset = thickness / 2 + grid.resolution
|
||||||
|
|
||||||
|
const frontX = midX + perpX * offset
|
||||||
|
const frontZ = midZ + perpZ * offset
|
||||||
|
const backX = midX - perpX * offset
|
||||||
|
const backZ = midZ - perpZ * offset
|
||||||
|
|
||||||
|
// Check what space each side faces
|
||||||
|
const frontKey = getCellKey(grid, frontX, frontZ)
|
||||||
|
const backKey = getCellKey(grid, backX, backZ)
|
||||||
|
|
||||||
|
const frontCell = frontKey ? grid.cells.get(frontKey) : undefined
|
||||||
|
const backCell = backKey ? grid.cells.get(backKey) : undefined
|
||||||
|
|
||||||
|
const frontSide = classifySide(frontCell)
|
||||||
|
const backSide = classifySide(backCell)
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
wallId: wall.id,
|
||||||
|
frontSide,
|
||||||
|
backSide,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a cell as interior, exterior, or unknown
|
||||||
|
*/
|
||||||
|
function classifySide(cell: string | undefined): 'interior' | 'exterior' | 'unknown' {
|
||||||
|
if (cell === 'exterior') return 'exterior'
|
||||||
|
if (cell === 'interior') return 'interior'
|
||||||
|
// Wall cells or out-of-bounds (undefined) are unknown
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GRID UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets grid cell key from world coordinates
|
||||||
|
*/
|
||||||
|
function getCellKey(grid: Grid, x: number, z: number): string | null {
|
||||||
|
const cellX = Math.floor((x - grid.minX) / grid.resolution)
|
||||||
|
const cellZ = Math.floor((z - grid.minZ) / grid.resolution)
|
||||||
|
|
||||||
|
if (cellX < 0 || cellX >= grid.width || cellZ < 0 || cellZ >= grid.height) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${cellX},${cellZ}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets cell key from grid indices
|
||||||
|
*/
|
||||||
|
function getCellKeyFromIndex(x: number, z: number, width: number): string {
|
||||||
|
return `${x},${z}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses cell key back to indices
|
||||||
|
*/
|
||||||
|
function parseCellKey(key: string): [number, number] {
|
||||||
|
const parts = key.split(',')
|
||||||
|
return [Number.parseInt(parts[0]!, 10), Number.parseInt(parts[1]!, 10)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WALL CONNECTIVITY DETECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a wall touches any other walls
|
||||||
|
* Used to determine if space detection should run
|
||||||
|
*/
|
||||||
|
export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean {
|
||||||
|
const threshold = 0.1 // 10cm connection threshold
|
||||||
|
|
||||||
|
for (const other of otherWalls) {
|
||||||
|
if (other.id === wall.id) continue
|
||||||
|
|
||||||
|
// Check if any endpoint of wall is close to any endpoint or segment of other
|
||||||
|
if (
|
||||||
|
distanceToSegment(wall.start, other.start, other.end) < threshold ||
|
||||||
|
distanceToSegment(wall.end, other.start, other.end) < threshold ||
|
||||||
|
distanceToSegment(other.start, wall.start, wall.end) < threshold ||
|
||||||
|
distanceToSegment(other.end, wall.start, wall.end) < threshold
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance from point to line segment
|
||||||
|
*/
|
||||||
|
function distanceToSegment(
|
||||||
|
point: [number, number],
|
||||||
|
segStart: [number, number],
|
||||||
|
segEnd: [number, number],
|
||||||
|
): number {
|
||||||
|
const [px, pz] = point
|
||||||
|
const [x1, z1] = segStart
|
||||||
|
const [x2, z2] = segEnd
|
||||||
|
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dz = z2 - z1
|
||||||
|
const lenSq = dx * dx + dz * dz
|
||||||
|
|
||||||
|
if (lenSq < 0.0001) {
|
||||||
|
// Segment is a point
|
||||||
|
const dpx = px - x1
|
||||||
|
const dpz = pz - z1
|
||||||
|
return Math.sqrt(dpx * dpx + dpz * dpz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project point onto line
|
||||||
|
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lenSq))
|
||||||
|
const projX = x1 + t * dx
|
||||||
|
const projZ = z1 + t * dz
|
||||||
|
|
||||||
|
const distX = px - projX
|
||||||
|
const distZ = pz - projZ
|
||||||
|
|
||||||
|
return Math.sqrt(distX * distX + distZ * distZ)
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ export const WallNode = BaseNode.extend({
|
|||||||
// e.g., start/end points for path
|
// e.g., start/end points for path
|
||||||
start: z.tuple([z.number(), z.number()]),
|
start: z.tuple([z.number(), z.number()]),
|
||||||
end: z.tuple([z.number(), z.number()]),
|
end: z.tuple([z.number(), z.number()]),
|
||||||
|
// Space detection for cutaway mode
|
||||||
|
frontSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'),
|
||||||
|
backSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Wall node - used to represent a wall in the building
|
Wall node - used to represent a wall in the building
|
||||||
@@ -24,6 +27,8 @@ export const WallNode = BaseNode.extend({
|
|||||||
- start: start point of the wall in level coordinate system
|
- start: start point of the wall in level coordinate system
|
||||||
- end: end point of the wall in level coordinate system
|
- end: end point of the wall in level coordinate system
|
||||||
- size: size of the wall in grid units
|
- size: size of the wall in grid units
|
||||||
|
- frontSide: whether the front side faces interior, exterior, or unknown
|
||||||
|
- backSide: whether the back side faces interior, exterior, or unknown
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
export type WallNode = z.infer<typeof WallNode>
|
export type WallNode = z.infer<typeof WallNode>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const WallSystem = () => {
|
|||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
|
|
||||||
|
console.log('wall front/back', node.frontSide, node.backSide)
|
||||||
const levelId = node.parentId
|
const levelId = node.parentId
|
||||||
if (!levelId) return
|
if (!levelId) return
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import { useNodeEvents } from '../../../hooks/use-node-events'
|
|||||||
// 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({
|
||||||
color: 0xffffff,
|
color: 0xffffff,
|
||||||
roughness: 0.8,
|
roughness: 1,
|
||||||
metalness: 0,
|
metalness: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const glassMaterial = new MeshStandardNodeMaterial({
|
const glassMaterial = new MeshStandardNodeMaterial({
|
||||||
name: 'glass',
|
name: 'glass',
|
||||||
color: 'skyblue',
|
color: 'lightgray',
|
||||||
roughness: 0.8,
|
roughness: 0.8,
|
||||||
metalness: 0,
|
metalness: 0,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.25,
|
opacity: 0.35,
|
||||||
side: DoubleSide,
|
side: DoubleSide,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
<mesh ref={ref} castShadow receiveShadow visible={node.visible}>
|
<mesh ref={ref} castShadow receiveShadow visible={node.visible}>
|
||||||
{/* WallSystem will replace this geometry in the next frame */}
|
{/* WallSystem will replace this geometry in the next frame */}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
<meshStandardMaterial color="white" />
|
|
||||||
<mesh name="collision-mesh" {...handlers} visible={false}>
|
<mesh name="collision-mesh" {...handlers} visible={false}>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as THREE from 'three/webgpu'
|
|||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { LevelSystem } from '../../systems/level/level-system'
|
import { LevelSystem } from '../../systems/level/level-system'
|
||||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||||
|
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||||
import { Lights } from './lights'
|
import { Lights } from './lights'
|
||||||
import PostProcessing from './post-processing'
|
import PostProcessing from './post-processing'
|
||||||
@@ -27,12 +28,12 @@ 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-[#fafafa]'}
|
||||||
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()
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
renderer.toneMappingExposure = 1.2
|
renderer.toneMappingExposure = 0.9
|
||||||
return renderer
|
return renderer
|
||||||
}}
|
}}
|
||||||
shadows={{
|
shadows={{
|
||||||
@@ -41,7 +42,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
|
|||||||
}}
|
}}
|
||||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||||
>
|
>
|
||||||
<color attach="background" args={['#ececec']} />
|
<color attach="background" args={['#fafafa']} />
|
||||||
<ViewerCamera />
|
<ViewerCamera />
|
||||||
|
|
||||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||||
@@ -55,6 +56,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
|
|||||||
<LevelSystem />
|
<LevelSystem />
|
||||||
<GuideSystem />
|
<GuideSystem />
|
||||||
<ScanSystem />
|
<ScanSystem />
|
||||||
|
<WallCutout />
|
||||||
{/* Core systems */}
|
{/* Core systems */}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<ItemSystem />
|
<ItemSystem />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Environment } from '@react-three/drei'
|
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import type { DirectionalLight, OrthographicCamera } from 'three/webgpu'
|
import type { DirectionalLight, OrthographicCamera } from 'three/webgpu'
|
||||||
|
|
||||||
@@ -12,30 +11,42 @@ export function Lights() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<directionalLight
|
<directionalLight
|
||||||
ref={lightRef}
|
ref={lightRef}
|
||||||
position={[10, 10, 10]}
|
position={[10, 10, 10]}
|
||||||
castShadow
|
castShadow
|
||||||
intensity={1}
|
intensity={4}
|
||||||
shadow-bias={-0.002}
|
shadow-bias={-0.002}
|
||||||
shadow-normalBias={0.3}
|
shadow-normalBias={0.3}
|
||||||
shadow-mapSize={[1024, 1024]}
|
shadow-mapSize={[1024, 1024]}
|
||||||
shadow-radius={3}
|
shadow-radius={3}
|
||||||
>
|
shadow-intensity={0.4}
|
||||||
<orthographicCamera
|
>
|
||||||
ref={shadowCamera}
|
<orthographicCamera
|
||||||
attach="shadow-camera"
|
ref={shadowCamera}
|
||||||
near={1}
|
attach="shadow-camera"
|
||||||
far={100}
|
near={1}
|
||||||
left={-shadowCameraSize}
|
far={100}
|
||||||
right={shadowCameraSize}
|
left={-shadowCameraSize}
|
||||||
top={shadowCameraSize}
|
right={shadowCameraSize}
|
||||||
bottom={-shadowCameraSize}
|
top={shadowCameraSize}
|
||||||
/>
|
bottom={-shadowCameraSize}
|
||||||
</directionalLight>
|
/>
|
||||||
|
</directionalLight>
|
||||||
|
|
||||||
<ambientLight intensity={0.2} />
|
<directionalLight
|
||||||
<Environment preset="sunset" environmentIntensity={0.4} />
|
position={[-10, 10, -10]}
|
||||||
|
intensity={0.75}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<directionalLight
|
||||||
|
position={[-10, 10, 10]}
|
||||||
|
intensity={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ambientLight intensity={0.5}
|
||||||
|
color='white' />
|
||||||
|
{/* <Environment preset="sunset" environmentIntensity={0.4} /> */}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,186 @@
|
|||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from 'react'
|
||||||
import { Color } from "three";
|
import { Color, UnsignedByteType } from 'three'
|
||||||
import { outline } from "three/addons/tsl/display/OutlineNode.js";
|
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
|
||||||
import { oscSine, pass, time, uniform } from "three/tsl";
|
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
||||||
import { PostProcessing, type WebGPURenderer } from "three/webgpu";
|
import { traa } from 'three/addons/tsl/display/TRAANode.js'
|
||||||
import useViewer from "../../store/use-viewer";
|
import {
|
||||||
|
add,
|
||||||
|
colorToDirection,
|
||||||
|
diffuseColor,
|
||||||
|
directionToColor,
|
||||||
|
mrt,
|
||||||
|
normalView,
|
||||||
|
oscSine,
|
||||||
|
output,
|
||||||
|
pass,
|
||||||
|
sample,
|
||||||
|
time,
|
||||||
|
uniform,
|
||||||
|
vec4,
|
||||||
|
velocity,
|
||||||
|
} from 'three/tsl'
|
||||||
|
import { PostProcessing, type WebGPURenderer } from 'three/webgpu'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
|
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
|
||||||
|
export const SSGI_PARAMS = {
|
||||||
|
enabled: true,
|
||||||
|
sliceCount: 2,
|
||||||
|
stepCount: 8,
|
||||||
|
radius: 2,
|
||||||
|
expFactor: 2,
|
||||||
|
thickness: 0.5,
|
||||||
|
backfaceLighting: 0.5,
|
||||||
|
aoIntensity: 1.5,
|
||||||
|
giIntensity: 0.5,
|
||||||
|
useLinearThickness: false,
|
||||||
|
useScreenSpaceSampling: true,
|
||||||
|
useTemporalFiltering: true,
|
||||||
|
}
|
||||||
|
|
||||||
const PostProcessingPasses = () => {
|
const PostProcessingPasses = () => {
|
||||||
const { gl: renderer, scene, camera } = useThree();
|
const { gl: renderer, scene, camera } = useThree()
|
||||||
const postProcessingRef = useRef<PostProcessing | null>(null);
|
const postProcessingRef = useRef<PostProcessing | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!renderer || !scene || !camera) {
|
if (!renderer || !scene || !camera) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenePass = pass(scene, camera);
|
// Scene pass with MRT for SSGI
|
||||||
|
const scenePass = pass(scene, camera)
|
||||||
|
scenePass.setMRT(
|
||||||
|
mrt({
|
||||||
|
output: output,
|
||||||
|
diffuseColor: diffuseColor,
|
||||||
|
normal: directionToColor(normalView),
|
||||||
|
velocity: velocity,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get texture outputs
|
||||||
|
const scenePassColor = scenePass.getTextureNode('output')
|
||||||
|
const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')
|
||||||
|
const scenePassDepth = scenePass.getTextureNode('depth')
|
||||||
|
const scenePassNormal = scenePass.getTextureNode('normal')
|
||||||
|
const scenePassVelocity = scenePass.getTextureNode('velocity')
|
||||||
|
|
||||||
|
// Optimize texture bandwidth
|
||||||
|
const diffuseTexture = scenePass.getTexture('diffuseColor')
|
||||||
|
diffuseTexture.type = UnsignedByteType
|
||||||
|
|
||||||
|
const normalTexture = scenePass.getTexture('normal')
|
||||||
|
normalTexture.type = UnsignedByteType
|
||||||
|
|
||||||
|
// Extract normal from color-encoded texture
|
||||||
|
const sceneNormal = sample((uv) => {
|
||||||
|
return colorToDirection(scenePassNormal.sample(uv))
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSGI Pass (cast to PerspectiveCamera for SSGI)
|
||||||
|
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
|
||||||
|
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
|
||||||
|
giPass.stepCount.value = SSGI_PARAMS.stepCount
|
||||||
|
giPass.radius.value = SSGI_PARAMS.radius
|
||||||
|
giPass.expFactor.value = SSGI_PARAMS.expFactor
|
||||||
|
giPass.thickness.value = SSGI_PARAMS.thickness
|
||||||
|
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
|
||||||
|
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
|
||||||
|
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
|
||||||
|
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
|
||||||
|
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
||||||
|
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
||||||
|
|
||||||
|
// Extract GI and AO from SSGI pass
|
||||||
|
const gi = giPass.rgb
|
||||||
|
const ao = giPass.a
|
||||||
|
|
||||||
|
// Composite: scene * AO + diffuse * GI
|
||||||
|
const compositePass = vec4(
|
||||||
|
add(scenePassColor.rgb.mul(ao), scenePassDiffuse.rgb.mul(gi)),
|
||||||
|
scenePassColor.a,
|
||||||
|
)
|
||||||
|
|
||||||
|
// TRAA (Temporal Reprojection Anti-Aliasing)
|
||||||
|
const traaPass = traa(compositePass, scenePassDepth, scenePassVelocity, camera)
|
||||||
|
|
||||||
function generateSelectedOutlinePass() {
|
function generateSelectedOutlinePass() {
|
||||||
const edgeStrength = uniform(3);
|
const edgeStrength = uniform(3)
|
||||||
const edgeGlow = uniform(0);
|
const edgeGlow = uniform(0)
|
||||||
const edgeThickness = uniform(1);
|
const edgeThickness = uniform(1)
|
||||||
const visibleEdgeColor = uniform(new Color(0xffffff));
|
const visibleEdgeColor = uniform(new Color(0xffffff))
|
||||||
const hiddenEdgeColor = uniform(new Color(0xf3ff47));
|
const hiddenEdgeColor = uniform(new Color(0xf3ff47))
|
||||||
|
|
||||||
const outlinePass = outline(scene, camera, {
|
const outlinePass = outline(scene, camera, {
|
||||||
selectedObjects: useViewer.getState().outliner.selectedObjects,
|
selectedObjects: useViewer.getState().outliner.selectedObjects,
|
||||||
edgeGlow,
|
edgeGlow,
|
||||||
edgeThickness,
|
edgeThickness,
|
||||||
});
|
})
|
||||||
const { visibleEdge, hiddenEdge } = outlinePass;
|
const { visibleEdge, hiddenEdge } = outlinePass
|
||||||
|
|
||||||
const outlineColor = visibleEdge
|
const outlineColor = visibleEdge
|
||||||
.mul(visibleEdgeColor)
|
.mul(visibleEdgeColor)
|
||||||
.add(hiddenEdge.mul(hiddenEdgeColor))
|
.add(hiddenEdge.mul(hiddenEdgeColor))
|
||||||
.mul(edgeStrength);
|
.mul(edgeStrength)
|
||||||
|
|
||||||
return outlineColor;
|
return outlineColor
|
||||||
}
|
}
|
||||||
function generateHoverOutlinePass() {
|
function generateHoverOutlinePass() {
|
||||||
const edgeStrength = uniform(5);
|
const edgeStrength = uniform(5)
|
||||||
const edgeGlow = uniform(0.5);
|
const edgeGlow = uniform(0.5)
|
||||||
const edgeThickness = uniform(1.5);
|
const edgeThickness = uniform(1.5)
|
||||||
const pulsePeriod = uniform(3);
|
const pulsePeriod = uniform(3)
|
||||||
const visibleEdgeColor = uniform(new Color(0x00aaff));
|
const visibleEdgeColor = uniform(new Color(0x00aaff))
|
||||||
const hiddenEdgeColor = uniform(new Color(0xf3ff47));
|
const hiddenEdgeColor = uniform(new Color(0xf3ff47))
|
||||||
|
|
||||||
const outlinePass = outline(scene, camera, {
|
const outlinePass = outline(scene, camera, {
|
||||||
selectedObjects: useViewer.getState().outliner.hoveredObjects,
|
selectedObjects: useViewer.getState().outliner.hoveredObjects,
|
||||||
edgeGlow,
|
edgeGlow,
|
||||||
edgeThickness,
|
edgeThickness,
|
||||||
});
|
})
|
||||||
const { visibleEdge, hiddenEdge } = outlinePass;
|
const { visibleEdge, hiddenEdge } = outlinePass
|
||||||
|
|
||||||
const period = time.div(pulsePeriod).mul(2);
|
const period = time.div(pulsePeriod).mul(2)
|
||||||
const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ]
|
const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ]
|
||||||
|
|
||||||
const outlineColor = visibleEdge
|
const outlineColor = visibleEdge
|
||||||
.mul(visibleEdgeColor)
|
.mul(visibleEdgeColor)
|
||||||
.add(hiddenEdge.mul(hiddenEdgeColor))
|
.add(hiddenEdge.mul(hiddenEdgeColor))
|
||||||
.mul(edgeStrength);
|
.mul(edgeStrength)
|
||||||
const outlinePulse = pulsePeriod
|
const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor)
|
||||||
.greaterThan(0)
|
return outlinePulse
|
||||||
.select(outlineColor.mul(osc), outlineColor);
|
|
||||||
return outlinePulse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup post-processing
|
// Setup post-processing
|
||||||
const postProcessing = new PostProcessing(
|
const postProcessing = new PostProcessing(renderer as unknown as WebGPURenderer)
|
||||||
renderer as unknown as WebGPURenderer,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedOutlinePass = generateSelectedOutlinePass();
|
const selectedOutlinePass = generateSelectedOutlinePass()
|
||||||
const hoverOutlinePass = generateHoverOutlinePass();
|
const hoverOutlinePass = generateHoverOutlinePass()
|
||||||
|
|
||||||
postProcessing.outputNode = selectedOutlinePass
|
// Combine SSGI output with outlines
|
||||||
.add(hoverOutlinePass)
|
const finalOutput = SSGI_PARAMS.enabled
|
||||||
.add(scenePass);
|
? selectedOutlinePass.add(hoverOutlinePass).add(traaPass)
|
||||||
postProcessingRef.current = postProcessing;
|
: selectedOutlinePass.add(hoverOutlinePass).add(scenePassColor)
|
||||||
|
|
||||||
|
postProcessing.outputNode = finalOutput
|
||||||
|
postProcessingRef.current = postProcessing
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (postProcessingRef.current) {
|
if (postProcessingRef.current) {
|
||||||
postProcessingRef.current.dispose();
|
postProcessingRef.current.dispose()
|
||||||
}
|
}
|
||||||
postProcessingRef.current = null;
|
postProcessingRef.current = null
|
||||||
};
|
}
|
||||||
}, [renderer, scene, camera]);
|
}, [renderer, scene, camera])
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (postProcessingRef.current) {
|
if (postProcessingRef.current) {
|
||||||
postProcessingRef.current.render();
|
postProcessingRef.current.render()
|
||||||
}
|
}
|
||||||
}, 1);
|
}, 1)
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
export default PostProcessingPasses;
|
export default PostProcessingPasses
|
||||||
|
|||||||
@@ -24,32 +24,35 @@ type Outliner = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ViewerState = {
|
type ViewerState = {
|
||||||
selection: SelectionPath;
|
selection: SelectionPath
|
||||||
hoveredId: AnyNode["id"] | ZoneNode["id"] | null;
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
setHoveredId: (id: AnyNode["id"] | ZoneNode["id"] | null) => void;
|
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||||
|
|
||||||
cameraMode: "perspective" | "orthographic";
|
cameraMode: 'perspective' | 'orthographic'
|
||||||
setCameraMode: (mode: "perspective" | "orthographic") => void;
|
setCameraMode: (mode: 'perspective' | 'orthographic') => void
|
||||||
|
|
||||||
levelMode: "stacked" | "exploded" | "solo" | "manual";
|
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||||
setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void;
|
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||||
|
|
||||||
showScans: boolean;
|
wallMode: 'up' | 'cutaway' | 'down'
|
||||||
setShowScans: (show: boolean) => void;
|
setWallMode: (mode: 'up' | 'cutaway' | 'down') => void
|
||||||
|
|
||||||
showGuides: boolean;
|
showScans: boolean
|
||||||
setShowGuides: (show: boolean) => void;
|
setShowScans: (show: boolean) => void
|
||||||
|
|
||||||
|
showGuides: boolean
|
||||||
|
setShowGuides: (show: boolean) => void
|
||||||
|
|
||||||
// Smart selection update
|
// Smart selection update
|
||||||
setSelection: (updates: Partial<SelectionPath>) => void;
|
setSelection: (updates: Partial<SelectionPath>) => void
|
||||||
resetSelection: () => void;
|
resetSelection: () => void
|
||||||
|
|
||||||
outliner: Outliner; // No setter as we will manipulate directly the arrays
|
outliner: Outliner // No setter as we will manipulate directly the arrays
|
||||||
|
|
||||||
// Export functionality
|
// Export functionality
|
||||||
exportScene: (() => Promise<void>) | null;
|
exportScene: (() => Promise<void>) | null
|
||||||
setExportScene: (fn: (() => Promise<void>) | null) => void;
|
setExportScene: (fn: (() => Promise<void>) | null) => void
|
||||||
};
|
}
|
||||||
|
|
||||||
const useViewer = create<ViewerState>()((set, get) => ({
|
const useViewer = create<ViewerState>()((set, get) => ({
|
||||||
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
||||||
@@ -62,6 +65,9 @@ const useViewer = create<ViewerState>()((set, get) => ({
|
|||||||
levelMode: "stacked",
|
levelMode: "stacked",
|
||||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||||
|
|
||||||
|
wallMode: 'cutaway',
|
||||||
|
setWallMode: (mode) => set({ wallMode: mode }),
|
||||||
|
|
||||||
showScans: true,
|
showScans: true,
|
||||||
setShowScans: (show) => set({ showScans: show }),
|
setShowScans: (show) => set({ showScans: show }),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||||
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
||||||
|
|
||||||
|
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
|
const tmpVec = new Vector3()
|
||||||
|
const u = new Vector3()
|
||||||
|
const v = new Vector3()
|
||||||
|
|
||||||
|
// Dot pattern shader
|
||||||
|
const dotPattern = Fn(() => {
|
||||||
|
// Create a repeating grid pattern based on world position
|
||||||
|
const scale = float(0.1) // Dot grid spacing (10cm)
|
||||||
|
const dotSize = float(0.3) // Size of dots relative to grid
|
||||||
|
|
||||||
|
// Use XY coordinates for pattern on wall face
|
||||||
|
const uv = vec2(positionLocal.x, positionLocal.y).div(scale)
|
||||||
|
const gridUV = fract(uv)
|
||||||
|
|
||||||
|
// Distance from center of grid cell (creates circular dots)
|
||||||
|
const dist = length(gridUV.sub(0.5))
|
||||||
|
|
||||||
|
// Create dots: 1 where we want dots, 0 elsewhere
|
||||||
|
const dots = step(dist, dotSize.mul(0.5))
|
||||||
|
|
||||||
|
// Vertical fade: fade out as Y increases (from bottom to top)
|
||||||
|
const fadeHeight = float(2.5) // Fade over 2.5 meters
|
||||||
|
const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y))
|
||||||
|
|
||||||
|
return dots.mul(yFade)
|
||||||
|
})
|
||||||
|
|
||||||
|
const invsibleWallMaterial = new MeshStandardNodeMaterial({
|
||||||
|
transparent: true,
|
||||||
|
opacityNode: mix(float(0.0), float(0.24), dotPattern()),
|
||||||
|
color: 'white',
|
||||||
|
depthWrite: false,
|
||||||
|
emissive: 'white',
|
||||||
|
})
|
||||||
|
const wallMaterial = new MeshStandardNodeMaterial({
|
||||||
|
color: 'white',
|
||||||
|
roughness: 1,
|
||||||
|
metalness: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const WallCutout = () => {
|
||||||
|
const lastCameraPosition = useRef(new Vector3())
|
||||||
|
const lastCameraTarget = useRef(new Vector3())
|
||||||
|
const lastUpdateTime = useRef(0)
|
||||||
|
const lastWallMode = useRef<string>(useViewer.getState().wallMode)
|
||||||
|
const lastNumberOfWalls = useRef(0)
|
||||||
|
|
||||||
|
useFrame(({ camera, clock }) => {
|
||||||
|
const wallMode = useViewer.getState().wallMode
|
||||||
|
const currentTime = clock.elapsedTime
|
||||||
|
const currentCameraPosition = camera.position
|
||||||
|
camera.getWorldDirection(tmpVec)
|
||||||
|
tmpVec.add(currentCameraPosition)
|
||||||
|
|
||||||
|
// Throttle: only update if camera moved significantly AND enough time passed
|
||||||
|
const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)
|
||||||
|
const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)
|
||||||
|
const timeSinceUpdate = currentTime - lastUpdateTime.current
|
||||||
|
|
||||||
|
// Update if moved > 0.5m OR direction changed > 0.3 AND at least 100ms passed
|
||||||
|
if (
|
||||||
|
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
|
||||||
|
lastWallMode.current !== wallMode ||
|
||||||
|
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current
|
||||||
|
) {
|
||||||
|
// Camera has moved, update cutout logic here
|
||||||
|
|
||||||
|
// Update last known positions and time
|
||||||
|
lastCameraPosition.current.copy(currentCameraPosition)
|
||||||
|
lastCameraTarget.current.copy(tmpVec)
|
||||||
|
lastUpdateTime.current = currentTime
|
||||||
|
camera.getWorldDirection(u)
|
||||||
|
|
||||||
|
const walls = sceneRegistry.byType.wall
|
||||||
|
walls.forEach((wallId) => {
|
||||||
|
const wallMesh = sceneRegistry.nodes.get(wallId)
|
||||||
|
if (!wallMesh) return
|
||||||
|
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
|
||||||
|
if (!wallNode || wallNode.type !== 'wall') return
|
||||||
|
let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior'
|
||||||
|
|
||||||
|
if (wallMode === 'up') {
|
||||||
|
hideWall = false
|
||||||
|
} else if (wallMode === 'down') {
|
||||||
|
hideWall = true
|
||||||
|
} else {
|
||||||
|
wallMesh.getWorldDirection(v)
|
||||||
|
if (v.dot(u) < 0) {
|
||||||
|
// Front side
|
||||||
|
if (wallNode.frontSide === 'exterior' && wallNode.backSide !== 'exterior') {
|
||||||
|
hideWall = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Back side
|
||||||
|
if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') {
|
||||||
|
hideWall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;(wallMesh as Mesh).material = hideWall ? invsibleWallMaterial : wallMaterial
|
||||||
|
})
|
||||||
|
lastWallMode.current = wallMode
|
||||||
|
lastNumberOfWalls.current = sceneRegistry.byType.wall.size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user