From a15952055a773e6802fcbf7155d4adb58092bc1b Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 3 Feb 2026 12:03:03 +0900 Subject: [PATCH 1/6] wall sides feed with floo fill algorithm --- apps/editor/components/editor/index.tsx | 4 +- apps/editor/store/use-editor.tsx | 9 +- packages/core/src/index.ts | 2 + packages/core/src/lib/space-detection.ts | 641 ++++++++++++++++++ packages/core/src/schema/nodes/wall.ts | 5 + .../core/src/systems/wall/wall-system.tsx | 1 + 6 files changed, 659 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/lib/space-detection.ts diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index e20d54bd..7e531da4 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { initSpatialGridSync, sceneRegistry, useScene } from '@pascal-app/core' +import { initSpatialGridSync, initSpaceDetectionSync, sceneRegistry, useScene } from '@pascal-app/core' import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' @@ -10,6 +10,7 @@ import { MathUtils, type Mesh } from 'three' import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' import { useKeyboard } from '@/hooks/use-keyboard' +import useEditor from '@/store/use-editor' import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' @@ -23,6 +24,7 @@ import { SelectionManager } from './selection-manager' useScene.getState().loadScene() console.log('Loaded scene in editor') initSpatialGridSync() +initSpaceDetectionSync(useScene, useEditor) export default function Editor() { useKeyboard() diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index e64c271a..ee4ece60 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -1,6 +1,6 @@ '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 { create } from 'zustand' import type { AssetInput } from '@pascal-app/core' @@ -50,7 +50,7 @@ type EditorState = { setMode: (mode: Mode) => void tool: Tool | null setTool: (tool: Tool | null) => void - structureLayer: StructureLayer + structureLayer: StructureLayer setStructureLayer: (layer: StructureLayer) => void catalogCategory: CatalogCategory | null setCatalogCategory: (category: CatalogCategory | null) => void @@ -60,6 +60,9 @@ type EditorState = { setMovingNode: (node: ItemNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void + // Space detection for cutaway mode + spaces: Record + setSpaces: (spaces: Record) => void } const useEditor = create()((set, get) => ({ @@ -174,6 +177,8 @@ const useEditor = create()((set, get) => ({ setMovingNode: (node) => set({ movingNode: node }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + spaces: {}, + setSpaces: (spaces) => set({ spaces }), })) export default useEditor diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 73ceda2b..dbad2144 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,3 +40,5 @@ export { WallSystem } from './systems/wall/wall-system' export { isObject } from './utils/types' // Asset storage export { saveAsset, loadAssetUrl } from './lib/asset-storage' +// Space detection +export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts new file mode 100644 index 00000000..cae5426e --- /dev/null +++ b/packages/core/src/lib/space-detection.ts @@ -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>() + 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>() + + // 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() + + // 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 = {} + + 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 + 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 = [ + [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() + + // 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() + 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 = [ + [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, 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) +} diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 6ce0ddef..143419a5 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -16,6 +16,9 @@ export const WallNode = BaseNode.extend({ // e.g., start/end points for path start: 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( dedent` 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 - end: end point of the wall in level coordinate system - 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 diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 8042b1da..66132dc5 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -38,6 +38,7 @@ export const WallSystem = () => { const node = nodes[id] if (!node || node.type !== 'wall') return + console.log('wall front/back', node.frontSide, node.backSide) const levelId = node.parentId if (!levelId) return From b27f44d685f3472865e9c5ced337084fd9793c46 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 3 Feb 2026 13:45:56 +0900 Subject: [PATCH 2/6] wall cutout --- .../renderers/wall/wall-renderer.tsx | 1 - .../viewer/src/components/viewer/index.tsx | 2 + .../viewer/src/systems/wall/wall-cutout.tsx | 65 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 packages/viewer/src/systems/wall/wall-cutout.tsx diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index 7dfe9767..d456c5ff 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -15,7 +15,6 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { {/* WallSystem will replace this geometry in the next frame */} - diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 37588f9b..fb5db148 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -7,6 +7,7 @@ import * as THREE from 'three/webgpu' import { GuideSystem } from '../../systems/guide/guide-system' import { LevelSystem } from '../../systems/level/level-system' import { ScanSystem } from '../../systems/scan/scan-system' +import { WallCutout } from '../../systems/wall/wall-cutout' import { SceneRenderer } from '../renderers/scene-renderer' import { Lights } from './lights' import PostProcessing from './post-processing' @@ -55,6 +56,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' + {/* Core systems */} diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx new file mode 100644 index 00000000..e556f6fb --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -0,0 +1,65 @@ +import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useRef } from 'react' +import { float, mix, positionLocal } from 'three/tsl' + +import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' + +const tmpVec = new Vector3() +const u = new Vector3() +const v = new Vector3() + +const invsibleWallMaterial = new MeshStandardNodeMaterial({ + // opacity: 0.1, + transparent: true, + opacityNode: mix(float(1), float(0.1), positionLocal.y.add(0.1)), +}) +const wallMaterial = new MeshStandardNodeMaterial({ + color: 'white', +}) + +export const WallCutout = () => { + const lastCameraPosition = useRef(new Vector3()) + const lastCameraTarget = useRef(new Vector3()) + + useFrame(({ camera }) => { + const currentCameraPosition = camera.position + camera.getWorldDirection(tmpVec) + tmpVec.add(currentCameraPosition) + + if ( + !currentCameraPosition.equals(lastCameraPosition.current) || + !tmpVec.equals(lastCameraTarget.current) + ) { + // Camera has moved, update cutout logic here + + // Update last known positions + lastCameraPosition.current.copy(currentCameraPosition) + lastCameraTarget.current.copy(tmpVec) + camera.getWorldDirection(u) + // TODO: Debounce + 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 + wallMesh.getWorldDirection(v) + let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior' + if (v.dot(u) < 0) { + // Front side + if (wallNode.frontSide === 'exterior') { + hideWall = true + } + } else { + // Back side + if (wallNode.backSide === 'exterior') { + hideWall = true + } + } + ;(wallMesh as Mesh).material = hideWall ? invsibleWallMaterial : wallMaterial + }) + } + }) + return null +} From 30653b49c9e25a7ee14c3fc3033471fe57cb7ed0 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 3 Feb 2026 14:28:06 +0900 Subject: [PATCH 3/6] wall cutout --- .../editor/app/viewer/[id]/viewer-overlay.tsx | 33 ++++++++ .../ui/action-menu/view-toggles.tsx | 60 ++++++++++++++ packages/viewer/src/store/use-viewer.ts | 40 +++++---- .../viewer/src/systems/wall/wall-cutout.tsx | 82 +++++++++++++++---- 4 files changed, 181 insertions(+), 34 deletions(-) diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx index f88effef..0dc17b6b 100644 --- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx +++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx @@ -21,6 +21,7 @@ export const ViewerOverlay = () => { const showGuides = useViewer((s) => s.showGuides) const cameraMode = useViewer((s) => s.cameraMode) const levelMode = useViewer((s) => s.levelMode) + const wallMode = useViewer((s) => s.wallMode) const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null @@ -202,6 +203,38 @@ export const ViewerOverlay = () => { Solo + + {/* Wall Mode */} +
+ Wall Mode + + + +
) diff --git a/apps/editor/components/ui/action-menu/view-toggles.tsx b/apps/editor/components/ui/action-menu/view-toggles.tsx index 48bd6294..4985e405 100644 --- a/apps/editor/components/ui/action-menu/view-toggles.tsx +++ b/apps/editor/components/ui/action-menu/view-toggles.tsx @@ -18,11 +18,39 @@ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] +type WallMode = 'up' | 'cutaway' | 'down' + +const wallModeConfig: Record< + WallMode, + { icon: React.FC>; label: string } +> = { + up: { + icon: (props) => ( + Full Height + ), + label: 'Full Height', + }, + cutaway: { + icon: (props) => ( + Cutaway + ), + label: 'Cutaway', + }, + down: { + icon: (props) => Low, + label: 'Low', + }, +} + +const wallModeOrder: WallMode[] = ['cutaway', 'up', 'down'] + export function ViewToggles() { const cameraMode = useViewer((state) => state.cameraMode) const setCameraMode = useViewer((state) => state.setCameraMode) const levelMode = useViewer((state) => state.levelMode) const setLevelMode = useViewer((state) => state.setLevelMode) + const wallMode = useViewer((state) => state.wallMode) + const setWallMode = useViewer((state) => state.setWallMode) const showScans = useViewer((state) => state.showScans) const setShowScans = useViewer((state) => state.setShowScans) const showGuides = useViewer((state) => state.showGuides) @@ -43,6 +71,13 @@ export function ViewToggles() { 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 (
{/* Camera Mode */} @@ -91,6 +126,31 @@ export function ViewToggles() { + {/* Wall Mode */} + + + + + +

Walls: {wallModeConfig[wallMode].label}

+
+
+ {/* Show Scans */} diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 690f20e5..b772e157 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -24,32 +24,35 @@ type Outliner = { }; type ViewerState = { - selection: SelectionPath; - hoveredId: AnyNode["id"] | ZoneNode["id"] | null; - setHoveredId: (id: AnyNode["id"] | ZoneNode["id"] | null) => void; + selection: SelectionPath + hoveredId: AnyNode['id'] | ZoneNode['id'] | null + setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void - cameraMode: "perspective" | "orthographic"; - setCameraMode: (mode: "perspective" | "orthographic") => void; + cameraMode: 'perspective' | 'orthographic' + setCameraMode: (mode: 'perspective' | 'orthographic') => void - levelMode: "stacked" | "exploded" | "solo" | "manual"; - setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void; + levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' + setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void - showScans: boolean; - setShowScans: (show: boolean) => void; + wallMode: 'up' | 'cutaway' | 'down' + setWallMode: (mode: 'up' | 'cutaway' | 'down') => void - showGuides: boolean; - setShowGuides: (show: boolean) => void; + showScans: boolean + setShowScans: (show: boolean) => void + + showGuides: boolean + setShowGuides: (show: boolean) => void // Smart selection update - setSelection: (updates: Partial) => void; - resetSelection: () => void; + setSelection: (updates: Partial) => 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 - exportScene: (() => Promise) | null; - setExportScene: (fn: (() => Promise) | null) => void; -}; + exportScene: (() => Promise) | null + setExportScene: (fn: (() => Promise) | null) => void +} const useViewer = create()((set, get) => ({ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, @@ -62,6 +65,9 @@ const useViewer = create()((set, get) => ({ levelMode: "stacked", setLevelMode: (mode) => set({ levelMode: mode }), + wallMode: 'cutaway', + setWallMode: (mode) => set({ wallMode: mode }), + showScans: true, setShowScans: (show) => set({ showScans: show }), diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index e556f6fb..6a3bd110 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,18 +1,44 @@ import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' -import { float, mix, positionLocal } from 'three/tsl' +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({ - // opacity: 0.1, transparent: true, - opacityNode: mix(float(1), float(0.1), positionLocal.y.add(0.1)), + opacityNode: mix(float(0.0), float(0.24), dotPattern()), + color: 'white', + depthWrite: false, + emissive: 'white', }) const wallMaterial = new MeshStandardNodeMaterial({ color: 'white', @@ -21,44 +47,66 @@ const wallMaterial = new MeshStandardNodeMaterial({ export const WallCutout = () => { const lastCameraPosition = useRef(new Vector3()) const lastCameraTarget = useRef(new Vector3()) + const lastUpdateTime = useRef(0) + const lastWallMode = useRef(useViewer.getState().wallMode) + const lastNumberOfWalls = useRef(0) - useFrame(({ camera }) => { + 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 ( - !currentCameraPosition.equals(lastCameraPosition.current) || - !tmpVec.equals(lastCameraTarget.current) + ((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 + // Update last known positions and time lastCameraPosition.current.copy(currentCameraPosition) lastCameraTarget.current.copy(tmpVec) + lastUpdateTime.current = currentTime camera.getWorldDirection(u) - // TODO: Debounce + 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 - wallMesh.getWorldDirection(v) let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior' - if (v.dot(u) < 0) { - // Front side - if (wallNode.frontSide === 'exterior') { - hideWall = true - } + + if (wallMode === 'up') { + hideWall = false + } else if (wallMode === 'down') { + hideWall = true } else { - // Back side - if (wallNode.backSide === 'exterior') { - hideWall = true + 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 From 66463f35266aad86b031a4534bd9fd294737223d Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 3 Feb 2026 15:50:09 +0900 Subject: [PATCH 4/6] ssgi + ao --- apps/editor/components/editor/index.tsx | 10 +- .../renderers/item/item-renderer.tsx | 6 +- .../viewer/src/components/viewer/index.tsx | 6 +- .../viewer/src/components/viewer/lights.tsx | 59 +++--- .../src/components/viewer/post-processing.tsx | 193 +++++++++++++----- .../viewer/src/systems/wall/wall-cutout.tsx | 2 + 6 files changed, 190 insertions(+), 86 deletions(-) diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 7e531da4..5c495509 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -1,6 +1,11 @@ 'use client' -import { initSpatialGridSync, initSpaceDetectionSync, sceneRegistry, useScene } from '@pascal-app/core' +import { + initSpaceDetectionSync, + initSpatialGridSync, + sceneRegistry, + useScene, +} from '@pascal-app/core' import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' @@ -43,7 +48,7 @@ export default function Editor() { {/* Editor only system to toggle zone visibility */} {/* */} - + @@ -51,7 +56,6 @@ export default function Editor() { ) } - const Grid = ({ cellSize = 0.5, cellThickness = 0.5, diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index ad9a2528..f247dd29 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -9,17 +9,17 @@ import { useNodeEvents } from '../../../hooks/use-node-events' // Shared materials to avoid creating new instances for every mesh const defaultMaterial = new MeshStandardNodeMaterial({ color: 0xffffff, - roughness: 0.8, + roughness: 1, metalness: 0, }) const glassMaterial = new MeshStandardNodeMaterial({ name: 'glass', - color: 'skyblue', + color: 'lightgray', roughness: 0.8, metalness: 0, transparent: true, - opacity: 0.25, + opacity: 0.35, side: DoubleSide, depthWrite: false, }) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index fb5db148..df13ebb8 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -28,12 +28,12 @@ interface ViewerProps { const Viewer: React.FC = ({ children, selectionManager = 'default' }) => { return ( { const renderer = new THREE.WebGPURenderer(props as any) await renderer.init() renderer.toneMapping = THREE.ACESFilmicToneMapping - renderer.toneMappingExposure = 1.2 + renderer.toneMappingExposure = 0.9 return renderer }} shadows={{ @@ -42,7 +42,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' }} camera={{ position: [50, 50, 50], fov: 50 }} > - + {/* - - - + + + - - + + + + + + {/* */} ) } diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b6c179dc..c4290f73 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,99 +1,186 @@ -import { useFrame, useThree } from "@react-three/fiber"; -import { useEffect, useRef } from "react"; -import { Color } from "three"; -import { outline } from "three/addons/tsl/display/OutlineNode.js"; -import { oscSine, pass, time, uniform } from "three/tsl"; -import { PostProcessing, type WebGPURenderer } from "three/webgpu"; -import useViewer from "../../store/use-viewer"; +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { Color, UnsignedByteType } from 'three' +import { outline } from 'three/addons/tsl/display/OutlineNode.js' +import { ssgi } from 'three/addons/tsl/display/SSGINode.js' +import { traa } from 'three/addons/tsl/display/TRAANode.js' +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: 1, + useLinearThickness: false, + useScreenSpaceSampling: true, + useTemporalFiltering: true, +} const PostProcessingPasses = () => { - const { gl: renderer, scene, camera } = useThree(); - const postProcessingRef = useRef(null); + const { gl: renderer, scene, camera } = useThree() + const postProcessingRef = useRef(null) useEffect(() => { 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() { - const edgeStrength = uniform(3); - const edgeGlow = uniform(0); - const edgeThickness = uniform(1); - const visibleEdgeColor = uniform(new Color(0xffffff)); - const hiddenEdgeColor = uniform(new Color(0xf3ff47)); + const edgeStrength = uniform(3) + const edgeGlow = uniform(0) + const edgeThickness = uniform(1) + const visibleEdgeColor = uniform(new Color(0xffffff)) + const hiddenEdgeColor = uniform(new Color(0xf3ff47)) const outlinePass = outline(scene, camera, { selectedObjects: useViewer.getState().outliner.selectedObjects, edgeGlow, edgeThickness, - }); - const { visibleEdge, hiddenEdge } = outlinePass; + }) + const { visibleEdge, hiddenEdge } = outlinePass const outlineColor = visibleEdge .mul(visibleEdgeColor) .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength); + .mul(edgeStrength) - return outlineColor; + return outlineColor } function generateHoverOutlinePass() { - const edgeStrength = uniform(5); - const edgeGlow = uniform(0.5); - const edgeThickness = uniform(1.5); - const pulsePeriod = uniform(3); - const visibleEdgeColor = uniform(new Color(0x00aaff)); - const hiddenEdgeColor = uniform(new Color(0xf3ff47)); + const edgeStrength = uniform(5) + const edgeGlow = uniform(0.5) + const edgeThickness = uniform(1.5) + const pulsePeriod = uniform(3) + const visibleEdgeColor = uniform(new Color(0x00aaff)) + const hiddenEdgeColor = uniform(new Color(0xf3ff47)) const outlinePass = outline(scene, camera, { selectedObjects: useViewer.getState().outliner.hoveredObjects, edgeGlow, edgeThickness, - }); - const { visibleEdge, hiddenEdge } = outlinePass; + }) + const { visibleEdge, hiddenEdge } = outlinePass - const period = time.div(pulsePeriod).mul(2); - const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ] + const period = time.div(pulsePeriod).mul(2) + const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ] const outlineColor = visibleEdge .mul(visibleEdgeColor) .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength); - const outlinePulse = pulsePeriod - .greaterThan(0) - .select(outlineColor.mul(osc), outlineColor); - return outlinePulse; + .mul(edgeStrength) + const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor) + return outlinePulse } // Setup post-processing - const postProcessing = new PostProcessing( - renderer as unknown as WebGPURenderer, - ); + const postProcessing = new PostProcessing(renderer as unknown as WebGPURenderer) - const selectedOutlinePass = generateSelectedOutlinePass(); - const hoverOutlinePass = generateHoverOutlinePass(); + const selectedOutlinePass = generateSelectedOutlinePass() + const hoverOutlinePass = generateHoverOutlinePass() - postProcessing.outputNode = selectedOutlinePass - .add(hoverOutlinePass) - .add(scenePass); - postProcessingRef.current = postProcessing; + // Combine SSGI output with outlines + const finalOutput = SSGI_PARAMS.enabled + ? selectedOutlinePass.add(hoverOutlinePass).add(traaPass) + : selectedOutlinePass.add(hoverOutlinePass).add(scenePassColor) + + postProcessing.outputNode = finalOutput + postProcessingRef.current = postProcessing return () => { if (postProcessingRef.current) { - postProcessingRef.current.dispose(); + postProcessingRef.current.dispose() } - postProcessingRef.current = null; - }; - }, [renderer, scene, camera]); + postProcessingRef.current = null + } + }, [renderer, scene, camera]) useFrame(() => { if (postProcessingRef.current) { - postProcessingRef.current.render(); + postProcessingRef.current.render() } - }, 1); + }, 1) - return null; -}; + return null +} -export default PostProcessingPasses; +export default PostProcessingPasses diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 6a3bd110..b9ee4f53 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -42,6 +42,8 @@ const invsibleWallMaterial = new MeshStandardNodeMaterial({ }) const wallMaterial = new MeshStandardNodeMaterial({ color: 'white', + roughness: 1, + metalness: 0, }) export const WallCutout = () => { From 4a7d0966652f78f35d162bb035d18433753224fa Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 3 Feb 2026 15:50:28 +0900 Subject: [PATCH 5/6] ssgi + ao --- packages/viewer/src/components/viewer/post-processing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index c4290f73..d4af84a9 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -33,7 +33,7 @@ export const SSGI_PARAMS = { thickness: 0.5, backfaceLighting: 0.5, aoIntensity: 1.5, - giIntensity: 1, + giIntensity: 0.5, useLinearThickness: false, useScreenSpaceSampling: true, useTemporalFiltering: true, From 51ab0d97396cf569558b1cff6e22fff6cebb45f6 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 3 Feb 2026 15:52:25 +0900 Subject: [PATCH 6/6] fix build --- packages/core/src/lib/space-detection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index cae5426e..3f631124 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -352,7 +352,7 @@ function floodFillFromEdges(grid: Grid): void { const [x, z] = parseCellKey(key) // Check 4 neighbors - const neighbors = [ + const neighbors: [number, number][] = [ [x + 1, z], [x - 1, z], [x, z + 1], @@ -408,7 +408,7 @@ function findInteriorSpaces(grid: Grid, levelId: string): Space[] { const curKey = queue.shift()! const [cx, cz] = parseCellKey(curKey) - const neighbors = [ + const neighbors: [number, number][] = [ [cx + 1, cz], [cx - 1, cz], [cx, cz + 1],