wall cutout
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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,44 @@
|
|||||||
import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useRef } from 'react'
|
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 { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
const tmpVec = new Vector3()
|
const tmpVec = new Vector3()
|
||||||
const u = new Vector3()
|
const u = new Vector3()
|
||||||
const v = 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({
|
const invsibleWallMaterial = new MeshStandardNodeMaterial({
|
||||||
// opacity: 0.1,
|
|
||||||
transparent: true,
|
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({
|
const wallMaterial = new MeshStandardNodeMaterial({
|
||||||
color: 'white',
|
color: 'white',
|
||||||
@@ -21,44 +47,66 @@ const wallMaterial = new MeshStandardNodeMaterial({
|
|||||||
export const WallCutout = () => {
|
export const WallCutout = () => {
|
||||||
const lastCameraPosition = useRef(new Vector3())
|
const lastCameraPosition = useRef(new Vector3())
|
||||||
const lastCameraTarget = 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 }) => {
|
useFrame(({ camera, clock }) => {
|
||||||
|
const wallMode = useViewer.getState().wallMode
|
||||||
|
const currentTime = clock.elapsedTime
|
||||||
const currentCameraPosition = camera.position
|
const currentCameraPosition = camera.position
|
||||||
camera.getWorldDirection(tmpVec)
|
camera.getWorldDirection(tmpVec)
|
||||||
tmpVec.add(currentCameraPosition)
|
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 (
|
if (
|
||||||
!currentCameraPosition.equals(lastCameraPosition.current) ||
|
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
|
||||||
!tmpVec.equals(lastCameraTarget.current)
|
lastWallMode.current !== wallMode ||
|
||||||
|
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current
|
||||||
) {
|
) {
|
||||||
// Camera has moved, update cutout logic here
|
// Camera has moved, update cutout logic here
|
||||||
|
|
||||||
// Update last known positions
|
// Update last known positions and time
|
||||||
lastCameraPosition.current.copy(currentCameraPosition)
|
lastCameraPosition.current.copy(currentCameraPosition)
|
||||||
lastCameraTarget.current.copy(tmpVec)
|
lastCameraTarget.current.copy(tmpVec)
|
||||||
|
lastUpdateTime.current = currentTime
|
||||||
camera.getWorldDirection(u)
|
camera.getWorldDirection(u)
|
||||||
// TODO: Debounce
|
|
||||||
const walls = sceneRegistry.byType.wall
|
const walls = sceneRegistry.byType.wall
|
||||||
walls.forEach((wallId) => {
|
walls.forEach((wallId) => {
|
||||||
const wallMesh = sceneRegistry.nodes.get(wallId)
|
const wallMesh = sceneRegistry.nodes.get(wallId)
|
||||||
if (!wallMesh) return
|
if (!wallMesh) return
|
||||||
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
|
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
|
||||||
if (!wallNode || wallNode.type !== 'wall') return
|
if (!wallNode || wallNode.type !== 'wall') return
|
||||||
wallMesh.getWorldDirection(v)
|
|
||||||
let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior'
|
let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior'
|
||||||
if (v.dot(u) < 0) {
|
|
||||||
// Front side
|
if (wallMode === 'up') {
|
||||||
if (wallNode.frontSide === 'exterior') {
|
hideWall = false
|
||||||
hideWall = true
|
} else if (wallMode === 'down') {
|
||||||
}
|
hideWall = true
|
||||||
} else {
|
} else {
|
||||||
// Back side
|
wallMesh.getWorldDirection(v)
|
||||||
if (wallNode.backSide === 'exterior') {
|
if (v.dot(u) < 0) {
|
||||||
hideWall = true
|
// 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
|
;(wallMesh as Mesh).material = hideWall ? invsibleWallMaterial : wallMaterial
|
||||||
})
|
})
|
||||||
|
lastWallMode.current = wallMode
|
||||||
|
lastNumberOfWalls.current = sceneRegistry.byType.wall.size
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user