Merge pull request #116 from pascalorg/fix/community-feedback-pass-3

Fix/community feedback pass 3
This commit is contained in:
Wassim SAMAD
2026-02-24 16:17:44 +09:00
committed by GitHub
20 changed files with 795 additions and 480 deletions
@@ -44,14 +44,19 @@ 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
requestAnimationFrame(() => {
controls.current.setLookAt( controls.current.setLookAt(
position[0], position[0],
position[1], position[1],
@@ -61,6 +66,11 @@ export const ViewerCameraControls = () => {
target[2], target[2],
true, true,
) )
})
return
}
if (!targetNodeId) {
// No selection and no site - do nothing
return return
} }
+74 -18
View File
@@ -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,17 +54,21 @@ 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 =
selection.selectedIds.length > 0
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined) ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
: null : null
// Get all levels for the selected building // Get all levels for the selected building
const levels = building?.children const levels =
building?.children
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined) .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
.filter((n): n is LevelNode => n?.type === 'level') .filter((n): n is LevelNode => n?.type === 'level')
.sort((a, b) => a.level - b.level) ?? [] .sort((a, b) => a.level - b.level) ?? []
@@ -69,7 +95,7 @@ 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">
@@ -132,7 +158,9 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
{zone && ( {zone && (
<> <>
<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'}`}> <span
className={`transition-colors truncate ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}
>
{zone.name} {zone.name}
</span> </span>
</> </>
@@ -141,7 +169,9 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
{selectedNode && zone && ( {selectedNode && 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="text-neutral-800 font-medium truncate">
{getNodeName(selectedNode)}
</span>
</> </>
)} )}
</div> </div>
@@ -171,7 +201,7 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
</div> </div>
{/* Controls Panel - Top Right */} {/* Controls Panel - Top Right */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2"> <div className="absolute top-4 right-4 z-20 flex flex-col gap-2">
{/* Visibility Controls */} {/* Visibility Controls */}
{(canShowScans || canShowGuides) && ( {(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)]"> <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)]">
@@ -205,7 +235,11 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
<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">Camera</span> <span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
<button <button
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')} 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" className="flex items-center gap-2 px-2 py-1 rounded text-sm text-neutral-700 hover:bg-neutral-100 transition-colors"
> >
{cameraMode === 'perspective' ? ( {cameraMode === 'perspective' ? (
@@ -223,7 +257,9 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
<button <button
onClick={() => useViewer.getState().setLevelMode('stacked')} onClick={() => useViewer.getState().setLevelMode('stacked')}
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${ className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
levelMode === 'stacked' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100' levelMode === 'stacked'
? 'bg-blue-500 text-white'
: 'text-neutral-700 hover:bg-neutral-100'
}`} }`}
> >
<Layers className="w-4 h-4" /> <Layers className="w-4 h-4" />
@@ -232,7 +268,9 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
<button <button
onClick={() => useViewer.getState().setLevelMode('exploded')} onClick={() => useViewer.getState().setLevelMode('exploded')}
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${ 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' levelMode === 'exploded'
? 'bg-blue-500 text-white'
: 'text-neutral-700 hover:bg-neutral-100'
}`} }`}
> >
<Layers2 className="w-4 h-4" /> <Layers2 className="w-4 h-4" />
@@ -241,7 +279,9 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
<button <button
onClick={() => useViewer.getState().setLevelMode('solo')} onClick={() => useViewer.getState().setLevelMode('solo')}
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${ 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' levelMode === 'solo'
? 'bg-blue-500 text-white'
: 'text-neutral-700 hover:bg-neutral-100'
}`} }`}
> >
<Diamond className="w-4 h-4" /> <Diamond className="w-4 h-4" />
@@ -255,10 +295,18 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
<button <button
onClick={() => useViewer.getState().setWallMode('cutaway')} onClick={() => useViewer.getState().setWallMode('cutaway')}
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${ 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' 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" /> <img
alt="Cutaway"
height={16}
src="/icons/wallcut.png"
width={16}
className="w-4 h-4"
/>
Cutaway Cutaway
</button> </button>
<button <button
@@ -267,13 +315,21 @@ export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShow
wallMode === 'up' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100' 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" /> <img
alt="Full Height"
height={16}
src="/icons/room.png"
width={16}
className="w-4 h-4"
/>
Full Height Full Height
</button> </button>
<button <button
onClick={() => useViewer.getState().setWallMode('down')} onClick={() => useViewer.getState().setWallMode('down')}
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${ 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' 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" /> <img alt="Low" height={16} src="/icons/walllow.png" width={16} className="w-4 h-4" />
@@ -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)
}
} }
}) })
}) })
+15 -9
View File
@@ -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>
) )
+114 -114
View File
@@ -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"
)} )}
>
<button
className="flex-1 flex items-center gap-2 px-3 py-2 cursor-pointer min-w-0"
onClick={() => setSelection({ buildingId: building.id })} onClick={() => setSelection({ buildingId: building.id })}
> >
<Building2 className="w-4 h-4 shrink-0" /> <Building2 className="w-4 h-4 shrink-0" />
<span className="truncate">{building.name || "Building"}</span> <span className="truncate">{building.name || "Building"}</span>
</button> </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,25 +572,41 @@ 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)
let inHole = false
const midX = (start[0] + end[0]) / 2
const midZ = (start[1] + end[1]) / 2
const holes = slab.holes || [] 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
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) {
hasValidPoint = true
break
}
}
if (hasValidPoint) {
const elevation = slab.elevation ?? 0.05 const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) { if (elevation > maxElevation) maxElevation = elevation
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)
})
} }
-1
View File
@@ -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'
}} }}
+17 -2
View File
@@ -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,7 +58,9 @@ type ViewerState = {
setCameraDragging: (dragging: boolean) => void setCameraDragging: (dragging: boolean) => void
} }
const useViewer = create<ViewerState>()((set, get) => ({ const useViewer = create<ViewerState>()(
persist(
(set) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
hoveredId: null, hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }), setHoveredId: (id) => set({ hoveredId: id }),
@@ -113,6 +116,18 @@ const useViewer = create<ViewerState>()((set, get) => ({
cameraDragging: false, cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }), setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
})); }),
{
name: 'viewer-preferences',
partialize: (state) => ({
cameraMode: state.cameraMode,
levelMode: state.levelMode,
wallMode: state.wallMode,
showScans: state.showScans,
showGuides: state.showGuides,
}),
},
),
);
export default useViewer; export default useViewer;