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