@@ -8,14 +8,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
type ReactNode,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
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 { EditorLayoutV2 } from './editor-layout-v2'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
||||
import { FloorplanPanel } from './floorplan-panel'
|
||||
@@ -61,9 +55,8 @@ import { Grid } from './grid'
|
||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
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 { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||
|
||||
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
||||
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||
@@ -123,7 +116,7 @@ export interface EditorProps {
|
||||
isLoading?: boolean
|
||||
|
||||
// Thumbnail
|
||||
onThumbnailCapture?: (blob: Blob) => void
|
||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||
|
||||
// Version preview overlays (rendered by host app)
|
||||
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({
|
||||
layoutVersion = 'v1',
|
||||
appMenuButton,
|
||||
@@ -543,51 +740,11 @@ export default function Editor({
|
||||
|
||||
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
||||
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
||||
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
|
||||
null,
|
||||
)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
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 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(() => {
|
||||
const teardown = initializeEditorRuntime()
|
||||
@@ -661,39 +818,7 @@ export default function Editor({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
|
||||
}, [])
|
||||
|
||||
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 = (
|
||||
<Viewer selectionManager="default">
|
||||
@@ -709,86 +834,15 @@ export default function Editor({
|
||||
</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 = (
|
||||
<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"
|
||||
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}
|
||||
<ViewerCanvas
|
||||
hasLoadedInitialScene={hasLoadedInitialScene}
|
||||
isFirstPersonMode={isFirstPersonMode}
|
||||
isLoading={isLoading}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
showLoader={showLoader}
|
||||
/>
|
||||
) : null}
|
||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
|
||||
</div>
|
||||
</div>
|
||||
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// ── V2 layout ──
|
||||
@@ -858,9 +912,7 @@ export default function Editor({
|
||||
{/* First-person overlay — rendered on top of normal layout */}
|
||||
{isFirstPersonMode && (
|
||||
<div className="fixed inset-0 z-50 pointer-events-none">
|
||||
<FirstPersonOverlay
|
||||
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
||||
/>
|
||||
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||
</div>
|
||||
)}
|
||||
<EditorCommands />
|
||||
@@ -906,9 +958,7 @@ export default function Editor({
|
||||
</SidebarSlot>
|
||||
|
||||
{/* Viewer area */}
|
||||
<div className="relative flex-1 overflow-hidden rounded-xl" ref={viewerAreaRef}>
|
||||
{viewerCanvas}
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden rounded-xl">{viewerCanvas}</div>
|
||||
|
||||
{/* Fixed UI overlays scoped to the viewer area */}
|
||||
<ViewerOverlays left={overlayLeft}>
|
||||
|
||||
@@ -142,7 +142,9 @@ function createHighlightedMaterials(
|
||||
|
||||
function disposeHighlightedMaterials(material: Material | Material[]) {
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((entry) => entry.dispose())
|
||||
material.forEach((entry) => {
|
||||
entry.dispose()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -825,6 +827,31 @@ const SelectionMaterialSync = () => {
|
||||
})
|
||||
}, [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(() => {
|
||||
return () => {
|
||||
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { snapLevelsToTruePositions } from '@pascal-app/viewer'
|
||||
import { emitter, useScene } from '@pascal-app/core'
|
||||
import { SSGI_PARAMS, snapLevelsToTruePositions } from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
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'
|
||||
|
||||
const THUMBNAIL_WIDTH = 1920
|
||||
@@ -18,15 +36,107 @@ interface ThumbnailGeneratorProps {
|
||||
export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
|
||||
const gl = useThree((state) => state.gl)
|
||||
const scene = useThree((state) => state.scene)
|
||||
const mainCamera = useThree((state) => state.camera)
|
||||
const isGenerating = useRef(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingAutoRef = useRef(false)
|
||||
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
|
||||
|
||||
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
|
||||
const pipelineRef = useRef<RenderPipeline | null>(null)
|
||||
const renderTargetRef = useRef<RenderTarget | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
onThumbnailCaptureRef.current = 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 () => {
|
||||
if (isGenerating.current) return
|
||||
if (!onThumbnailCaptureRef.current) return
|
||||
@@ -34,50 +144,109 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
isGenerating.current = true
|
||||
|
||||
try {
|
||||
const thumbnailCamera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,
|
||||
0.1,
|
||||
1000,
|
||||
)
|
||||
const thumbnailCamera = thumbnailCameraRef.current
|
||||
if (!thumbnailCamera) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const siteNode = Object.values(nodes).find((n) => n.type === 'site')
|
||||
|
||||
if (siteNode?.camera) {
|
||||
const { position, target } = siteNode.camera
|
||||
thumbnailCamera.position.set(position[0], position[1], position[2])
|
||||
thumbnailCamera.lookAt(target[0], target[1], target[2])
|
||||
} else {
|
||||
thumbnailCamera.position.set(8, 8, 8)
|
||||
thumbnailCamera.lookAt(0, 0, 0)
|
||||
// Copy the main camera's transform and projection so the thumbnail
|
||||
// matches exactly what the user sees in the viewport.
|
||||
thumbnailCamera.position.copy(mainCamera.position)
|
||||
thumbnailCamera.quaternion.copy(mainCamera.quaternion)
|
||||
if (mainCamera instanceof THREE.PerspectiveCamera) {
|
||||
thumbnailCamera.fov = mainCamera.fov
|
||||
thumbnailCamera.near = mainCamera.near
|
||||
thumbnailCamera.far = mainCamera.far
|
||||
}
|
||||
thumbnailCamera.layers.disable(EDITOR_LAYER)
|
||||
|
||||
const { width, height } = gl.domElement
|
||||
thumbnailCamera.aspect = width / height
|
||||
thumbnailCamera.updateProjectionMatrix()
|
||||
|
||||
const restoreLevels = snapLevelsToTruePositions()
|
||||
|
||||
const visibilitySnapshot = new Map<string, boolean>()
|
||||
for (const type of ['scan', 'guide'] as const) {
|
||||
sceneRegistry.byType[type].forEach((id) => {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj) {
|
||||
visibilitySnapshot.set(id, obj.visible)
|
||||
obj.visible = false
|
||||
}
|
||||
})
|
||||
let blob: Blob
|
||||
|
||||
if (pipelineRef.current && renderTargetRef.current) {
|
||||
const rt = renderTargetRef.current
|
||||
|
||||
// Resize RT if the canvas dimensions changed
|
||||
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()
|
||||
visibilitySnapshot.forEach((wasVisible, id) => {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj) obj.visible = wasVisible
|
||||
})
|
||||
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
@@ -97,21 +266,33 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
offscreen.width = THUMBNAIL_WIDTH
|
||||
offscreen.height = THUMBNAIL_HEIGHT
|
||||
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) => {
|
||||
if (blob) {
|
||||
onThumbnailCaptureRef.current?.(blob)
|
||||
} else {
|
||||
console.error('❌ Failed to create blob from canvas')
|
||||
blob = await new Promise<Blob>((resolve, reject) =>
|
||||
offscreen.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
||||
'image/png',
|
||||
),
|
||||
)
|
||||
}
|
||||
isGenerating.current = false
|
||||
}, 'image/png')
|
||||
|
||||
onThumbnailCaptureRef.current?.(blob)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate thumbnail:', error)
|
||||
} finally {
|
||||
isGenerating.current = false
|
||||
}
|
||||
}, [gl, scene])
|
||||
}, [gl, scene, mainCamera])
|
||||
|
||||
// Manual trigger via emitter
|
||||
useEffect(() => {
|
||||
|
||||
@@ -143,6 +143,8 @@ export const DoorTool: React.FC = () => {
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
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, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
@@ -150,6 +152,16 @@ export const DoorTool: React.FC = () => {
|
||||
parentId: 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(
|
||||
|
||||
@@ -165,6 +165,8 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
// Wall changed mid-move: must updateNode to reparent
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
@@ -172,10 +174,17 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
markWallDirty(currentWallId)
|
||||
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)
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
// Wall changed mid-move: must updateNode to reparent
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
@@ -192,10 +194,17 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
markWallDirty(currentWallId)
|
||||
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)
|
||||
|
||||
|
||||
@@ -151,6 +151,8 @@ export const WindowTool: React.FC = () => {
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
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, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
@@ -158,6 +160,16 @@ export const WindowTool: React.FC = () => {
|
||||
parentId: 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(
|
||||
|
||||
+22
-15
@@ -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 { Building2, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -11,37 +12,42 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface BuildingTreeNodeProps {
|
||||
node: BuildingNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) {
|
||||
export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const createNode = useScene((state) => state.createNode)
|
||||
const isSelected = useViewer((state) => state.selection.buildingId === node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
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 handleClick = () => {
|
||||
setSelection({ buildingId: node.id })
|
||||
setSelection({ buildingId: nodeId })
|
||||
}
|
||||
|
||||
const handleAddLevel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const newLevel = LevelNode.parse({
|
||||
level: node.children.length,
|
||||
level: children.length,
|
||||
children: [],
|
||||
parentId: node.id,
|
||||
parentId: nodeId,
|
||||
})
|
||||
createNode(newLevel, node.id)
|
||||
createNode(newLevel, nodeId)
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={
|
||||
<div className="flex items-center gap-0.5">
|
||||
<TreeNodeActions node={node} />
|
||||
<TreeNodeActions nodeId={nodeId} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -57,20 +63,21 @@ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps)
|
||||
}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
hasChildren={children.length > 0}
|
||||
icon={<Building2 className="h-3.5 w-3.5" />}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
label={node.name || 'Building'}
|
||||
isVisible={isVisible}
|
||||
label={name || 'Building'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
{children.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
isLast={index === children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
|
||||
@@ -1,104 +1,112 @@
|
||||
import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
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 { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface CeilingTreeNodeProps {
|
||||
node: CeilingNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
|
||||
export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
const children = useScene(
|
||||
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 setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
||||
useEffect(() => {
|
||||
return useViewer.subscribe((state) => {
|
||||
const { selectedIds } = state.selection
|
||||
if (selectedIds.length === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true
|
||||
break
|
||||
if (current.parentId === nodeId) {
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId]
|
||||
}
|
||||
if (isDescendant) break
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [selectedIds, node.id])
|
||||
})
|
||||
}, [nodeId])
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
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') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
|
||||
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 = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
||||
const area = calculatePolygonArea(polygon).toFixed(1)
|
||||
const defaultName = `Ceiling (${area}m²)`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
hasChildren={children.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId as AnyNodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
{children.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
isLast={index === children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
|
||||
@@ -1,33 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import type { DoorNode } from '@pascal-app/core'
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface DoorTreeNodeProps {
|
||||
node: DoorNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
|
||||
export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
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 (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
@@ -37,26 +54,20 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
defaultName="Door"
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId as AnyNodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
|
||||
onMouseEnter={() => setHoveredId(nodeId)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||
import {
|
||||
Popover,
|
||||
@@ -393,8 +394,15 @@ function LevelReferences({
|
||||
onUploadAsset,
|
||||
onDeleteAsset,
|
||||
}: LevelReferencesProps) {
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
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 uploadState = useUploadStore((s) => s.uploads[levelId])
|
||||
const clearUpload = useUploadStore((s) => s.clearUpload)
|
||||
@@ -409,11 +417,6 @@ function LevelReferences({
|
||||
|
||||
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 file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
@@ -457,7 +460,10 @@ function LevelReferences({
|
||||
|
||||
const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
|
||||
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 (
|
||||
projectId &&
|
||||
@@ -789,21 +795,28 @@ function LevelsSection({
|
||||
onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
|
||||
onDeleteAsset?: (projectId: string, url: string) => void
|
||||
} = {}) {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const createNode = useScene((state) => state.createNode)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const selectedBuildingId = useViewer((state) => state.selection.buildingId)
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
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
|
||||
|
||||
const levels = building.children
|
||||
.map((id) => nodes[id])
|
||||
.filter((node): node is LevelNode => node?.type === 'level')
|
||||
|
||||
const handleAddLevel = () => {
|
||||
const newLevel = LevelNode.parse({
|
||||
level: levels.length,
|
||||
@@ -1175,7 +1188,6 @@ function MultiSelectionBadge() {
|
||||
}
|
||||
|
||||
function ContentSection() {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
@@ -1183,7 +1195,25 @@ function ContentSection() {
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
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) {
|
||||
return (
|
||||
@@ -1192,11 +1222,6 @@ function ContentSection() {
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
setPhase('structure')
|
||||
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) {
|
||||
return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeDragProvider>
|
||||
<div className="flex flex-col">
|
||||
@@ -1431,7 +1444,6 @@ export interface SitePanelProps {
|
||||
}
|
||||
|
||||
export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const selectedBuildingId = useViewer((state) => state.selection.buildingId)
|
||||
@@ -1442,13 +1454,20 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
||||
const [siteCameraOpen, setSiteCameraOpen] = useState(false)
|
||||
const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null)
|
||||
|
||||
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
|
||||
const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
|
||||
const siteNode = useScene((s) =>
|
||||
rootNodeIds[0] ? ((s.nodes[rootNodeIds[0]] as SiteNode | undefined) ?? null) : null,
|
||||
)
|
||||
const buildings = useScene(
|
||||
useShallow((s) => {
|
||||
if (!siteNode) return []
|
||||
return siteNode.children
|
||||
.map((child) => {
|
||||
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')
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
|
||||
+14
-13
@@ -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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
|
||||
interface InlineRenameInputProps {
|
||||
node: AnyNode
|
||||
nodeId: AnyNodeId
|
||||
isEditing: boolean
|
||||
onStopEditing: () => void
|
||||
defaultName: string
|
||||
@@ -12,8 +12,8 @@ interface InlineRenameInputProps {
|
||||
onStartEditing?: () => void
|
||||
}
|
||||
|
||||
export function InlineRenameInput({
|
||||
node,
|
||||
export const InlineRenameInput = memo(function InlineRenameInput({
|
||||
nodeId,
|
||||
isEditing,
|
||||
onStopEditing,
|
||||
defaultName,
|
||||
@@ -21,13 +21,14 @@ export function InlineRenameInput({
|
||||
onStartEditing,
|
||||
}: InlineRenameInputProps) {
|
||||
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 inputSize = Math.max((value || defaultName).length, 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setValue(node.name || '')
|
||||
setValue(name || '')
|
||||
// Focus and select all text after a short delay
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
@@ -36,15 +37,15 @@ export function InlineRenameInput({
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}, [isEditing, node.name])
|
||||
}, [isEditing, name])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed !== node.name) {
|
||||
updateNode(node.id, { name: trimmed || undefined })
|
||||
if (trimmed !== name) {
|
||||
updateNode(nodeId, { name: trimmed || undefined })
|
||||
}
|
||||
onStopEditing()
|
||||
}, [value, node.id, node.name, updateNode, onStopEditing])
|
||||
}, [value, nodeId, name, updateNode, onStopEditing])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -60,7 +61,7 @@ export function InlineRenameInput({
|
||||
return (
|
||||
<div className="group/rename flex h-5 min-w-0 items-center gap-1">
|
||||
<span className={cn('truncate border-transparent border-b', className)}>
|
||||
{node.name || defaultName}
|
||||
{name || defaultName}
|
||||
</span>
|
||||
{onStartEditing && (
|
||||
<button
|
||||
@@ -95,4 +96,4 @@ export function InlineRenameInput({
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
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 { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
@@ -18,67 +19,73 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
}
|
||||
|
||||
interface ItemTreeNodeProps {
|
||||
node: ItemNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
||||
export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || '/icons/couch.png'
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const children = useScene(
|
||||
useShallow((s) => (s.nodes[nodeId] as ItemNode | undefined)?.children ?? []),
|
||||
)
|
||||
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 setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
||||
useEffect(() => {
|
||||
return useViewer.subscribe((state) => {
|
||||
const { selectedIds } = state.selection
|
||||
if (selectedIds.length === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true
|
||||
break
|
||||
if (current.parentId === nodeId) {
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId]
|
||||
}
|
||||
if (isDescendant) break
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [selectedIds, node.id])
|
||||
})
|
||||
}, [nodeId])
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
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') {
|
||||
useEditor.getState().setPhase('furnish')
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||
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 = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
const defaultName = node.asset.name || 'Item'
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
|
||||
const defaultName = asset?.name || 'Item'
|
||||
const hasChildren = children.length > 0
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={hasChildren}
|
||||
@@ -86,28 +93,28 @@ export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{hasChildren &&
|
||||
node.children.map((childId, index) => (
|
||||
children.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
isLast={index === children.length - 1}
|
||||
key={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 { 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 { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface LevelTreeNodeProps {
|
||||
node: LevelNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {
|
||||
export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isSelected = useViewer((state) => state.selection.levelId === node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
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 handleClick = () => {
|
||||
setSelection({ levelId: node.id })
|
||||
}
|
||||
const handleClick = useCallback(() => setSelection({ levelId: nodeId }), [nodeId, setSelection])
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
|
||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const defaultName = `Level ${node.level}`
|
||||
const defaultName = `Level ${level}`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
hasChildren={children.length > 0}
|
||||
icon={<Layers className="h-3.5 w-3.5" />}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
{children.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
isLast={index === children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
@@ -10,47 +11,58 @@ import { TreeNodeActions } from './tree-node-actions'
|
||||
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
||||
|
||||
interface RoofTreeNodeProps {
|
||||
node: RoofNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
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()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
const handled = handleTreeSelection(
|
||||
e,
|
||||
nodeId,
|
||||
useViewer.getState().selection.selectedIds,
|
||||
setSelection,
|
||||
)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
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))
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||
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), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected || hasSelectedChild) {
|
||||
@@ -59,7 +71,7 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
}, [isSelected, hasSelectedChild])
|
||||
|
||||
// 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(() => {
|
||||
if (isDropTarget && !expanded) {
|
||||
setExpanded(true)
|
||||
@@ -72,12 +84,12 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
// Hide the dragged segment from every roof while dragging
|
||||
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 (
|
||||
<div data-drop-target={node.id}>
|
||||
<div data-drop-target={nodeId}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={segments.length > 0}
|
||||
@@ -88,22 +100,22 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
isHovered={isHovered || isDropTarget}
|
||||
isLast={isLast && !expanded}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{visibleSegments.map((seg, i) => {
|
||||
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
|
||||
@@ -147,18 +159,20 @@ function RoofSegmentTreeNode({
|
||||
isLast?: boolean
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const { startDrag, isDragging } = useTreeNodeDrag()
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isDragging) return
|
||||
e.stopPropagation()
|
||||
handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
}
|
||||
handleTreeSelection(e, node.id, useViewer.getState().selection.selectedIds, setSelection)
|
||||
},
|
||||
[node.id, isDragging, setSelection],
|
||||
)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
@@ -169,12 +183,15 @@ function RoofSegmentTreeNode({
|
||||
[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)`
|
||||
|
||||
return (
|
||||
<div data-drop-child={node.id}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={node.id} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
@@ -196,9 +213,9 @@ function RoofSegmentTreeNode({
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={node.id}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
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 Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface SlabTreeNodeProps {
|
||||
node: SlabNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
|
||||
export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const polygon = useScene((s) => (s.nodes[nodeId] as SlabNode | 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 setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
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') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
||||
const area = calculatePolygonArea(polygon).toFixed(1)
|
||||
const defaultName = `Slab (${area}m²)`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
@@ -57,21 +56,21 @@ export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||
onMouseEnter={() => setHoveredId(nodeId)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
@@ -10,47 +11,58 @@ import { TreeNodeActions } from './tree-node-actions'
|
||||
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
||||
|
||||
interface StairTreeNodeProps {
|
||||
node: StairNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
||||
export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
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()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
const handled = handleTreeSelection(
|
||||
e,
|
||||
nodeId,
|
||||
useViewer.getState().selection.selectedIds,
|
||||
setSelection,
|
||||
)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
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))
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||
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), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected || hasSelectedChild) {
|
||||
@@ -59,7 +71,7 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
||||
}, [isSelected, hasSelectedChild])
|
||||
|
||||
// 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(() => {
|
||||
if (isDropTarget && !expanded) {
|
||||
setExpanded(true)
|
||||
@@ -72,12 +84,12 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
||||
// Hide the dragged segment from every stair while dragging
|
||||
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 (
|
||||
<div data-drop-target={node.id}>
|
||||
<div data-drop-target={nodeId}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={segments.length > 0}
|
||||
@@ -88,22 +100,22 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
||||
isHovered={isHovered || isDropTarget}
|
||||
isLast={isLast && !expanded}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{visibleSegments.map((seg, i) => {
|
||||
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
|
||||
@@ -147,18 +159,20 @@ function StairSegmentTreeNode({
|
||||
isLast?: boolean
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const { startDrag, isDragging } = useTreeNodeDrag()
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isDragging) return
|
||||
e.stopPropagation()
|
||||
handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
}
|
||||
handleTreeSelection(e, node.id, useViewer.getState().selection.selectedIds, setSelection)
|
||||
},
|
||||
[node.id, isDragging, setSelection],
|
||||
)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
@@ -170,13 +184,16 @@ function StairSegmentTreeNode({
|
||||
[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 defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
|
||||
|
||||
return (
|
||||
<div data-drop-child={node.id}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={node.id} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
@@ -198,9 +215,9 @@ function StairSegmentTreeNode({
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={node.id}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
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 { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
@@ -9,21 +9,21 @@ import {
|
||||
} from './../../../../../components/ui/primitives/popover'
|
||||
|
||||
interface TreeNodeActionsProps {
|
||||
node: AnyNode
|
||||
nodeId: AnyNodeId
|
||||
}
|
||||
|
||||
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||
export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const updateNodes = useScene((state) => state.updateNodes)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const hasCamera = !!node.camera
|
||||
const isVisible = node.visible !== false
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const hasCamera = useScene((s) => !!(s.nodes[nodeId] as any)?.camera)
|
||||
|
||||
const toggleVisibility = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const newVisibility = !isVisible
|
||||
if (selectedIds?.includes(node.id)) {
|
||||
const selectedIds = useViewer.getState().selection.selectedIds
|
||||
if (selectedIds?.includes(nodeId)) {
|
||||
updateNodes(
|
||||
selectedIds.map((id) => ({
|
||||
id: id as AnyNodeId,
|
||||
@@ -31,24 +31,24 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
updateNode(node.id, { visible: newVisibility })
|
||||
updateNode(nodeId, { visible: newVisibility })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCaptureCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
emitter.emit('camera-controls:capture', { nodeId: node.id })
|
||||
emitter.emit('camera-controls:capture', { nodeId })
|
||||
setOpen(false)
|
||||
}
|
||||
const handleViewCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
emitter.emit('camera-controls:view', { nodeId: node.id })
|
||||
emitter.emit('camera-controls:view', { nodeId })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleClearCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
updateNode(node.id, { camera: undefined })
|
||||
updateNode(nodeId, { camera: undefined })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,33 +73,33 @@ interface 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':
|
||||
return <BuildingTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'ceiling':
|
||||
return <CeilingTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'level':
|
||||
return <LevelTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'slab':
|
||||
return <SlabTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'wall':
|
||||
return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'roof':
|
||||
return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'stair':
|
||||
return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'item':
|
||||
return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'door':
|
||||
return <DoorTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'window':
|
||||
return <WindowTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'zone':
|
||||
return <ZoneTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,102 +1,106 @@
|
||||
import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
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 { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface WallTreeNodeProps {
|
||||
node: WallNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
||||
export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
const children = useScene(
|
||||
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 setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
||||
useEffect(() => {
|
||||
return useViewer.subscribe((state) => {
|
||||
const { selectedIds } = state.selection
|
||||
if (selectedIds.length === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true
|
||||
break
|
||||
if (current.parentId === nodeId) {
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId]
|
||||
}
|
||||
if (isDescendant) break
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [selectedIds, node.id])
|
||||
})
|
||||
}, [nodeId])
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
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') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
const defaultName = 'Wall'
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
|
||||
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), [])
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
hasChildren={children.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
defaultName="Wall"
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId as AnyNodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
{children.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
isLast={index === children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
|
||||
@@ -1,33 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import type { WindowNode } from '@pascal-app/core'
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface WindowTreeNodeProps {
|
||||
node: WindowNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
|
||||
export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
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 (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
@@ -37,26 +54,20 @@ export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
defaultName="Window"
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId as AnyNodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
|
||||
onMouseEnter={() => setHoveredId(nodeId)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
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 { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface ZoneTreeNodeProps {
|
||||
node: ZoneNode
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
||||
export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const isSelected = useViewer((state) => state.selection.zoneId === node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
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 setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ zoneId: node.id })
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null)
|
||||
}
|
||||
const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
||||
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
||||
const area = calculatePolygonArea(polygon).toFixed(1)
|
||||
const defaultName = `Zone (${area}m²)`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={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}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={handleStartEditing}
|
||||
onStopEditing={handleStopEditing}
|
||||
/>
|
||||
}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
const AUTOSAVE_DEBOUNCE_MS = 1000
|
||||
@@ -26,9 +26,7 @@ export function useAutoSave({
|
||||
onDirty,
|
||||
onSaveStatusChange,
|
||||
isVersionPreviewMode = false,
|
||||
}: UseAutoSaveOptions): { saveStatus: SaveStatus; isLoadingSceneRef: MutableRefObject<boolean> } {
|
||||
const [saveStatus, _setSaveStatus] = useState<SaveStatus>('idle')
|
||||
|
||||
}: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject<boolean> } {
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const isSavingRef = useRef(false)
|
||||
const isLoadingSceneRef = useRef(false)
|
||||
@@ -56,7 +54,6 @@ export function useAutoSave({
|
||||
}, [isVersionPreviewMode])
|
||||
|
||||
const setSaveStatus = useCallback((status: SaveStatus) => {
|
||||
_setSaveStatus(status)
|
||||
onSaveStatusChangeRef.current?.(status)
|
||||
}, [])
|
||||
|
||||
@@ -190,5 +187,5 @@ export function useAutoSave({
|
||||
setSaveStatus('saved')
|
||||
}, [isVersionPreviewMode, setSaveStatus])
|
||||
|
||||
return { saveStatus, isLoadingSceneRef }
|
||||
return { isLoadingSceneRef }
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
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)) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
|
||||
@@ -9,6 +9,7 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
|
||||
|
||||
useRegistry(node.id, node.type, ref)
|
||||
const handlers = useNodeEvents(node, 'building')
|
||||
|
||||
return (
|
||||
<group
|
||||
position={node.position}
|
||||
|
||||
@@ -100,7 +100,6 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
perf = false,
|
||||
}) => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as Viewer } from './components/viewer'
|
||||
export { SSGI_PARAMS } from './components/viewer/post-processing'
|
||||
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
|
||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||
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 useViewer from '../../store/use-viewer'
|
||||
|
||||
@@ -14,5 +14,30 @@ export const GuideSystem = () => {
|
||||
}
|
||||
})
|
||||
}, [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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sceneRegistry } from '@pascal-app/core'
|
||||
import { emitter, sceneRegistry } from '@pascal-app/core'
|
||||
import { useEffect } from 'react'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
@@ -14,5 +14,30 @@ export const ScanSystem = () => {
|
||||
}
|
||||
})
|
||||
}, [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
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
baseMaterial,
|
||||
emitter,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
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 { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
||||
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
||||
@@ -273,5 +275,41 @@ export const WallCutout = () => {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user