diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 34dc070f..6c82681b 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -39,7 +39,6 @@ export function FloatingActionMenu() { const selectedIds = useViewer((s) => s.selection.selectedIds) const nodes = useScene((s) => s.nodes) const mode = useEditor((s) => s.mode) - const setMode = useEditor((s) => s.setMode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const setMovingNode = useEditor((s) => s.setMovingNode) const setSelection = useViewer((s) => s.setSelection) @@ -199,11 +198,11 @@ export function FloatingActionMenu() { const handleDelete = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - // Activate delete mode (sledgehammer tool) instead of deleting directly + if (!selectedId) return setSelection({ selectedIds: [] }) - setMode('delete') + useScene.getState().deleteNode(selectedId as AnyNodeId) }, - [setSelection, setMode], + [selectedId, setSelection], ) if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 910bd7f8..74fe0694 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -8,14 +8,7 @@ import { useScene, } from '@pascal-app/core' import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' -import { - type ReactNode, - type PointerEvent as ReactPointerEvent, - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' @@ -54,6 +47,7 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar' import { CustomCameraControls } from './custom-camera-controls' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' +import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls' import { FloatingActionMenu } from './floating-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloorplanPanel } from './floorplan-panel' @@ -61,9 +55,8 @@ import { Grid } from './grid' import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' -import { ThumbnailGenerator } from './thumbnail-generator' +import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { WallMeasurementLabel } from './wall-measurement-label' -import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const DELETE_CURSOR_BADGE_COLOR = '#ef4444' @@ -123,7 +116,7 @@ export interface EditorProps { isLoading?: boolean // Thumbnail - onThumbnailCapture?: (blob: Blob) => void + onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void // Version preview overlays (rendered by host app) sidebarOverlay?: ReactNode @@ -507,6 +500,210 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) ) } +// ── Viewer scene content: memoized so 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 && } + {!isVersionPreviewMode && !isFirstPersonMode && } + {!isVersionPreviewMode && !isFirstPersonMode && } + {!isVersionPreviewMode && !isFirstPersonMode && } + {!isFirstPersonMode && } + + {isFirstPersonMode ? : } + + + + {!isLoading && !isFirstPersonMode && ( + + )} + {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && } + {isFirstPersonMode && } + + + + {!isFirstPersonMode && } + {isFirstPersonMode && } + + ) +}) + +// ── 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 + 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 +} + +// ── 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( + null, + ) + + const viewerAreaRef = useRef(null) + const viewer3dRef = useRef(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 ( + }> +
+ {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */} +
+
+ +
+ {viewMode === 'split' && ( +
+
+
+ )} +
+ + {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */} +
+ + {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( + + ) : null} + + + + +
+
+ {!(isLoading || isVersionPreviewMode) && } + + ) +}) + export default function Editor({ layoutVersion = 'v1', appMenuButton, @@ -543,51 +740,11 @@ export default function Editor({ const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) - const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState( - null, - ) const isPreviewMode = useEditor((s) => s.isPreviewMode) - const mode = useEditor((s) => s.mode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) - const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) - const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) - const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio) - const [viewerCursorPosition, setViewerCursorPosition] = useState<{ x: number; y: number } | null>( - null, - ) const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) - const viewerAreaRef = useRef(null) - const isResizingFloorplan = useRef(false) - - const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault() - isResizingFloorplan.current = true - document.body.style.cursor = 'col-resize' - document.body.style.userSelect = 'none' - }, []) - - useEffect(() => { - const handlePointerMove = (e: PointerEvent) => { - if (!isResizingFloorplan.current) return - if (!viewerAreaRef.current) return - const rect = viewerAreaRef.current.getBoundingClientRect() - const newRatio = (e.clientX - rect.left) / rect.width - setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio))) - } - const handlePointerUp = () => { - isResizingFloorplan.current = false - document.body.style.cursor = '' - document.body.style.userSelect = '' - } - window.addEventListener('pointermove', handlePointerMove) - window.addEventListener('pointerup', handlePointerUp) - return () => { - window.removeEventListener('pointermove', handlePointerMove) - window.removeEventListener('pointerup', handlePointerUp) - } - }, []) useEffect(() => { const teardown = initializeEditorRuntime() @@ -661,39 +818,7 @@ export default function Editor({ } }, []) - useEffect(() => { - setIsCameraControlsHintVisible(!readCameraControlsHintDismissed()) - }, []) - const showLoader = isLoading || isSceneLoading - const dismissCameraControlsHint = useCallback(() => { - setIsCameraControlsHintVisible(false) - writeCameraControlsHintDismissed(true) - }, []) - - // ── Shared viewer scene content ── - const viewerSceneContent = ( - <> - {!isFirstPersonMode && } - {!isVersionPreviewMode && !isFirstPersonMode && } - {!isVersionPreviewMode && !isFirstPersonMode && } - {!isVersionPreviewMode && !isFirstPersonMode && } - {!isFirstPersonMode && } - - {isFirstPersonMode ? : } - - - - {!isLoading && !isFirstPersonMode && } - {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && } - {isFirstPersonMode && } - - - - {!isFirstPersonMode && } - {isFirstPersonMode && } - - ) const previewViewerContent = ( @@ -709,86 +834,15 @@ export default function Editor({ ) - // ── 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) => { - 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 = ( - }> -
- {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */} -
-
- -
- {viewMode === 'split' && ( -
-
-
- )} -
- - {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */} -
- {showDeleteCursorBadge && viewerCursorPosition ? ( - - ) : null} - {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( - - ) : null} - - {viewerSceneContent} -
-
- {!(isLoading || isVersionPreviewMode) && } - + ) // ── V2 layout ── @@ -858,9 +912,7 @@ export default function Editor({ {/* First-person overlay — rendered on top of normal layout */} {isFirstPersonMode && (
- useEditor.getState().setFirstPersonMode(false)} - /> + useEditor.getState().setFirstPersonMode(false)} />
)} @@ -906,9 +958,7 @@ export default function Editor({ {/* Viewer area */} -
- {viewerCanvas} -
+
{viewerCanvas}
{/* Fixed UI overlays scoped to the viewer area */} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 18dbcf35..04518047 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -142,7 +142,9 @@ function createHighlightedMaterials( function disposeHighlightedMaterials(material: Material | Material[]) { if (Array.isArray(material)) { - material.forEach((entry) => entry.dispose()) + material.forEach((entry) => { + entry.dispose() + }) return } @@ -825,6 +827,31 @@ const SelectionMaterialSync = () => { }) }, [syncSelectionMaterials]) + useEffect(() => { + const restoreForCapture = () => { + for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) { + if (mesh.material === entry.highlightedMaterial) { + mesh.material = entry.originalMaterial + } + } + } + + const reapplyAfterCapture = () => { + for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) { + if (mesh.material === entry.originalMaterial) { + mesh.material = entry.highlightedMaterial + } + } + } + + emitter.on('thumbnail:before-capture', restoreForCapture) + emitter.on('thumbnail:after-capture', reapplyAfterCapture) + return () => { + emitter.off('thumbnail:before-capture', restoreForCapture) + emitter.off('thumbnail:after-capture', reapplyAfterCapture) + } + }, []) + useEffect(() => { return () => { for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) { diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 0f8061ae..c734f248 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -1,10 +1,28 @@ 'use client' -import { emitter, sceneRegistry, useScene } from '@pascal-app/core' -import { snapLevelsToTruePositions } from '@pascal-app/viewer' +import { emitter, useScene } from '@pascal-app/core' +import { SSGI_PARAMS, snapLevelsToTruePositions } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' import * as THREE from 'three' +import { UnsignedByteType } from 'three' +import { ssgi } from 'three/addons/tsl/display/SSGINode.js' +import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' +import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js' +import { + colorToDirection, + convertToTexture, + diffuseColor, + directionToColor, + float, + mrt, + normalView, + output, + pass, + sample, + vec4, +} from 'three/tsl' +import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' const THUMBNAIL_WIDTH = 1920 @@ -18,15 +36,107 @@ interface ThumbnailGeneratorProps { export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => { const gl = useThree((state) => state.gl) const scene = useThree((state) => state.scene) + const mainCamera = useThree((state) => state.camera) const isGenerating = useRef(false) const debounceTimerRef = useRef | null>(null) const pendingAutoRef = useRef(false) const onThumbnailCaptureRef = useRef(onThumbnailCapture) + const thumbnailCameraRef = useRef(null) + const pipelineRef = useRef(null) + const renderTargetRef = useRef(null) + useEffect(() => { onThumbnailCaptureRef.current = onThumbnailCapture }, [onThumbnailCapture]) + // Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture. + useEffect(() => { + const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000) + cam.layers.disable(EDITOR_LAYER) + thumbnailCameraRef.current = cam + + let mounted = true + + const buildPipeline = async () => { + try { + if ((gl as any).init) await (gl as any).init() + if (!mounted) return + + // pass() handles MRT internally for all material types, including custom + // shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials. + // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered. + const scenePass = pass(scene, cam) + scenePass.setMRT( + mrt({ + output, + diffuseColor, + normal: directionToColor(normalView), + }), + ) + + const scenePassColor = scenePass.getTextureNode('output') + const scenePassDepth = scenePass.getTextureNode('depth') + const scenePassNormal = scenePass.getTextureNode('normal') + + scenePass.getTexture('diffuseColor').type = UnsignedByteType + scenePass.getTexture('normal').type = UnsignedByteType + + const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv))) + + const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any) + giPass.sliceCount.value = SSGI_PARAMS.sliceCount + giPass.stepCount.value = SSGI_PARAMS.stepCount + giPass.radius.value = SSGI_PARAMS.radius + giPass.expFactor.value = SSGI_PARAMS.expFactor + giPass.thickness.value = SSGI_PARAMS.thickness + giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting + giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity + giPass.giIntensity.value = SSGI_PARAMS.giIntensity + giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness + giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling + giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering + + const giTexture = (giPass as any).getTextureNode() + const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1)) + const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam) + denoisePass.index.value = 0 + denoisePass.radius.value = 4 + + const ao = (denoisePass as any).r + const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a) + + // FXAA requires a texture node as input; convertToTexture renders finalOutput + // into an intermediate RT so FXAA can sample it with neighbour UV offsets. + const aaOutput = fxaa(convertToTexture(finalOutput)) + + const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer) + pipeline.outputNode = aaOutput + pipelineRef.current = pipeline + + // Dedicated render target — pipeline outputs here instead of the canvas, + // so R3F's main render loop can never overwrite our capture. + const { width, height } = gl.domElement + renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true }) + } catch (error) { + console.error( + '[thumbnail] Failed to build post-processing pipeline, will use fallback render.', + error, + ) + } + } + + buildPipeline() + + return () => { + mounted = false + pipelineRef.current?.dispose() + pipelineRef.current = null + renderTargetRef.current?.dispose() + renderTargetRef.current = null + } + }, [gl, scene]) + const generate = useCallback(async () => { if (isGenerating.current) return if (!onThumbnailCaptureRef.current) return @@ -34,84 +144,155 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro isGenerating.current = true try { - const thumbnailCamera = new THREE.PerspectiveCamera( - 60, - THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, - 0.1, - 1000, - ) - - const nodes = useScene.getState().nodes - const siteNode = Object.values(nodes).find((n) => n.type === 'site') - - if (siteNode?.camera) { - const { position, target } = siteNode.camera - thumbnailCamera.position.set(position[0], position[1], position[2]) - thumbnailCamera.lookAt(target[0], target[1], target[2]) - } else { - thumbnailCamera.position.set(8, 8, 8) - thumbnailCamera.lookAt(0, 0, 0) - } - thumbnailCamera.layers.disable(EDITOR_LAYER) + const thumbnailCamera = thumbnailCameraRef.current + if (!thumbnailCamera) return + // Copy the main camera's transform and projection so the thumbnail + // matches exactly what the user sees in the viewport. + thumbnailCamera.position.copy(mainCamera.position) + thumbnailCamera.quaternion.copy(mainCamera.quaternion) + if (mainCamera instanceof THREE.PerspectiveCamera) { + thumbnailCamera.fov = mainCamera.fov + thumbnailCamera.near = mainCamera.near + thumbnailCamera.far = mainCamera.far + } const { width, height } = gl.domElement thumbnailCamera.aspect = width / height thumbnailCamera.updateProjectionMatrix() const restoreLevels = snapLevelsToTruePositions() - const visibilitySnapshot = new Map() - for (const type of ['scan', 'guide'] as const) { - sceneRegistry.byType[type].forEach((id) => { - const obj = sceneRegistry.nodes.get(id) - if (obj) { - visibilitySnapshot.set(id, obj.visible) - obj.visible = false - } - }) - } + let blob: Blob - gl.render(scene, thumbnailCamera) - - restoreLevels() - visibilitySnapshot.forEach((wasVisible, id) => { - const obj = sceneRegistry.nodes.get(id) - if (obj) obj.visible = wasVisible - }) - - const srcAspect = width / height - const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT - 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) - } + if (pipelineRef.current && renderTargetRef.current) { + const rt = renderTargetRef.current + + // Resize RT if the canvas dimensions changed + if (rt.width !== width || rt.height !== height) { + rt.setSize(width, height) + } - const offscreen = document.createElement('canvas') - offscreen.width = THUMBNAIL_WIDTH - offscreen.height = THUMBNAIL_HEIGHT - const ctx = offscreen.getContext('2d')! - ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + const renderer = gl as unknown as WebGPURenderer - offscreen.toBlob((blob) => { - if (blob) { - onThumbnailCaptureRef.current?.(blob) + // 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 { - console.error('❌ Failed to create blob from canvas') + // 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) } - isGenerating.current = false - }, 'image/png') + + const imageData = new ImageData( + tightPixels as unknown as Uint8ClampedArray, + 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() + + 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 offscreen = document.createElement('canvas') + offscreen.width = THUMBNAIL_WIDTH + offscreen.height = THUMBNAIL_HEIGHT + const ctx = offscreen.getContext('2d')! + ctx.drawImage( + gl.domElement, + sx, + sy, + sWidth, + sHeight, + 0, + 0, + THUMBNAIL_WIDTH, + THUMBNAIL_HEIGHT, + ) + + blob = await new Promise((resolve, reject) => + offscreen.toBlob( + (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))), + 'image/png', + ), + ) + } + + onThumbnailCaptureRef.current?.(blob) } catch (error) { console.error('❌ Failed to generate thumbnail:', error) + } finally { isGenerating.current = false } - }, [gl, scene]) + }, [gl, scene, mainCamera]) // Manual trigger via emitter useEffect(() => { diff --git a/packages/editor/src/components/tools/door/door-tool.tsx b/packages/editor/src/components/tools/door/door-tool.tsx index 67e45831..8f21ff70 100644 --- a/packages/editor/src/components/tools/door/door-tool.tsx +++ b/packages/editor/src/components/tools/door/door-tool.tsx @@ -143,13 +143,25 @@ export const DoorTool: React.FC = () => { const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) if (draftRef.current) { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) + if (event.node.id !== draftRef.current.parentId) { + // Wall changed without enter/leave: must updateNode to reparent + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + } else { + // Same wall: update Three.js mesh directly to avoid store churn + const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId) + if (draftMesh) { + draftMesh.position.set(clampedX, clampedY, 0) + draftMesh.rotation.set(0, itemRotation, 0) + draftMesh.updateMatrixWorld(true) + } + markWallDirty(event.node.id) + } } const valid = !hasWallChildOverlap( diff --git a/packages/editor/src/components/tools/door/move-door-tool.tsx b/packages/editor/src/components/tools/door/move-door-tool.tsx index 361493b1..8c900340 100644 --- a/packages/editor/src/components/tools/door/move-door-tool.tsx +++ b/packages/editor/src/components/tools/door/move-door-tool.tsx @@ -165,17 +165,26 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod movingDoorNode.height, ) - useScene.getState().updateNode(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - if (currentWallId !== event.node.id) { + // Wall changed mid-move: must updateNode to reparent + useScene.getState().updateNode(movingDoorNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) markWallDirty(currentWallId) currentWallId = event.node.id + } else { + // Same wall: update Three.js mesh directly to avoid store churn + // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions + const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId) + if (doorMesh) { + doorMesh.position.set(clampedX, clampedY, 0) + doorMesh.rotation.set(0, itemRotation, 0) + doorMesh.updateMatrixWorld(true) + } } markWallDirty(event.node.id) diff --git a/packages/editor/src/components/tools/window/move-window-tool.tsx b/packages/editor/src/components/tools/window/move-window-tool.tsx index 792812a1..320c2dab 100644 --- a/packages/editor/src/components/tools/window/move-window-tool.tsx +++ b/packages/editor/src/components/tools/window/move-window-tool.tsx @@ -185,17 +185,26 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin movingWindowNode.height, ) - useScene.getState().updateNode(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - if (currentWallId !== event.node.id) { + // Wall changed mid-move: must updateNode to reparent + useScene.getState().updateNode(movingWindowNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) markWallDirty(currentWallId) currentWallId = event.node.id + } else { + // Same wall: update Three.js mesh directly to avoid store churn + // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions + const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) + if (windowMesh) { + windowMesh.position.set(clampedX, clampedY, 0) + windowMesh.rotation.set(0, itemRotation, 0) + windowMesh.updateMatrixWorld(true) + } } markWallDirty(event.node.id) diff --git a/packages/editor/src/components/tools/window/window-tool.tsx b/packages/editor/src/components/tools/window/window-tool.tsx index 072bf118..a268592f 100644 --- a/packages/editor/src/components/tools/window/window-tool.tsx +++ b/packages/editor/src/components/tools/window/window-tool.tsx @@ -151,13 +151,25 @@ export const WindowTool: React.FC = () => { const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) if (draftRef.current) { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) + if (event.node.id !== draftRef.current.parentId) { + // Wall changed without enter/leave: must updateNode to reparent + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + } else { + // Same wall: update Three.js mesh directly to avoid store churn + const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId) + if (draftMesh) { + draftMesh.position.set(clampedX, clampedY, 0) + draftMesh.rotation.set(0, itemRotation, 0) + draftMesh.updateMatrixWorld(true) + } + markWallDirty(event.node.id) + } } const valid = !hasWallChildOverlap( diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index b99f454b..d8030052 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -1,7 +1,8 @@ -import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Building2, Plus } from 'lucide-react' import { useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { Tooltip, TooltipContent, @@ -11,37 +12,42 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface BuildingTreeNodeProps { - node: BuildingNode + nodeId: AnyNodeId depth: number isLast?: boolean } -export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) { +export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) { const [expanded, setExpanded] = useState(true) const createNode = useScene((state) => state.createNode) - const isSelected = useViewer((state) => state.selection.buildingId === node.id) - const isHovered = useViewer((state) => state.hoveredId === node.id) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const name = useScene((s) => s.nodes[nodeId]?.name) + const children = useScene( + useShallow((s) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []), + ) + const isSelected = useViewer((state) => state.selection.buildingId === nodeId) + const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) const handleClick = () => { - setSelection({ buildingId: node.id }) + setSelection({ buildingId: nodeId }) } const handleAddLevel = (e: React.MouseEvent) => { e.stopPropagation() const newLevel = LevelNode.parse({ - level: node.children.length, + level: children.length, children: [], - parentId: node.id, + parentId: nodeId, }) - createNode(newLevel, node.id) + createNode(newLevel, nodeId) } return ( - +