Merge pull request #116 from pascalorg/fix/community-feedback-pass-3
Fix/community feedback pass 3
This commit is contained in:
@@ -44,23 +44,33 @@ export const ViewerCameraControls = () => {
|
|||||||
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
|
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetNodeId) return
|
|
||||||
|
|
||||||
const node = nodes[targetNodeId]
|
let node = targetNodeId ? nodes[targetNodeId] : null;
|
||||||
|
if (!targetNodeId) {
|
||||||
|
const site = Object.values(nodes).find((n) => n.type === 'site')
|
||||||
|
node = site || null
|
||||||
|
}
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
// Check if node has a saved camera
|
// Check if node has a saved camera
|
||||||
if (node.camera) {
|
if (node.camera) {
|
||||||
|
|
||||||
const { position, target } = node.camera
|
const { position, target } = node.camera
|
||||||
controls.current.setLookAt(
|
requestAnimationFrame(() => {
|
||||||
position[0],
|
controls.current.setLookAt(
|
||||||
position[1],
|
position[0],
|
||||||
position[2],
|
position[1],
|
||||||
target[0],
|
position[2],
|
||||||
target[1],
|
target[0],
|
||||||
target[2],
|
target[1],
|
||||||
true,
|
target[2],
|
||||||
)
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!targetNodeId) {
|
||||||
|
// No selection and no site - do nothing
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type BuildingNode,
|
||||||
|
type LevelNode,
|
||||||
|
useScene,
|
||||||
|
type ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Box,
|
||||||
|
ChevronRight,
|
||||||
|
Diamond,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Image,
|
||||||
|
Layers,
|
||||||
|
Layers2,
|
||||||
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft, Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react'
|
|
||||||
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||||
|
|
||||||
const getNodeName = (node: AnyNode): string => {
|
const getNodeName = (node: AnyNode): string => {
|
||||||
@@ -23,7 +40,12 @@ interface ViewerOverlayProps {
|
|||||||
canShowGuides?: boolean
|
canShowGuides?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShowGuides = true }: ViewerOverlayProps) => {
|
export const ViewerOverlay = ({
|
||||||
|
projectName,
|
||||||
|
owner,
|
||||||
|
canShowScans = true,
|
||||||
|
canShowGuides = true,
|
||||||
|
}: ViewerOverlayProps) => {
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
const showScans = useViewer((s) => s.showScans)
|
const showScans = useViewer((s) => s.showScans)
|
||||||
@@ -32,20 +54,24 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
|
|||||||
const levelMode = useViewer((s) => s.levelMode)
|
const levelMode = useViewer((s) => s.levelMode)
|
||||||
const wallMode = useViewer((s) => s.wallMode)
|
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
|
||||||
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
|
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
|
||||||
|
|
||||||
// Get the first selected item (if any)
|
// Get the first selected item (if any)
|
||||||
const selectedNode = selection.selectedIds.length > 0
|
const selectedNode =
|
||||||
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
|
selection.selectedIds.length > 0
|
||||||
: null
|
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
|
||||||
|
: null
|
||||||
|
|
||||||
// Get all levels for the selected building
|
// Get all levels for the selected building
|
||||||
const levels = building?.children
|
const levels =
|
||||||
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
|
building?.children
|
||||||
.filter((n): n is LevelNode => n?.type === 'level')
|
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
|
||||||
.sort((a, b) => a.level - b.level) ?? []
|
.filter((n): n is LevelNode => n?.type === 'level')
|
||||||
|
.sort((a, b) => a.level - b.level) ?? []
|
||||||
|
|
||||||
const handleLevelClick = (levelId: LevelNode['id']) => {
|
const handleLevelClick = (levelId: LevelNode['id']) => {
|
||||||
// When switching levels, deselect zone and items
|
// When switching levels, deselect zone and items
|
||||||
@@ -68,219 +94,249 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Unified top-left card */}
|
{/* Unified top-left card */}
|
||||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
|
<div className="absolute top-4 left-4 z-20 flex flex-col gap-3">
|
||||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg rounded-smooth shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] overflow-hidden">
|
<div className="bg-white/80 backdrop-blur-sm rounded-lg rounded-smooth shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] overflow-hidden">
|
||||||
{/* Project info + back */}
|
{/* Project info + back */}
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 transition-colors"
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 text-neutral-500" />
|
<ArrowLeft className="h-4 w-4 text-neutral-500" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">
|
<div className="text-sm font-medium text-neutral-800 truncate">
|
||||||
{projectName || 'Untitled'}
|
{projectName || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
{owner?.username && (
|
||||||
|
<Link
|
||||||
|
href={`/u/${owner.username}`}
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
@{owner.username}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{owner?.username && (
|
|
||||||
<Link
|
|
||||||
href={`/u/${owner.username}`}
|
|
||||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
|
||||||
>
|
|
||||||
@{owner.username}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Breadcrumb — only shown when navigated into a building */}
|
{/* Breadcrumb — only shown when navigated into a building */}
|
||||||
{building && (
|
{building && (
|
||||||
<div className="border-t border-neutral-100 px-3 py-1.5">
|
<div className="border-t border-neutral-100 px-3 py-1.5">
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<button
|
|
||||||
onClick={() => handleBreadcrumbClick('root')}
|
|
||||||
className="text-neutral-500 hover:text-neutral-800 transition-colors"
|
|
||||||
>
|
|
||||||
Site
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{building && (
|
|
||||||
<>
|
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('building')}
|
onClick={() => handleBreadcrumbClick('root')}
|
||||||
className={`transition-colors truncate ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
className="text-neutral-500 hover:text-neutral-800 transition-colors"
|
||||||
>
|
>
|
||||||
{building.name || 'Building'}
|
Site
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{level && (
|
{building && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('level')}
|
onClick={() => handleBreadcrumbClick('building')}
|
||||||
className={`transition-colors truncate ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
className={`transition-colors truncate ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
||||||
>
|
>
|
||||||
{level.name || `Level ${level.level}`}
|
{building.name || 'Building'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{zone && (
|
{level && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<span className={`transition-colors truncate ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}>
|
<button
|
||||||
{zone.name}
|
onClick={() => handleBreadcrumbClick('level')}
|
||||||
</span>
|
className={`transition-colors truncate ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
||||||
</>
|
>
|
||||||
)}
|
{level.name || `Level ${level.level}`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedNode && zone && (
|
{zone && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<span className="text-neutral-800 font-medium truncate">{getNodeName(selectedNode)}</span>
|
<span
|
||||||
</>
|
className={`transition-colors truncate ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}
|
||||||
)}
|
>
|
||||||
</div>
|
{zone.name}
|
||||||
</div>
|
</span>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Level List (only when building is selected) */}
|
{selectedNode && zone && (
|
||||||
{building && levels.length > 0 && (
|
<>
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] w-40">
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
|
<span className="text-neutral-800 font-medium truncate">
|
||||||
{levels.map((lvl) => (
|
{getNodeName(selectedNode)}
|
||||||
<button
|
</span>
|
||||||
key={lvl.id}
|
</>
|
||||||
onClick={() => handleLevelClick(lvl.id)}
|
)}
|
||||||
className={`text-left px-2 py-1 rounded text-sm transition-colors ${
|
</div>
|
||||||
lvl.id === selection.levelId
|
</div>
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{lvl.name || `Level ${lvl.level}`}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls Panel - Top Right */}
|
|
||||||
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
|
||||||
{/* Visibility Controls */}
|
|
||||||
{(canShowScans || canShowGuides) && (
|
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
|
|
||||||
{canShowScans && (
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
showScans ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Box className="w-4 h-4" />
|
|
||||||
3D Scans
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canShowGuides && (
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
showGuides ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Image className="w-4 h-4" />
|
|
||||||
Guides
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Camera Mode */}
|
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
|
||||||
className="flex items-center gap-2 px-2 py-1 rounded text-sm text-neutral-700 hover:bg-neutral-100 transition-colors"
|
|
||||||
>
|
|
||||||
{cameraMode === 'perspective' ? (
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="w-4 h-4" />
|
|
||||||
)}
|
)}
|
||||||
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
</div>
|
||||||
</button>
|
|
||||||
|
{/* Level List (only when building is selected) */}
|
||||||
|
{building && levels.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] w-40">
|
||||||
|
<span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
|
||||||
|
{levels.map((lvl) => (
|
||||||
|
<button
|
||||||
|
key={lvl.id}
|
||||||
|
onClick={() => handleLevelClick(lvl.id)}
|
||||||
|
className={`text-left px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
lvl.id === selection.levelId
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lvl.name || `Level ${lvl.level}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Level Mode */}
|
{/* Controls Panel - Top Right */}
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
<div className="absolute top-4 right-4 z-20 flex flex-col gap-2">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
|
{/* Visibility Controls */}
|
||||||
<button
|
{(canShowScans || canShowGuides) && (
|
||||||
onClick={() => useViewer.getState().setLevelMode('stacked')}
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
|
||||||
levelMode === 'stacked' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
{canShowScans && (
|
||||||
}`}
|
<button
|
||||||
>
|
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
||||||
<Layers className="w-4 h-4" />
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
Stacked
|
showScans ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
</button>
|
}`}
|
||||||
<button
|
>
|
||||||
onClick={() => useViewer.getState().setLevelMode('exploded')}
|
<Box className="w-4 h-4" />
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
3D Scans
|
||||||
levelMode === 'exploded' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
</button>
|
||||||
}`}
|
)}
|
||||||
>
|
{canShowGuides && (
|
||||||
<Layers2 className="w-4 h-4" />
|
<button
|
||||||
Exploded
|
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
||||||
</button>
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
<button
|
showGuides ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
onClick={() => useViewer.getState().setLevelMode('solo')}
|
}`}
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
>
|
||||||
levelMode === 'solo' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
<Image className="w-4 h-4" />
|
||||||
}`}
|
Guides
|
||||||
>
|
</button>
|
||||||
<Diamond className="w-4 h-4" />
|
)}
|
||||||
Solo
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wall Mode */}
|
{/* Camera Mode */}
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Wall Mode</span>
|
<span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => useViewer.getState().setWallMode('cutaway')}
|
onClick={() =>
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
useViewer
|
||||||
wallMode === 'cutaway' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
.getState()
|
||||||
}`}
|
.setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
||||||
>
|
}
|
||||||
<img alt="Cutaway" height={16} src="/icons/wallcut.png" width={16} className="w-4 h-4" />
|
className="flex items-center gap-2 px-2 py-1 rounded text-sm text-neutral-700 hover:bg-neutral-100 transition-colors"
|
||||||
Cutaway
|
>
|
||||||
</button>
|
{cameraMode === 'perspective' ? (
|
||||||
<button
|
<Eye className="w-4 h-4" />
|
||||||
onClick={() => useViewer.getState().setWallMode('up')}
|
) : (
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
<EyeOff className="w-4 h-4" />
|
||||||
wallMode === 'up' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
)}
|
||||||
}`}
|
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
||||||
>
|
</button>
|
||||||
<img alt="Full Height" height={16} src="/icons/room.png" width={16} className="w-4 h-4" />
|
</div>
|
||||||
Full Height
|
|
||||||
</button>
|
{/* Level Mode */}
|
||||||
<button
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
onClick={() => useViewer.getState().setWallMode('down')}
|
<span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
<button
|
||||||
wallMode === 'down' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
onClick={() => useViewer.getState().setLevelMode('stacked')}
|
||||||
}`}
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
>
|
levelMode === 'stacked'
|
||||||
<img alt="Low" height={16} src="/icons/walllow.png" width={16} className="w-4 h-4" />
|
? 'bg-blue-500 text-white'
|
||||||
Low
|
: 'text-neutral-700 hover:bg-neutral-100'
|
||||||
</button>
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
Stacked
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setLevelMode('exploded')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
levelMode === 'exploded'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers2 className="w-4 h-4" />
|
||||||
|
Exploded
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setLevelMode('solo')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
levelMode === 'solo'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Diamond className="w-4 h-4" />
|
||||||
|
Solo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wall Mode */}
|
||||||
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
|
<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>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type ZoneNode, sceneRegistry, useScene } from '@pascal-app/core'
|
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
|
||||||
@@ -28,7 +28,13 @@ export const ViewerZoneSystem = () => {
|
|||||||
// Also hide the label
|
// Also hide the label
|
||||||
const label = obj.getObjectByName('label')
|
const label = obj.getObjectByName('label')
|
||||||
if (label) {
|
if (label) {
|
||||||
label.position.y = shouldShow ? 1 : -1000
|
// Hide label if zone layer is off OR if in solo mode on a different level
|
||||||
|
const labelPosition = obj.userData.labelPosition as [number, number, number] | undefined
|
||||||
|
if (shouldShow && labelPosition) {
|
||||||
|
label.position.set(...labelPosition)
|
||||||
|
} else {
|
||||||
|
label.position.set(-9999, -9999, -9999)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ export function PascalRadio() {
|
|||||||
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
|
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
|
||||||
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
|
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
|
||||||
|
|
||||||
|
// Keep a ref so the track-init effect can read current volume/muted/isPlaying
|
||||||
|
// without those values being part of its dependency array (which would restart the song).
|
||||||
|
const effectiveVolumeRef = useRef(effectiveVolume)
|
||||||
|
const mutedRef = useRef(muted)
|
||||||
|
const isPlayingRef = useRef(isPlaying)
|
||||||
|
effectiveVolumeRef.current = effectiveVolume
|
||||||
|
mutedRef.current = muted
|
||||||
|
isPlayingRef.current = isPlaying
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
|
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
|
||||||
}, [shuffledPlaylist.length])
|
}, [shuffledPlaylist.length])
|
||||||
@@ -83,32 +92,29 @@ export function PascalRadio() {
|
|||||||
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
|
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
|
||||||
}, [shuffledPlaylist.length])
|
}, [shuffledPlaylist.length])
|
||||||
|
|
||||||
// Initialize Howler when track changes
|
// Initialize Howler only when the track changes — not on volume/mute/play-state changes.
|
||||||
|
// Volume and mute are handled by the separate effect below.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clean up previous sound
|
|
||||||
if (soundRef.current) {
|
if (soundRef.current) {
|
||||||
soundRef.current.unload()
|
soundRef.current.unload()
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasPlaying = isPlaying
|
const wasPlaying = isPlayingRef.current
|
||||||
|
|
||||||
// Create new sound
|
|
||||||
soundRef.current = new Howl({
|
soundRef.current = new Howl({
|
||||||
src: [currentTrack.file],
|
src: [currentTrack.file],
|
||||||
volume: muted ? 0 : effectiveVolume,
|
volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
|
||||||
onend: handleNext,
|
onend: handleNext,
|
||||||
})
|
})
|
||||||
|
|
||||||
// If was playing, play new track
|
if (wasPlaying && !mutedRef.current) {
|
||||||
if (wasPlaying && !muted) {
|
|
||||||
soundRef.current?.play()
|
soundRef.current?.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
soundRef.current?.unload()
|
soundRef.current?.unload()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [handleNext, currentTrack.file])
|
||||||
}, [handleNext, currentTrack.file, muted, isPlaying, effectiveVolume])
|
|
||||||
|
|
||||||
// Update volume when settings change
|
// Update volume when settings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ZoneNode, sceneRegistry, useScene } from '@pascal-app/core'
|
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
@@ -24,10 +24,17 @@ export const ZoneSystem = () => {
|
|||||||
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel
|
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel
|
||||||
|
|
||||||
obj.visible = visible
|
obj.visible = visible
|
||||||
|
|
||||||
const label = obj.getObjectByName('label')
|
const label = obj.getObjectByName('label')
|
||||||
if (label) {
|
if (label) {
|
||||||
// Hide label if zone layer is off OR if in solo mode on a different level
|
// Hide label if zone layer is off OR if in solo mode on a different level
|
||||||
label.position.y = (visible && !hideInSoloMode) ? 1 : -1000
|
const showLabel = visible && !hideInSoloMode;
|
||||||
|
const labelPosition = obj.userData.labelPosition as [number, number, number] | undefined
|
||||||
|
if (showLabel && labelPosition) {
|
||||||
|
label.position.set(...labelPosition)
|
||||||
|
} else {
|
||||||
|
label.position.set(-9999, -9999, -9999)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [structureLayer, levelMode, selectedLevelId])
|
}, [structureLayer, levelMode, selectedLevelId])
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
||||||
import useEditor from '@/store/use-editor'
|
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
const CEILING_HEIGHT = 2.52
|
const CEILING_HEIGHT = 2.52
|
||||||
const GRID_OFFSET = 0.02
|
const GRID_OFFSET = 0.02
|
||||||
@@ -97,12 +98,19 @@ export const CeilingTool: React.FC = () => {
|
|||||||
|
|
||||||
// Calculate snapped display position (bypass snap when Shift is held)
|
// Calculate snapped display position (bypass snap when Shift is held)
|
||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
|
const displayPoint =
|
||||||
|
shiftPressed.current || !lastPoint
|
||||||
|
? gridPosition
|
||||||
|
: calculateSnapPoint(lastPoint, gridPosition)
|
||||||
setSnappedCursorPosition(displayPoint)
|
setSnappedCursorPosition(displayPoint)
|
||||||
|
|
||||||
// Play snap sound when the snapped position actually changes (only when drawing)
|
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||||
if (points.length > 0 && previousSnappedPointRef.current &&
|
if (
|
||||||
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
|
points.length > 0 &&
|
||||||
|
previousSnappedPointRef.current &&
|
||||||
|
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||||
|
displayPoint[1] !== previousSnappedPointRef.current[1])
|
||||||
|
) {
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +158,12 @@ export const CeilingTool: React.FC = () => {
|
|||||||
setPoints([])
|
setPoints([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
|
if (e.key === 'Shift') shiftPressed.current = true
|
||||||
|
}
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') shiftPressed.current = false
|
||||||
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
document.addEventListener('keyup', onKeyUp)
|
document.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
@@ -242,15 +254,12 @@ export const CeilingTool: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Cursor at ceiling height */}
|
{/* Cursor at ceiling height */}
|
||||||
<mesh ref={cursorRef}>
|
<CursorSphere ref={cursorRef} />
|
||||||
<sphereGeometry args={[0.1, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#d4d4d4" depthTest={false} depthWrite={false} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Grid-level cursor indicator */}
|
{/* Grid-level cursor indicator */}
|
||||||
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
|
||||||
<ringGeometry args={[0.15, 0.2, 32]} />
|
<ringGeometry args={[0.15, 0.2, 32]} />
|
||||||
<meshBasicMaterial color="#a3a3a3" side={DoubleSide} depthTest={false} depthWrite={false} />
|
<meshBasicMaterial color="#a3a3a3" side={DoubleSide} depthTest={false} depthWrite={true} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Preview fill */}
|
{/* Preview fill */}
|
||||||
@@ -294,14 +303,11 @@ export const CeilingTool: React.FC = () => {
|
|||||||
|
|
||||||
{/* Point markers */}
|
{/* Point markers */}
|
||||||
{points.map(([x, z], index) => (
|
{points.map(([x, z], index) => (
|
||||||
<mesh key={index} position={[x, levelY + CEILING_HEIGHT + 0.01, z]}>
|
<CursorSphere
|
||||||
<sphereGeometry args={[0.1, 16, 16]} />
|
key={index}
|
||||||
<meshBasicMaterial
|
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
|
||||||
color={index === 0 ? '#22c55e' : '#d4d4d4'}
|
color={index === 0 ? '#22c55e' : undefined}
|
||||||
depthTest={false}
|
/>
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,37 +1,44 @@
|
|||||||
import { emitter, type GridEvent, useScene, RoofNode, type LevelNode, type AnyNode } from "@pascal-app/core";
|
import {
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
type AnyNode,
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
emitter,
|
||||||
import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
|
type GridEvent,
|
||||||
import useEditor from "@/store/use-editor";
|
type LevelNode,
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus';
|
RoofNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { BufferGeometry, DoubleSide, type Line, Vector3 } from 'three'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
|
||||||
// Default roof dimensions
|
// Default roof dimensions
|
||||||
const DEFAULT_HEIGHT = 1.5;
|
const DEFAULT_HEIGHT = 1.5
|
||||||
const PREVIEW_LINE_HEIGHT = 0.03; // Very thin preview
|
const PREVIEW_LINE_HEIGHT = 0.03 // Very thin preview
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a roof with the given corners
|
* Creates a roof with the given corners
|
||||||
*/
|
*/
|
||||||
const commitRoofPlacement = (
|
const commitRoofPlacement = (
|
||||||
levelId: LevelNode["id"],
|
levelId: LevelNode['id'],
|
||||||
corner1: [number, number, number],
|
corner1: [number, number, number],
|
||||||
corner2: [number, number, number]
|
corner2: [number, number, number],
|
||||||
): RoofNode["id"] => {
|
): RoofNode['id'] => {
|
||||||
const { createNode, nodes } = useScene.getState();
|
const { createNode, nodes } = useScene.getState()
|
||||||
|
|
||||||
// Calculate center position and dimensions from corners
|
// Calculate center position and dimensions from corners
|
||||||
const centerX = (corner1[0] + corner2[0]) / 2;
|
const centerX = (corner1[0] + corner2[0]) / 2
|
||||||
const centerZ = (corner1[2] + corner2[2]) / 2;
|
const centerZ = (corner1[2] + corner2[2]) / 2
|
||||||
|
|
||||||
const length = Math.abs(corner2[0] - corner1[0]);
|
const length = Math.abs(corner2[0] - corner1[0])
|
||||||
const width = Math.abs(corner2[2] - corner1[2]);
|
const width = Math.abs(corner2[2] - corner1[2])
|
||||||
|
|
||||||
// Split width evenly between left and right slopes
|
// Split width evenly between left and right slopes
|
||||||
const slopeWidth = Math.max(width / 2, 0.5);
|
const slopeWidth = Math.max(width / 2, 0.5)
|
||||||
|
|
||||||
// Count existing roofs for naming
|
// Count existing roofs for naming
|
||||||
const roofCount = Object.values(nodes).filter((n) => n.type === "roof").length;
|
const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
|
||||||
const name = `Roof ${roofCount + 1}`;
|
const name = `Roof ${roofCount + 1}`
|
||||||
|
|
||||||
const roof = RoofNode.parse({
|
const roof = RoofNode.parse({
|
||||||
name,
|
name,
|
||||||
@@ -40,151 +47,153 @@ const commitRoofPlacement = (
|
|||||||
height: DEFAULT_HEIGHT,
|
height: DEFAULT_HEIGHT,
|
||||||
leftWidth: slopeWidth,
|
leftWidth: slopeWidth,
|
||||||
rightWidth: slopeWidth,
|
rightWidth: slopeWidth,
|
||||||
});
|
})
|
||||||
|
|
||||||
createNode(roof, levelId);
|
createNode(roof, levelId)
|
||||||
sfxEmitter.emit('sfx:structure-build');
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
return roof.id;
|
return roof.id
|
||||||
};
|
}
|
||||||
|
|
||||||
type PreviewState = {
|
type PreviewState = {
|
||||||
corner1: [number, number, number] | null;
|
corner1: [number, number, number] | null
|
||||||
cursorPosition: [number, number, number];
|
cursorPosition: [number, number, number]
|
||||||
levelY: number;
|
levelY: number
|
||||||
};
|
}
|
||||||
|
|
||||||
export const RoofTool: React.FC = () => {
|
export const RoofTool: React.FC = () => {
|
||||||
const outlineRef = useRef<Line>(null!);
|
const outlineRef = useRef<Line>(null!)
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setTool = useEditor((state) => state.setTool);
|
const setTool = useEditor((state) => state.setTool)
|
||||||
const setMode = useEditor((state) => state.setMode);
|
const setMode = useEditor((state) => state.setMode)
|
||||||
|
|
||||||
const corner1Ref = useRef<[number, number, number] | null>(null);
|
const corner1Ref = useRef<[number, number, number] | null>(null)
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null);
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
const [preview, setPreview] = useState<PreviewState>({
|
const [preview, setPreview] = useState<PreviewState>({
|
||||||
corner1: null,
|
corner1: null,
|
||||||
cursorPosition: [0, 0, 0],
|
cursorPosition: [0, 0, 0],
|
||||||
levelY: 0,
|
levelY: 0,
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentLevelId) return;
|
if (!currentLevelId) return
|
||||||
|
|
||||||
// Initialize outline geometry
|
// Initialize outline geometry
|
||||||
outlineRef.current.geometry = new BufferGeometry();
|
outlineRef.current.geometry = new BufferGeometry()
|
||||||
|
|
||||||
const updateOutline = (corner1: [number, number, number], corner2: [number, number, number]) => {
|
const updateOutline = (
|
||||||
const y = corner1[1] + PREVIEW_LINE_HEIGHT;
|
corner1: [number, number, number],
|
||||||
|
corner2: [number, number, number],
|
||||||
|
) => {
|
||||||
|
const y = corner1[1] + PREVIEW_LINE_HEIGHT
|
||||||
const points = [
|
const points = [
|
||||||
new Vector3(corner1[0], y, corner1[2]),
|
new Vector3(corner1[0], y, corner1[2]),
|
||||||
new Vector3(corner2[0], y, corner1[2]),
|
new Vector3(corner2[0], y, corner1[2]),
|
||||||
new Vector3(corner2[0], y, corner2[2]),
|
new Vector3(corner2[0], y, corner2[2]),
|
||||||
new Vector3(corner1[0], y, corner2[2]),
|
new Vector3(corner1[0], y, corner2[2]),
|
||||||
new Vector3(corner1[0], y, corner1[2]), // Close the loop
|
new Vector3(corner1[0], y, corner1[2]), // Close the loop
|
||||||
];
|
]
|
||||||
outlineRef.current.geometry.dispose();
|
outlineRef.current.geometry.dispose()
|
||||||
outlineRef.current.geometry = new BufferGeometry().setFromPoints(points);
|
outlineRef.current.geometry = new BufferGeometry().setFromPoints(points)
|
||||||
outlineRef.current.visible = true;
|
outlineRef.current.visible = true
|
||||||
};
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
// Snap to 0.5 grid
|
// Snap to 0.5 grid
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
const gridX = Math.round(event.position[0] * 2) / 2
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||||
const y = event.position[1];
|
const y = event.position[1]
|
||||||
|
|
||||||
const cursorPosition: [number, number, number] = [gridX, y, gridZ];
|
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
|
||||||
|
|
||||||
// Play snap sound when grid position changes (only when placing)
|
// Play snap sound when grid position changes (only when placing)
|
||||||
if (corner1Ref.current && previousGridPosRef.current &&
|
if (
|
||||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
|
corner1Ref.current &&
|
||||||
sfxEmitter.emit('sfx:grid-snap');
|
previousGridPosRef.current &&
|
||||||
|
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||||
|
) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
previousGridPosRef.current = [gridX, gridZ];
|
previousGridPosRef.current = [gridX, gridZ]
|
||||||
|
|
||||||
setPreview({
|
setPreview({
|
||||||
corner1: corner1Ref.current,
|
corner1: corner1Ref.current,
|
||||||
cursorPosition,
|
cursorPosition,
|
||||||
levelY: y,
|
levelY: y,
|
||||||
});
|
})
|
||||||
|
|
||||||
// Update outline if we have first corner
|
// Update outline if we have first corner
|
||||||
if (corner1Ref.current) {
|
if (corner1Ref.current) {
|
||||||
updateOutline(corner1Ref.current, cursorPosition);
|
updateOutline(corner1Ref.current, cursorPosition)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
if (!currentLevelId) return;
|
if (!currentLevelId) return
|
||||||
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
const gridX = Math.round(event.position[0] * 2) / 2
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||||
const y = event.position[1];
|
const y = event.position[1]
|
||||||
|
|
||||||
if (!corner1Ref.current) {
|
if (!corner1Ref.current) {
|
||||||
// First click - set corner 1
|
// First click - set corner 1
|
||||||
corner1Ref.current = [gridX, y, gridZ];
|
corner1Ref.current = [gridX, y, gridZ]
|
||||||
setPreview((prev) => ({
|
setPreview((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
corner1: corner1Ref.current,
|
corner1: corner1Ref.current,
|
||||||
}));
|
}))
|
||||||
} else {
|
} else {
|
||||||
// Second click - create the roof
|
// Second click - create the roof
|
||||||
const roofId = commitRoofPlacement(
|
const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
|
||||||
currentLevelId,
|
|
||||||
corner1Ref.current,
|
|
||||||
[gridX, y, gridZ]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-select the newly created roof
|
// Auto-select the newly created roof
|
||||||
setSelection({ selectedIds: [roofId as AnyNode["id"]] });
|
setSelection({ selectedIds: [roofId as AnyNode['id']] })
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
corner1Ref.current = null;
|
corner1Ref.current = null
|
||||||
outlineRef.current.visible = false;
|
outlineRef.current.visible = false
|
||||||
|
|
||||||
// Switch to select mode and deactivate tool
|
// Switch to select mode and deactivate tool
|
||||||
setMode('select');
|
setMode('select')
|
||||||
setTool(null);
|
setTool(null)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
if (corner1Ref.current) {
|
if (corner1Ref.current) {
|
||||||
corner1Ref.current = null;
|
corner1Ref.current = null
|
||||||
outlineRef.current.visible = false;
|
outlineRef.current.visible = false
|
||||||
setPreview((prev) => ({ ...prev, corner1: null }));
|
setPreview((prev) => ({ ...prev, corner1: null }))
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Subscribe to events
|
// Subscribe to events
|
||||||
emitter.on("grid:move", onGridMove);
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on("grid:click", onGridClick);
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on("tool:cancel", onCancel);
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off("grid:move", onGridMove);
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off("grid:click", onGridClick);
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off("tool:cancel", onCancel);
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
|
||||||
// Reset state on unmount
|
// Reset state on unmount
|
||||||
corner1Ref.current = null;
|
corner1Ref.current = null
|
||||||
};
|
}
|
||||||
}, [currentLevelId, setTool, setSelection, setMode]);
|
}, [currentLevelId, setTool, setSelection, setMode])
|
||||||
|
|
||||||
const { corner1, cursorPosition, levelY } = preview;
|
const { corner1, cursorPosition, levelY } = preview
|
||||||
|
|
||||||
// Calculate preview dimensions for display
|
// Calculate preview dimensions for display
|
||||||
const previewDimensions = useMemo(() => {
|
const previewDimensions = useMemo(() => {
|
||||||
if (!corner1) return null;
|
if (!corner1) return null
|
||||||
const length = Math.abs(cursorPosition[0] - corner1[0]);
|
const length = Math.abs(cursorPosition[0] - corner1[0])
|
||||||
const width = Math.abs(cursorPosition[2] - corner1[2]);
|
const width = Math.abs(cursorPosition[2] - corner1[2])
|
||||||
const centerX = (corner1[0] + cursorPosition[0]) / 2;
|
const centerX = (corner1[0] + cursorPosition[0]) / 2
|
||||||
const centerZ = (corner1[2] + cursorPosition[2]) / 2;
|
const centerZ = (corner1[2] + cursorPosition[2]) / 2
|
||||||
return { length, width, centerX, centerZ };
|
return { length, width, centerX, centerZ }
|
||||||
}, [corner1, cursorPosition]);
|
}, [corner1, cursorPosition])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -192,34 +201,25 @@ export const RoofTool: React.FC = () => {
|
|||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||||
<bufferGeometry />
|
<bufferGeometry />
|
||||||
<lineBasicNodeMaterial
|
<lineBasicNodeMaterial color="#8b4513" linewidth={2} depthTest={false} depthWrite={false} />
|
||||||
color="#8b4513"
|
|
||||||
linewidth={2}
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</line>
|
</line>
|
||||||
|
|
||||||
{/* First corner marker */}
|
{/* First corner marker */}
|
||||||
{corner1 && (
|
{corner1 && (
|
||||||
<mesh position={[corner1[0], levelY + 0.02, corner1[2]]} rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh position={[corner1[0], levelY + 0.02, corner1[2]]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
|
||||||
<ringGeometry args={[0.1, 0.15, 32]} />
|
<ringGeometry args={[0.1, 0.15, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial color="#22c55e" depthTest={false} depthWrite={true} />
|
||||||
color="#22c55e"
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cursor marker on ground */}
|
{/* Cursor marker on ground */}
|
||||||
<mesh position={[cursorPosition[0], cursorPosition[1] + 0.02, cursorPosition[2]]} rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh
|
||||||
|
position={[cursorPosition[0], cursorPosition[1] + 0.02, cursorPosition[2]]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
renderOrder={2}
|
||||||
|
>
|
||||||
<ringGeometry args={[0.1, 0.15, 32]} />
|
<ringGeometry args={[0.1, 0.15, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial color="#8b4513" depthTest={false} depthWrite={true} />
|
||||||
color="#8b4513"
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Thin preview fill when drawing */}
|
{/* Thin preview fill when drawing */}
|
||||||
@@ -240,5 +240,5 @@ export const RoofTool: React.FC = () => {
|
|||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ThreeElements } from '@react-three/fiber'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
import type { Mesh } from 'three'
|
||||||
|
|
||||||
|
interface CursorSphereProps extends Omit<ThreeElements['mesh'], 'ref'> {
|
||||||
|
color?: string
|
||||||
|
depthWrite?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CursorSphere = forwardRef<Mesh, CursorSphereProps>(function CursorSphere(
|
||||||
|
{ color = '#f1c066', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<mesh ref={ref} {...props} renderOrder={2}>
|
||||||
|
<sphereGeometry args={[0.1, 16, 16]} />
|
||||||
|
<meshBasicMaterial color={color} depthTest={false} depthWrite={true} />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
const Y_OFFSET = 0.02
|
const Y_OFFSET = 0.02
|
||||||
|
|
||||||
@@ -236,10 +237,7 @@ export const SlabTool: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Cursor */}
|
{/* Cursor */}
|
||||||
<mesh ref={cursorRef}>
|
<CursorSphere ref={cursorRef} />
|
||||||
<sphereGeometry args={[0.1, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#a3a3a3" depthTest={false} depthWrite={false} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Preview fill */}
|
{/* Preview fill */}
|
||||||
{previewShape && (
|
{previewShape && (
|
||||||
@@ -282,14 +280,7 @@ export const SlabTool: React.FC = () => {
|
|||||||
|
|
||||||
{/* Point markers */}
|
{/* Point markers */}
|
||||||
{points.map(([x, z], index) => (
|
{points.map(([x, z], index) => (
|
||||||
<mesh key={index} position={[x, levelY + Y_OFFSET + 0.01, z]}>
|
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color={index === 0 ? '#22c55e' : undefined} />
|
||||||
<sphereGeometry args={[0.1, 16, 16]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color={index === 0 ? '#22c55e' : '#a3a3a3'}
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
const WALL_HEIGHT = 2.5
|
const WALL_HEIGHT = 2.5
|
||||||
const WALL_THICKNESS = 0.15
|
const WALL_THICKNESS = 0.15
|
||||||
@@ -183,10 +184,7 @@ export const WallTool: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Cursor indicator */}
|
{/* Cursor indicator */}
|
||||||
<mesh ref={cursorRef}>
|
<CursorSphere ref={cursorRef} />
|
||||||
<sphereGeometry args={[0.1, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#a3a3a3" depthTest={false} depthWrite={false} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Wall preview */}
|
{/* Wall preview */}
|
||||||
<mesh ref={wallPreviewRef} visible={false} renderOrder={1}>
|
<mesh ref={wallPreviewRef} visible={false} renderOrder={1}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
|
import { CursorSphere } from "../shared/cursor-sphere";
|
||||||
|
|
||||||
// Zone colors for cycling through
|
// Zone colors for cycling through
|
||||||
const ZONE_COLORS = [
|
const ZONE_COLORS = [
|
||||||
@@ -317,14 +318,7 @@ export const ZoneTool: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Cursor */}
|
{/* Cursor */}
|
||||||
<mesh ref={cursorRef}>
|
<CursorSphere ref={cursorRef} color="#3b82f6" />
|
||||||
<sphereGeometry args={[0.1, 16, 16]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color="#3b82f6"
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Preview fill */}
|
{/* Preview fill */}
|
||||||
{previewShape && (
|
{previewShape && (
|
||||||
@@ -373,14 +367,7 @@ export const ZoneTool: React.FC = () => {
|
|||||||
{/* Point markers */}
|
{/* Point markers */}
|
||||||
{points.map(([x, z], index) =>
|
{points.map(([x, z], index) =>
|
||||||
isValidPoint([x, z]) ? (
|
isValidPoint([x, z]) ? (
|
||||||
<mesh key={index} position={[x, levelY + Y_OFFSET + 0.01, z]}>
|
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color={index === 0 ? "#22c55e" : "#3b82f6"} />
|
||||||
<sphereGeometry args={[0.1, 16, 16]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color={index === 0 ? "#22c55e" : "#3b82f6"}
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
emitter,
|
emitter,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
@@ -225,20 +226,122 @@ function PropertyLineSection() {
|
|||||||
// SITE PHASE VIEW - Property line + building buttons
|
// SITE PHASE VIEW - Property line + building buttons
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
function CameraPopover({
|
||||||
|
nodeId,
|
||||||
|
hasCamera,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
buttonClassName,
|
||||||
|
}: {
|
||||||
|
nodeId: AnyNodeId;
|
||||||
|
hasCamera: boolean;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
buttonClassName?: string;
|
||||||
|
}) {
|
||||||
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"relative w-6 h-6 flex items-center justify-center rounded cursor-pointer",
|
||||||
|
buttonClassName
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Camera snapshot"
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{hasCamera && (
|
||||||
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="w-auto p-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{hasCamera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:view", { nodeId });
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
View snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:capture", { nodeId });
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{hasCamera ? "Update snapshot" : "Take snapshot"}
|
||||||
|
</button>
|
||||||
|
{hasCamera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateNode(nodeId, { camera: undefined });
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SitePhaseView() {
|
function SitePhaseView() {
|
||||||
const nodes = useScene((state) => state.nodes);
|
const nodes = useScene((state) => state.nodes);
|
||||||
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
||||||
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
const [siteCameraOpen, setSiteCameraOpen] = useState(false);
|
||||||
|
const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
// Get site node and its building children
|
|
||||||
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
|
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
|
||||||
const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
|
const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
|
||||||
.map((child) => typeof child === 'string' ? nodes[child] : child)
|
.map((child) => {
|
||||||
|
const id = typeof child === 'string' ? child : child.id;
|
||||||
|
return nodes[id] as BuildingNode | undefined;
|
||||||
|
})
|
||||||
.filter((node): node is BuildingNode => node?.type === "building");
|
.filter((node): node is BuildingNode => node?.type === "building");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Site row */}
|
||||||
|
{siteNode && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
|
||||||
|
</div>
|
||||||
|
<CameraPopover
|
||||||
|
nodeId={siteNode.id as AnyNodeId}
|
||||||
|
hasCamera={!!siteNode.camera}
|
||||||
|
open={siteCameraOpen}
|
||||||
|
onOpenChange={setSiteCameraOpen}
|
||||||
|
buttonClassName="hover:bg-accent text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<PropertyLineSection />
|
<PropertyLineSection />
|
||||||
{buildings.length === 0 ? (
|
{buildings.length === 0 ? (
|
||||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
@@ -247,19 +350,91 @@ function SitePhaseView() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1 p-2">
|
<div className="flex flex-col gap-1 p-2">
|
||||||
{buildings.map((building) => (
|
{buildings.map((building) => (
|
||||||
<button
|
<div
|
||||||
key={building.id}
|
key={building.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer",
|
"group/building flex items-center rounded-md text-sm transition-colors",
|
||||||
selectedBuildingId === building.id
|
selectedBuildingId === building.id
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-accent/50 hover:bg-accent text-foreground"
|
: "bg-accent/50 hover:bg-accent text-foreground"
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelection({ buildingId: building.id })}
|
|
||||||
>
|
>
|
||||||
<Building2 className="w-4 h-4 shrink-0" />
|
<button
|
||||||
<span className="truncate">{building.name || "Building"}</span>
|
className="flex-1 flex items-center gap-2 px-3 py-2 cursor-pointer min-w-0"
|
||||||
</button>
|
onClick={() => setSelection({ buildingId: building.id })}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate">{building.name || "Building"}</span>
|
||||||
|
</button>
|
||||||
|
<Popover
|
||||||
|
open={buildingCameraOpen === building.id}
|
||||||
|
onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"relative opacity-0 group-hover/building:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded cursor-pointer shrink-0",
|
||||||
|
selectedBuildingId === building.id
|
||||||
|
? "hover:bg-primary-foreground/20"
|
||||||
|
: "hover:bg-accent-foreground/10"
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Camera snapshot"
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{building.camera && (
|
||||||
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="w-auto p-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{building.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:view", { nodeId: building.id });
|
||||||
|
setBuildingCameraOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
View snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:capture", { nodeId: building.id });
|
||||||
|
setBuildingCameraOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{building.camera ? "Update snapshot" : "Take snapshot"}
|
||||||
|
</button>
|
||||||
|
{building.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateNode(building.id, { camera: undefined });
|
||||||
|
setBuildingCameraOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -280,7 +455,10 @@ function BuildingSelector() {
|
|||||||
// Get site node and its building children
|
// Get site node and its building children
|
||||||
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
|
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
|
||||||
const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
|
const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
|
||||||
.map((child) => typeof child === 'string' ? nodes[child] : child)
|
.map((child) => {
|
||||||
|
const id = typeof child === 'string' ? child : child.id;
|
||||||
|
return nodes[id] as BuildingNode | undefined;
|
||||||
|
})
|
||||||
.filter((node): node is BuildingNode => node?.type === "building");
|
.filter((node): node is BuildingNode => node?.type === "building");
|
||||||
|
|
||||||
const selectedBuilding = selectedBuildingId
|
const selectedBuilding = selectedBuildingId
|
||||||
|
|||||||
@@ -201,6 +201,21 @@ export function wallOverlapsPolygon(
|
|||||||
const nz = (dz / len) * step
|
const nz = (dz / len) * step
|
||||||
if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true
|
if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true
|
||||||
if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true
|
if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true
|
||||||
|
|
||||||
|
// Also nudge perpendicular to the wall (into the slab interior) for walls that
|
||||||
|
// lie exactly on the slab boundary. The along-wall nudge keeps points on the
|
||||||
|
// boundary where pointInPolygon is unreliable; a perpendicular inward nudge
|
||||||
|
// moves the point clearly inside (or outside) the polygon.
|
||||||
|
// Sample the wall at 1/4, 1/2, 3/4 positions with a perpendicular nudge.
|
||||||
|
const PERP_STEP = 1e-4
|
||||||
|
const pnx = (-nz / step) * PERP_STEP // perpendicular left
|
||||||
|
const pnz = (nx / step) * PERP_STEP
|
||||||
|
for (const t of [0.25, 0.5, 0.75]) {
|
||||||
|
const bx = start[0] + dx * t
|
||||||
|
const bz = start[1] + dz * t
|
||||||
|
if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true
|
||||||
|
if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if midpoint is inside (catches walls crossing through)
|
// Check if midpoint is inside (catches walls crossing through)
|
||||||
@@ -557,26 +572,42 @@ export class SpatialGridManager {
|
|||||||
let maxElevation = -Infinity
|
let maxElevation = -Infinity
|
||||||
for (const slab of slabMap.values()) {
|
for (const slab of slabMap.values()) {
|
||||||
if (slab.polygon.length < 3) continue
|
if (slab.polygon.length < 3) continue
|
||||||
if (wallOverlapsPolygon(start, end, slab.polygon)) {
|
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
|
||||||
// Check if wall midpoint is in a hole (if so, ignore this slab)
|
|
||||||
|
const holes = slab.holes || []
|
||||||
|
if (holes.length === 0) {
|
||||||
|
// No holes: wall is on this slab
|
||||||
|
const elevation = slab.elevation ?? 0.05
|
||||||
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample multiple points along the wall to check whether any portion lies on
|
||||||
|
// solid slab (not inside any hole). Checking only the midpoint fails when the
|
||||||
|
// midpoint falls in a staircase hole but the wall's endpoints are on solid slab.
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dz = end[1] - start[1]
|
||||||
|
let hasValidPoint = false
|
||||||
|
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
|
||||||
|
const px = start[0] + dx * t
|
||||||
|
const pz = start[1] + dz * t
|
||||||
let inHole = false
|
let inHole = false
|
||||||
const midX = (start[0] + end[0]) / 2
|
|
||||||
const midZ = (start[1] + end[1]) / 2
|
|
||||||
const holes = slab.holes || []
|
|
||||||
for (const hole of holes) {
|
for (const hole of holes) {
|
||||||
if (hole.length >= 3 && pointInPolygon(midX, midZ, hole)) {
|
if (hole.length >= 3 && pointInPolygon(px, pz, hole)) {
|
||||||
inHole = true
|
inHole = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inHole) {
|
if (!inHole) {
|
||||||
const elevation = slab.elevation ?? 0.05
|
hasValidPoint = true
|
||||||
if (elevation > maxElevation) {
|
break
|
||||||
maxElevation = elevation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasValidPoint) {
|
||||||
|
const elevation = slab.elevation ?? 0.05
|
||||||
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return maxElevation === -Infinity ? 0 : maxElevation
|
return maxElevation === -Infinity ? 0 : maxElevation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function initSpatialGridSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (node.type === 'slab' && prev.type === 'slab') {
|
} else if (node.type === 'slab' && prev.type === 'slab') {
|
||||||
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) {
|
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes) {
|
||||||
const levelId = resolveLevelId(node, state.nodes)
|
const levelId = resolveLevelId(node, state.nodes)
|
||||||
spatialGridManager.handleNodeUpdated(node, levelId)
|
spatialGridManager.handleNodeUpdated(node, levelId)
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ export const deleteNodesAction = (
|
|||||||
return { nodes: nextNodes, rootNodeIds: nextRootIds }
|
return { nodes: nextNodes, rootNodeIds: nextRootIds }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify systems that the parent has changed (e.g. Wall needs to fill a window hole)
|
|
||||||
parentsToMarkDirty.forEach((pId) => get().markDirty(pId))
|
// Trigger a full scene re-validation after deleting node (as deleting a slab can cause widespread changes to level elevations)
|
||||||
|
const currentNodes = get().nodes
|
||||||
|
Object.values(currentNodes).forEach((node) => {
|
||||||
|
get().markDirty(node.id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,6 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
rootNodeIds: state.rootNodeIds,
|
rootNodeIds: state.rootNodeIds,
|
||||||
}),
|
}),
|
||||||
merge: (persistedState, currentState) => {
|
merge: (persistedState, currentState) => {
|
||||||
console.log('merge calling...', persistedState, currentState)
|
|
||||||
const persisted = persistedState as Partial<SceneState>
|
const persisted = persistedState as Partial<SceneState>
|
||||||
// Backward compat: add default scale to item nodes saved before scale was added
|
// Backward compat: add default scale to item nodes saved before scale was added
|
||||||
if (persisted.nodes) {
|
if (persisted.nodes) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type * as THREE from 'three'
|
|||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||||
import { getScaledDimensions, type AnyNodeId, type ItemNode, type WallNode } from '../../schema'
|
import { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -32,7 +32,7 @@ export const ItemSystem = () => {
|
|||||||
if (parentWall && parentWall.type === 'wall') {
|
if (parentWall && parentWall.type === 'wall') {
|
||||||
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
|
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
|
||||||
const side = item.side === 'front' ? 1 : -1
|
const side = item.side === 'front' ? 1 : -1
|
||||||
mesh.position.z = (wallThickness / 2) * side;
|
mesh.position.z = (wallThickness / 2) * side
|
||||||
}
|
}
|
||||||
} else if (!item.asset.attachTo) {
|
} else if (!item.asset.attachTo) {
|
||||||
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
|
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
|
||||||
@@ -51,8 +51,8 @@ export const ItemSystem = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearDirty(id as AnyNodeId)
|
clearDirty(id as AnyNodeId)
|
||||||
}, 2)
|
})
|
||||||
})
|
}, 2)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type CeilingNode, useRegistry } from '@pascal-app/core'
|
import { type CeilingNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl'
|
import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl'
|
||||||
import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ import { NodeRenderer } from '../node-renderer'
|
|||||||
// - Front face (looking down at ceiling from above): 30% opacity
|
// - Front face (looking down at ceiling from above): 30% opacity
|
||||||
const ceilingMaterial = new MeshBasicNodeMaterial({
|
const ceilingMaterial = new MeshBasicNodeMaterial({
|
||||||
color: 0x999999,
|
color: 0x999999,
|
||||||
side: DoubleSide,
|
|
||||||
transparent: true,
|
transparent: true,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
@@ -30,8 +29,8 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
|
|||||||
// Combine: if either X or Y is a line, show the line
|
// Combine: if either X or Y is a line, show the line
|
||||||
const gridPattern = lineX.max(lineY)
|
const gridPattern = lineX.max(lineY)
|
||||||
|
|
||||||
// Grid lines at 0.8 opacity, spaces at 0.1 opacity
|
// Grid lines at 0.5 opacity, spaces at 0 opacity
|
||||||
const gridOpacity = mix(float(0.1), float(0.8), gridPattern)
|
const gridOpacity = mix(float(0.0), float(0.5), gridPattern)
|
||||||
|
|
||||||
// faceDirection is 1.0 for front face, -1.0 for back face
|
// faceDirection is 1.0 for front face, -1.0 for back face
|
||||||
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
|
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={ref} {...handlers}>
|
<group ref={ref} {...handlers} userData={{
|
||||||
|
labelPosition: [centroid[0], 1, centroid[1]]
|
||||||
|
}}>
|
||||||
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
||||||
pointerEvents: 'none'
|
pointerEvents: 'none'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
import type { Object3D } from "three";
|
import type { Object3D } from "three";
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
type SelectionPath = {
|
type SelectionPath = {
|
||||||
buildingId: BuildingNode["id"] | null;
|
buildingId: BuildingNode["id"] | null;
|
||||||
@@ -57,62 +58,76 @@ type ViewerState = {
|
|||||||
setCameraDragging: (dragging: boolean) => void
|
setCameraDragging: (dragging: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useViewer = create<ViewerState>()((set, get) => ({
|
const useViewer = create<ViewerState>()(
|
||||||
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
persist(
|
||||||
hoveredId: null,
|
(set) => ({
|
||||||
setHoveredId: (id) => set({ hoveredId: id }),
|
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
||||||
|
hoveredId: null,
|
||||||
|
setHoveredId: (id) => set({ hoveredId: id }),
|
||||||
|
|
||||||
cameraMode: "perspective",
|
cameraMode: "perspective",
|
||||||
setCameraMode: (mode) => set({ cameraMode: mode }),
|
setCameraMode: (mode) => set({ cameraMode: mode }),
|
||||||
|
|
||||||
levelMode: "stacked",
|
levelMode: "stacked",
|
||||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||||
|
|
||||||
wallMode: 'cutaway',
|
wallMode: 'cutaway',
|
||||||
setWallMode: (mode) => set({ wallMode: mode }),
|
setWallMode: (mode) => set({ wallMode: mode }),
|
||||||
|
|
||||||
showScans: true,
|
showScans: true,
|
||||||
setShowScans: (show) => set({ showScans: show }),
|
setShowScans: (show) => set({ showScans: show }),
|
||||||
|
|
||||||
showGuides: true,
|
showGuides: true,
|
||||||
setShowGuides: (show) => set({ showGuides: show }),
|
setShowGuides: (show) => set({ showGuides: show }),
|
||||||
|
|
||||||
setSelection: (updates) =>
|
setSelection: (updates) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newSelection = { ...state.selection, ...updates };
|
const newSelection = { ...state.selection, ...updates };
|
||||||
|
|
||||||
// Hierarchy Guard: If we change a high-level parent, reset the children
|
// Hierarchy Guard: If we change a high-level parent, reset the children
|
||||||
if (updates.buildingId !== undefined) {
|
if (updates.buildingId !== undefined) {
|
||||||
newSelection.levelId = null;
|
newSelection.levelId = null;
|
||||||
newSelection.zoneId = null;
|
newSelection.zoneId = null;
|
||||||
newSelection.selectedIds = [];
|
newSelection.selectedIds = [];
|
||||||
} else if (updates.levelId !== undefined) {
|
} else if (updates.levelId !== undefined) {
|
||||||
newSelection.zoneId = null;
|
newSelection.zoneId = null;
|
||||||
newSelection.selectedIds = [];
|
newSelection.selectedIds = [];
|
||||||
} else if (updates.zoneId !== undefined) {
|
} else if (updates.zoneId !== undefined) {
|
||||||
newSelection.selectedIds = [];
|
newSelection.selectedIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { selection: newSelection };
|
return { selection: newSelection };
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetSelection: () =>
|
||||||
|
set({
|
||||||
|
selection: {
|
||||||
|
buildingId: null,
|
||||||
|
levelId: null,
|
||||||
|
zoneId: null,
|
||||||
|
selectedIds: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
outliner: { selectedObjects: [], hoveredObjects: [] },
|
||||||
|
|
||||||
|
exportScene: null,
|
||||||
|
setExportScene: (fn) => set({ exportScene: fn }),
|
||||||
|
|
||||||
|
cameraDragging: false,
|
||||||
|
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
resetSelection: () =>
|
name: 'viewer-preferences',
|
||||||
set({
|
partialize: (state) => ({
|
||||||
selection: {
|
cameraMode: state.cameraMode,
|
||||||
buildingId: null,
|
levelMode: state.levelMode,
|
||||||
levelId: null,
|
wallMode: state.wallMode,
|
||||||
zoneId: null,
|
showScans: state.showScans,
|
||||||
selectedIds: [],
|
showGuides: state.showGuides,
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
outliner: { selectedObjects: [], hoveredObjects: [] },
|
);
|
||||||
|
|
||||||
exportScene: null,
|
|
||||||
setExportScene: (fn) => set({ exportScene: fn }),
|
|
||||||
|
|
||||||
cameraDragging: false,
|
|
||||||
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default useViewer;
|
export default useViewer;
|
||||||
|
|||||||
Reference in New Issue
Block a user