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) => (
+
+ ),
+ label: 'Full Height',
+ },
+ cutaway: {
+ icon: (props) => (
+
+ ),
+ label: 'Cutaway',
+ },
+ down: {
+ icon: (props) =>
,
+ 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