From 30653b49c9e25a7ee14c3fc3033471fe57cb7ed0 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 3 Feb 2026 14:28:06 +0900 Subject: [PATCH] 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