sync: backport monorepo PRs #240 #251 #253 (#225)

This commit is contained in:
Pascal
2026-04-13 11:24:13 -04:00
committed by GitHub
parent b25be8227a
commit 682e2a1a12
30 changed files with 1246 additions and 755 deletions
+217 -167
View File
@@ -8,14 +8,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -54,6 +47,7 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
import { CustomCameraControls } from './custom-camera-controls' import { CustomCameraControls } from './custom-camera-controls'
import { EditorLayoutV2 } from './editor-layout-v2' import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager' import { ExportManager } from './export-manager'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
import { FloatingActionMenu } from './floating-action-menu' import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu'
import { FloorplanPanel } from './floorplan-panel' import { FloorplanPanel } from './floorplan-panel'
@@ -61,9 +55,8 @@ import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels' import { SiteEdgeLabels } from './site-edge-labels'
import { ThumbnailGenerator } from './thumbnail-generator' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label' import { WallMeasurementLabel } from './wall-measurement-label'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
const DELETE_CURSOR_BADGE_COLOR = '#ef4444' const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
@@ -123,7 +116,7 @@ export interface EditorProps {
isLoading?: boolean isLoading?: boolean
// Thumbnail // Thumbnail
onThumbnailCapture?: (blob: Blob) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
// Version preview overlays (rendered by host app) // Version preview overlays (rendered by host app)
sidebarOverlay?: ReactNode sidebarOverlay?: ReactNode
@@ -507,6 +500,210 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
) )
} }
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
const ViewerSceneContent = memo(function ViewerSceneContent({
isVersionPreviewMode,
isLoading,
isFirstPersonMode,
onThumbnailCapture,
}: {
isVersionPreviewMode: boolean
isLoading: boolean
isFirstPersonMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}) {
return (
<>
{!isFirstPersonMode && <SelectionManager />}
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
<RoofEditSystem />
<StairEditSystem />
{!isLoading && !isFirstPersonMode && (
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
)}
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
{isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<PresetThumbnailGenerator />
{!isFirstPersonMode && <SiteEdgeLabels />}
{isFirstPersonMode && <InteractiveSystem />}
</>
)
})
// ── Delete cursor badge: isolated component so cursor moves don't re-render ViewerCanvas ──
// Subscribes to mode itself and manages cursor position state independently.
function DeleteCursorLayer({
containerRef,
isVersionPreviewMode,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
isVersionPreviewMode: boolean
}) {
const mode = useEditor((s) => s.mode)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const active = mode === 'delete' && !isVersionPreviewMode
useEffect(() => {
if (!active) {
setPosition(null)
return
}
const el = containerRef.current
if (!el) return
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect()
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
}
const onLeave = () => setPosition(null)
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave)
return () => {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave)
}
}, [active, containerRef])
if (!(active && position)) return null
return <DeleteCursorBadge position={position} />
}
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
// This prevents Editor from re-rendering when those values change.
const ViewerCanvas = memo(function ViewerCanvas({
isVersionPreviewMode,
isLoading,
hasLoadedInitialScene,
showLoader,
isFirstPersonMode,
onThumbnailCapture,
}: {
isVersionPreviewMode: boolean
isLoading: boolean
hasLoadedInitialScene: boolean
showLoader: boolean
isFirstPersonMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}) {
const viewMode = useEditor((s) => s.viewMode)
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
null,
)
const viewerAreaRef = useRef<HTMLDivElement>(null)
const viewer3dRef = useRef<HTMLDivElement>(null)
const isResizingFloorplan = useRef(false)
const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
isResizingFloorplan.current = true
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [])
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
if (!isResizingFloorplan.current) return
if (!viewerAreaRef.current) return
const rect = viewerAreaRef.current.getBoundingClientRect()
const newRatio = (e.clientX - rect.left) / rect.width
setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
}
const handlePointerUp = () => {
isResizingFloorplan.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [setFloorplanPaneRatio])
useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
}, [])
const dismissCameraControlsHint = useCallback(() => {
setIsCameraControlsHintVisible(false)
writeCameraControlsHintDismissed(true)
}, [])
const show2d = viewMode === '2d' || viewMode === 'split'
const show3d = viewMode === '3d' || viewMode === 'split'
return (
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
<div className="flex h-full" ref={viewerAreaRef}>
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
<div
className="relative h-full flex-shrink-0"
style={{
width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,
display: show2d ? undefined : 'none',
}}
>
<div className="h-full w-full overflow-hidden">
<FloorplanPanel />
</div>
{viewMode === 'split' && (
<div
className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
onPointerDown={handleFloorplanDividerDown}
>
<div className="h-8 w-1 rounded-full bg-neutral-400" />
</div>
)}
</div>
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
<div
className="relative min-w-0 flex-1 overflow-hidden"
ref={viewer3dRef}
style={{ display: show3d ? undefined : 'none' }}
>
<DeleteCursorLayer
containerRef={viewer3dRef}
isVersionPreviewMode={isVersionPreviewMode}
/>
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
<ViewerCanvasControlsHint
isPreviewMode={isPreviewMode}
onDismiss={dismissCameraControlsHint}
/>
) : null}
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
<ViewerSceneContent
isFirstPersonMode={isFirstPersonMode}
isLoading={isLoading}
isVersionPreviewMode={isVersionPreviewMode}
onThumbnailCapture={onThumbnailCapture}
/>
</Viewer>
</div>
</div>
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
</ErrorBoundary>
)
})
export default function Editor({ export default function Editor({
layoutVersion = 'v1', layoutVersion = 'v1',
appMenuButton, appMenuButton,
@@ -543,51 +740,11 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false) const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
null,
)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const mode = useEditor((s) => s.mode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
const [viewerCursorPosition, setViewerCursorPosition] = useState<{ x: number; y: number } | null>(
null,
)
const sidebarWidth = useSidebarStore((s) => s.width) const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
const viewerAreaRef = useRef<HTMLDivElement>(null)
const isResizingFloorplan = useRef(false)
const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
isResizingFloorplan.current = true
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [])
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
if (!isResizingFloorplan.current) return
if (!viewerAreaRef.current) return
const rect = viewerAreaRef.current.getBoundingClientRect()
const newRatio = (e.clientX - rect.left) / rect.width
setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
}
const handlePointerUp = () => {
isResizingFloorplan.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [])
useEffect(() => { useEffect(() => {
const teardown = initializeEditorRuntime() const teardown = initializeEditorRuntime()
@@ -661,39 +818,7 @@ export default function Editor({
} }
}, []) }, [])
useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
}, [])
const showLoader = isLoading || isSceneLoading const showLoader = isLoading || isSceneLoading
const dismissCameraControlsHint = useCallback(() => {
setIsCameraControlsHintVisible(false)
writeCameraControlsHintDismissed(true)
}, [])
// ── Shared viewer scene content ──
const viewerSceneContent = (
<>
{!isFirstPersonMode && <SelectionManager />}
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
<RoofEditSystem />
<StairEditSystem />
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
{isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<PresetThumbnailGenerator />
{!isFirstPersonMode && <SiteEdgeLabels />}
{isFirstPersonMode && <InteractiveSystem />}
</>
)
const previewViewerContent = ( const previewViewerContent = (
<Viewer selectionManager="default"> <Viewer selectionManager="default">
@@ -709,86 +834,15 @@ export default function Editor({
</Viewer> </Viewer>
) )
// ── Shared viewer canvas (handles split/2d/3d) ──
const viewMode = useEditor((s) => s.viewMode)
const show2d = viewMode === '2d' || viewMode === 'split'
const show3d = viewMode === '3d' || viewMode === 'split'
const showDeleteCursorBadge = mode === 'delete' && !isVersionPreviewMode
useEffect(() => {
if (!(showDeleteCursorBadge && show3d)) {
setViewerCursorPosition(null)
}
}, [show3d, showDeleteCursorBadge])
const handleViewerPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!showDeleteCursorBadge) {
setViewerCursorPosition(null)
return
}
const rect = event.currentTarget.getBoundingClientRect()
setViewerCursorPosition({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
})
},
[showDeleteCursorBadge],
)
const handleViewerPointerLeave = useCallback(() => {
setViewerCursorPosition(null)
}, [])
const viewerCanvas = ( const viewerCanvas = (
<ErrorBoundary fallback={<EditorSceneCrashFallback />}> <ViewerCanvas
<div className="flex h-full" ref={viewerAreaRef}> hasLoadedInitialScene={hasLoadedInitialScene}
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */} isFirstPersonMode={isFirstPersonMode}
<div isLoading={isLoading}
className="relative h-full flex-shrink-0" isVersionPreviewMode={isVersionPreviewMode}
style={{ onThumbnailCapture={onThumbnailCapture}
width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`, showLoader={showLoader}
display: show2d ? undefined : 'none',
}}
>
<div className="h-full w-full overflow-hidden">
<FloorplanPanel />
</div>
{viewMode === 'split' && (
<div
className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
onPointerDown={handleFloorplanDividerDown}
>
<div className="h-8 w-1 rounded-full bg-neutral-400" />
</div>
)}
</div>
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
<div
className="relative min-w-0 flex-1 overflow-hidden"
onPointerEnter={handleViewerPointerMove}
onPointerLeave={handleViewerPointerLeave}
onPointerMove={handleViewerPointerMove}
style={{ display: show3d ? undefined : 'none' }}
>
{showDeleteCursorBadge && viewerCursorPosition ? (
<DeleteCursorBadge position={viewerCursorPosition} />
) : null}
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
<ViewerCanvasControlsHint
isPreviewMode={isPreviewMode}
onDismiss={dismissCameraControlsHint}
/> />
) : null}
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
</div>
</div>
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
</ErrorBoundary>
) )
// ── V2 layout ── // ── V2 layout ──
@@ -858,9 +912,7 @@ export default function Editor({
{/* First-person overlay — rendered on top of normal layout */} {/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && ( {isFirstPersonMode && (
<div className="fixed inset-0 z-50 pointer-events-none"> <div className="fixed inset-0 z-50 pointer-events-none">
<FirstPersonOverlay <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
onExit={() => useEditor.getState().setFirstPersonMode(false)}
/>
</div> </div>
)} )}
<EditorCommands /> <EditorCommands />
@@ -906,9 +958,7 @@ export default function Editor({
</SidebarSlot> </SidebarSlot>
{/* Viewer area */} {/* Viewer area */}
<div className="relative flex-1 overflow-hidden rounded-xl" ref={viewerAreaRef}> <div className="relative flex-1 overflow-hidden rounded-xl">{viewerCanvas}</div>
{viewerCanvas}
</div>
{/* Fixed UI overlays scoped to the viewer area */} {/* Fixed UI overlays scoped to the viewer area */}
<ViewerOverlays left={overlayLeft}> <ViewerOverlays left={overlayLeft}>
@@ -142,7 +142,9 @@ function createHighlightedMaterials(
function disposeHighlightedMaterials(material: Material | Material[]) { function disposeHighlightedMaterials(material: Material | Material[]) {
if (Array.isArray(material)) { if (Array.isArray(material)) {
material.forEach((entry) => entry.dispose()) material.forEach((entry) => {
entry.dispose()
})
return return
} }
@@ -825,6 +827,31 @@ const SelectionMaterialSync = () => {
}) })
}, [syncSelectionMaterials]) }, [syncSelectionMaterials])
useEffect(() => {
const restoreForCapture = () => {
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
if (mesh.material === entry.highlightedMaterial) {
mesh.material = entry.originalMaterial
}
}
}
const reapplyAfterCapture = () => {
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
if (mesh.material === entry.originalMaterial) {
mesh.material = entry.highlightedMaterial
}
}
}
emitter.on('thumbnail:before-capture', restoreForCapture)
emitter.on('thumbnail:after-capture', reapplyAfterCapture)
return () => {
emitter.off('thumbnail:before-capture', restoreForCapture)
emitter.off('thumbnail:after-capture', reapplyAfterCapture)
}
}, [])
useEffect(() => { useEffect(() => {
return () => { return () => {
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) { for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
@@ -1,10 +1,28 @@
'use client' 'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core' import { emitter, useScene } from '@pascal-app/core'
import { snapLevelsToTruePositions } from '@pascal-app/viewer' import { SSGI_PARAMS, snapLevelsToTruePositions } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { UnsignedByteType } from 'three'
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
import {
colorToDirection,
convertToTexture,
diffuseColor,
directionToColor,
float,
mrt,
normalView,
output,
pass,
sample,
vec4,
} from 'three/tsl'
import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
const THUMBNAIL_WIDTH = 1920 const THUMBNAIL_WIDTH = 1920
@@ -18,15 +36,107 @@ interface ThumbnailGeneratorProps {
export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => { export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
const gl = useThree((state) => state.gl) const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene) const scene = useThree((state) => state.scene)
const mainCamera = useThree((state) => state.camera)
const isGenerating = useRef(false) const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false) const pendingAutoRef = useRef(false)
const onThumbnailCaptureRef = useRef(onThumbnailCapture) const onThumbnailCaptureRef = useRef(onThumbnailCapture)
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
const pipelineRef = useRef<RenderPipeline | null>(null)
const renderTargetRef = useRef<RenderTarget | null>(null)
useEffect(() => { useEffect(() => {
onThumbnailCaptureRef.current = onThumbnailCapture onThumbnailCaptureRef.current = onThumbnailCapture
}, [onThumbnailCapture]) }, [onThumbnailCapture])
// Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture.
useEffect(() => {
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
cam.layers.disable(EDITOR_LAYER)
thumbnailCameraRef.current = cam
let mounted = true
const buildPipeline = async () => {
try {
if ((gl as any).init) await (gl as any).init()
if (!mounted) return
// pass() handles MRT internally for all material types, including custom
// shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
const scenePass = pass(scene, cam)
scenePass.setMRT(
mrt({
output,
diffuseColor,
normal: directionToColor(normalView),
}),
)
const scenePassColor = scenePass.getTextureNode('output')
const scenePassDepth = scenePass.getTextureNode('depth')
const scenePassNormal = scenePass.getTextureNode('normal')
scenePass.getTexture('diffuseColor').type = UnsignedByteType
scenePass.getTexture('normal').type = UnsignedByteType
const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
giPass.stepCount.value = SSGI_PARAMS.stepCount
giPass.radius.value = SSGI_PARAMS.radius
giPass.expFactor.value = SSGI_PARAMS.expFactor
giPass.thickness.value = SSGI_PARAMS.thickness
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
const giTexture = (giPass as any).getTextureNode()
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
denoisePass.index.value = 0
denoisePass.radius.value = 4
const ao = (denoisePass as any).r
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
// FXAA requires a texture node as input; convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
const aaOutput = fxaa(convertToTexture(finalOutput))
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
pipeline.outputNode = aaOutput
pipelineRef.current = pipeline
// Dedicated render target — pipeline outputs here instead of the canvas,
// so R3F's main render loop can never overwrite our capture.
const { width, height } = gl.domElement
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
} catch (error) {
console.error(
'[thumbnail] Failed to build post-processing pipeline, will use fallback render.',
error,
)
}
}
buildPipeline()
return () => {
mounted = false
pipelineRef.current?.dispose()
pipelineRef.current = null
renderTargetRef.current?.dispose()
renderTargetRef.current = null
}
}, [gl, scene])
const generate = useCallback(async () => { const generate = useCallback(async () => {
if (isGenerating.current) return if (isGenerating.current) return
if (!onThumbnailCaptureRef.current) return if (!onThumbnailCaptureRef.current) return
@@ -34,50 +144,109 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
isGenerating.current = true isGenerating.current = true
try { try {
const thumbnailCamera = new THREE.PerspectiveCamera( const thumbnailCamera = thumbnailCameraRef.current
60, if (!thumbnailCamera) return
THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,
0.1,
1000,
)
const nodes = useScene.getState().nodes // Copy the main camera's transform and projection so the thumbnail
const siteNode = Object.values(nodes).find((n) => n.type === 'site') // matches exactly what the user sees in the viewport.
thumbnailCamera.position.copy(mainCamera.position)
if (siteNode?.camera) { thumbnailCamera.quaternion.copy(mainCamera.quaternion)
const { position, target } = siteNode.camera if (mainCamera instanceof THREE.PerspectiveCamera) {
thumbnailCamera.position.set(position[0], position[1], position[2]) thumbnailCamera.fov = mainCamera.fov
thumbnailCamera.lookAt(target[0], target[1], target[2]) thumbnailCamera.near = mainCamera.near
} else { thumbnailCamera.far = mainCamera.far
thumbnailCamera.position.set(8, 8, 8)
thumbnailCamera.lookAt(0, 0, 0)
} }
thumbnailCamera.layers.disable(EDITOR_LAYER)
const { width, height } = gl.domElement const { width, height } = gl.domElement
thumbnailCamera.aspect = width / height thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix() thumbnailCamera.updateProjectionMatrix()
const restoreLevels = snapLevelsToTruePositions() const restoreLevels = snapLevelsToTruePositions()
const visibilitySnapshot = new Map<string, boolean>() let blob: Blob
for (const type of ['scan', 'guide'] as const) {
sceneRegistry.byType[type].forEach((id) => { if (pipelineRef.current && renderTargetRef.current) {
const obj = sceneRegistry.nodes.get(id) const rt = renderTargetRef.current
if (obj) {
visibilitySnapshot.set(id, obj.visible) // Resize RT if the canvas dimensions changed
obj.visible = false if (rt.width !== width || rt.height !== height) {
} rt.setSize(width, height)
})
} }
gl.render(scene, thumbnailCamera) const renderer = gl as unknown as WebGPURenderer
// Swap selected-item materials back to originals for the capture,
// then re-apply highlights immediately after.
emitter.emit('thumbnail:before-capture', undefined)
;(renderer as any).setClearAlpha(0)
renderer.setRenderTarget(rt)
pipelineRef.current.render()
renderer.setRenderTarget(null)
emitter.emit('thumbnail:after-capture', undefined)
// Restore level positions immediately after the render — before the async GPU readback.
restoreLevels()
// Read pixels from the RT asynchronously.
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
// depad the rows before constructing ImageData.
const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
rt,
0,
0,
width,
height,
)) as Uint8Array
const actualBytesPerRow = width * 4
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
let tightPixels: Uint8ClampedArray
if (paddedBytesPerRow === actualBytesPerRow) {
// No padding — use the buffer directly
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
} else {
// Depad rows
tightPixels = new Uint8ClampedArray(width * height * 4)
for (let row = 0; row < height; row++) {
tightPixels.set(
pixels.subarray(row * paddedBytesPerRow, row * paddedBytesPerRow + actualBytesPerRow),
row * actualBytesPerRow,
)
}
}
// Crop to thumbnail aspect ratio and draw to offscreen canvas
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0,
sy = 0,
sWidth = width,
sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
const imageData = new ImageData(
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
width,
height,
)
const srcCanvas = new OffscreenCanvas(width, height)
srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
const offscreen = new OffscreenCanvas(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
offscreen
.getContext('2d')!
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else {
// Fallback: plain render directly to the canvas
gl.render(scene, thumbnailCamera)
restoreLevels() restoreLevels()
visibilitySnapshot.forEach((wasVisible, id) => {
const obj = sceneRegistry.nodes.get(id)
if (obj) obj.visible = wasVisible
})
const srcAspect = width / height const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
@@ -97,21 +266,33 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
offscreen.width = THUMBNAIL_WIDTH offscreen.width = THUMBNAIL_WIDTH
offscreen.height = THUMBNAIL_HEIGHT offscreen.height = THUMBNAIL_HEIGHT
const ctx = offscreen.getContext('2d')! const ctx = offscreen.getContext('2d')!
ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) ctx.drawImage(
gl.domElement,
sx,
sy,
sWidth,
sHeight,
0,
0,
THUMBNAIL_WIDTH,
THUMBNAIL_HEIGHT,
)
offscreen.toBlob((blob) => { blob = await new Promise<Blob>((resolve, reject) =>
if (blob) { offscreen.toBlob(
onThumbnailCaptureRef.current?.(blob) (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
} else { 'image/png',
console.error('❌ Failed to create blob from canvas') ),
)
} }
isGenerating.current = false
}, 'image/png') onThumbnailCaptureRef.current?.(blob)
} catch (error) { } catch (error) {
console.error('❌ Failed to generate thumbnail:', error) console.error('❌ Failed to generate thumbnail:', error)
} finally {
isGenerating.current = false isGenerating.current = false
} }
}, [gl, scene]) }, [gl, scene, mainCamera])
// Manual trigger via emitter // Manual trigger via emitter
useEffect(() => { useEffect(() => {
@@ -143,6 +143,8 @@ export const DoorTool: React.FC = () => {
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
if (draftRef.current) { if (draftRef.current) {
if (event.node.id !== draftRef.current.parentId) {
// Wall changed without enter/leave: must updateNode to reparent
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
@@ -150,6 +152,16 @@ export const DoorTool: React.FC = () => {
parentId: event.node.id, parentId: event.node.id,
wallId: event.node.id, wallId: event.node.id,
}) })
} else {
// Same wall: update Three.js mesh directly to avoid store churn
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
if (draftMesh) {
draftMesh.position.set(clampedX, clampedY, 0)
draftMesh.rotation.set(0, itemRotation, 0)
draftMesh.updateMatrixWorld(true)
}
markWallDirty(event.node.id)
}
} }
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
@@ -165,6 +165,8 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
movingDoorNode.height, movingDoorNode.height,
) )
if (currentWallId !== event.node.id) {
// Wall changed mid-move: must updateNode to reparent
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
@@ -172,10 +174,17 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
parentId: event.node.id, parentId: event.node.id,
wallId: event.node.id, wallId: event.node.id,
}) })
if (currentWallId !== event.node.id) {
markWallDirty(currentWallId) markWallDirty(currentWallId)
currentWallId = event.node.id currentWallId = event.node.id
} else {
// Same wall: update Three.js mesh directly to avoid store churn
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
if (doorMesh) {
doorMesh.position.set(clampedX, clampedY, 0)
doorMesh.rotation.set(0, itemRotation, 0)
doorMesh.updateMatrixWorld(true)
}
} }
markWallDirty(event.node.id) markWallDirty(event.node.id)
@@ -185,6 +185,8 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
movingWindowNode.height, movingWindowNode.height,
) )
if (currentWallId !== event.node.id) {
// Wall changed mid-move: must updateNode to reparent
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
@@ -192,10 +194,17 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
parentId: event.node.id, parentId: event.node.id,
wallId: event.node.id, wallId: event.node.id,
}) })
if (currentWallId !== event.node.id) {
markWallDirty(currentWallId) markWallDirty(currentWallId)
currentWallId = event.node.id currentWallId = event.node.id
} else {
// Same wall: update Three.js mesh directly to avoid store churn
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
if (windowMesh) {
windowMesh.position.set(clampedX, clampedY, 0)
windowMesh.rotation.set(0, itemRotation, 0)
windowMesh.updateMatrixWorld(true)
}
} }
markWallDirty(event.node.id) markWallDirty(event.node.id)
@@ -151,6 +151,8 @@ export const WindowTool: React.FC = () => {
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
if (draftRef.current) { if (draftRef.current) {
if (event.node.id !== draftRef.current.parentId) {
// Wall changed without enter/leave: must updateNode to reparent
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
@@ -158,6 +160,16 @@ export const WindowTool: React.FC = () => {
parentId: event.node.id, parentId: event.node.id,
wallId: event.node.id, wallId: event.node.id,
}) })
} else {
// Same wall: update Three.js mesh directly to avoid store churn
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
if (draftMesh) {
draftMesh.position.set(clampedX, clampedY, 0)
draftMesh.rotation.set(0, itemRotation, 0)
draftMesh.updateMatrixWorld(true)
}
markWallDirty(event.node.id)
}
} }
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
@@ -1,7 +1,8 @@
import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core' import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Building2, Plus } from 'lucide-react' import { Building2, Plus } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -11,37 +12,42 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface BuildingTreeNodeProps { interface BuildingTreeNodeProps {
node: BuildingNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) { export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) {
const [expanded, setExpanded] = useState(true) const [expanded, setExpanded] = useState(true)
const createNode = useScene((state) => state.createNode) const createNode = useScene((state) => state.createNode)
const isSelected = useViewer((state) => state.selection.buildingId === node.id) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const isHovered = useViewer((state) => state.hoveredId === node.id) const name = useScene((s) => s.nodes[nodeId]?.name)
const children = useScene(
useShallow((s) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []),
)
const isSelected = useViewer((state) => state.selection.buildingId === nodeId)
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const handleClick = () => { const handleClick = () => {
setSelection({ buildingId: node.id }) setSelection({ buildingId: nodeId })
} }
const handleAddLevel = (e: React.MouseEvent) => { const handleAddLevel = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const newLevel = LevelNode.parse({ const newLevel = LevelNode.parse({
level: node.children.length, level: children.length,
children: [], children: [],
parentId: node.id, parentId: nodeId,
}) })
createNode(newLevel, node.id) createNode(newLevel, nodeId)
} }
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={ actions={
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<TreeNodeActions node={node} /> <TreeNodeActions nodeId={nodeId} />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@@ -57,20 +63,21 @@ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps)
} }
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={node.children.length > 0} hasChildren={children.length > 0}
icon={<Building2 className="h-3.5 w-3.5" />} icon={<Building2 className="h-3.5 w-3.5" />}
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
label={node.name || 'Building'} isVisible={isVisible}
label={name || 'Building'}
onClick={handleClick} onClick={handleClick}
onDoubleClick={() => focusTreeNode(node.id)} onDoubleClick={() => focusTreeNode(nodeId)}
onToggle={() => setExpanded(!expanded)} onToggle={() => setExpanded(!expanded)}
> >
{node.children.map((childId, index) => ( {children.map((childId, index) => (
<TreeNode <TreeNode
depth={depth + 1} depth={depth + 1}
isLast={index === node.children.length - 1} isLast={index === children.length - 1}
key={childId} key={childId}
nodeId={childId} nodeId={childId}
/> />
@@ -1,104 +1,112 @@
import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core' import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface CeilingTreeNodeProps { interface CeilingTreeNodeProps {
node: CeilingNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) { export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const isSelected = selectedIds.includes(node.id) const children = useScene(
const isHovered = useViewer((state) => state.hoveredId === node.id) useShallow((s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.children ?? []),
)
const polygon = useScene(
(s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.polygon ?? [],
)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
useEffect(() => { useEffect(() => {
return useViewer.subscribe((state) => {
const { selectedIds } = state.selection
if (selectedIds.length === 0) return if (selectedIds.length === 0) return
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
let isDescendant = false
for (const id of selectedIds) { for (const id of selectedIds) {
let current = nodes[id as AnyNodeId] let current = nodes[id as AnyNodeId]
while (current?.parentId) { while (current?.parentId) {
if (current.parentId === node.id) { if (current.parentId === nodeId) {
isDescendant = true setExpanded(true)
break return
} }
current = nodes[current.parentId as AnyNodeId] current = nodes[current.parentId as AnyNodeId]
} }
if (isDescendant) break
} }
if (isDescendant) { })
setExpanded(true) }, [nodeId])
}
}, [selectedIds, node.id])
const handleClick = (e: React.MouseEvent) => { const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') { if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
} }
} },
[nodeId, setSelection],
)
const handleDoubleClick = () => { const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
focusTreeNode(node.id) const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
} const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const handleMouseEnter = () => { const area = calculatePolygonArea(polygon).toFixed(1)
setHoveredId(node.id)
}
const handleMouseLeave = () => {
setHoveredId(null)
}
// Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1)
const defaultName = `Ceiling (${area}m²)` const defaultName = `Ceiling (${area}m²)`
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={node.children.length > 0} hasChildren={children.length > 0}
icon={ icon={
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} /> <Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId as AnyNodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onToggle={() => setExpanded(!expanded)} onToggle={handleToggle}
> >
{node.children.map((childId, index) => ( {children.map((childId, index) => (
<TreeNode <TreeNode
depth={depth + 1} depth={depth + 1}
isLast={index === node.children.length - 1} isLast={index === children.length - 1}
key={childId} key={childId}
nodeId={childId} nodeId={childId}
/> />
@@ -1,33 +1,50 @@
'use client' 'use client'
import type { DoorNode } from '@pascal-app/core' import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { useState } from 'react' import { useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface DoorTreeNodeProps { interface DoorTreeNodeProps {
node: DoorNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) { export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const isSelected = selectedIds.includes(node.id) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const defaultName = 'Door' const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
},
[nodeId, setSelection],
)
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
@@ -37,26 +54,20 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName="Door"
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId as AnyNodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={(e: React.MouseEvent) => { onClick={handleClick}
e.stopPropagation() onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) onMouseEnter={() => setHoveredId(nodeId)}
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
}}
onDoubleClick={() => focusTreeNode(node.id)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}} onToggle={() => {}}
/> />
@@ -24,6 +24,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { AnimatePresence, LayoutGroup, motion } from 'motion/react' import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot' import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
import { import {
Popover, Popover,
@@ -393,8 +394,15 @@ function LevelReferences({
onUploadAsset, onUploadAsset,
onDeleteAsset, onDeleteAsset,
}: LevelReferencesProps) { }: LevelReferencesProps) {
const nodes = useScene((s) => s.nodes)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const references = useScene(
useShallow((s) =>
Object.values(s.nodes).filter(
(node): node is ScanNode | GuideNode =>
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
),
),
)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const uploadState = useUploadStore((s) => s.uploads[levelId]) const uploadState = useUploadStore((s) => s.uploads[levelId])
const clearUpload = useUploadStore((s) => s.clearUpload) const clearUpload = useUploadStore((s) => s.clearUpload)
@@ -409,11 +417,6 @@ function LevelReferences({
const scanInputRef = useRef<HTMLInputElement>(null) const scanInputRef = useRef<HTMLInputElement>(null)
const references = Object.values(nodes).filter(
(node): node is ScanNode | GuideNode =>
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
)
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
@@ -457,7 +460,10 @@ function LevelReferences({
const handleDelete = async (nodeId: string, e: React.MouseEvent) => { const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined const refNode = useScene.getState().nodes[nodeId as AnyNodeId] as
| ScanNode
| GuideNode
| undefined
if ( if (
projectId && projectId &&
@@ -789,21 +795,28 @@ function LevelsSection({
onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
onDeleteAsset?: (projectId: string, url: string) => void onDeleteAsset?: (projectId: string, url: string) => void
} = {}) { } = {}) {
const nodes = useScene((state) => state.nodes)
const createNode = useScene((state) => state.createNode) const createNode = useScene((state) => state.createNode)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const selectedBuildingId = useViewer((state) => state.selection.buildingId) const selectedBuildingId = useViewer((state) => state.selection.buildingId)
const selectedLevelId = useViewer((state) => state.selection.levelId) const selectedLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) : null const building = useScene((s) =>
selectedBuildingId ? ((s.nodes[selectedBuildingId] as BuildingNode | undefined) ?? null) : null,
)
const levels = useScene(
useShallow((s) => {
if (!selectedBuildingId) return []
const bldg = s.nodes[selectedBuildingId] as BuildingNode | undefined
if (!bldg) return []
return bldg.children
.map((id) => s.nodes[id])
.filter((node): node is LevelNode => node?.type === 'level')
}),
)
if (!building) return null if (!building) return null
const levels = building.children
.map((id) => nodes[id])
.filter((node): node is LevelNode => node?.type === 'level')
const handleAddLevel = () => { const handleAddLevel = () => {
const newLevel = LevelNode.parse({ const newLevel = LevelNode.parse({
level: levels.length, level: levels.length,
@@ -1175,7 +1188,6 @@ function MultiSelectionBadge() {
} }
function ContentSection() { function ContentSection() {
const nodes = useScene((state) => state.nodes)
const selectedLevelId = useViewer((state) => state.selection.levelId) const selectedLevelId = useViewer((state) => state.selection.levelId)
const structureLayer = useEditor((state) => state.structureLayer) const structureLayer = useEditor((state) => state.structureLayer)
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
@@ -1183,7 +1195,25 @@ function ContentSection() {
const setMode = useEditor((state) => state.setMode) const setMode = useEditor((state) => state.setMode)
const setTool = useEditor((state) => state.setTool) const setTool = useEditor((state) => state.setTool)
const level = selectedLevelId ? (nodes[selectedLevelId] as LevelNode) : null const level = useScene((s) =>
selectedLevelId ? ((s.nodes[selectedLevelId] as LevelNode | undefined) ?? null) : null,
)
const levelZones = useScene(
useShallow((s) => {
if (!selectedLevelId) return []
return Object.values(s.nodes).filter(
(node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,
)
}),
)
const elementChildren = useScene(
useShallow((s) => {
if (!selectedLevelId) return []
const lvl = s.nodes[selectedLevelId] as LevelNode | undefined
if (!lvl) return []
return lvl.children.filter((childId) => s.nodes[childId]?.type !== 'zone')
}),
)
if (!level) { if (!level) {
return ( return (
@@ -1192,11 +1222,6 @@ function ContentSection() {
} }
if (structureLayer === 'zones') { if (structureLayer === 'zones') {
// Show zones for this level
const levelZones = Object.values(nodes).filter(
(node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,
)
const handleAddZone = () => { const handleAddZone = () => {
setPhase('structure') setPhase('structure')
setMode('build') setMode('build')
@@ -1223,21 +1248,9 @@ function ContentSection() {
) )
} }
// Filter elements based on phase
const elementChildren = level.children.filter((childId) => {
const childNode = nodes[childId]
if (!childNode || childNode.type === 'zone') return false
// We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode
// This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible
// and selectable in both modes, ensuring seamless transition in the tree view.
return true
})
if (elementChildren.length === 0) { if (elementChildren.length === 0) {
return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div> return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div>
} }
return ( return (
<TreeNodeDragProvider> <TreeNodeDragProvider>
<div className="flex flex-col"> <div className="flex flex-col">
@@ -1431,7 +1444,6 @@ export interface SitePanelProps {
} }
export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) { export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) {
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 updateNode = useScene((state) => state.updateNode)
const selectedBuildingId = useViewer((state) => state.selection.buildingId) const selectedBuildingId = useViewer((state) => state.selection.buildingId)
@@ -1442,13 +1454,20 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
const [siteCameraOpen, setSiteCameraOpen] = useState(false) const [siteCameraOpen, setSiteCameraOpen] = useState(false)
const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null) const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null)
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null const siteNode = useScene((s) =>
const buildings = (siteNode?.type === 'site' ? siteNode.children : []) rootNodeIds[0] ? ((s.nodes[rootNodeIds[0]] as SiteNode | undefined) ?? null) : null,
)
const buildings = useScene(
useShallow((s) => {
if (!siteNode) return []
return siteNode.children
.map((child) => { .map((child) => {
const id = typeof child === 'string' ? child : child.id const id = typeof child === 'string' ? child : child.id
return nodes[id] as BuildingNode | undefined return s.nodes[id] as BuildingNode | undefined
}) })
.filter((node): node is BuildingNode => node?.type === 'building') .filter((node): node is BuildingNode => node?.type === 'building')
}),
)
return ( return (
<LayoutGroup> <LayoutGroup>
@@ -1,10 +1,10 @@
import { type AnyNode, useScene } from '@pascal-app/core' import { type AnyNodeId, useScene } from '@pascal-app/core'
import { Pencil } from 'lucide-react' import { Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { cn } from './../../../../../lib/utils' import { cn } from './../../../../../lib/utils'
interface InlineRenameInputProps { interface InlineRenameInputProps {
node: AnyNode nodeId: AnyNodeId
isEditing: boolean isEditing: boolean
onStopEditing: () => void onStopEditing: () => void
defaultName: string defaultName: string
@@ -12,8 +12,8 @@ interface InlineRenameInputProps {
onStartEditing?: () => void onStartEditing?: () => void
} }
export function InlineRenameInput({ export const InlineRenameInput = memo(function InlineRenameInput({
node, nodeId,
isEditing, isEditing,
onStopEditing, onStopEditing,
defaultName, defaultName,
@@ -21,13 +21,14 @@ export function InlineRenameInput({
onStartEditing, onStartEditing,
}: InlineRenameInputProps) { }: InlineRenameInputProps) {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const [value, setValue] = useState(node.name || '') const name = useScene((s) => s.nodes[nodeId]?.name)
const [value, setValue] = useState(name || '')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const inputSize = Math.max((value || defaultName).length, 1) const inputSize = Math.max((value || defaultName).length, 1)
useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
setValue(node.name || '') setValue(name || '')
// Focus and select all text after a short delay // Focus and select all text after a short delay
setTimeout(() => { setTimeout(() => {
if (inputRef.current) { if (inputRef.current) {
@@ -36,15 +37,15 @@ export function InlineRenameInput({
} }
}, 0) }, 0)
} }
}, [isEditing, node.name]) }, [isEditing, name])
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
const trimmed = value.trim() const trimmed = value.trim()
if (trimmed !== node.name) { if (trimmed !== name) {
updateNode(node.id, { name: trimmed || undefined }) updateNode(nodeId, { name: trimmed || undefined })
} }
onStopEditing() onStopEditing()
}, [value, node.id, node.name, updateNode, onStopEditing]) }, [value, nodeId, name, updateNode, onStopEditing])
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -60,7 +61,7 @@ export function InlineRenameInput({
return ( return (
<div className="group/rename flex h-5 min-w-0 items-center gap-1"> <div className="group/rename flex h-5 min-w-0 items-center gap-1">
<span className={cn('truncate border-transparent border-b', className)}> <span className={cn('truncate border-transparent border-b', className)}>
{node.name || defaultName} {name || defaultName}
</span> </span>
{onStartEditing && ( {onStartEditing && (
<button <button
@@ -95,4 +96,4 @@ export function InlineRenameInput({
value={value} value={value}
/> />
) )
} })
@@ -1,7 +1,8 @@
import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core' import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
@@ -18,67 +19,73 @@ const CATEGORY_ICONS: Record<string, string> = {
} }
interface ItemTreeNodeProps { interface ItemTreeNodeProps {
node: ItemNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) { export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(true) const [expanded, setExpanded] = useState(true)
const iconSrc = CATEGORY_ICONS[node.asset.category] || '/icons/couch.png' const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const children = useScene(
const isSelected = selectedIds.includes(node.id) useShallow((s) => (s.nodes[nodeId] as ItemNode | undefined)?.children ?? []),
const isHovered = useViewer((state) => state.hoveredId === node.id) )
const asset = useScene((s) => (s.nodes[nodeId] as ItemNode | undefined)?.asset)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
useEffect(() => { useEffect(() => {
return useViewer.subscribe((state) => {
const { selectedIds } = state.selection
if (selectedIds.length === 0) return if (selectedIds.length === 0) return
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
let isDescendant = false
for (const id of selectedIds) { for (const id of selectedIds) {
let current = nodes[id as AnyNodeId] let current = nodes[id as AnyNodeId]
while (current?.parentId) { while (current?.parentId) {
if (current.parentId === node.id) { if (current.parentId === nodeId) {
isDescendant = true setExpanded(true)
break return
} }
current = nodes[current.parentId as AnyNodeId] current = nodes[current.parentId as AnyNodeId]
} }
if (isDescendant) break
} }
if (isDescendant) { })
setExpanded(true) }, [nodeId])
}
}, [selectedIds, node.id])
const handleClick = (e: React.MouseEvent) => { const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'structure') { if (!handled && useEditor.getState().phase === 'structure') {
useEditor.getState().setPhase('furnish') useEditor.getState().setPhase('furnish')
} }
} },
[nodeId, setSelection],
)
const handleDoubleClick = () => { const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
focusTreeNode(node.id) const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
} const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const handleMouseEnter = () => { const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
setHoveredId(node.id) const defaultName = asset?.name || 'Item'
} const hasChildren = children.length > 0
const handleMouseLeave = () => {
setHoveredId(null)
}
const defaultName = node.asset.name || 'Item'
const hasChildren = node.children && node.children.length > 0
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={hasChildren} hasChildren={hasChildren}
@@ -86,28 +93,28 @@ export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onToggle={() => setExpanded(!expanded)} onToggle={handleToggle}
> >
{hasChildren && {hasChildren &&
node.children.map((childId, index) => ( children.map((childId, index) => (
<TreeNode <TreeNode
depth={depth + 1} depth={depth + 1}
isLast={index === node.children.length - 1} isLast={index === children.length - 1}
key={childId} key={childId}
nodeId={childId} nodeId={childId}
/> />
@@ -1,61 +1,66 @@
import type { LevelNode } from '@pascal-app/core' import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Layers } from 'lucide-react' import { Layers } from 'lucide-react'
import { useState } from 'react' import { useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface LevelTreeNodeProps { interface LevelTreeNodeProps {
node: LevelNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) { export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
const [expanded, setExpanded] = useState(true) const [expanded, setExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const isSelected = useViewer((state) => state.selection.levelId === node.id) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const isHovered = useViewer((state) => state.hoveredId === node.id) const children = useScene(
useShallow((s) => (s.nodes[nodeId] as LevelNode | undefined)?.children ?? []),
)
const level = useScene((s) => (s.nodes[nodeId] as LevelNode | undefined)?.level ?? 0)
const isSelected = useViewer((state) => state.selection.levelId === nodeId)
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const handleClick = () => { const handleClick = useCallback(() => setSelection({ levelId: nodeId }), [nodeId, setSelection])
setSelection({ levelId: node.id }) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
} const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const handleDoubleClick = () => { const defaultName = `Level ${level}`
focusTreeNode(node.id)
}
const defaultName = `Level ${node.level}`
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={node.children.length > 0} hasChildren={children.length > 0}
icon={<Layers className="h-3.5 w-3.5" />} icon={<Layers className="h-3.5 w-3.5" />}
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onToggle={() => setExpanded(!expanded)} onToggle={handleToggle}
> >
{node.children.map((childId, index) => ( {children.map((childId, index) => (
<TreeNode <TreeNode
depth={depth + 1} depth={depth + 1}
isLast={index === node.children.length - 1} isLast={index === children.length - 1}
key={childId} key={childId}
nodeId={childId} nodeId={childId}
/> />
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence } from 'motion/react' import { AnimatePresence } from 'motion/react'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '../../../../../store/use-editor' import useEditor from '../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -10,47 +11,58 @@ import { TreeNodeActions } from './tree-node-actions'
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag' import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
interface RoofTreeNodeProps { interface RoofTreeNodeProps {
node: RoofNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) { export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const isSelected = selectedIds.includes(node.id) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const nodes = useScene((state) => state.nodes)
const { drag, dropTarget } = useTreeNodeDrag() const { drag, dropTarget } = useTreeNodeDrag()
const handleClick = (e: React.MouseEvent) => { const segments = useScene(
useShallow((s) => {
const n = s.nodes[nodeId] as RoofNode | undefined
if (!n) return [] as RoofSegmentNode[]
return (n.children ?? [])
.map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
}),
)
// Targeted selector — only re-renders when a segment of THIS roof is selected/deselected
const hasSelectedChild = useViewer((state) =>
segments.some((seg) => state.selection.selectedIds.includes(seg.id)),
)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') { if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
} }
} },
[nodeId, setSelection],
)
const handleDoubleClick = () => { const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
focusTreeNode(node.id) const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
} const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleMouseEnter = () => { const handleStartEditing = useCallback(() => setIsEditing(true), [])
setHoveredId(node.id) const handleStopEditing = useCallback(() => setIsEditing(false), [])
}
const handleMouseLeave = () => {
setHoveredId(null)
}
const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
useEffect(() => { useEffect(() => {
if (isSelected || hasSelectedChild) { if (isSelected || hasSelectedChild) {
@@ -59,7 +71,7 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
}, [isSelected, hasSelectedChild]) }, [isSelected, hasSelectedChild])
// Auto-expand when a segment is being dragged over this roof // Auto-expand when a segment is being dragged over this roof
const isDropTarget = drag !== null && dropTarget?.parentId === node.id const isDropTarget = drag !== null && dropTarget?.parentId === nodeId
useEffect(() => { useEffect(() => {
if (isDropTarget && !expanded) { if (isDropTarget && !expanded) {
setExpanded(true) setExpanded(true)
@@ -72,12 +84,12 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
// Hide the dragged segment from every roof while dragging // Hide the dragged segment from every roof while dragging
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
const isValidDropTarget = drag !== null && drag.nodeId !== node.id const isValidDropTarget = drag !== null && drag.nodeId !== nodeId
return ( return (
<div data-drop-target={node.id}> <div data-drop-target={nodeId}>
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={segments.length > 0} hasChildren={segments.length > 0}
@@ -88,22 +100,22 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
isHovered={isHovered || isDropTarget} isHovered={isHovered || isDropTarget}
isLast={isLast && !expanded} isLast={isLast && !expanded}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onToggle={() => setExpanded(!expanded)} onToggle={handleToggle}
> >
{visibleSegments.map((seg, i) => { {visibleSegments.map((seg, i) => {
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
@@ -147,18 +159,20 @@ function RoofSegmentTreeNode({
isLast?: boolean isLast?: boolean
}) { }) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const { startDrag, isDragging } = useTreeNodeDrag() const { startDrag, isDragging } = useTreeNodeDrag()
const handleClick = (e: React.MouseEvent) => { const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isDragging) return if (isDragging) return
e.stopPropagation() e.stopPropagation()
handleTreeSelection(e, node.id, selectedIds, setSelection) handleTreeSelection(e, node.id, useViewer.getState().selection.selectedIds, setSelection)
} },
[node.id, isDragging, setSelection],
)
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
@@ -169,12 +183,15 @@ function RoofSegmentTreeNode({
[node.id, node.type, node.parentId, node.roofType, node.width, node.depth, startDrag], [node.id, node.type, node.parentId, node.roofType, node.width, node.depth, startDrag],
) )
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)` const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`
return ( return (
<div data-drop-child={node.id}> <div data-drop-child={node.id}>
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={node.id} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
@@ -196,9 +213,9 @@ function RoofSegmentTreeNode({
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={node.id}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={node.id}
@@ -1,53 +1,52 @@
import type { SlabNode } from '@pascal-app/core' import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { useState } from 'react' import { useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface SlabTreeNodeProps { interface SlabTreeNodeProps {
node: SlabNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) { export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const isSelected = selectedIds.includes(node.id) const polygon = useScene((s) => (s.nodes[nodeId] as SlabNode | undefined)?.polygon ?? [])
const isHovered = useViewer((state) => state.hoveredId === node.id) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const handleClick = (e: React.MouseEvent) => { const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') { if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
} }
} },
[nodeId, setSelection],
)
const handleDoubleClick = () => { const handleStartEditing = useCallback(() => setIsEditing(true), [])
focusTreeNode(node.id) const handleStopEditing = useCallback(() => setIsEditing(false), [])
}
const handleMouseEnter = () => { const area = calculatePolygonArea(polygon).toFixed(1)
setHoveredId(node.id)
}
const handleMouseLeave = () => {
setHoveredId(null)
}
// Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1)
const defaultName = `Slab (${area}m²)` const defaultName = `Slab (${area}m²)`
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
@@ -57,21 +56,21 @@ export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={handleMouseEnter} onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={handleMouseLeave} onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}} onToggle={() => {}}
/> />
) )
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence } from 'motion/react' import { AnimatePresence } from 'motion/react'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '../../../../../store/use-editor' import useEditor from '../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -10,47 +11,58 @@ import { TreeNodeActions } from './tree-node-actions'
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag' import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
interface StairTreeNodeProps { interface StairTreeNodeProps {
node: StairNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) { export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const isSelected = selectedIds.includes(node.id) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const nodes = useScene((state) => state.nodes)
const { drag, dropTarget } = useTreeNodeDrag() const { drag, dropTarget } = useTreeNodeDrag()
const handleClick = (e: React.MouseEvent) => { const segments = useScene(
useShallow((s) => {
const n = s.nodes[nodeId] as StairNode | undefined
if (!n) return [] as StairSegmentNode[]
return (n.children ?? [])
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
}),
)
// Targeted selector — only re-renders when a segment of THIS stair is selected/deselected
const hasSelectedChild = useViewer((state) =>
segments.some((seg) => state.selection.selectedIds.includes(seg.id)),
)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') { if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
} }
} },
[nodeId, setSelection],
)
const handleDoubleClick = () => { const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
focusTreeNode(node.id) const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
} const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleMouseEnter = () => { const handleStartEditing = useCallback(() => setIsEditing(true), [])
setHoveredId(node.id) const handleStopEditing = useCallback(() => setIsEditing(false), [])
}
const handleMouseLeave = () => {
setHoveredId(null)
}
const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
useEffect(() => { useEffect(() => {
if (isSelected || hasSelectedChild) { if (isSelected || hasSelectedChild) {
@@ -59,7 +71,7 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
}, [isSelected, hasSelectedChild]) }, [isSelected, hasSelectedChild])
// Auto-expand when a segment is being dragged over this stair // Auto-expand when a segment is being dragged over this stair
const isDropTarget = drag !== null && dropTarget?.parentId === node.id const isDropTarget = drag !== null && dropTarget?.parentId === nodeId
useEffect(() => { useEffect(() => {
if (isDropTarget && !expanded) { if (isDropTarget && !expanded) {
setExpanded(true) setExpanded(true)
@@ -72,12 +84,12 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
// Hide the dragged segment from every stair while dragging // Hide the dragged segment from every stair while dragging
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
const isValidDropTarget = drag !== null && drag.nodeId !== node.id const isValidDropTarget = drag !== null && drag.nodeId !== nodeId
return ( return (
<div data-drop-target={node.id}> <div data-drop-target={nodeId}>
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={segments.length > 0} hasChildren={segments.length > 0}
@@ -88,22 +100,22 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
isHovered={isHovered || isDropTarget} isHovered={isHovered || isDropTarget}
isLast={isLast && !expanded} isLast={isLast && !expanded}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onToggle={() => setExpanded(!expanded)} onToggle={handleToggle}
> >
{visibleSegments.map((seg, i) => { {visibleSegments.map((seg, i) => {
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
@@ -147,18 +159,20 @@ function StairSegmentTreeNode({
isLast?: boolean isLast?: boolean
}) { }) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const { startDrag, isDragging } = useTreeNodeDrag() const { startDrag, isDragging } = useTreeNodeDrag()
const handleClick = (e: React.MouseEvent) => { const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isDragging) return if (isDragging) return
e.stopPropagation() e.stopPropagation()
handleTreeSelection(e, node.id, selectedIds, setSelection) handleTreeSelection(e, node.id, useViewer.getState().selection.selectedIds, setSelection)
} },
[node.id, isDragging, setSelection],
)
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
@@ -170,13 +184,16 @@ function StairSegmentTreeNode({
[node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag], [node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],
) )
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing' const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)` const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
return ( return (
<div data-drop-child={node.id}> <div data-drop-child={node.id}>
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={node.id} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
@@ -198,9 +215,9 @@ function StairSegmentTreeNode({
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={node.id}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={node.id}
@@ -1,4 +1,4 @@
import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react' import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
@@ -9,21 +9,21 @@ import {
} from './../../../../../components/ui/primitives/popover' } from './../../../../../components/ui/primitives/popover'
interface TreeNodeActionsProps { interface TreeNodeActionsProps {
node: AnyNode nodeId: AnyNodeId
} }
export function TreeNodeActions({ node }: TreeNodeActionsProps) { export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const updateNodes = useScene((state) => state.updateNodes) const updateNodes = useScene((state) => state.updateNodes)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const hasCamera = !!node.camera const hasCamera = useScene((s) => !!(s.nodes[nodeId] as any)?.camera)
const isVisible = node.visible !== false
const toggleVisibility = (e: React.MouseEvent) => { const toggleVisibility = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const newVisibility = !isVisible const newVisibility = !isVisible
if (selectedIds?.includes(node.id)) { const selectedIds = useViewer.getState().selection.selectedIds
if (selectedIds?.includes(nodeId)) {
updateNodes( updateNodes(
selectedIds.map((id) => ({ selectedIds.map((id) => ({
id: id as AnyNodeId, id: id as AnyNodeId,
@@ -31,24 +31,24 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
})), })),
) )
} else { } else {
updateNode(node.id, { visible: newVisibility }) updateNode(nodeId, { visible: newVisibility })
} }
} }
const handleCaptureCamera = (e: React.MouseEvent) => { const handleCaptureCamera = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
emitter.emit('camera-controls:capture', { nodeId: node.id }) emitter.emit('camera-controls:capture', { nodeId })
setOpen(false) setOpen(false)
} }
const handleViewCamera = (e: React.MouseEvent) => { const handleViewCamera = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
emitter.emit('camera-controls:view', { nodeId: node.id }) emitter.emit('camera-controls:view', { nodeId })
setOpen(false) setOpen(false)
} }
const handleClearCamera = (e: React.MouseEvent) => { const handleClearCamera = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
updateNode(node.id, { camera: undefined }) updateNode(nodeId, { camera: undefined })
setOpen(false) setOpen(false)
} }
@@ -73,33 +73,33 @@ interface TreeNodeProps {
} }
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
const node = useScene((state) => state.nodes[nodeId]) const nodeType = useScene((state) => state.nodes[nodeId]?.type)
if (!node) return null if (!nodeType) return null
switch (node.type) { switch (nodeType) {
case 'building': case 'building':
return <BuildingTreeNode depth={depth} isLast={isLast} node={node as any} /> return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'ceiling': case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} node={node as any} /> return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level': case 'level':
return <LevelTreeNode depth={depth} isLast={isLast} node={node as any} /> return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'slab': case 'slab':
return <SlabTreeNode depth={depth} isLast={isLast} node={node as any} /> return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'wall': case 'wall':
return <WallTreeNode depth={depth} isLast={isLast} node={node as any} /> return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'roof': case 'roof':
return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} /> return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'stair': case 'stair':
return <StairTreeNode depth={depth} isLast={isLast} node={node as any} /> return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'item': case 'item':
return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} /> return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'door': case 'door':
return <DoorTreeNode depth={depth} isLast={isLast} node={node as any} /> return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'window': case 'window':
return <WindowTreeNode depth={depth} isLast={isLast} node={node as any} /> return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'zone': case 'zone':
return <ZoneTreeNode depth={depth} isLast={isLast} node={node as any} /> return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
default: default:
return null return null
} }
@@ -1,102 +1,106 @@
import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core' import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface WallTreeNodeProps { interface WallTreeNodeProps {
node: WallNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) { export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const isSelected = selectedIds.includes(node.id) const children = useScene(
const isHovered = useViewer((state) => state.hoveredId === node.id) useShallow((s) => (s.nodes[nodeId as AnyNodeId] as WallNode | undefined)?.children ?? []),
)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
useEffect(() => { useEffect(() => {
return useViewer.subscribe((state) => {
const { selectedIds } = state.selection
if (selectedIds.length === 0) return if (selectedIds.length === 0) return
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
let isDescendant = false
for (const id of selectedIds) { for (const id of selectedIds) {
let current = nodes[id as AnyNodeId] let current = nodes[id as AnyNodeId]
while (current?.parentId) { while (current?.parentId) {
if (current.parentId === node.id) { if (current.parentId === nodeId) {
isDescendant = true setExpanded(true)
break return
} }
current = nodes[current.parentId as AnyNodeId] current = nodes[current.parentId as AnyNodeId]
} }
if (isDescendant) break
} }
if (isDescendant) { })
setExpanded(true) }, [nodeId])
}
}, [selectedIds, node.id])
const handleClick = (e: React.MouseEvent) => { const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') { if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
} }
} },
[nodeId, setSelection],
)
const handleDoubleClick = () => { const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
focusTreeNode(node.id) const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
} const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleMouseEnter = () => { const handleStartEditing = useCallback(() => setIsEditing(true), [])
setHoveredId(node.id) const handleStopEditing = useCallback(() => setIsEditing(false), [])
}
const handleMouseLeave = () => {
setHoveredId(null)
}
const defaultName = 'Wall'
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
depth={depth} depth={depth}
expanded={expanded} expanded={expanded}
hasChildren={node.children.length > 0} hasChildren={children.length > 0}
icon={ icon={
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} /> <Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
} }
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName="Wall"
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId as AnyNodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onToggle={() => setExpanded(!expanded)} onToggle={handleToggle}
> >
{node.children.map((childId, index) => ( {children.map((childId, index) => (
<TreeNode <TreeNode
depth={depth + 1} depth={depth + 1}
isLast={index === node.children.length - 1} isLast={index === children.length - 1}
key={childId} key={childId}
nodeId={childId} nodeId={childId}
/> />
@@ -1,33 +1,50 @@
'use client' 'use client'
import type { WindowNode } from '@pascal-app/core' import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { useState } from 'react' import { useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface WindowTreeNodeProps { interface WindowTreeNodeProps {
node: WindowNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) { export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds) const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const isSelected = selectedIds.includes(node.id) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const defaultName = 'Window' const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
},
[nodeId, setSelection],
)
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
@@ -37,26 +54,20 @@ export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={node.visible !== false} isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName="Window"
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId as AnyNodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
nodeId={node.id} nodeId={nodeId}
onClick={(e: React.MouseEvent) => { onClick={handleClick}
e.stopPropagation() onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection) onMouseEnter={() => setHoveredId(nodeId)}
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
}}
onDoubleClick={() => focusTreeNode(node.id)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}} onToggle={() => {}}
/> />
@@ -1,62 +1,57 @@
import { useScene, type ZoneNode } from '@pascal-app/core' import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useState } from 'react' import { useCallback, useState } from 'react'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot' import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNodeWrapper } from './tree-node' import { focusTreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
interface ZoneTreeNodeProps { interface ZoneTreeNodeProps {
node: ZoneNode nodeId: AnyNodeId
depth: number depth: number
isLast?: boolean isLast?: boolean
} }
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) { export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const isSelected = useViewer((state) => state.selection.zoneId === node.id) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const isHovered = useViewer((state) => state.hoveredId === node.id) const color = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.color)
const polygon = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.polygon ?? [])
const isSelected = useViewer((state) => state.selection.zoneId === nodeId)
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId) const setHoveredId = useViewer((state) => state.setHoveredId)
const handleClick = () => { const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
setSelection({ zoneId: node.id }) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
} const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleDoubleClick = () => { const handleStartEditing = useCallback(() => setIsEditing(true), [])
focusTreeNode(node.id) const handleStopEditing = useCallback(() => setIsEditing(false), [])
}
const handleMouseEnter = () => {
setHoveredId(node.id)
}
const handleMouseLeave = () => {
setHoveredId(null)
}
// Calculate approximate area from polygon // Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1) const area = calculatePolygonArea(polygon).toFixed(1)
const defaultName = `Zone (${area}m²)` const defaultName = `Zone (${area}m²)`
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
icon={<ColorDot color={node.color} onChange={(color) => updateNode(node.id, { color })} />} icon={<ColorDot color={color} onChange={(c) => updateNode(nodeId, { color: c })} />}
isHovered={isHovered} isHovered={isHovered}
isLast={isLast} isLast={isLast}
isSelected={isSelected} isSelected={isSelected}
isVisible={isVisible}
label={ label={
<InlineRenameInput <InlineRenameInput
defaultName={defaultName} defaultName={defaultName}
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={nodeId}
onStartEditing={() => setIsEditing(true)} onStartEditing={handleStartEditing}
onStopEditing={() => setIsEditing(false)} onStopEditing={handleStopEditing}
/> />
} }
onClick={handleClick} onClick={handleClick}
+3 -6
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene' import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
const AUTOSAVE_DEBOUNCE_MS = 1000 const AUTOSAVE_DEBOUNCE_MS = 1000
@@ -26,9 +26,7 @@ export function useAutoSave({
onDirty, onDirty,
onSaveStatusChange, onSaveStatusChange,
isVersionPreviewMode = false, isVersionPreviewMode = false,
}: UseAutoSaveOptions): { saveStatus: SaveStatus; isLoadingSceneRef: MutableRefObject<boolean> } { }: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject<boolean> } {
const [saveStatus, _setSaveStatus] = useState<SaveStatus>('idle')
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined) const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const isSavingRef = useRef(false) const isSavingRef = useRef(false)
const isLoadingSceneRef = useRef(false) const isLoadingSceneRef = useRef(false)
@@ -56,7 +54,6 @@ export function useAutoSave({
}, [isVersionPreviewMode]) }, [isVersionPreviewMode])
const setSaveStatus = useCallback((status: SaveStatus) => { const setSaveStatus = useCallback((status: SaveStatus) => {
_setSaveStatus(status)
onSaveStatusChangeRef.current?.(status) onSaveStatusChangeRef.current?.(status)
}, []) }, [])
@@ -190,5 +187,5 @@ export function useAutoSave({
setSaveStatus('saved') setSaveStatus('saved')
}, [isVersionPreviewMode, setSaveStatus]) }, [isVersionPreviewMode, setSaveStatus])
return { saveStatus, isLoadingSceneRef } return { isLoadingSceneRef }
} }
@@ -84,6 +84,10 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements') useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().setMode('delete')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
@@ -9,6 +9,7 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
useRegistry(node.id, node.type, ref) useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, 'building') const handlers = useNodeEvents(node, 'building')
return ( return (
<group <group
position={node.position} position={node.position}
@@ -100,7 +100,6 @@ const Viewer: React.FC<ViewerProps> = ({
perf = false, perf = false,
}) => { }) => {
const theme = useViewer((state) => state.theme) const theme = useViewer((state) => state.theme)
return ( return (
<Canvas <Canvas
camera={{ position: [50, 50, 50], fov: 50 }} camera={{ position: [50, 50, 50], fov: 50 }}
+1
View File
@@ -1,4 +1,5 @@
export { default as Viewer } from './components/viewer' export { default as Viewer } from './components/viewer'
export { SSGI_PARAMS } from './components/viewer/post-processing'
export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { WalkthroughControls } from './components/viewer/walkthrough-controls'
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
@@ -1,4 +1,4 @@
import { sceneRegistry } from '@pascal-app/core' import { emitter, sceneRegistry } from '@pascal-app/core'
import { useEffect } from 'react' import { useEffect } from 'react'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
@@ -14,5 +14,30 @@ export const GuideSystem = () => {
} }
}) })
}, [showGuides]) }, [showGuides])
useEffect(() => {
const hideForCapture = () => {
const guides = sceneRegistry.byType.guide || new Set()
guides.forEach((guideId) => {
const node = sceneRegistry.nodes.get(guideId)
if (node) node.visible = false
})
}
const restoreAfterCapture = () => {
const showGuidesNow = useViewer.getState().showGuides
const guides = sceneRegistry.byType.guide || new Set()
guides.forEach((guideId) => {
const node = sceneRegistry.nodes.get(guideId)
if (node) node.visible = showGuidesNow
})
}
emitter.on('thumbnail:before-capture', hideForCapture)
emitter.on('thumbnail:after-capture', restoreAfterCapture)
return () => {
emitter.off('thumbnail:before-capture', hideForCapture)
emitter.off('thumbnail:after-capture', restoreAfterCapture)
}
}, [])
return null return null
} }
@@ -1,4 +1,4 @@
import { sceneRegistry } from '@pascal-app/core' import { emitter, sceneRegistry } from '@pascal-app/core'
import { useEffect } from 'react' import { useEffect } from 'react'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
@@ -14,5 +14,30 @@ export const ScanSystem = () => {
} }
}) })
}, [showScans]) }, [showScans])
useEffect(() => {
const hideForCapture = () => {
const scans = sceneRegistry.byType.scan || new Set()
scans.forEach((scanId) => {
const node = sceneRegistry.nodes.get(scanId)
if (node) node.visible = false
})
}
const restoreAfterCapture = () => {
const showScansNow = useViewer.getState().showScans
const scans = sceneRegistry.byType.scan || new Set()
scans.forEach((scanId) => {
const node = sceneRegistry.nodes.get(scanId)
if (node) node.visible = showScansNow
})
}
emitter.on('thumbnail:before-capture', hideForCapture)
emitter.on('thumbnail:after-capture', restoreAfterCapture)
return () => {
emitter.off('thumbnail:before-capture', hideForCapture)
emitter.off('thumbnail:after-capture', restoreAfterCapture)
}
}, [])
return null return null
} }
@@ -1,12 +1,14 @@
import { import {
type AnyNodeId, type AnyNodeId,
baseMaterial, baseMaterial,
emitter,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useRef } from 'react' import { useEffect, useRef } from 'react'
import type { Material } from 'three'
import { Color } from 'three' import { Color } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
@@ -273,5 +275,41 @@ export const WallCutout = () => {
lastHighlightKey.current = highlightKey lastHighlightKey.current = highlightKey
} }
}) })
useEffect(() => {
const snapshot = new Map<Mesh, Material>()
const restoreForCapture = () => {
sceneRegistry.byType.wall.forEach((wallId) => {
const wallMesh = sceneRegistry.nodes.get(wallId) as Mesh | undefined
if (!wallMesh) return
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode || wallNode.type !== 'wall') return
const mats = getMaterialsForWall(wallNode)
const current = wallMesh.material as Material
snapshot.set(wallMesh, current)
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
wallMesh.material = mats.visible
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
wallMesh.material = mats.invisible
}
})
}
const reapplyAfterCapture = () => {
snapshot.forEach((mat, mesh) => {
mesh.material = mat
})
snapshot.clear()
}
emitter.on('thumbnail:before-capture', restoreForCapture)
emitter.on('thumbnail:after-capture', reapplyAfterCapture)
return () => {
emitter.off('thumbnail:before-capture', restoreForCapture)
emitter.off('thumbnail:after-capture', reapplyAfterCapture)
}
}, [])
return null return null
} }