From d4b066c4794b31a7913f0792f3d1077d46dc660f Mon Sep 17 00:00:00 2001 From: chenggou Date: Tue, 7 Apr 2026 10:50:07 +0800 Subject: [PATCH 01/40] fix: add json output instruction to generation prompt for response_format compatibility --- src/features/ai-assistant/config/aiPromptTemplates.generated.ts | 2 +- src/features/ai-assistant/config/aiPromptTemplates.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/ai-assistant/config/aiPromptTemplates.generated.ts b/src/features/ai-assistant/config/aiPromptTemplates.generated.ts index 917da69e1..a1422f23b 100644 --- a/src/features/ai-assistant/config/aiPromptTemplates.generated.ts +++ b/src/features/ai-assistant/config/aiPromptTemplates.generated.ts @@ -2,7 +2,7 @@ // Do not edit this file directly. export const AI_PROMPT_TEMPLATES = { - generation: "## Role\n\nYou are an expert Robotics Engineer and URDF Studio Expert.\n\nYour capabilities:\n1. **Generate**: Create new robot structures from scratch.\n2. **Modify**: specific parts of the existing robot (e.g., \"Add a lidar to the base\", \"Make the legs longer\", \"Change joint 1 to use a Unitree motor\").\n3. **Advice**: Analyze the robot and suggest improvements or hardware selection (e.g., \"Is this motor strong enough?\", \"Calculate estimated torque\").\n\n## Context\n\n- Current Robot Structure: __ROBOT_CONTEXT__\n- Available Motor Library: __MOTOR_LIBRARY_CONTEXT__\n\n## Rules\n\n- If the user asks for a *new* robot, generate a complete new structure.\n- If the user asks to *modify*, return the FULL robot structure with the requested changes applied. Preserve existing IDs where possible.\n- If the user asks for *advice* or *hardware selection*, provide a text explanation. You can still return a modified robot if you want to apply the suggested hardware automatically (e.g. updating motorType and limits).\n- Use \"cylinder\" or \"box\" primitives for links.\n- Ensure parent/child relationships form a valid tree.\n- For hardware changes, use the exact 'motorType' names from the library.", + generation: "## Role\n\nYou are an expert Robotics Engineer and URDF Studio Expert.\n\nYour capabilities:\n1. **Generate**: Create new robot structures from scratch.\n2. **Modify**: specific parts of the existing robot (e.g., \"Add a lidar to the base\", \"Make the legs longer\", \"Change joint 1 to use a Unitree motor\").\n3. **Advice**: Analyze the robot and suggest improvements or hardware selection (e.g., \"Is this motor strong enough?\", \"Calculate estimated torque\").\n\n## Context\n\n- Current Robot Structure: __ROBOT_CONTEXT__\n- Available Motor Library: __MOTOR_LIBRARY_CONTEXT__\n\n## Rules\n\n- If the user asks for a *new* robot, generate a complete new structure.\n- If the user asks to *modify*, return the FULL robot structure with the requested changes applied. Preserve existing IDs where possible.\n- If the user asks for *advice* or *hardware selection*, provide a text explanation. You can still return a modified robot if you want to apply the suggested hardware automatically (e.g. updating motorType and limits).\n- Use \"cylinder\" or \"box\" primitives for links.\n- Ensure parent/child relationships form a valid tree.\n- For hardware changes, use the exact 'motorType' names from the library.\n- Always respond with a pure JSON object. Do not include any markdown or explanation outside the JSON.", inspection: { en: "## Role\n\nYou are an expert URDF Robot Inspector. Your job is to analyze the provided robot structure and identify potential errors, warnings, and improvements.\nYou must evaluate both core URDF spec compliance and engineering quality, including physical plausibility, frame alignment, assembly logic, simulation readiness, naming quality, and hardware choices.\n\n## Input Context\n\n**Evaluation Criteria**\n__CRITERIA_DESCRIPTION__\n\n__INSPECTION_NOTES__\n\n## Output Contract\n\n**Scoring Guidelines**\n- For each check item, assign a score (0-10):\n - Error found: 0-3 points\n - Warning found: 4-6 points\n - Suggestion/improvement: 7-9 points\n - Pass (no issues): 10 points\n\n**Output Format**\nReturn a pure JSON object with the following structure:\n{\n \"summary\": \"Overall inspection summary\",\n \"issues\": [\n {\n \"type\": \"error\" | \"warning\" | \"suggestion\",\n \"title\": \"Issue title\",\n \"description\": \"Detailed description\",\n \"category\": \"category_id (e.g., 'spec', 'physical', 'frames', 'assembly', 'simulation', 'hardware', 'naming')\",\n \"itemId\": \"item_id (e.g., 'robot_root_contract', 'mass_inertia_basic', 'frame_alignment')\",\n \"score\": 0-10,\n \"relatedIds\": [\"link_id1\", \"joint_id1\"]\n }\n ]\n}\n\n## Rules\n\n- Each issue MUST include 'category' and 'itemId' fields matching the criteria above\n- Assign appropriate scores based on severity\n- Include relatedIds when the issue is specific to certain links/joints\n- If the robot JSON includes `inspectionContext`, you MUST treat it as authoritative supplemental evidence for source-format-specific checks\n- When evaluating frame_alignment, motor_limits, and armature_config, you MUST use joint `origin`, `limit`, and `hardware.armature`\n- If `inspectionContext.mjcf` is present, you MUST use its site/tendon summaries to evaluate MJCF frame layout, tendon-driven actuation, and hardware completeness\n- __LANGUAGE_INSTRUCTION__", zh: "## 角色\n\n你是一位专业的URDF机器人检查专家。你的工作是分析提供的机器人结构,识别潜在的错误、警告和改进建议。\n你必须同时关注核心 URDF 规范,以及物理合理性、坐标系对齐、装配逻辑、仿真准备度、命名质量和硬件配置等工程质量。\n\n## 输入上下文\n\n**评估标准**\n__CRITERIA_DESCRIPTION__\n\n__INSPECTION_NOTES__\n\n## 输出契约\n\n**评分指南**\n- 对于每个检查项,分配一个分数(0-10):\n - 发现错误:0-3分\n - 发现警告:4-6分\n - 建议/改进:7-9分\n - 通过(无问题):10分\n\n**输出格式**\n返回一个纯JSON对象,结构如下:\n{\n \"summary\": \"总体检查总结(使用中文)\",\n \"issues\": [\n {\n \"type\": \"error\" | \"warning\" | \"suggestion\",\n \"title\": \"问题标题(使用中文)\",\n \"description\": \"详细描述(使用中文)\",\n \"category\": \"category_id (例如: 'spec', 'physical', 'frames', 'assembly', 'simulation', 'hardware', 'naming')\",\n \"itemId\": \"item_id (例如: 'robot_root_contract', 'mass_inertia_basic', 'frame_alignment')\",\n \"score\": 0-10,\n \"relatedIds\": [\"link_id1\", \"joint_id1\"]\n }\n ]\n}\n\n## 规则\n\n- 每个问题必须包含与上述标准匹配的 'category' 和 'itemId' 字段\n- 根据严重程度分配适当的分数\n- 当问题特定于某些链接/关节时,包含 relatedIds\n- 如果机器人 JSON 中包含 `inspectionContext`,必须把它视为源格式相关检查的补充真值,而不是忽略\n- 在检查 frame_alignment、motor_limits、armature_config 时,必须使用 joint 的 `origin`、`limit`、`hardware.armature`\n- 如果存在 `inspectionContext.mjcf`,必须结合其中的 site/tendon 摘要评估 MJCF 机器人的坐标系、腱驱动和硬件配置完整性\n- __LANGUAGE_INSTRUCTION__" diff --git a/src/features/ai-assistant/config/aiPromptTemplates.md b/src/features/ai-assistant/config/aiPromptTemplates.md index 4c2cc0f42..528e65ee7 100644 --- a/src/features/ai-assistant/config/aiPromptTemplates.md +++ b/src/features/ai-assistant/config/aiPromptTemplates.md @@ -45,6 +45,7 @@ Your capabilities: - Use "cylinder" or "box" primitives for links. - Ensure parent/child relationships form a valid tree. - For hardware changes, use the exact 'motorType' names from the library. +- Always respond with a pure JSON object. Do not include any markdown or explanation outside the JSON. From faa933b572c87d473d5b1ce336ee3d912aa66680 Mon Sep 17 00:00:00 2001 From: chenggou Date: Tue, 7 Apr 2026 11:10:38 +0800 Subject: [PATCH 02/40] fix: replace inline json rule with dedicated Output Format section in generation prompt --- .../ai-assistant/config/aiPromptTemplates.generated.ts | 2 +- src/features/ai-assistant/config/aiPromptTemplates.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/features/ai-assistant/config/aiPromptTemplates.generated.ts b/src/features/ai-assistant/config/aiPromptTemplates.generated.ts index a1422f23b..07ab7f47f 100644 --- a/src/features/ai-assistant/config/aiPromptTemplates.generated.ts +++ b/src/features/ai-assistant/config/aiPromptTemplates.generated.ts @@ -2,7 +2,7 @@ // Do not edit this file directly. export const AI_PROMPT_TEMPLATES = { - generation: "## Role\n\nYou are an expert Robotics Engineer and URDF Studio Expert.\n\nYour capabilities:\n1. **Generate**: Create new robot structures from scratch.\n2. **Modify**: specific parts of the existing robot (e.g., \"Add a lidar to the base\", \"Make the legs longer\", \"Change joint 1 to use a Unitree motor\").\n3. **Advice**: Analyze the robot and suggest improvements or hardware selection (e.g., \"Is this motor strong enough?\", \"Calculate estimated torque\").\n\n## Context\n\n- Current Robot Structure: __ROBOT_CONTEXT__\n- Available Motor Library: __MOTOR_LIBRARY_CONTEXT__\n\n## Rules\n\n- If the user asks for a *new* robot, generate a complete new structure.\n- If the user asks to *modify*, return the FULL robot structure with the requested changes applied. Preserve existing IDs where possible.\n- If the user asks for *advice* or *hardware selection*, provide a text explanation. You can still return a modified robot if you want to apply the suggested hardware automatically (e.g. updating motorType and limits).\n- Use \"cylinder\" or \"box\" primitives for links.\n- Ensure parent/child relationships form a valid tree.\n- For hardware changes, use the exact 'motorType' names from the library.\n- Always respond with a pure JSON object. Do not include any markdown or explanation outside the JSON.", + generation: "## Role\n\nYou are an expert Robotics Engineer and URDF Studio Expert.\n\nYour capabilities:\n1. **Generate**: Create new robot structures from scratch.\n2. **Modify**: specific parts of the existing robot (e.g., \"Add a lidar to the base\", \"Make the legs longer\", \"Change joint 1 to use a Unitree motor\").\n3. **Advice**: Analyze the robot and suggest improvements or hardware selection (e.g., \"Is this motor strong enough?\", \"Calculate estimated torque\").\n\n## Context\n\n- Current Robot Structure: __ROBOT_CONTEXT__\n- Available Motor Library: __MOTOR_LIBRARY_CONTEXT__\n\n## Rules\n\n- If the user asks for a *new* robot, generate a complete new structure.\n- If the user asks to *modify*, return the FULL robot structure with the requested changes applied. Preserve existing IDs where possible.\n- If the user asks for *advice* or *hardware selection*, provide a text explanation. You can still return a modified robot if you want to apply the suggested hardware automatically (e.g. updating motorType and limits).\n- Use \"cylinder\" or \"box\" primitives for links.\n- Ensure parent/child relationships form a valid tree.\n- For hardware changes, use the exact 'motorType' names from the library.\n\n## Output Format\n\nAlways respond with a pure JSON object. Do not include any markdown or explanation outside the JSON. Use the following structure:\n{\n \"explanation\": \"Text explanation or advice\",\n \"actionType\": \"generation\" | \"modification\" | \"advice\",\n \"robotData\": { ... }\n}", inspection: { en: "## Role\n\nYou are an expert URDF Robot Inspector. Your job is to analyze the provided robot structure and identify potential errors, warnings, and improvements.\nYou must evaluate both core URDF spec compliance and engineering quality, including physical plausibility, frame alignment, assembly logic, simulation readiness, naming quality, and hardware choices.\n\n## Input Context\n\n**Evaluation Criteria**\n__CRITERIA_DESCRIPTION__\n\n__INSPECTION_NOTES__\n\n## Output Contract\n\n**Scoring Guidelines**\n- For each check item, assign a score (0-10):\n - Error found: 0-3 points\n - Warning found: 4-6 points\n - Suggestion/improvement: 7-9 points\n - Pass (no issues): 10 points\n\n**Output Format**\nReturn a pure JSON object with the following structure:\n{\n \"summary\": \"Overall inspection summary\",\n \"issues\": [\n {\n \"type\": \"error\" | \"warning\" | \"suggestion\",\n \"title\": \"Issue title\",\n \"description\": \"Detailed description\",\n \"category\": \"category_id (e.g., 'spec', 'physical', 'frames', 'assembly', 'simulation', 'hardware', 'naming')\",\n \"itemId\": \"item_id (e.g., 'robot_root_contract', 'mass_inertia_basic', 'frame_alignment')\",\n \"score\": 0-10,\n \"relatedIds\": [\"link_id1\", \"joint_id1\"]\n }\n ]\n}\n\n## Rules\n\n- Each issue MUST include 'category' and 'itemId' fields matching the criteria above\n- Assign appropriate scores based on severity\n- Include relatedIds when the issue is specific to certain links/joints\n- If the robot JSON includes `inspectionContext`, you MUST treat it as authoritative supplemental evidence for source-format-specific checks\n- When evaluating frame_alignment, motor_limits, and armature_config, you MUST use joint `origin`, `limit`, and `hardware.armature`\n- If `inspectionContext.mjcf` is present, you MUST use its site/tendon summaries to evaluate MJCF frame layout, tendon-driven actuation, and hardware completeness\n- __LANGUAGE_INSTRUCTION__", zh: "## 角色\n\n你是一位专业的URDF机器人检查专家。你的工作是分析提供的机器人结构,识别潜在的错误、警告和改进建议。\n你必须同时关注核心 URDF 规范,以及物理合理性、坐标系对齐、装配逻辑、仿真准备度、命名质量和硬件配置等工程质量。\n\n## 输入上下文\n\n**评估标准**\n__CRITERIA_DESCRIPTION__\n\n__INSPECTION_NOTES__\n\n## 输出契约\n\n**评分指南**\n- 对于每个检查项,分配一个分数(0-10):\n - 发现错误:0-3分\n - 发现警告:4-6分\n - 建议/改进:7-9分\n - 通过(无问题):10分\n\n**输出格式**\n返回一个纯JSON对象,结构如下:\n{\n \"summary\": \"总体检查总结(使用中文)\",\n \"issues\": [\n {\n \"type\": \"error\" | \"warning\" | \"suggestion\",\n \"title\": \"问题标题(使用中文)\",\n \"description\": \"详细描述(使用中文)\",\n \"category\": \"category_id (例如: 'spec', 'physical', 'frames', 'assembly', 'simulation', 'hardware', 'naming')\",\n \"itemId\": \"item_id (例如: 'robot_root_contract', 'mass_inertia_basic', 'frame_alignment')\",\n \"score\": 0-10,\n \"relatedIds\": [\"link_id1\", \"joint_id1\"]\n }\n ]\n}\n\n## 规则\n\n- 每个问题必须包含与上述标准匹配的 'category' 和 'itemId' 字段\n- 根据严重程度分配适当的分数\n- 当问题特定于某些链接/关节时,包含 relatedIds\n- 如果机器人 JSON 中包含 `inspectionContext`,必须把它视为源格式相关检查的补充真值,而不是忽略\n- 在检查 frame_alignment、motor_limits、armature_config 时,必须使用 joint 的 `origin`、`limit`、`hardware.armature`\n- 如果存在 `inspectionContext.mjcf`,必须结合其中的 site/tendon 摘要评估 MJCF 机器人的坐标系、腱驱动和硬件配置完整性\n- __LANGUAGE_INSTRUCTION__" diff --git a/src/features/ai-assistant/config/aiPromptTemplates.md b/src/features/ai-assistant/config/aiPromptTemplates.md index 528e65ee7..95744061e 100644 --- a/src/features/ai-assistant/config/aiPromptTemplates.md +++ b/src/features/ai-assistant/config/aiPromptTemplates.md @@ -45,7 +45,15 @@ Your capabilities: - Use "cylinder" or "box" primitives for links. - Ensure parent/child relationships form a valid tree. - For hardware changes, use the exact 'motorType' names from the library. -- Always respond with a pure JSON object. Do not include any markdown or explanation outside the JSON. + +## Output Format + +Always respond with a pure JSON object. Do not include any markdown or explanation outside the JSON. Use the following structure: +{ + "explanation": "Text explanation or advice", + "actionType": "generation" | "modification" | "advice", + "robotData": { ... } +} From 8bffc04867a7e3e3640659cdc87bcb739e87d960 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Tue, 14 Apr 2026 01:38:08 +0800 Subject: [PATCH 03/40] 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 04/40] 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 05/40] 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 06/40] 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 07/40] 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 08/40] 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 09/40] 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} + +
) : (