From 8bffc04867a7e3e3640659cdc87bcb739e87d960 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Tue, 14 Apr 2026 01:38:08 +0800 Subject: [PATCH 01/14] feat: add isolated live snapshot preview --- .gitignore | 1 + src/app/AppLayout.tsx | 90 +++++- src/app/components/SnapshotDialog.test.tsx | 82 ++++++ src/app/components/SnapshotDialog.tsx | 142 +++++++-- src/app/components/UnifiedViewer.tsx | 4 + .../SnapshotPreviewRenderer.tsx | 269 ++++++++++++++++++ .../previewActionState.test.ts | 45 +++ .../snapshot-preview/previewActionState.ts | 5 + src/app/components/snapshot-preview/types.ts | 32 +++ src/shared/components/3d/SceneUtilities.tsx | 3 + src/shared/components/3d/index.ts | 12 +- .../components/3d/scene/SnapshotManager.tsx | 144 +++++++--- .../3d/scene/WorkspaceOrbitControls.tsx | 18 +- src/shared/components/3d/scene/index.ts | 3 + .../components/3d/scene/snapshotConfig.ts | 10 + .../3d/scene/snapshotPreviewConfig.test.ts | 42 +++ .../3d/scene/snapshotPreviewConfig.ts | 15 + .../3d/workspace/WorkspaceCanvas.tsx | 11 + src/shared/components/3d/workspace/index.ts | 6 + .../workspace/workspaceCameraSnapshot.test.ts | 83 ++++++ .../3d/workspace/workspaceCameraSnapshot.ts | 112 ++++++++ src/shared/i18n/locales/en.ts | 9 + src/shared/i18n/locales/zh.ts | 9 + src/shared/i18n/types.ts | 9 + 24 files changed, 1074 insertions(+), 82 deletions(-) create mode 100644 src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx create mode 100644 src/app/components/snapshot-preview/previewActionState.test.ts create mode 100644 src/app/components/snapshot-preview/previewActionState.ts create mode 100644 src/app/components/snapshot-preview/types.ts create mode 100644 src/shared/components/3d/scene/snapshotPreviewConfig.test.ts create mode 100644 src/shared/components/3d/scene/snapshotPreviewConfig.ts create mode 100644 src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts create mode 100644 src/shared/components/3d/workspace/workspaceCameraSnapshot.ts diff --git a/.gitignore b/.gitignore index 942159e9e..34202cfca 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ public/__codex_anymal_c.zip .claude/ .codex/ AGENTS.md +.worktrees/ .vscode/ .tmp/ diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index 64d8e3756..ad623cec1 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -3,6 +3,7 @@ * Main application layout with Header and workspace area */ import React, { useRef, useCallback, useEffect, useMemo, useState, lazy, Suspense } from 'react'; +import type { RootState } from '@react-three/fiber'; import { useShallow } from 'zustand/react/shallow'; import { Header } from './components/Header'; import { IkToolPanel } from './components/IkToolPanel'; @@ -66,7 +67,10 @@ import { } from '@/store'; import type { BridgeJoint, RobotFile, UrdfJoint, UrdfLink } from '@/types'; import { translations } from '@/shared/i18n'; -import type { SnapshotCaptureOptions } from '@/shared/components/3d'; +import { + captureWorkspaceCameraSnapshot, + type SnapshotCaptureOptions, +} from '@/shared/components/3d'; import { normalizeMergedAppMode } from '@/shared/utils/appMode'; import { hasSingleDofJoints } from '@/shared/utils/jointTypes'; import { isAssetLibraryOnlyFormat, ROBOT_IMPORT_ACCEPT_ATTRIBUTE } from '@/shared/utils'; @@ -77,6 +81,7 @@ import { resolveDocumentLoadingOverlayTargetFileName } from './utils/documentLoa import { clearIkDragHelperSelection } from './utils/ikDragSession'; import { resolveIkToolSelectionState } from './utils/ikToolSelectionState'; import { resolveAssemblyRootComponentSelectionAvailability } from './utils/assemblyRootComponentSelection'; +import type { SnapshotPreviewSession } from './components/snapshot-preview/types'; interface ProModeRoundtripSession { baselineSnapshot: string; @@ -165,6 +170,7 @@ export function AppLayout({ sidebarTab, sourceCodeAutoApply, setViewOption, + groundPlaneOffset, } = useUIStore( useShallow((state) => ({ appMode: state.appMode, @@ -176,6 +182,7 @@ export function AppLayout({ sidebarTab: state.sidebarTab, sourceCodeAutoApply: state.sourceCodeAutoApply, setViewOption: state.setViewOption, + groundPlaneOffset: state.groundPlaneOffset, })), ); @@ -343,6 +350,7 @@ export function AppLayout({ const snapshotActionRef = useRef< ((options?: Partial) => Promise) | null >(null); + const viewerCanvasStateRef = useRef(null); const transformPendingRef = useRef(false); const pendingUsdAssemblyFileRef = useRef(null); const proModeRoundtripSessionRef = useRef(null); @@ -353,6 +361,8 @@ export function AppLayout({ const [isCollisionOptimizerOpen, setIsCollisionOptimizerOpen] = useState(false); const [isSnapshotDialogOpen, setIsSnapshotDialogOpen] = useState(false); const [isSnapshotCapturing, setIsSnapshotCapturing] = useState(false); + const [snapshotPreviewSession, setSnapshotPreviewSession] = + useState(null); const [isIkToolPanelOpen, setIsIkToolPanelOpen] = useState(false); const [shouldRenderBridgeModal, setShouldRenderBridgeModal] = useState(false); const [bridgePreview, setBridgePreview] = useState(null); @@ -801,9 +811,69 @@ export function AppLayout({ [handleCodeChange, sourceCodeDocuments], ); + const viewerSourceFile = useMemo( + () => + getViewerSourceFile({ + selectedFile, + shouldRenderAssembly, + workspaceSourceFile: workspaceViewerMjcfSourceFile, + }), + [selectedFile, shouldRenderAssembly, workspaceViewerMjcfSourceFile], + ); + + const handleCloseSnapshotDialog = useCallback(() => { + setIsSnapshotDialogOpen(false); + setSnapshotPreviewSession(null); + }, []); + const handleSnapshot = useCallback(() => { + const viewerCanvasState = viewerCanvasStateRef.current; + const cameraSnapshot = viewerCanvasState + ? captureWorkspaceCameraSnapshot(viewerCanvasState) + : null; + const viewportAspectRatio = + cameraSnapshot?.aspectRatio ?? + (viewerCanvasState?.size.width && viewerCanvasState.size.height + ? viewerCanvasState.size.width / viewerCanvasState.size.height + : 16 / 9); + + setSnapshotPreviewSession({ + theme, + cameraSnapshot, + viewportAspectRatio, + robotName: previewFileName ?? (viewerRobot.name || 'robot'), + robot: viewerRobot, + assets: viewerAssets, + availableFiles, + urdfContent: urdfContentForViewer, + viewerSourceFormat, + sourceFilePath: viewerSourceFilePath, + sourceFile: viewerSourceFile, + jointAngleState, + jointMotionState, + showVisual, + isMeshPreview: selectedFile?.format === 'mesh', + viewerReloadKey, + groundPlaneOffset, + }); setIsSnapshotDialogOpen(true); - }, []); + }, [ + availableFiles, + groundPlaneOffset, + jointAngleState, + jointMotionState, + previewFileName, + selectedFile?.format, + showVisual, + theme, + urdfContentForViewer, + viewerAssets, + viewerReloadKey, + viewerRobot, + viewerSourceFile, + viewerSourceFilePath, + viewerSourceFormat, + ]); const handleSetIkDragActive = useCallback( (active: boolean) => { @@ -874,7 +944,7 @@ export function AppLayout({ try { setIsSnapshotCapturing(true); await snapshotActionRef.current(options); - setIsSnapshotDialogOpen(false); + handleCloseSnapshotDialog(); } catch (error) { console.error('Snapshot failed:', error); showToast(t.snapshotFailed, 'info'); @@ -882,7 +952,7 @@ export function AppLayout({ setIsSnapshotCapturing(false); } }, - [showToast, t], + [handleCloseSnapshotDialog, showToast, t], ); const { @@ -1044,6 +1114,9 @@ export function AppLayout({ showVisual={showVisual} setShowVisual={handleSetShowVisual} snapshotAction={snapshotActionRef} + onCanvasCreated={(state) => { + viewerCanvasStateRef.current = state; + }} showToolbar={viewConfig.showToolbar} setShowToolbar={(show) => setViewConfig((prev) => ({ ...prev, showToolbar: show }))} showOptionsPanel={viewConfig.showOptionsPanel} @@ -1056,11 +1129,7 @@ export function AppLayout({ urdfContent={urdfContentForViewer} viewerSourceFormat={viewerSourceFormat} sourceFilePath={viewerSourceFilePath} - sourceFile={getViewerSourceFile({ - selectedFile, - shouldRenderAssembly, - workspaceSourceFile: workspaceViewerMjcfSourceFile, - })} + sourceFile={viewerSourceFile} onRobotDataResolved={handleRobotDataResolved} onDocumentLoadEvent={handleViewerDocumentLoadEvent} onRuntimeRobotLoaded={handleViewerRuntimeRobotLoaded} @@ -1134,7 +1203,8 @@ export function AppLayout({ isOpen={isSnapshotDialogOpen} isCapturing={isSnapshotCapturing} lang={lang} - onClose={() => setIsSnapshotDialogOpen(false)} + previewSession={snapshotPreviewSession} + onClose={handleCloseSnapshotDialog} onCapture={handleCaptureSnapshot} /> diff --git a/src/app/components/SnapshotDialog.test.tsx b/src/app/components/SnapshotDialog.test.tsx index 14d2237fc..40dd82b2e 100644 --- a/src/app/components/SnapshotDialog.test.tsx +++ b/src/app/components/SnapshotDialog.test.tsx @@ -88,3 +88,85 @@ test('SnapshotDialog reuses the segmented surface tone for AA choices', async () dom.window.close(); } }); + +test('SnapshotDialog renders the live preview state and frozen-view hint', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'refreshing', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const previewImage = container.querySelector('img[alt="Snapshot live preview"]'); + assert.ok(previewImage, 'snapshot dialog should render the latest preview image'); + assert.equal(previewImage?.getAttribute('src'), 'blob:preview'); + + const textContent = container.textContent ?? ''; + assert.match(textContent, /Live Preview/); + assert.match(textContent, /Based on the view when this dialog opened/); + assert.match(textContent, /Updating preview/); + assert.match(textContent, /Final export quality still follows the selected resolution/); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog keeps the live preview inside the scrollable content area', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const scrollableContent = container.querySelector('.overflow-y-auto'); + assert.ok(scrollableContent, 'snapshot dialog should keep a scrollable content region'); + assert.match( + scrollableContent.textContent ?? '', + /Live Preview/, + 'preview content should stay inside the scrollable body instead of competing with the footer', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); diff --git a/src/app/components/SnapshotDialog.tsx b/src/app/components/SnapshotDialog.tsx index f365dea7c..d0c301d12 100644 --- a/src/app/components/SnapshotDialog.tsx +++ b/src/app/components/SnapshotDialog.tsx @@ -15,6 +15,8 @@ import { type SnapshotCaptureOptions, } from '@/shared/components/3d'; import { translations, type Language } from '@/shared/i18n'; +import { SnapshotPreviewRenderer } from './snapshot-preview/SnapshotPreviewRenderer'; +import type { SnapshotDialogPreviewState, SnapshotPreviewSession } from './snapshot-preview/types'; const SNAPSHOT_RESOLUTION_OPTIONS = [ { value: '1280', label: '720p' }, @@ -35,6 +37,8 @@ interface SnapshotDialogProps { lang: Language; onClose: () => void; onCapture: (options: SnapshotCaptureOptions) => Promise | void; + previewSession?: SnapshotPreviewSession | null; + previewState?: SnapshotDialogPreviewState; } function SnapshotSection({ title, children }: { title: string; children: React.ReactNode }) { @@ -63,6 +67,8 @@ export function SnapshotDialog({ lang, onClose, onCapture, + previewSession = null, + previewState, }: SnapshotDialogProps) { const t = translations[lang]; const [resolutionPreset, setResolutionPreset] = useState( @@ -81,11 +87,16 @@ export function SnapshotDialog({ DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.backgroundStyle, ); const [hideGrid, setHideGrid] = useState(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.hideGrid); + const [internalPreviewState, setInternalPreviewState] = useState({ + status: 'idle', + imageUrl: null, + aspectRatio: previewSession?.viewportAspectRatio ?? 16 / 9, + }); const windowState = useDraggableWindow({ isOpen, - defaultSize: { width: 560, height: 332 }, - minSize: { width: 500, height: 308 }, + defaultSize: { width: 560, height: 560 }, + minSize: { width: 500, height: 420 }, centerOnMount: true, enableMinimize: false, enableMaximize: false, @@ -113,7 +124,12 @@ export function SnapshotDialog({ setDofMode(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.dofMode); setBackgroundStyle(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.backgroundStyle); setHideGrid(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.hideGrid); - }, [isOpen]); + setInternalPreviewState({ + status: 'idle', + imageUrl: null, + aspectRatio: previewSession?.viewportAspectRatio ?? 16 / 9, + }); + }, [isOpen, previewSession?.viewportAspectRatio]); useEffect(() => { if (imageFormat === 'jpeg' && backgroundStyle === 'transparent') { @@ -277,6 +293,17 @@ export function SnapshotDialog({ ? 80 : 60 : 'lossless'; + const effectivePreviewState = previewState ?? internalPreviewState; + const previewStatusText = + effectivePreviewState.status === 'loading' || effectivePreviewState.status === 'idle' + ? t.snapshotPreviewLoading + : effectivePreviewState.status === 'refreshing' + ? t.snapshotPreviewRefreshing + : effectivePreviewState.status === 'error' + ? t.snapshotPreviewFailed + : t.snapshotPreviewReady; + const previewAspectRatio = + effectivePreviewState.aspectRatio > 0 ? effectivePreviewState.aspectRatio : 16 / 9; if (!isOpen) { return null; @@ -443,38 +470,99 @@ export function SnapshotDialog({ - -
-
-
- {captureSummary} +
+
+
+
+ {t.snapshotPreviewTitle} +
+
+ {t.snapshotPreviewFrozenView} +
+
+
+ {previewStatusText} +
-
- - + {effectivePreviewState.imageUrl ? ( +
+ {t.snapshotPreviewAlt} + {effectivePreviewState.status === 'refreshing' ? ( +
+
+ {t.snapshotPreviewRefreshing} +
+
+ ) : null} +
+ ) : ( +
+ {effectivePreviewState.status === 'error' + ? t.snapshotPreviewFailed + : t.snapshotPreviewLoading} +
+ )} +
+
+ +
+
+
{captureSummary}
+
{t.snapshotPreviewQualityHint}
+
+ {effectivePreviewState.status === 'error' ? ( +
+ {t.snapshotPreviewRetryingHint} +
+ ) : null}
+ +
+
+ + +
+
+ {!previewState && previewSession ? ( + + ) : null} ); } diff --git a/src/app/components/UnifiedViewer.tsx b/src/app/components/UnifiedViewer.tsx index a6eb40a36..3e81fda6f 100644 --- a/src/app/components/UnifiedViewer.tsx +++ b/src/app/components/UnifiedViewer.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import type { RootState } from '@react-three/fiber'; import type { Group as ThreeGroup, Object3D as ThreeObject3D } from 'three'; import type { AppMode, @@ -82,6 +83,7 @@ interface UnifiedViewerProps { showVisual?: boolean; setShowVisual?: (show: boolean) => void; snapshotAction?: React.RefObject; + onCanvasCreated?: (state: RootState) => void; showToolbar?: boolean; setShowToolbar?: (show: boolean) => void; showOptionsPanel?: boolean; @@ -167,6 +169,7 @@ export const UnifiedViewer = React.memo( showVisual, setShowVisual, snapshotAction, + onCanvasCreated, showToolbar = true, setShowToolbar, showOptionsPanel = true, @@ -550,6 +553,7 @@ export const UnifiedViewer = React.memo( renderKey={`viewer:stable:${viewerReloadKey}`} containerRef={viewerController.containerRef} snapshotAction={snapshotAction} + onCreated={onCanvasCreated} onPointerDownCapture={handleWorkspacePointerDownCapture} onPointerMissed={handleViewerPointerMissed} onMouseMove={viewerController.handleMouseMove} diff --git a/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx b/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx new file mode 100644 index 000000000..111dc7202 --- /dev/null +++ b/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx @@ -0,0 +1,269 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + STUDIO_ENVIRONMENT_INTENSITY, + WORKSPACE_CANVAS_BACKGROUND, + WorkspaceCanvas, + type SnapshotCaptureOptions, + type SnapshotPreviewAction, + useWorkspaceCanvasTheme, +} from '@/shared/components/3d'; +import { translations, type Language } from '@/shared/i18n'; +import { useViewerController, resolveDefaultViewerToolMode } from '@/features/editor'; +import { buildUnifiedViewerResourceScopes } from '@/app/utils/unifiedViewerResourceScopes'; +import { resolveSnapshotPreviewSurfaceSize } from '@/shared/components/3d'; +import { ViewerSceneConnector } from '../unified-viewer/ViewerSceneConnector'; +import { toSnapshotPreviewActionState } from './previewActionState'; + +import type { SnapshotDialogPreviewState, SnapshotPreviewSession } from './types'; + +interface SnapshotPreviewRendererProps { + isOpen: boolean; + lang: Language; + session: SnapshotPreviewSession | null; + options: SnapshotCaptureOptions; + onStateChange: (state: SnapshotDialogPreviewState) => void; +} + +export function SnapshotPreviewRenderer({ + isOpen, + lang, + session, + options, + onStateChange, +}: SnapshotPreviewRendererProps) { + const t = translations[lang]; + const previousViewerResourceScopeRef = useRef< + ReturnType['viewerResourceScope'] | null + >(null); + const previewRequestIdRef = useRef(0); + const previewTimerRef = useRef(null); + const previewUrlRef = useRef(null); + const previewInFlightRef = useRef(false); + const queuedPreviewRef = useRef<{ + requestId: number; + options: SnapshotCaptureOptions; + aspectRatio: number; + } | null>(null); + const [previewAction, setPreviewAction] = useState(null); + const effectiveTheme = useWorkspaceCanvasTheme(session?.theme ?? 'light'); + const surfaceSize = useMemo( + () => resolveSnapshotPreviewSurfaceSize(session?.viewportAspectRatio ?? 16 / 9), + [session?.viewportAspectRatio], + ); + const handlePreviewActionChange = useCallback((nextAction: SnapshotPreviewAction | null) => { + setPreviewAction(toSnapshotPreviewActionState(nextAction)); + }, []); + + const controller = useViewerController({ + active: false, + showJointPanel: false, + jointAngleState: session?.jointAngleState, + jointMotionState: session?.jointMotionState, + showVisual: session?.showVisual ?? true, + groundPlaneOffset: session?.groundPlaneOffset ?? 0, + groundPlaneOffsetReadOnly: true, + defaultToolMode: resolveDefaultViewerToolMode(session?.sourceFile?.format), + toolModeScopeKey: session?.sourceFile?.name + ? `snapshot-preview:${session.sourceFile.name}` + : 'snapshot-preview:inline', + }); + + const viewerResourceScope = useMemo(() => { + const next = buildUnifiedViewerResourceScopes({ + activePreview: undefined, + urdfContent: session?.urdfContent ?? '', + sourceFilePath: session?.sourceFilePath, + sourceFile: session?.sourceFile, + assets: session?.assets ?? {}, + availableFiles: session?.availableFiles ?? [], + viewerRobotLinks: session?.robot.links, + viewerRobotMaterials: session?.robot.materials, + previousViewerResourceScope: previousViewerResourceScopeRef.current, + }); + previousViewerResourceScopeRef.current = next.viewerResourceScope; + return next.viewerResourceScope; + }, [ + session?.assets, + session?.availableFiles, + session?.robot.links, + session?.robot.materials, + session?.sourceFile, + session?.sourceFilePath, + session?.urdfContent, + ]); + + useEffect(() => { + return () => { + previewRequestIdRef.current += 1; + if (previewTimerRef.current !== null) { + window.clearTimeout(previewTimerRef.current); + previewTimerRef.current = null; + } + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + queuedPreviewRef.current = null; + previewInFlightRef.current = false; + }; + }, []); + + const executePreviewRequest = useCallback( + (requestId: number, nextOptions: SnapshotCaptureOptions, aspectRatio: number) => { + if (!previewAction) { + return; + } + + if (previewInFlightRef.current) { + queuedPreviewRef.current = { + requestId, + options: nextOptions, + aspectRatio, + }; + return; + } + + previewInFlightRef.current = true; + previewAction(nextOptions) + .then((result) => { + if (requestId !== previewRequestIdRef.current) { + return; + } + + const nextUrl = URL.createObjectURL(result.blob); + const previousUrl = previewUrlRef.current; + previewUrlRef.current = nextUrl; + if (previousUrl) { + URL.revokeObjectURL(previousUrl); + } + onStateChange({ + status: 'ready', + imageUrl: nextUrl, + aspectRatio: result.width / Math.max(1, result.height), + }); + }) + .catch((error) => { + console.error('[SnapshotPreviewRenderer] Failed to refresh preview.', error); + if (requestId !== previewRequestIdRef.current) { + return; + } + onStateChange({ + status: 'error', + imageUrl: previewUrlRef.current, + aspectRatio, + }); + }) + .finally(() => { + previewInFlightRef.current = false; + const queuedPreview = queuedPreviewRef.current; + if (!queuedPreview) { + return; + } + + queuedPreviewRef.current = null; + if (queuedPreview.requestId === previewRequestIdRef.current) { + executePreviewRequest( + queuedPreview.requestId, + queuedPreview.options, + queuedPreview.aspectRatio, + ); + } + }); + }, + [onStateChange, previewAction], + ); + + useEffect(() => { + if (!isOpen || !session) { + previewRequestIdRef.current += 1; + queuedPreviewRef.current = null; + if (previewTimerRef.current !== null) { + window.clearTimeout(previewTimerRef.current); + previewTimerRef.current = null; + } + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + onStateChange({ + status: 'idle', + imageUrl: null, + aspectRatio: session?.viewportAspectRatio ?? 16 / 9, + }); + return; + } + + if (!previewAction) { + return; + } + + const nextRequestId = ++previewRequestIdRef.current; + const previousImageUrl = previewUrlRef.current; + onStateChange({ + status: previousImageUrl ? 'refreshing' : 'loading', + imageUrl: previousImageUrl, + aspectRatio: session.viewportAspectRatio, + }); + + if (previewTimerRef.current !== null) { + window.clearTimeout(previewTimerRef.current); + } + + previewTimerRef.current = window.setTimeout(() => { + previewTimerRef.current = null; + executePreviewRequest(nextRequestId, options, session.viewportAspectRatio); + }, 300); + }, [executePreviewRequest, isOpen, onStateChange, options, previewAction, session]); + + if (!isOpen || !session) { + return null; + } + + return ( + + ); +} diff --git a/src/app/components/snapshot-preview/previewActionState.test.ts b/src/app/components/snapshot-preview/previewActionState.test.ts new file mode 100644 index 000000000..c04b9b162 --- /dev/null +++ b/src/app/components/snapshot-preview/previewActionState.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { SnapshotPreviewAction } from '@/shared/components/3d'; + +import { toSnapshotPreviewActionState } from './previewActionState'; + +test('toSnapshotPreviewActionState preserves callback identity without invoking it', async () => { + let callCount = 0; + const action: SnapshotPreviewAction = async () => { + callCount += 1; + return { + blob: new Blob(['preview']), + width: 640, + height: 360, + options: { + longEdgePx: 1280, + imageFormat: 'png', + imageQuality: 96, + detailLevel: 'high', + environmentPreset: 'city', + shadowStyle: 'balanced', + groundStyle: 'shadow', + dofMode: 'off', + backgroundStyle: 'studio', + hideGrid: true, + }, + }; + }; + + const updater = toSnapshotPreviewActionState(action); + const storedAction = updater(null); + + assert.equal(callCount, 0, 'wrapping the callback should not invoke it eagerly'); + assert.equal(storedAction, action, 'the updater should preserve the original callback identity'); +}); + +test('toSnapshotPreviewActionState can clear the stored callback', () => { + const updater = toSnapshotPreviewActionState(null); + + assert.equal( + updater(() => Promise.reject(new Error('unused'))), + null, + ); +}); diff --git a/src/app/components/snapshot-preview/previewActionState.ts b/src/app/components/snapshot-preview/previewActionState.ts new file mode 100644 index 000000000..3597fd949 --- /dev/null +++ b/src/app/components/snapshot-preview/previewActionState.ts @@ -0,0 +1,5 @@ +import type { SnapshotPreviewAction } from '@/shared/components/3d'; + +export function toSnapshotPreviewActionState(nextAction: SnapshotPreviewAction | null) { + return (_previousAction: SnapshotPreviewAction | null) => nextAction; +} diff --git a/src/app/components/snapshot-preview/types.ts b/src/app/components/snapshot-preview/types.ts new file mode 100644 index 000000000..bef86ab0a --- /dev/null +++ b/src/app/components/snapshot-preview/types.ts @@ -0,0 +1,32 @@ +import type { ViewerJointMotionStateValue, ViewerRobotSourceFormat } from '@/features/editor'; +import type { WorkspaceCameraSnapshot } from '@/shared/components/3d'; +import type { RobotFile, RobotState } from '@/types'; +import type { Theme } from '@/types'; + +export type SnapshotDialogPreviewStatus = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'; + +export interface SnapshotDialogPreviewState { + status: SnapshotDialogPreviewStatus; + imageUrl: string | null; + aspectRatio: number; +} + +export interface SnapshotPreviewSession { + theme: Theme; + cameraSnapshot: WorkspaceCameraSnapshot | null; + viewportAspectRatio: number; + robotName: string; + robot: RobotState; + assets: Record; + availableFiles: RobotFile[]; + urdfContent: string; + viewerSourceFormat?: ViewerRobotSourceFormat; + sourceFilePath?: string; + sourceFile?: RobotFile | null; + jointAngleState?: Record; + jointMotionState?: Record; + showVisual: boolean; + isMeshPreview: boolean; + viewerReloadKey: number; + groundPlaneOffset: number; +} diff --git a/src/shared/components/3d/SceneUtilities.tsx b/src/shared/components/3d/SceneUtilities.tsx index 7d939389c..991491ec5 100644 --- a/src/shared/components/3d/SceneUtilities.tsx +++ b/src/shared/components/3d/SceneUtilities.tsx @@ -23,6 +23,7 @@ export { normalizeSnapshotCaptureOptions, normalizeSnapshotImageQuality, normalizeSnapshotLongEdgePx, + resolveSnapshotPreviewCaptureOptions, type SnapshotBackgroundStyle, type SnapshotCaptureAction, type SnapshotCaptureOptions, @@ -31,6 +32,8 @@ export { type SnapshotEnvironmentPreset, type SnapshotGroundStyle, type SnapshotImageFormat, + type SnapshotPreviewAction, + type SnapshotPreviewResult, type SnapshotShadowStyle, NeutralStudioEnvironment, SceneLighting, diff --git a/src/shared/components/3d/index.ts b/src/shared/components/3d/index.ts index 6393d9eed..069c29cda 100644 --- a/src/shared/components/3d/index.ts +++ b/src/shared/components/3d/index.ts @@ -35,6 +35,7 @@ export { normalizeSnapshotCaptureOptions, normalizeSnapshotImageQuality, normalizeSnapshotLongEdgePx, + resolveSnapshotPreviewCaptureOptions, type SnapshotBackgroundStyle, type SnapshotCaptureAction, type SnapshotCaptureOptions, @@ -43,6 +44,8 @@ export { type SnapshotEnvironmentPreset, type SnapshotGroundStyle, type SnapshotImageFormat, + type SnapshotPreviewAction, + type SnapshotPreviewResult, type SnapshotShadowStyle, NeutralStudioEnvironment, SceneLighting, @@ -85,8 +88,15 @@ export { export * from './helpers'; export { WorkspaceCanvas } from './workspace'; -export { resolveWorkspaceCanvasEnvironmentIntensity, useWorkspaceCanvasTheme } from './workspace'; +export { + applyWorkspaceCameraSnapshot, + captureWorkspaceCameraSnapshot, + resolveSnapshotPreviewSurfaceSize, + resolveWorkspaceCanvasEnvironmentIntensity, + useWorkspaceCanvasTheme, +} from './workspace'; export type { WorkspaceCanvasEnvironmentIntensityByTheme } from './workspace'; +export type { WorkspaceCameraSnapshot } from './workspace'; export { UsageGuide } from './UsageGuide'; export { ViewModeBadge } from './ViewModeBadge'; diff --git a/src/shared/components/3d/scene/SnapshotManager.tsx b/src/shared/components/3d/scene/SnapshotManager.tsx index e630465ea..e25385953 100644 --- a/src/shared/components/3d/scene/SnapshotManager.tsx +++ b/src/shared/components/3d/scene/SnapshotManager.tsx @@ -11,7 +11,10 @@ import { normalizeSnapshotCaptureOptions, SNAPSHOT_DETAIL_SUPERSAMPLE_SCALE, type SnapshotCaptureAction, + type SnapshotCaptureOptions, + type SnapshotPreviewAction, } from './snapshotConfig'; +import { resolveSnapshotPreviewCaptureOptions } from './snapshotPreviewConfig'; import { applySnapshotBackgroundStyle, applySnapshotLightingPreset, @@ -58,6 +61,8 @@ function ensureSnapshotHdrPreloaded(): Promise { interface SnapshotManagerProps { actionRef?: RefObject; + previewActionRef?: RefObject; + onPreviewActionChange?: (action: SnapshotPreviewAction | null) => void; robotName: string; theme: Theme; groundOffset?: number; @@ -65,6 +70,8 @@ interface SnapshotManagerProps { export const SnapshotManager = ({ actionRef, + previewActionRef, + onPreviewActionChange, robotName, theme, groundOffset = 0, @@ -77,7 +84,9 @@ export const SnapshotManager = ({ const { setSnapshotRenderActive } = useSnapshotRenderContext(); useEffect(() => { - if (!actionRef) return; + if (!actionRef && !previewActionRef && !onPreviewActionChange) { + return; + } const cloneSnapshotCamera = (camera: THREE.Camera) => { const snapshotCamera = camera.clone(); @@ -151,40 +160,15 @@ export const SnapshotManager = ({ return Math.max(12_000_000, Math.floor(baseBudget * SNAPSHOT_DOF_PIXEL_BUDGET_MULTIPLIER)); }; - const downloadCanvas = async ( - canvas: HTMLCanvasElement, - requestedOptions?: Parameters[0], - ) => { - const options = normalizeSnapshotCaptureOptions(requestedOptions); - const safeRobotName = (robotName || 'robot').replace(/[\\/:*?"<>|]/g, '_'); - const now = new Date(); - const timestamp = [ - now.getFullYear(), - String(now.getMonth() + 1).padStart(2, '0'), - String(now.getDate()).padStart(2, '0'), - '_', - String(now.getHours()).padStart(2, '0'), - String(now.getMinutes()).padStart(2, '0'), - String(now.getSeconds()).padStart(2, '0'), - ].join(''); - const filename = `${safeRobotName}_snapshot_${timestamp}.${getSnapshotFileExtension(options.imageFormat)}`; + const canvasToBlob = async (canvas: HTMLCanvasElement, options: SnapshotCaptureOptions) => { const mimeType = getSnapshotMimeType(options.imageFormat); const quality = mimeType === 'image/png' ? undefined : Math.min(1, Math.max(0.6, options.imageQuality / 100)); - const triggerDownload = (href: string) => { - const link = document.createElement('a'); - link.href = href; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - if (canvas.toBlob) { - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { @@ -196,19 +180,46 @@ export const SnapshotManager = ({ return; } - const url = URL.createObjectURL(blob); - triggerDownload(url); - URL.revokeObjectURL(url); - resolve(); + resolve(blob); }, mimeType, quality, ); }); - return; } - triggerDownload(canvas.toDataURL(mimeType, quality)); + const dataUrl = canvas.toDataURL(mimeType, quality); + const response = await fetch(dataUrl); + return response.blob(); + }; + + const downloadCanvas = async (canvas: HTMLCanvasElement, options: SnapshotCaptureOptions) => { + const safeRobotName = (robotName || 'robot').replace(/[\\/:*?"<>|]/g, '_'); + const now = new Date(); + const timestamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + '_', + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + String(now.getSeconds()).padStart(2, '0'), + ].join(''); + const filename = `${safeRobotName}_snapshot_${timestamp}.${getSnapshotFileExtension(options.imageFormat)}`; + + const triggerDownload = (href: string) => { + const link = document.createElement('a'); + link.href = href; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const blob = await canvasToBlob(canvas, options); + const url = URL.createObjectURL(blob); + triggerDownload(url); + URL.revokeObjectURL(url); }; const buildCanvasFromPixelBuffer = (pixelBuffer: Uint8Array, width: number, height: number) => { @@ -327,11 +338,10 @@ export const SnapshotManager = ({ return exportCanvas; }; - const renderAndDownloadHighRes = async ( - requestedOptions?: Parameters[0], + const renderSnapshotCanvas = async ( + snapshotOptions: SnapshotCaptureOptions, frozenCamera?: THREE.Camera, ) => { - const snapshotOptions = normalizeSnapshotCaptureOptions(requestedOptions); const outputPlan = resolveSnapshotSize(snapshotOptions.longEdgePx); const supersampleScale = SNAPSHOT_DETAIL_SUPERSAMPLE_SCALE[snapshotOptions.detailLevel]; const renderPlan = clampSnapshotRenderPlanToPixelBudget( @@ -433,8 +443,13 @@ export const SnapshotManager = ({ outputPlan.targetHeight, backgroundFill, ); - await downloadCanvas(capturedCanvas, snapshotOptions); invalidate(); + return { + canvas: capturedCanvas, + width: outputPlan.targetWidth, + height: outputPlan.targetHeight, + options: snapshotOptions, + }; } catch (error) { restoreBackgroundStyle?.(); restoreShadowQuality?.(); @@ -446,8 +461,11 @@ export const SnapshotManager = ({ } }; - actionRef.current = async (requestedOptions) => { - const snapshotOptions = normalizeSnapshotCaptureOptions(requestedOptions); + const runSnapshotCapture = async ( + requestedOptions: Parameters[0], + resolveOptions: (options?: Partial | null) => SnapshotCaptureOptions, + ) => { + const snapshotOptions = resolveOptions(requestedOptions); const frozenCamera = cloneSnapshotCamera(get().camera); await ensureSnapshotHdrPreloaded(); clearPendingFrames(); @@ -457,7 +475,7 @@ export const SnapshotManager = ({ try { await waitFrames(resolveSnapshotWarmupFrameCount(snapshotOptions)); - await renderAndDownloadHighRes(snapshotOptions, frozenCamera); + return await renderSnapshotCanvas(snapshotOptions, frozenCamera); } finally { setActiveSnapshotOptions(null); setSnapshotRenderActive(false); @@ -465,12 +483,52 @@ export const SnapshotManager = ({ } }; + if (actionRef) { + actionRef.current = async (requestedOptions) => { + const capture = await runSnapshotCapture(requestedOptions, normalizeSnapshotCaptureOptions); + await downloadCanvas(capture.canvas, capture.options); + }; + } + + const previewAction: SnapshotPreviewAction = async (requestedOptions) => { + const capture = await runSnapshotCapture( + requestedOptions, + resolveSnapshotPreviewCaptureOptions, + ); + return { + blob: await canvasToBlob(capture.canvas, capture.options), + width: capture.width, + height: capture.height, + options: capture.options, + }; + }; + + if (previewActionRef) { + previewActionRef.current = previewAction; + } + onPreviewActionChange?.(previewAction); + return () => { clearPendingFrames(); setSnapshotRenderActive(false); - actionRef.current = null; + if (actionRef) { + actionRef.current = null; + } + if (previewActionRef) { + previewActionRef.current = null; + } + onPreviewActionChange?.(null); }; - }, [actionRef, get, gl, invalidate, robotName, setSnapshotRenderActive]); + }, [ + actionRef, + get, + gl, + invalidate, + onPreviewActionChange, + previewActionRef, + robotName, + setSnapshotRenderActive, + ]); return activeSnapshotOptions ? ( diff --git a/src/shared/components/3d/scene/WorkspaceOrbitControls.tsx b/src/shared/components/3d/scene/WorkspaceOrbitControls.tsx index b80374e79..6716193da 100644 --- a/src/shared/components/3d/scene/WorkspaceOrbitControls.tsx +++ b/src/shared/components/3d/scene/WorkspaceOrbitControls.tsx @@ -9,6 +9,10 @@ import { syncWorkspacePerspectiveClipPlanes, } from './workspaceOrbitClipping'; import { resolveWorkspaceOrbitPanSpeed } from './workspaceOrbitPan'; +import { + applyWorkspaceCameraSnapshot, + type WorkspaceCameraSnapshot, +} from '../workspace/workspaceCameraSnapshot'; const WORKSPACE_ORBIT_CONTROL_TUNING = { dampingFactor: 0.08, @@ -32,6 +36,7 @@ export interface WorkspaceOrbitControlsProps { zoomToCursor?: boolean; minDistance?: number; maxDistance?: number; + initialCameraSnapshot?: WorkspaceCameraSnapshot | null; } export function WorkspaceOrbitControls({ @@ -46,6 +51,7 @@ export function WorkspaceOrbitControls({ zoomToCursor = WORKSPACE_ORBIT_CONTROL_TUNING.zoomToCursor, minDistance = WORKSPACE_ORBIT_CONTROL_TUNING.minDistance, maxDistance, + initialCameraSnapshot = null, }: WorkspaceOrbitControlsProps) { const camera = useThree((state) => state.camera); const scene = useThree((state) => state.scene); @@ -54,7 +60,9 @@ export function WorkspaceOrbitControls({ const panSceneBoundsRef = useRef(undefined); const refreshSceneBounds = useCallback(() => { - clipSceneBoundsRef.current = computeVisibleMeshBounds(scene, { includeGroundPlaneHelpers: true }); + clipSceneBoundsRef.current = computeVisibleMeshBounds(scene, { + includeGroundPlaneHelpers: true, + }); panSceneBoundsRef.current = computeVisibleMeshBounds(scene); }, [scene]); @@ -62,6 +70,14 @@ export function WorkspaceOrbitControls({ refreshSceneBounds(); }, [refreshSceneBounds]); + useEffect(() => { + if (!controlsRef.current) { + return; + } + + applyWorkspaceCameraSnapshot(camera, controlsRef.current, initialCameraSnapshot); + }, [camera, initialCameraSnapshot]); + useFrame(() => { if (!controlsRef.current) { return; diff --git a/src/shared/components/3d/scene/index.ts b/src/shared/components/3d/scene/index.ts index 69faf0263..b01570102 100644 --- a/src/shared/components/3d/scene/index.ts +++ b/src/shared/components/3d/scene/index.ts @@ -18,6 +18,8 @@ export { normalizeSnapshotCaptureOptions, normalizeSnapshotImageQuality, normalizeSnapshotLongEdgePx, + type SnapshotPreviewAction, + type SnapshotPreviewResult, type SnapshotBackgroundStyle, type SnapshotCaptureAction, type SnapshotCaptureOptions, @@ -28,6 +30,7 @@ export { type SnapshotImageFormat, type SnapshotShadowStyle, } from './snapshotConfig'; +export { resolveSnapshotPreviewCaptureOptions } from './snapshotPreviewConfig'; export { NeutralStudioEnvironment } from './NeutralStudioEnvironment'; export { SceneLighting } from './SceneLighting'; export { GroundShadowPlane } from './GroundShadowPlane'; diff --git a/src/shared/components/3d/scene/snapshotConfig.ts b/src/shared/components/3d/scene/snapshotConfig.ts index 8ee4c04fa..0e9640775 100644 --- a/src/shared/components/3d/scene/snapshotConfig.ts +++ b/src/shared/components/3d/scene/snapshotConfig.ts @@ -49,6 +49,16 @@ export interface SnapshotCaptureOptions { } export type SnapshotCaptureAction = (options?: Partial) => Promise; +export interface SnapshotPreviewResult { + blob: Blob; + width: number; + height: number; + options: SnapshotCaptureOptions; +} + +export type SnapshotPreviewAction = ( + options?: Partial, +) => Promise; export const DEFAULT_SNAPSHOT_CAPTURE_OPTIONS: SnapshotCaptureOptions = { longEdgePx: SNAPSHOT_MIN_LONG_EDGE, diff --git a/src/shared/components/3d/scene/snapshotPreviewConfig.test.ts b/src/shared/components/3d/scene/snapshotPreviewConfig.test.ts new file mode 100644 index 000000000..3fd28b549 --- /dev/null +++ b/src/shared/components/3d/scene/snapshotPreviewConfig.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveSnapshotPreviewCaptureOptions } from './snapshotPreviewConfig'; + +test('resolveSnapshotPreviewCaptureOptions keeps the export look but caps preview budget', () => { + const options = resolveSnapshotPreviewCaptureOptions({ + longEdgePx: 7680, + imageFormat: 'webp', + imageQuality: 80, + detailLevel: 'ultra', + environmentPreset: 'contrast', + shadowStyle: 'crisp', + groundStyle: 'reflective', + dofMode: 'hero', + backgroundStyle: 'dark', + hideGrid: false, + }); + + assert.equal(options.longEdgePx, 800); + assert.equal(options.detailLevel, 'high'); + assert.equal(options.imageFormat, 'webp'); + assert.equal(options.imageQuality, 80); + assert.equal(options.environmentPreset, 'contrast'); + assert.equal(options.shadowStyle, 'crisp'); + assert.equal(options.groundStyle, 'reflective'); + assert.equal(options.dofMode, 'hero'); + assert.equal(options.backgroundStyle, 'dark'); + assert.equal(options.hideGrid, false); +}); + +test('resolveSnapshotPreviewCaptureOptions keeps transparent alpha-safe previews intact', () => { + const options = resolveSnapshotPreviewCaptureOptions({ + imageFormat: 'png', + backgroundStyle: 'transparent', + dofMode: 'hero', + }); + + assert.equal(options.longEdgePx, 800); + assert.equal(options.backgroundStyle, 'transparent'); + assert.equal(options.dofMode, 'off'); +}); diff --git a/src/shared/components/3d/scene/snapshotPreviewConfig.ts b/src/shared/components/3d/scene/snapshotPreviewConfig.ts new file mode 100644 index 000000000..9a4bfbf29 --- /dev/null +++ b/src/shared/components/3d/scene/snapshotPreviewConfig.ts @@ -0,0 +1,15 @@ +import { normalizeSnapshotCaptureOptions, type SnapshotCaptureOptions } from './snapshotConfig'; + +const SNAPSHOT_PREVIEW_LONG_EDGE = 800; + +export function resolveSnapshotPreviewCaptureOptions( + options?: Partial | null, +): SnapshotCaptureOptions { + const normalized = normalizeSnapshotCaptureOptions(options); + + return { + ...normalized, + longEdgePx: SNAPSHOT_PREVIEW_LONG_EDGE, + detailLevel: normalized.detailLevel === 'ultra' ? 'high' : normalized.detailLevel, + }; +} diff --git a/src/shared/components/3d/workspace/WorkspaceCanvas.tsx b/src/shared/components/3d/workspace/WorkspaceCanvas.tsx index 8f03abb10..8634d4fef 100644 --- a/src/shared/components/3d/workspace/WorkspaceCanvas.tsx +++ b/src/shared/components/3d/workspace/WorkspaceCanvas.tsx @@ -17,6 +17,7 @@ import { SceneLighting, SnapshotManager, type SnapshotCaptureAction, + type SnapshotPreviewAction, useAdaptiveInteractionQuality, WorkspaceCanvasInteractionStateProvider, WorkspaceOrbitControls, @@ -31,6 +32,7 @@ import { type WorkspaceCanvasEnvironmentIntensityByTheme, useWorkspaceCanvasTheme, } from './workspaceCanvasConfig'; +import type { WorkspaceCameraSnapshot } from './workspaceCameraSnapshot'; import { WorkspaceCanvasErrorBoundary } from './WorkspaceCanvasErrorBoundary'; import { WorkspaceCanvasErrorNotice } from './WorkspaceCanvasErrorNotice'; import { @@ -48,6 +50,8 @@ interface WorkspaceCanvasProps { containerRef?: React.RefObject; sceneRef?: React.RefObject; snapshotAction?: React.RefObject; + previewAction?: React.RefObject; + onPreviewActionChange?: (action: SnapshotPreviewAction | null) => void; children: React.ReactNode; overlays?: React.ReactNode; onPointerMissed?: () => void; @@ -73,6 +77,7 @@ interface WorkspaceCanvasProps { showWorldOriginAxes?: boolean; showUsageGuide?: boolean; renderKey?: string; + initialCameraSnapshot?: WorkspaceCameraSnapshot | null; } function CanvasRenderKeyInvalidator({ renderKey }: { renderKey: string }) { @@ -93,6 +98,8 @@ export const WorkspaceCanvas = ({ containerRef, sceneRef, snapshotAction, + previewAction, + onPreviewActionChange, children, overlays, onPointerMissed, @@ -115,6 +122,7 @@ export const WorkspaceCanvas = ({ showWorldOriginAxes = true, showUsageGuide = true, renderKey = 'default', + initialCameraSnapshot = null, }: WorkspaceCanvasProps) => { const effectiveTheme = useWorkspaceCanvasTheme(theme); const t = translations[lang ?? 'en']; @@ -415,6 +423,8 @@ export const WorkspaceCanvas = ({ /> } {!snapshotRenderActive && ( diff --git a/src/shared/components/3d/workspace/index.ts b/src/shared/components/3d/workspace/index.ts index 6738d4a32..fdc295e71 100644 --- a/src/shared/components/3d/workspace/index.ts +++ b/src/shared/components/3d/workspace/index.ts @@ -4,3 +4,9 @@ export { useWorkspaceCanvasTheme, type WorkspaceCanvasEnvironmentIntensityByTheme, } from './workspaceCanvasConfig'; +export { + applyWorkspaceCameraSnapshot, + captureWorkspaceCameraSnapshot, + resolveSnapshotPreviewSurfaceSize, + type WorkspaceCameraSnapshot, +} from './workspaceCameraSnapshot'; diff --git a/src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts b/src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts new file mode 100644 index 000000000..6fe0ffd55 --- /dev/null +++ b/src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as THREE from 'three'; + +import { + applyWorkspaceCameraSnapshot, + captureWorkspaceCameraSnapshot, + resolveSnapshotPreviewSurfaceSize, +} from './workspaceCameraSnapshot'; + +test('captureWorkspaceCameraSnapshot reads the current camera and orbit target', () => { + const camera = new THREE.PerspectiveCamera(52, 2, 0.1, 500); + camera.position.set(4, 5, 6); + camera.up.set(0, 1, 0); + camera.zoom = 1.5; + camera.lookAt(new THREE.Vector3(1, 2, 3)); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + const snapshot = captureWorkspaceCameraSnapshot({ + camera, + controls: { + target: new THREE.Vector3(1, 2, 3), + }, + size: { + width: 1200, + height: 600, + }, + } as any); + + assert.ok(snapshot, 'expected a workspace camera snapshot'); + assert.equal(snapshot?.aspectRatio, 2); + assert.deepEqual(snapshot?.target, { x: 1, y: 2, z: 3 }); + assert.deepEqual(snapshot?.position, { x: 4, y: 5, z: 6 }); + assert.equal(snapshot?.zoom, 1.5); + assert.equal(snapshot?.kind, 'perspective'); + assert.equal(snapshot?.fov, 52); +}); + +test('applyWorkspaceCameraSnapshot restores camera transform and orbit target', () => { + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); + let controlsUpdated = false; + const controls = { + target: new THREE.Vector3(), + update: () => { + controlsUpdated = true; + }, + }; + + applyWorkspaceCameraSnapshot(camera, controls as any, { + kind: 'perspective', + position: { x: -3, y: 1.5, z: 9 }, + quaternion: { x: 0.05, y: 0.35, z: -0.1, w: 0.93 }, + up: { x: 0, y: 1, z: 0 }, + zoom: 1.25, + target: { x: 2, y: -1, z: 0.5 }, + aspectRatio: 1.6, + fov: 48, + near: 0.25, + far: 420, + }); + + assert.equal(camera.position.x, -3); + assert.equal(camera.position.y, 1.5); + assert.equal(camera.position.z, 9); + assert.equal(camera.zoom, 1.25); + assert.equal(camera.aspect, 1.6); + assert.equal(camera.fov, 48); + assert.equal(camera.near, 0.25); + assert.equal(camera.far, 420); + assert.ok( + camera.quaternion.angleTo(new THREE.Quaternion(0.05, 0.35, -0.1, 0.93).normalize()) < 1e-6, + ); + assert.deepEqual(controls.target.toArray(), [2, -1, 0.5]); + assert.equal(controlsUpdated, true); +}); + +test('resolveSnapshotPreviewSurfaceSize preserves the frozen viewport aspect ratio', () => { + assert.deepEqual(resolveSnapshotPreviewSurfaceSize(2), { width: 960, height: 480 }); + assert.deepEqual(resolveSnapshotPreviewSurfaceSize(0.5), { width: 480, height: 960 }); + assert.deepEqual(resolveSnapshotPreviewSurfaceSize(0), { width: 960, height: 960 }); +}); diff --git a/src/shared/components/3d/workspace/workspaceCameraSnapshot.ts b/src/shared/components/3d/workspace/workspaceCameraSnapshot.ts new file mode 100644 index 000000000..5f040f461 --- /dev/null +++ b/src/shared/components/3d/workspace/workspaceCameraSnapshot.ts @@ -0,0 +1,112 @@ +import * as THREE from 'three'; +import type { RootState } from '@react-three/fiber'; + +export interface WorkspaceCameraSnapshot { + kind: 'perspective'; + position: { x: number; y: number; z: number }; + quaternion: { x: number; y: number; z: number; w: number }; + up: { x: number; y: number; z: number }; + zoom: number; + target: { x: number; y: number; z: number }; + aspectRatio: number; + fov: number; + near: number; + far: number; +} + +interface OrbitControlsLike { + target: THREE.Vector3; + update?: () => void; +} + +function vectorToObject(vector: THREE.Vector3) { + return { + x: vector.x, + y: vector.y, + z: vector.z, + }; +} + +function quaternionToObject(quaternion: THREE.Quaternion) { + return { + x: quaternion.x, + y: quaternion.y, + z: quaternion.z, + w: quaternion.w, + }; +} + +function isPerspectiveCamera(camera: THREE.Camera): camera is THREE.PerspectiveCamera { + return camera instanceof THREE.PerspectiveCamera; +} + +export function captureWorkspaceCameraSnapshot( + state: Pick, +): WorkspaceCameraSnapshot | null { + if (!isPerspectiveCamera(state.camera)) { + return null; + } + + const controls = state.controls as unknown as OrbitControlsLike | undefined; + const target = controls?.target ?? new THREE.Vector3(0, 0, 0); + const aspectRatio = + state.size.width > 0 && state.size.height > 0 ? state.size.width / state.size.height : 1; + + return { + kind: 'perspective', + position: vectorToObject(state.camera.position), + quaternion: quaternionToObject(state.camera.quaternion), + up: vectorToObject(state.camera.up), + zoom: state.camera.zoom, + target: vectorToObject(target), + aspectRatio, + fov: state.camera.fov, + near: state.camera.near, + far: state.camera.far, + }; +} + +export function applyWorkspaceCameraSnapshot( + camera: THREE.Camera, + controls: OrbitControlsLike | null | undefined, + snapshot: WorkspaceCameraSnapshot | null | undefined, +) { + if (!snapshot || !isPerspectiveCamera(camera)) { + return; + } + + camera.position.set(snapshot.position.x, snapshot.position.y, snapshot.position.z); + camera.quaternion + .set(snapshot.quaternion.x, snapshot.quaternion.y, snapshot.quaternion.z, snapshot.quaternion.w) + .normalize(); + camera.up.set(snapshot.up.x, snapshot.up.y, snapshot.up.z); + camera.zoom = snapshot.zoom; + camera.aspect = snapshot.aspectRatio; + camera.fov = snapshot.fov; + camera.near = snapshot.near; + camera.far = snapshot.far; + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + if (controls) { + controls.target.set(snapshot.target.x, snapshot.target.y, snapshot.target.z); + controls.update?.(); + } +} + +export function resolveSnapshotPreviewSurfaceSize(aspectRatio: number) { + const safeAspectRatio = Number.isFinite(aspectRatio) && aspectRatio > 0 ? aspectRatio : 1; + const targetLongEdge = 960; + + if (safeAspectRatio >= 1) { + return { + width: targetLongEdge, + height: Math.max(1, Math.round(targetLongEdge / safeAspectRatio)), + }; + } + + return { + width: Math.max(1, Math.round(targetLongEdge * safeAspectRatio)), + height: targetLongEdge, + }; +} diff --git a/src/shared/i18n/locales/en.ts b/src/shared/i18n/locales/en.ts index cb35910f4..68795327d 100644 --- a/src/shared/i18n/locales/en.ts +++ b/src/shared/i18n/locales/en.ts @@ -652,6 +652,15 @@ export const en: TranslationKeys = { snapshotHideGrid: 'Remove reference grid from the snapshot', snapshotAAMode: 'Supersampled AA', snapshotAdvancedLook: 'Advanced Look', + snapshotPreviewTitle: 'Live Preview', + snapshotPreviewAlt: 'Snapshot live preview', + snapshotPreviewFrozenView: 'Based on the view when this dialog opened', + snapshotPreviewLoading: 'Generating preview…', + snapshotPreviewRefreshing: 'Updating preview…', + snapshotPreviewReady: 'Preview ready', + snapshotPreviewFailed: 'Preview update failed', + snapshotPreviewQualityHint: 'Final export quality still follows the selected resolution.', + snapshotPreviewRetryingHint: 'Adjusting options will trigger another preview attempt.', snapshotCapture: 'Export Snapshot', snapshotCapturing: 'Capturing…', failedToProcessFiles: 'Failed to process files', diff --git a/src/shared/i18n/locales/zh.ts b/src/shared/i18n/locales/zh.ts index e70a82dfd..284a5816f 100644 --- a/src/shared/i18n/locales/zh.ts +++ b/src/shared/i18n/locales/zh.ts @@ -620,6 +620,15 @@ export const zh: TranslationKeys = { snapshotHideGrid: '导出时移除参考网格', snapshotAAMode: '超采样抗锯齿', snapshotAdvancedLook: '高级光影', + snapshotPreviewTitle: '实时预览', + snapshotPreviewAlt: '快照实时预览', + snapshotPreviewFrozenView: '基于打开弹窗时的视角', + snapshotPreviewLoading: '正在生成预览…', + snapshotPreviewRefreshing: '正在更新预览…', + snapshotPreviewReady: '预览已就绪', + snapshotPreviewFailed: '预览更新失败', + snapshotPreviewQualityHint: '最终导出清晰度仍以所选输出分辨率为准。', + snapshotPreviewRetryingHint: '继续调整参数会自动再次尝试生成预览。', snapshotCapture: '导出快照', snapshotCapturing: '正在导出…', failedToProcessFiles: '处理文件失败', diff --git a/src/shared/i18n/types.ts b/src/shared/i18n/types.ts index e926c4f45..7000fbc36 100644 --- a/src/shared/i18n/types.ts +++ b/src/shared/i18n/types.ts @@ -590,6 +590,15 @@ export interface TranslationKeys { snapshotHideGrid: string; snapshotAAMode: string; snapshotAdvancedLook: string; + snapshotPreviewTitle: string; + snapshotPreviewAlt: string; + snapshotPreviewFrozenView: string; + snapshotPreviewLoading: string; + snapshotPreviewRefreshing: string; + snapshotPreviewReady: string; + snapshotPreviewFailed: string; + snapshotPreviewQualityHint: string; + snapshotPreviewRetryingHint: string; snapshotCapture: string; snapshotCapturing: string; failedToProcessFiles: string; From 5170506232081743939ed141e4f6310b4a9a0279 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Tue, 14 Apr 2026 01:56:35 +0800 Subject: [PATCH 02/14] fix: raise default snapshot dialog height --- src/app/components/SnapshotDialog.test.tsx | 40 ++++++++++++++++++++++ src/app/components/SnapshotDialog.tsx | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/app/components/SnapshotDialog.test.tsx b/src/app/components/SnapshotDialog.test.tsx index 40dd82b2e..bc78790d7 100644 --- a/src/app/components/SnapshotDialog.test.tsx +++ b/src/app/components/SnapshotDialog.test.tsx @@ -170,3 +170,43 @@ test('SnapshotDialog keeps the live preview inside the scrollable content area', dom.window.close(); } }); + +test('SnapshotDialog opens with a taller default height so the full panel fits without dragging', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const windowRoot = container.firstElementChild as HTMLElement | null; + assert.ok(windowRoot, 'snapshot dialog should render a draggable window root'); + assert.equal( + windowRoot.style.height, + '680px', + 'snapshot dialog should default to the taller desktop height', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); diff --git a/src/app/components/SnapshotDialog.tsx b/src/app/components/SnapshotDialog.tsx index d0c301d12..f80c907eb 100644 --- a/src/app/components/SnapshotDialog.tsx +++ b/src/app/components/SnapshotDialog.tsx @@ -95,7 +95,7 @@ export function SnapshotDialog({ const windowState = useDraggableWindow({ isOpen, - defaultSize: { width: 560, height: 560 }, + defaultSize: { width: 560, height: 680 }, minSize: { width: 500, height: 420 }, centerOnMount: true, enableMinimize: false, From b1554df18d69da0ed27df07bbc5ffd16a2639c20 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Tue, 14 Apr 2026 02:24:33 +0800 Subject: [PATCH 03/14] fix: stabilize snapshot preview capture flow --- src/app/AppLayout.tsx | 25 +++++++++-- src/app/components/SnapshotDialog.tsx | 4 ++ .../SnapshotPreviewRenderer.tsx | 4 ++ .../resolveSnapshotCaptureAction.test.ts | 44 +++++++++++++++++++ .../resolveSnapshotCaptureAction.ts | 15 +++++++ .../components/3d/scene/SnapshotManager.tsx | 17 ++++--- .../3d/workspace/WorkspaceCanvas.tsx | 3 ++ 7 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/app/components/snapshot-preview/resolveSnapshotCaptureAction.test.ts create mode 100644 src/app/components/snapshot-preview/resolveSnapshotCaptureAction.ts diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index ad623cec1..38b3cb181 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -12,6 +12,7 @@ import { ConnectedDocumentLoadingOverlay } from './components/ConnectedDocumentL import { FileDropOverlay } from './components/FileDropOverlay'; import { ImportPreparationOverlay } from './components/ImportPreparationOverlay'; import { SnapshotDialog } from './components/SnapshotDialog'; +import { resolveSnapshotCaptureAction } from './components/snapshot-preview/resolveSnapshotCaptureAction'; import { loadBridgeCreateModalModule, loadCollisionOptimizationDialogModule, @@ -69,6 +70,7 @@ import type { BridgeJoint, RobotFile, UrdfJoint, UrdfLink } from '@/types'; import { translations } from '@/shared/i18n'; import { captureWorkspaceCameraSnapshot, + type SnapshotCaptureAction, type SnapshotCaptureOptions, } from '@/shared/components/3d'; import { normalizeMergedAppMode } from '@/shared/utils/appMode'; @@ -363,6 +365,7 @@ export function AppLayout({ const [isSnapshotCapturing, setIsSnapshotCapturing] = useState(false); const [snapshotPreviewSession, setSnapshotPreviewSession] = useState(null); + const snapshotPreviewCaptureActionRef = useRef(null); const [isIkToolPanelOpen, setIsIkToolPanelOpen] = useState(false); const [shouldRenderBridgeModal, setShouldRenderBridgeModal] = useState(false); const [bridgePreview, setBridgePreview] = useState(null); @@ -824,8 +827,16 @@ export function AppLayout({ const handleCloseSnapshotDialog = useCallback(() => { setIsSnapshotDialogOpen(false); setSnapshotPreviewSession(null); + snapshotPreviewCaptureActionRef.current = null; }, []); + const handleSnapshotPreviewCaptureActionChange = useCallback( + (action: SnapshotCaptureAction | null) => { + snapshotPreviewCaptureActionRef.current = action; + }, + [], + ); + const handleSnapshot = useCallback(() => { const viewerCanvasState = viewerCanvasStateRef.current; const cameraSnapshot = viewerCanvasState @@ -837,6 +848,7 @@ export function AppLayout({ ? viewerCanvasState.size.width / viewerCanvasState.size.height : 16 / 9); + snapshotPreviewCaptureActionRef.current = null; setSnapshotPreviewSession({ theme, cameraSnapshot, @@ -936,14 +948,20 @@ export function AppLayout({ const handleCaptureSnapshot = useCallback( async (options: SnapshotCaptureOptions) => { - if (!snapshotActionRef.current) { + const captureAction = resolveSnapshotCaptureAction({ + liveCaptureAction: snapshotActionRef.current, + frozenPreviewCaptureAction: snapshotPreviewCaptureActionRef.current, + preferFrozenPreviewCapture: Boolean(snapshotPreviewSession), + }); + + if (!captureAction) { showToast(t.snapshotFailed, 'info'); return; } try { setIsSnapshotCapturing(true); - await snapshotActionRef.current(options); + await captureAction(options); handleCloseSnapshotDialog(); } catch (error) { console.error('Snapshot failed:', error); @@ -952,7 +970,7 @@ export function AppLayout({ setIsSnapshotCapturing(false); } }, - [handleCloseSnapshotDialog, showToast, t], + [handleCloseSnapshotDialog, showToast, snapshotPreviewSession, t], ); const { @@ -1204,6 +1222,7 @@ export function AppLayout({ isCapturing={isSnapshotCapturing} lang={lang} previewSession={snapshotPreviewSession} + onPreviewCaptureActionChange={handleSnapshotPreviewCaptureActionChange} onClose={handleCloseSnapshotDialog} onCapture={handleCaptureSnapshot} /> diff --git a/src/app/components/SnapshotDialog.tsx b/src/app/components/SnapshotDialog.tsx index f80c907eb..68d801133 100644 --- a/src/app/components/SnapshotDialog.tsx +++ b/src/app/components/SnapshotDialog.tsx @@ -12,6 +12,7 @@ import { DraggableWindow } from '@/shared/components'; import { useDraggableWindow } from '@/shared/hooks'; import { DEFAULT_SNAPSHOT_CAPTURE_OPTIONS, + type SnapshotCaptureAction, type SnapshotCaptureOptions, } from '@/shared/components/3d'; import { translations, type Language } from '@/shared/i18n'; @@ -39,6 +40,7 @@ interface SnapshotDialogProps { onCapture: (options: SnapshotCaptureOptions) => Promise | void; previewSession?: SnapshotPreviewSession | null; previewState?: SnapshotDialogPreviewState; + onPreviewCaptureActionChange?: (action: SnapshotCaptureAction | null) => void; } function SnapshotSection({ title, children }: { title: string; children: React.ReactNode }) { @@ -69,6 +71,7 @@ export function SnapshotDialog({ onCapture, previewSession = null, previewState, + onPreviewCaptureActionChange, }: SnapshotDialogProps) { const t = translations[lang]; const [resolutionPreset, setResolutionPreset] = useState( @@ -561,6 +564,7 @@ export function SnapshotDialog({ session={previewSession} options={resolvedOptions} onStateChange={setInternalPreviewState} + onCaptureActionChange={onPreviewCaptureActionChange} /> ) : null} diff --git a/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx b/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx index 111dc7202..1cf3ee55a 100644 --- a/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx +++ b/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx @@ -3,6 +3,7 @@ import { STUDIO_ENVIRONMENT_INTENSITY, WORKSPACE_CANVAS_BACKGROUND, WorkspaceCanvas, + type SnapshotCaptureAction, type SnapshotCaptureOptions, type SnapshotPreviewAction, useWorkspaceCanvasTheme, @@ -22,6 +23,7 @@ interface SnapshotPreviewRendererProps { session: SnapshotPreviewSession | null; options: SnapshotCaptureOptions; onStateChange: (state: SnapshotDialogPreviewState) => void; + onCaptureActionChange?: (action: SnapshotCaptureAction | null) => void; } export function SnapshotPreviewRenderer({ @@ -30,6 +32,7 @@ export function SnapshotPreviewRenderer({ session, options, onStateChange, + onCaptureActionChange, }: SnapshotPreviewRendererProps) { const t = translations[lang]; const previousViewerResourceScopeRef = useRef< @@ -230,6 +233,7 @@ export function SnapshotPreviewRenderer({ lang={lang} className="relative h-full w-full" robotName={session.robotName} + onSnapshotActionChange={onCaptureActionChange} onPreviewActionChange={handlePreviewActionChange} renderKey={`snapshot-preview:${session.viewerReloadKey}`} environment="studio" diff --git a/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.test.ts b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.test.ts new file mode 100644 index 000000000..c2602a308 --- /dev/null +++ b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { SnapshotCaptureAction } from '@/shared/components/3d'; + +import { resolveSnapshotCaptureAction } from './resolveSnapshotCaptureAction'; + +test('resolveSnapshotCaptureAction prefers the frozen preview capture path when requested', () => { + const liveCaptureAction: SnapshotCaptureAction = async () => {}; + const frozenPreviewCaptureAction: SnapshotCaptureAction = async () => {}; + + const resolvedAction = resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction, + preferFrozenPreviewCapture: true, + }); + + assert.equal(resolvedAction, frozenPreviewCaptureAction); +}); + +test('resolveSnapshotCaptureAction keeps using the live viewer capture path when no frozen preview export is needed', () => { + const liveCaptureAction: SnapshotCaptureAction = async () => {}; + const frozenPreviewCaptureAction: SnapshotCaptureAction = async () => {}; + + const resolvedAction = resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction, + preferFrozenPreviewCapture: false, + }); + + assert.equal(resolvedAction, liveCaptureAction); +}); + +test('resolveSnapshotCaptureAction does not fall back to the live viewer while a frozen preview export is pending', () => { + const liveCaptureAction: SnapshotCaptureAction = async () => {}; + + const resolvedAction = resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction: null, + preferFrozenPreviewCapture: true, + }); + + assert.equal(resolvedAction, null); +}); diff --git a/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.ts b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.ts new file mode 100644 index 000000000..9f01e800e --- /dev/null +++ b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.ts @@ -0,0 +1,15 @@ +import type { SnapshotCaptureAction } from '@/shared/components/3d'; + +interface ResolveSnapshotCaptureActionOptions { + liveCaptureAction: SnapshotCaptureAction | null; + frozenPreviewCaptureAction: SnapshotCaptureAction | null; + preferFrozenPreviewCapture: boolean; +} + +export function resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction, + preferFrozenPreviewCapture, +}: ResolveSnapshotCaptureActionOptions): SnapshotCaptureAction | null { + return preferFrozenPreviewCapture ? frozenPreviewCaptureAction : liveCaptureAction; +} diff --git a/src/shared/components/3d/scene/SnapshotManager.tsx b/src/shared/components/3d/scene/SnapshotManager.tsx index e25385953..cd2c2bc07 100644 --- a/src/shared/components/3d/scene/SnapshotManager.tsx +++ b/src/shared/components/3d/scene/SnapshotManager.tsx @@ -61,6 +61,7 @@ function ensureSnapshotHdrPreloaded(): Promise { interface SnapshotManagerProps { actionRef?: RefObject; + onSnapshotActionChange?: (action: SnapshotCaptureAction | null) => void; previewActionRef?: RefObject; onPreviewActionChange?: (action: SnapshotPreviewAction | null) => void; robotName: string; @@ -70,6 +71,7 @@ interface SnapshotManagerProps { export const SnapshotManager = ({ actionRef, + onSnapshotActionChange, previewActionRef, onPreviewActionChange, robotName, @@ -84,7 +86,7 @@ export const SnapshotManager = ({ const { setSnapshotRenderActive } = useSnapshotRenderContext(); useEffect(() => { - if (!actionRef && !previewActionRef && !onPreviewActionChange) { + if (!actionRef && !onSnapshotActionChange && !previewActionRef && !onPreviewActionChange) { return; } @@ -483,12 +485,15 @@ export const SnapshotManager = ({ } }; + const captureAction: SnapshotCaptureAction = async (requestedOptions) => { + const capture = await runSnapshotCapture(requestedOptions, normalizeSnapshotCaptureOptions); + await downloadCanvas(capture.canvas, capture.options); + }; + if (actionRef) { - actionRef.current = async (requestedOptions) => { - const capture = await runSnapshotCapture(requestedOptions, normalizeSnapshotCaptureOptions); - await downloadCanvas(capture.canvas, capture.options); - }; + actionRef.current = captureAction; } + onSnapshotActionChange?.(captureAction); const previewAction: SnapshotPreviewAction = async (requestedOptions) => { const capture = await runSnapshotCapture( @@ -514,6 +519,7 @@ export const SnapshotManager = ({ if (actionRef) { actionRef.current = null; } + onSnapshotActionChange?.(null); if (previewActionRef) { previewActionRef.current = null; } @@ -524,6 +530,7 @@ export const SnapshotManager = ({ get, gl, invalidate, + onSnapshotActionChange, onPreviewActionChange, previewActionRef, robotName, diff --git a/src/shared/components/3d/workspace/WorkspaceCanvas.tsx b/src/shared/components/3d/workspace/WorkspaceCanvas.tsx index 8634d4fef..e2eb55be2 100644 --- a/src/shared/components/3d/workspace/WorkspaceCanvas.tsx +++ b/src/shared/components/3d/workspace/WorkspaceCanvas.tsx @@ -50,6 +50,7 @@ interface WorkspaceCanvasProps { containerRef?: React.RefObject; sceneRef?: React.RefObject; snapshotAction?: React.RefObject; + onSnapshotActionChange?: (action: SnapshotCaptureAction | null) => void; previewAction?: React.RefObject; onPreviewActionChange?: (action: SnapshotPreviewAction | null) => void; children: React.ReactNode; @@ -98,6 +99,7 @@ export const WorkspaceCanvas = ({ containerRef, sceneRef, snapshotAction, + onSnapshotActionChange, previewAction, onPreviewActionChange, children, @@ -423,6 +425,7 @@ export const WorkspaceCanvas = ({ /> Date: Tue, 14 Apr 2026 02:25:53 +0800 Subject: [PATCH 04/14] feat(ai-assistant): add dual-mode inspection setup --- .../components/AIInspectionModal.test.tsx | 307 +++++++++++++++ .../components/AIInspectionModal.tsx | 362 ++++++++++++------ .../components/InspectionReport.tsx | 14 +- .../components/InspectionSetupNormalView.tsx | 164 ++++++++ .../components/inspectionCategoryIcon.tsx | 13 + src/shared/i18n/locales/en.ts | 6 + src/shared/i18n/locales/zh.ts | 6 + src/shared/i18n/types.ts | 5 + 8 files changed, 747 insertions(+), 130 deletions(-) create mode 100644 src/features/ai-assistant/components/InspectionSetupNormalView.tsx create mode 100644 src/features/ai-assistant/components/inspectionCategoryIcon.tsx diff --git a/src/features/ai-assistant/components/AIInspectionModal.test.tsx b/src/features/ai-assistant/components/AIInspectionModal.test.tsx index 6339f6afa..5a9af8fe5 100644 --- a/src/features/ai-assistant/components/AIInspectionModal.test.tsx +++ b/src/features/ai-assistant/components/AIInspectionModal.test.tsx @@ -10,6 +10,7 @@ import { __setPdfCanvasFactoryForTests, __setPdfGenerationDepsLoaderForTests, } from '@/features/file-io/utils/generatePdfFromHtml'; +import { INSPECTION_CRITERIA } from '../utils/inspectionCriteria'; import { GeometryType, JointType, type RobotState } from '@/types'; function installDom() { @@ -464,3 +465,309 @@ test('saving the report from regenerate confirmation returns to the inspection r dom.window.close(); } }); + +test('inspection setup restores the saved normal mode and keeps selection in sync with advanced mode', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + const firstCategory = INSPECTION_CRITERIA[0]; + const firstItem = firstCategory?.items[0]; + assert.ok(firstCategory, 'expected inspection criteria to include at least one category'); + assert.ok(firstItem, 'expected the first category to include at least one item'); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + assert.equal( + container.textContent?.includes(t.inspectionConfigureChecks), + true, + 'expected the saved normal mode to render the simplified setup heading', + ); + assert.equal( + container.textContent?.includes(t.inspectionScoringReference), + false, + 'expected the normal mode to hide advanced scoring references', + ); + + const firstItemButton = getButtonByText(firstItem!.nameZh); + assert.ok(firstItemButton, 'expected the normal mode item button to render'); + + await act(async () => { + firstItemButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const advancedModeButton = getButtonByText(t.inspectionAdvancedMode); + assert.ok(advancedModeButton, 'expected the advanced mode toggle to render'); + + await act(async () => { + advancedModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + container.textContent?.includes(t.inspectionScoringReference), + true, + 'expected the advanced mode to restore scoring references', + ); + assert.equal( + container.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount - 1)) + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected advanced mode to reflect the selection changed in normal mode', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup persists the last selected mode across remounts', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.removeItem('urdf-studio.ai-inspection.setup-mode'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const advancedModeButton = getButtonByText(t.inspectionAdvancedMode); + assert.ok(advancedModeButton, 'expected the advanced mode toggle to render'); + + await act(async () => { + advancedModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + dom.window.localStorage.getItem('urdf-studio.ai-inspection.setup-mode'), + 'advanced', + 'expected mode changes to persist into local storage', + ); + + await act(async () => { + root.unmount(); + }); + + const reopenedRoot = createRoot(container); + + try { + await act(async () => { + reopenedRoot.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + assert.equal( + container.textContent?.includes(t.inspectionScoringReference), + true, + 'expected the remounted setup to restore the last selected advanced mode', + ); + assert.equal( + container.textContent?.includes(t.inspectionConfigureChecks), + false, + 'expected the remounted setup to skip the normal-mode layout when advanced was saved', + ); + } finally { + await act(async () => { + reopenedRoot.unmount(); + }); + } + } finally { + dom.window.close(); + } +}); + +test('inspection setup keeps the mode switcher visually centered in the header', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const modeSwitcher = container.querySelector('[data-inspection-setup-mode-switcher]'); + assert.ok(modeSwitcher, 'expected the setup header to render a dedicated mode switcher wrapper'); + assert.equal( + modeSwitcher.className.includes('absolute left-1/2 top-1/2'), + true, + 'expected the setup mode switcher to anchor from the visual center of the header', + ); + assert.equal( + modeSwitcher.className.includes('-translate-x-1/2 -translate-y-1/2'), + true, + 'expected the setup mode switcher to translate back from the anchor point for true centering', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup header uses the same maximize and restore icons as AI conversation', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const maximizeButton = container.querySelector( + `button[aria-label="${t.maximize}"]`, + ); + assert.ok(maximizeButton, 'expected the setup header maximize button to render'); + assert.ok( + maximizeButton.querySelector('svg.lucide-maximize-2'), + 'expected the setup header maximize button to use the shared maximize icon', + ); + + await act(async () => { + maximizeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const restoreButton = container.querySelector( + `button[aria-label="${t.restore}"]`, + ); + assert.ok(restoreButton, 'expected the setup header restore button to render after maximizing'); + assert.ok( + restoreButton.querySelector('svg.lucide-minimize-2'), + 'expected the setup header restore button to use the shared restore icon', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('advanced setup summary chip uses content-based width instead of stretching across the footer', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'advanced'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const summaryChip = container.querySelector('[data-inspection-setup-summary]'); + assert.ok(summaryChip, 'expected the advanced setup footer to render a summary chip wrapper'); + assert.equal( + summaryChip.className.includes('inline-flex'), + true, + 'expected the advanced setup summary chip to size to its content', + ); + assert.equal( + summaryChip.className.includes('w-fit'), + true, + 'expected the advanced setup summary chip to stop expanding toward the footer actions', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); diff --git a/src/features/ai-assistant/components/AIInspectionModal.tsx b/src/features/ai-assistant/components/AIInspectionModal.tsx index e86e75bfc..5fba23f66 100644 --- a/src/features/ai-assistant/components/AIInspectionModal.tsx +++ b/src/features/ai-assistant/components/AIInspectionModal.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { MessageCircle, ScanSearch } from 'lucide-react'; +import { Bot, MessageCircle, ScanSearch } from 'lucide-react'; import type { InspectionReport, RobotState } from '@/types'; import type { Language } from '@/shared/i18n'; import { translations } from '@/shared/i18n'; import { DraggableWindow } from '@/shared/components'; import { Button } from '@/shared/components/ui/Button'; import { Dialog } from '@/shared/components/ui/Dialog'; +import { SegmentedControl } from '@/shared/components/ui/SegmentedControl'; import { useDraggableWindow } from '@/shared/hooks'; import { runRobotInspection } from '../services/aiService'; import { calculateOverallScore, INSPECTION_CRITERIA } from '../utils/inspectionCriteria'; @@ -23,6 +24,7 @@ import { InspectionReportView, } from './InspectionReport'; import { InspectionSidebar, type SelectedInspectionItems } from './InspectionSidebar'; +import { InspectionSetupNormalView } from './InspectionSetupNormalView'; import { InspectionSetupView } from './InspectionSetupView'; interface AIInspectionModalProps { @@ -50,6 +52,27 @@ interface ReportScrollTarget { anchorId: string; } +type InspectionSetupMode = 'normal' | 'advanced'; + +const INSPECTION_SETUP_MODE_STORAGE_KEY = 'urdf-studio.ai-inspection.setup-mode'; +const TOTAL_INSPECTION_ITEM_COUNT = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, +); + +function readStoredInspectionSetupMode(): InspectionSetupMode { + if (typeof window === 'undefined') { + return 'advanced'; + } + + try { + const storedMode = window.localStorage.getItem(INSPECTION_SETUP_MODE_STORAGE_KEY); + return storedMode === 'normal' || storedMode === 'advanced' ? storedMode : 'advanced'; + } catch { + return 'advanced'; + } +} + function createInitialSelectedItems(): SelectedInspectionItems { const initial: SelectedInspectionItems = {}; INSPECTION_CRITERIA.forEach((category) => { @@ -132,6 +155,9 @@ export function AIInspectionModal({ const [selectedItems, setSelectedItems] = useState(() => createInitialSelectedItems(), ); + const [inspectionSetupMode, setInspectionSetupMode] = useState(() => + readStoredInspectionSetupMode(), + ); const [focusedCategoryId, setFocusedCategoryId] = useState( INSPECTION_CRITERIA[0]?.id ?? '', ); @@ -168,6 +194,18 @@ export function AIInspectionModal({ } }, []); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(INSPECTION_SETUP_MODE_STORAGE_KEY, inspectionSetupMode); + } catch { + // Ignore storage write failures and keep the in-memory mode. + } + }, [inspectionSetupMode]); + useEffect(() => { isMountedRef.current = true; @@ -436,6 +474,14 @@ export function AIInspectionModal({ [inspectionReport, onOpenConversationWithReport, robot], ); + const isSetupView = !inspectionProgress && !inspectionReport; + const inspectionSetupSummary = + `${t.inspectionRunSummary}${lang === 'zh' ? ':' : ': '}` + + `${t.inspectionSelectedChecks.replace('{count}', String(totalSelectedCount))} | ` + + `${t.inspectionSelectedCategories}: ${selectedCategoryCount} | ` + + `${t.inspectionWeightedCoverage}: ${selectedWeightPercentage}% | ` + + `${t.inspectionMaxPossibleScore}: ${maxPossibleScore}`; + if (!isOpen) { return null; } @@ -448,31 +494,62 @@ export function AIInspectionModal({ window={windowState} onClose={handleClose} title={ - <> -
-
- + isSetupView ? ( +
+
+

{t.aiInspection}

- - {inspectionReport && !isMinimized && ( -
-
- - {t.overallScore}: {inspectionReport.overallScore?.toFixed(1)} - + ) : ( + <> +
+
+ +
+

{t.aiInspection}

- )} - + + {inspectionReport && !isMinimized && ( +
+
+ + {t.overallScore}: {inspectionReport.overallScore?.toFixed(1)} + +
+ )} + + ) } className="z-[100] flex flex-col overflow-hidden rounded-2xl border border-border-black bg-panel-bg text-text-primary shadow-xl select-none dark:bg-panel-bg" - headerClassName="h-12 border-b border-border-black flex items-center justify-between px-4 bg-element-bg shrink-0" + headerClassName="relative h-12 border-b border-border-black flex items-center justify-between px-4 bg-element-bg shrink-0" + headerLeftClassName={isSetupView ? 'flex min-w-0 items-center' : 'flex items-center gap-3'} + headerRightClassName={isSetupView ? 'flex shrink-0 items-center gap-1 ml-auto' : 'flex items-center gap-1'} + headerActions={ + isSetupView && !isMinimized ? ( +
+ + options={[ + { value: 'normal', label: t.inspectionNormalMode }, + { value: 'advanced', label: t.inspectionAdvancedMode }, + ]} + value={inspectionSetupMode} + onChange={setInspectionSetupMode} + stretch={false} + className="w-full max-w-[260px]" + itemClassName="min-w-[108px]" + /> +
+ ) : undefined + } interactionClassName="select-none" minimizeTitle={t.minimize} maximizeTitle={t.maximize} @@ -487,116 +564,165 @@ export function AIInspectionModal({ > {!isMinimized && (
- - -
-
- {inspectionProgress && inspectionRunContext ? ( - + - ) : inspectionReport ? ( -
-
- +
+ +
+
+ + ) : ( +
+
+ +
+
+ ) + ) : ( + <> + -
- +
+
+ {inspectionProgress && inspectionRunContext ? ( + + ) : inspectionReport ? ( +
+
+ + +
+ +
+
-
+ ) : null}
- ) : ( - - )} -
-
+
+ + )}
)} {!inspectionProgress && (
-
- {!inspectionReport && ( -
-
- - {t.inspectionRunSummary} - - - {t.inspectionSelectedChecks.replace('{count}', String(totalSelectedCount))} - - - {t.inspectionSelectedCategories}: {selectedCategoryCount} - - - {t.inspectionWeightedCoverage}: {selectedWeightPercentage}% - - - {t.inspectionMaxPossibleScore}: {maxPossibleScore} - -
+ {inspectionReport ? ( + <> +
+ +
+ +
+ + ) : ( + <> +
+ {inspectionSetupMode === 'normal' ? ( +
+ + {t.inspectionSelectedChecksLabel}: + + + {totalSelectedCount} + + / + + {TOTAL_INSPECTION_ITEM_COUNT} + +
+ ) : ( +
+ {inspectionSetupSummary} +
+ )}
- )} -
-
- {inspectionReport ? ( - - ) : ( - <> +
- - )} -
+
+ + )}
)} diff --git a/src/features/ai-assistant/components/InspectionReport.tsx b/src/features/ai-assistant/components/InspectionReport.tsx index 59d19d20e..5f6714ab3 100644 --- a/src/features/ai-assistant/components/InspectionReport.tsx +++ b/src/features/ai-assistant/components/InspectionReport.tsx @@ -23,6 +23,7 @@ import { resolveInspectionIssueSelectionTarget, } from '../utils/inspectionSelectionTargets'; import { getScoreBgColor, getScoreColor } from '../utils/scoreHelpers'; +import { getInspectionCategoryIcon } from './inspectionCategoryIcon'; interface RetestingItemState { categoryId: string; @@ -83,17 +84,6 @@ function compareIssuesByPriority( return (a.score ?? 10) - (b.score ?? 10); } -function getCategoryIcon(categoryId: string) { - if (categoryId === 'spec') return FileText; - if (categoryId === 'physical') return Box; - if (categoryId === 'frames') return RefreshCw; - if (categoryId === 'assembly') return LayoutGrid; - if (categoryId === 'simulation') return Sparkles; - if (categoryId === 'hardware') return Sparkles; - if (categoryId === 'naming') return FileText; - return Sparkles; -} - function getIssueMeta(issueType: string, lang: Language) { const t = translations[lang]; if (issueType === 'error') { @@ -562,7 +552,7 @@ export function InspectionReportView({ anchorId, }) => { const isExpanded = expandedCategories.has(category.id); - const CategoryIcon = getCategoryIcon(category.id); + const CategoryIcon = getInspectionCategoryIcon(category.id); return (
>; + onFocusCategory: (categoryId: string) => void; +} + +interface SelectionMarkProps { + checked: boolean; + indeterminate?: boolean; +} + +function SelectionMark({ checked, indeterminate = false }: SelectionMarkProps) { + const isActive = checked || indeterminate; + + return ( + + ); +} + +export function InspectionSetupNormalView({ + lang, + t, + selectedItems, + setSelectedItems, + onFocusCategory, +}: InspectionSetupNormalViewProps) { + const toggleCategorySelection = (categoryId: string) => { + setSelectedItems((prev) => { + const next = { ...prev }; + const category = INSPECTION_CRITERIA.find((entry) => entry.id === categoryId); + if (!category) { + return prev; + } + + const allSelected = category.items.every((item) => next[categoryId]?.has(item.id)); + next[categoryId] = allSelected ? new Set() : new Set(category.items.map((item) => item.id)); + return next; + }); + }; + + const toggleItemSelection = (categoryId: string, itemId: string) => { + setSelectedItems((prev) => { + const next = { ...prev }; + const itemSet = new Set(next[categoryId] ?? []); + if (itemSet.has(itemId)) { + itemSet.delete(itemId); + } else { + itemSet.add(itemId); + } + next[categoryId] = itemSet; + return next; + }); + }; + + return ( +
+
+

+ {t.inspectionConfigureChecks} +

+

+ {t.inspectionConfigureChecksDescription} +

+
+ +
+ {INSPECTION_CRITERIA.map((category) => { + const Icon = getInspectionCategoryIcon(category.id); + const categoryName = lang === 'zh' ? category.nameZh : category.name; + const selectedCount = selectedItems[category.id]?.size ?? 0; + const allSelected = selectedCount === category.items.length; + const someSelected = selectedCount > 0 && !allSelected; + + return ( +
+ + +
+ {category.items.map((item) => { + const itemName = lang === 'zh' ? item.nameZh : item.name; + const isSelected = selectedItems[category.id]?.has(item.id) ?? false; + + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); +} + +export default InspectionSetupNormalView; diff --git a/src/features/ai-assistant/components/inspectionCategoryIcon.tsx b/src/features/ai-assistant/components/inspectionCategoryIcon.tsx new file mode 100644 index 000000000..6ff06ede7 --- /dev/null +++ b/src/features/ai-assistant/components/inspectionCategoryIcon.tsx @@ -0,0 +1,13 @@ +import { Box, FileText, LayoutGrid, RefreshCw, Sparkles } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +export function getInspectionCategoryIcon(categoryId: string): LucideIcon { + if (categoryId === 'spec') return FileText; + if (categoryId === 'physical') return Box; + if (categoryId === 'frames') return RefreshCw; + if (categoryId === 'assembly') return LayoutGrid; + if (categoryId === 'simulation') return Sparkles; + if (categoryId === 'hardware') return Sparkles; + if (categoryId === 'naming') return FileText; + return Sparkles; +} diff --git a/src/shared/i18n/locales/en.ts b/src/shared/i18n/locales/en.ts index cb35910f4..10c0e2e52 100644 --- a/src/shared/i18n/locales/en.ts +++ b/src/shared/i18n/locales/en.ts @@ -324,6 +324,12 @@ export const en: TranslationKeys = { minimize: 'Minimize', maximize: 'Maximize', restore: 'Restore', + inspectionNormalMode: 'Normal Mode', + inspectionAdvancedMode: 'Advanced Mode', + inspectionConfigureChecks: 'Configure Inspection Checks', + inspectionConfigureChecksDescription: + 'Normal mode keeps setup focused on selection only. Switch to advanced mode to review weights, impact labels, and detailed scoring guidance.', + inspectionSelectedChecksLabel: 'Selected Checks', inspectionScopeDescription: 'Choose the categories and individual checks to include in this run.', inspectionSelectedChecksSummary: '{selected} of {total} checks selected', inspectionRobotSnapshot: 'Robot Snapshot', diff --git a/src/shared/i18n/locales/zh.ts b/src/shared/i18n/locales/zh.ts index e70a82dfd..aff7c318a 100644 --- a/src/shared/i18n/locales/zh.ts +++ b/src/shared/i18n/locales/zh.ts @@ -306,6 +306,12 @@ export const zh: TranslationKeys = { minimize: '最小化', maximize: '最大化', restore: '还原', + inspectionNormalMode: '常规模式', + inspectionAdvancedMode: '高级模式', + inspectionConfigureChecks: '配置检查项目', + inspectionConfigureChecksDescription: + '常规模式仅保留勾选操作;如需查看权重、影响程度和完整评分口径,请切换到高级模式。', + inspectionSelectedChecksLabel: '已选择检查项', inspectionScopeDescription: '按类别或单项勾选本次需要执行的审阅项。', inspectionSelectedChecksSummary: '已选择 {selected}/{total} 项检查', inspectionRobotSnapshot: '机器人快照', diff --git a/src/shared/i18n/types.ts b/src/shared/i18n/types.ts index e926c4f45..195243e8a 100644 --- a/src/shared/i18n/types.ts +++ b/src/shared/i18n/types.ts @@ -293,6 +293,11 @@ export interface TranslationKeys { minimize: string; maximize: string; restore: string; + inspectionNormalMode: string; + inspectionAdvancedMode: string; + inspectionConfigureChecks: string; + inspectionConfigureChecksDescription: string; + inspectionSelectedChecksLabel: string; inspectionScopeDescription: string; inspectionSelectedChecksSummary: string; inspectionRobotSnapshot: string; From 7eb93a54a7689cbd1a4fc7336fe2782b6dd89e35 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Tue, 14 Apr 2026 02:39:35 +0800 Subject: [PATCH 05/14] fix: keep snapshot dialog open after export --- src/app/AppLayout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index 38b3cb181..0654267ab 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -962,7 +962,6 @@ export function AppLayout({ try { setIsSnapshotCapturing(true); await captureAction(options); - handleCloseSnapshotDialog(); } catch (error) { console.error('Snapshot failed:', error); showToast(t.snapshotFailed, 'info'); From 6b71e287fc3133be47c84e81977150a178bd771c Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Tue, 14 Apr 2026 03:34:41 +0800 Subject: [PATCH 06/14] fix: capture live orbit target for snapshot preview --- .../workspace/workspaceCameraSnapshot.test.ts | 34 +++++++++++++++++++ .../3d/workspace/workspaceCameraSnapshot.ts | 26 ++++++++------ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts b/src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts index 6fe0ffd55..a2c84f9ab 100644 --- a/src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts +++ b/src/shared/components/3d/workspace/workspaceCameraSnapshot.test.ts @@ -38,6 +38,40 @@ test('captureWorkspaceCameraSnapshot reads the current camera and orbit target', assert.equal(snapshot?.fov, 52); }); +test('captureWorkspaceCameraSnapshot reads the live controls target from the current R3F store state', () => { + const camera = new THREE.PerspectiveCamera(52, 2, 0.1, 500); + camera.position.set(4, 5, 6); + camera.up.set(0, 1, 0); + camera.lookAt(new THREE.Vector3(1, 2, 3)); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + const liveControls = { + target: new THREE.Vector3(1, 2, 3), + }; + const staleCreatedState = { + camera, + controls: null, + size: { + width: 1200, + height: 600, + }, + get: () => ({ + camera, + controls: liveControls, + size: { + width: 1200, + height: 600, + }, + }), + }; + + const snapshot = captureWorkspaceCameraSnapshot(staleCreatedState as any); + + assert.ok(snapshot, 'expected a workspace camera snapshot'); + assert.deepEqual(snapshot?.target, { x: 1, y: 2, z: 3 }); +}); + test('applyWorkspaceCameraSnapshot restores camera transform and orbit target', () => { const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); let controlsUpdated = false; diff --git a/src/shared/components/3d/workspace/workspaceCameraSnapshot.ts b/src/shared/components/3d/workspace/workspaceCameraSnapshot.ts index 5f040f461..209d7e8f3 100644 --- a/src/shared/components/3d/workspace/workspaceCameraSnapshot.ts +++ b/src/shared/components/3d/workspace/workspaceCameraSnapshot.ts @@ -41,28 +41,32 @@ function isPerspectiveCamera(camera: THREE.Camera): camera is THREE.PerspectiveC } export function captureWorkspaceCameraSnapshot( - state: Pick, + state: Pick, ): WorkspaceCameraSnapshot | null { - if (!isPerspectiveCamera(state.camera)) { + const resolvedState = typeof state.get === 'function' ? state.get() : state; + + if (!isPerspectiveCamera(resolvedState.camera)) { return null; } - const controls = state.controls as unknown as OrbitControlsLike | undefined; + const controls = resolvedState.controls as unknown as OrbitControlsLike | undefined; const target = controls?.target ?? new THREE.Vector3(0, 0, 0); const aspectRatio = - state.size.width > 0 && state.size.height > 0 ? state.size.width / state.size.height : 1; + resolvedState.size.width > 0 && resolvedState.size.height > 0 + ? resolvedState.size.width / resolvedState.size.height + : 1; return { kind: 'perspective', - position: vectorToObject(state.camera.position), - quaternion: quaternionToObject(state.camera.quaternion), - up: vectorToObject(state.camera.up), - zoom: state.camera.zoom, + position: vectorToObject(resolvedState.camera.position), + quaternion: quaternionToObject(resolvedState.camera.quaternion), + up: vectorToObject(resolvedState.camera.up), + zoom: resolvedState.camera.zoom, target: vectorToObject(target), aspectRatio, - fov: state.camera.fov, - near: state.camera.near, - far: state.camera.far, + fov: resolvedState.camera.fov, + near: resolvedState.camera.near, + far: resolvedState.camera.far, }; } From 32b30ea9088450c2c91e3fcc7a03fe9efcbfede6 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Tue, 14 Apr 2026 03:45:19 +0800 Subject: [PATCH 07/14] feat(ai-assistant): refine inspection setup mode ui --- .../components/AIInspectionModal.test.tsx | 392 ++++++++++++++++++ .../components/AIInspectionModal.tsx | 35 +- .../components/InspectionSetupNormalView.tsx | 141 +++++-- src/shared/i18n/locales/en.ts | 4 +- src/shared/i18n/locales/zh.ts | 4 +- src/shared/i18n/types.ts | 2 + 6 files changed, 539 insertions(+), 39 deletions(-) diff --git a/src/features/ai-assistant/components/AIInspectionModal.test.tsx b/src/features/ai-assistant/components/AIInspectionModal.test.tsx index 5a9af8fe5..7d66a3c8d 100644 --- a/src/features/ai-assistant/components/AIInspectionModal.test.tsx +++ b/src/features/ai-assistant/components/AIInspectionModal.test.tsx @@ -551,6 +551,398 @@ test('inspection setup restores the saved normal mode and keeps selection in syn } }); +test('inspection setup normal mode shows the inline selection summary and page-level bulk actions', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const summaryChip = container.querySelector('[data-inspection-normal-summary]'); + assert.ok(summaryChip, 'expected the normal mode header to render an inline selection summary'); + assert.equal( + summaryChip.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount)) + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected the inline summary to reflect the initial all-selected state', + ); + + assert.ok(getButtonByText('全选全部'), 'expected a page-level select-all action to render'); + assert.ok(getButtonByText('清空全部'), 'expected a page-level clear-all action to render'); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode bulk actions keep selection counts and footer state in sync', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const summaryChip = () => + container.querySelector('[data-inspection-normal-summary]'); + const runButton = getButtonByText(t.runInspection) as HTMLButtonElement | null; + assert.ok(runButton, 'expected the normal mode run button to render'); + assert.equal(runButton.disabled, false, 'expected run inspection to start enabled'); + + const clearAllButton = getButtonByText('清空全部'); + assert.ok(clearAllButton, 'expected the normal mode clear-all action to render'); + + await act(async () => { + clearAllButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + summaryChip()?.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', '0') + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected clear-all to reset the inline summary count', + ); + assert.equal( + runButton.disabled, + true, + 'expected clear-all to disable running the inspection', + ); + + const selectAllButton = getButtonByText('全选全部'); + assert.ok(selectAllButton, 'expected the normal mode select-all action to render'); + + await act(async () => { + selectAllButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + summaryChip()?.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount)) + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected select-all to restore the inline summary count', + ); + assert.equal( + runButton.disabled, + false, + 'expected select-all to re-enable running the inspection', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode uses a compact visual scale aligned with advanced mode', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const title = container.querySelector('[data-inspection-normal-title]'); + assert.ok(title, 'expected the normal mode title to render a test hook'); + assert.equal( + title.className.includes('text-lg'), + true, + 'expected the normal mode title to use a compact heading scale', + ); + + const summaryChip = container.querySelector('[data-inspection-normal-summary]'); + assert.ok(summaryChip, 'expected the normal mode summary chip to render'); + assert.equal( + summaryChip.className.includes('text-[11px]'), + true, + 'expected the normal mode summary chip to use compact body sizing', + ); + + const actionButtons = Array.from( + container.querySelectorAll('[data-inspection-normal-action]'), + ); + assert.equal(actionButtons.length, 2, 'expected both normal mode bulk actions to render'); + assert.equal( + actionButtons.every((button) => button.className.includes('h-8')), + true, + 'expected the normal mode bulk actions to match the denser advanced-mode button height', + ); + + const firstCategoryCard = container.querySelector( + '[data-inspection-normal-category]', + ); + assert.ok(firstCategoryCard, 'expected a normal mode category card to render'); + assert.equal( + firstCategoryCard.className.includes('rounded-xl'), + true, + 'expected the normal mode category card to use the tighter card radius', + ); + + const categoryIcon = firstCategoryCard.querySelector( + '[data-inspection-normal-category-icon]', + ); + assert.ok(categoryIcon, 'expected the category card icon wrapper to render'); + assert.equal( + categoryIcon.className.includes('h-9 w-9'), + true, + 'expected the category icon wrapper to use the compact category scale', + ); + + const firstItemRow = firstCategoryCard.querySelector('[data-inspection-normal-item]'); + assert.ok(firstItemRow, 'expected a normal mode item row to render'); + assert.equal( + firstItemRow.className.includes('rounded-lg'), + true, + 'expected the normal mode item rows to use a tighter item shape', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode visually differentiates select-all and clear-all actions', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const selectAllButton = container.querySelector( + '[data-inspection-normal-action="select-all"]', + ); + const clearAllButton = container.querySelector( + '[data-inspection-normal-action="clear-all"]', + ); + + assert.ok(selectAllButton, 'expected the select-all action to render a dedicated test hook'); + assert.ok(clearAllButton, 'expected the clear-all action to render a dedicated test hook'); + assert.equal( + selectAllButton.className.includes('border-system-blue/25') && + selectAllButton.className.includes('bg-system-blue/10') && + selectAllButton.className.includes('text-system-blue'), + true, + 'expected select-all to use the emphasized positive action styling', + ); + assert.equal( + clearAllButton.className.includes('border-danger-border') && + clearAllButton.className.includes('bg-danger-soft') && + clearAllButton.className.includes('text-danger'), + true, + 'expected clear-all to use the reset action styling', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode footer uses a compact aligned count treatment', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const footerSummary = container.querySelector( + '[data-inspection-normal-footer-summary]', + ); + assert.ok(footerSummary, 'expected the normal mode footer to render a dedicated count summary'); + assert.equal( + footerSummary.className.includes('inline-flex items-center'), + true, + 'expected the footer summary to use an aligned inline-flex layout', + ); + + const primaryCount = container.querySelector( + '[data-inspection-normal-footer-primary-count]', + ); + const totalCount = container.querySelector( + '[data-inspection-normal-footer-total-count]', + ); + assert.ok(primaryCount, 'expected the footer summary to render the selected-count token'); + assert.ok(totalCount, 'expected the footer summary to render the total-count token'); + assert.equal( + primaryCount.className.includes('text-2xl'), + true, + 'expected the selected count to use the rebalanced primary size', + ); + assert.equal( + totalCount.className.includes('text-sm'), + true, + 'expected the total count to use the smaller supporting size', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup mode switcher uses the professional mode label', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + assert.ok( + getButtonByText(t.inspectionAdvancedMode), + 'expected the setup mode switcher to render the renamed professional mode label', + ); + assert.equal( + getButtonByText('高级模式'), + null, + 'expected the old advanced mode label to stop rendering in the setup switcher', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + test('inspection setup persists the last selected mode across remounts', async () => { const dom = installDom(); const container = dom.window.document.getElementById('root'); diff --git a/src/features/ai-assistant/components/AIInspectionModal.tsx b/src/features/ai-assistant/components/AIInspectionModal.tsx index 5fba23f66..e9a63b7f7 100644 --- a/src/features/ai-assistant/components/AIInspectionModal.tsx +++ b/src/features/ai-assistant/components/AIInspectionModal.tsx @@ -544,8 +544,8 @@ export function AIInspectionModal({ value={inspectionSetupMode} onChange={setInspectionSetupMode} stretch={false} - className="w-full max-w-[260px]" - itemClassName="min-w-[108px]" + className="w-full max-w-[300px]" + itemClassName="min-w-[126px]" />
) : undefined @@ -700,17 +700,28 @@ export function AIInspectionModal({ <>
{inspectionSetupMode === 'normal' ? ( -
- - {t.inspectionSelectedChecksLabel}: - - - {totalSelectedCount} - - / - - {TOTAL_INSPECTION_ITEM_COUNT} +
+ + {t.inspectionSelectedChecksLabel} +
+ + {totalSelectedCount} + + / + + {TOTAL_INSPECTION_ITEM_COUNT} + +
) : (