From 6aa652e332202b7494130b6ae9588a38d67dfaa5 Mon Sep 17 00:00:00 2001 From: joaner Date: Sun, 24 May 2026 13:54:28 +0800 Subject: [PATCH 1/3] feat(urdf-debug): add URDF debug panel with joint sliders and drag-drop upload Introduce UrdfDebug panel for offline URDF preview, manual joint pose tuning, optional MCAP JointState follow, mesh resolution, rotate_mesh alignment, and recipe/script export. Includes resizable settings layout, file drop zones, JointState-only topic filtering, en/zh/ja i18n, and self-contained tests. --- package-lock.json | 68 ++ package.json | 1 + scripts/acceptance-urdf-debug.mjs | 130 +++ scripts/gen-test-mcap.mjs | 45 +- src/features/panels/UrdfDebug/Component.tsx | 445 ++++++++++ .../panels/UrdfDebug/JointPoseSection.tsx | 235 +++++ .../panels/UrdfDebug/MeshBaseSection.tsx | 318 +++++++ src/features/panels/UrdfDebug/Preview.tsx | 299 +++++++ src/features/panels/UrdfDebug/defaults.ts | 41 + src/features/panels/UrdfDebug/definition.tsx | 89 ++ src/features/panels/UrdfDebug/diagnostics.ts | 87 ++ .../panels/UrdfDebug/embedded/fkEngine.js | 233 +++++ .../panels/UrdfDebug/fileDropUtils.test.ts | 20 + .../panels/UrdfDebug/fileDropUtils.ts | 14 + .../panels/UrdfDebug/foxgloveAdapter.ts | 73 ++ src/features/panels/UrdfDebug/index.ts | 2 + .../panels/UrdfDebug/jointPose.test.ts | 87 ++ src/features/panels/UrdfDebug/jointPose.ts | 91 ++ .../UrdfDebug/jointStateMapping.test.ts | 134 +++ .../panels/UrdfDebug/jointStateMapping.ts | 240 +++++ .../panels/UrdfDebug/meshBaseStatus.test.ts | 59 ++ .../panels/UrdfDebug/meshBaseStatus.ts | 111 +++ src/features/panels/UrdfDebug/meshResolver.ts | 135 +++ src/features/panels/UrdfDebug/recipe.ts | 168 ++++ src/features/panels/UrdfDebug/schema.ts | 71 ++ .../panels/UrdfDebug/scriptTemplates.test.ts | 149 ++++ .../panels/UrdfDebug/scriptTemplates.ts | 823 ++++++++++++++++++ .../panels/UrdfDebug/urdfAnalysis.test.ts | 106 +++ src/features/panels/UrdfDebug/urdfAnalysis.ts | 203 +++++ .../UrdfDebug/urdfVisualCorrection.test.ts | 86 ++ .../panels/UrdfDebug/urdfVisualCorrection.ts | 161 ++++ .../panels/framework/PanelRuntimeShell.tsx | 2 +- .../panels/framework/panelMessageSlug.ts | 1 + src/features/panels/framework/types.ts | 1 + src/features/panels/registry/index.ts | 4 + src/shared/intl/messages/en/panels.json | 102 ++- src/shared/intl/messages/ja/panels.json | 102 ++- src/shared/intl/messages/zh/panels.json | 102 ++- src/shared/jointstate2tf/index.ts | 341 ++++++++ src/shared/ui/file-drop-zone.tsx | 161 ++++ 40 files changed, 5535 insertions(+), 5 deletions(-) create mode 100644 scripts/acceptance-urdf-debug.mjs create mode 100644 src/features/panels/UrdfDebug/Component.tsx create mode 100644 src/features/panels/UrdfDebug/JointPoseSection.tsx create mode 100644 src/features/panels/UrdfDebug/MeshBaseSection.tsx create mode 100644 src/features/panels/UrdfDebug/Preview.tsx create mode 100644 src/features/panels/UrdfDebug/defaults.ts create mode 100644 src/features/panels/UrdfDebug/definition.tsx create mode 100644 src/features/panels/UrdfDebug/diagnostics.ts create mode 100644 src/features/panels/UrdfDebug/embedded/fkEngine.js create mode 100644 src/features/panels/UrdfDebug/fileDropUtils.test.ts create mode 100644 src/features/panels/UrdfDebug/fileDropUtils.ts create mode 100644 src/features/panels/UrdfDebug/foxgloveAdapter.ts create mode 100644 src/features/panels/UrdfDebug/index.ts create mode 100644 src/features/panels/UrdfDebug/jointPose.test.ts create mode 100644 src/features/panels/UrdfDebug/jointPose.ts create mode 100644 src/features/panels/UrdfDebug/jointStateMapping.test.ts create mode 100644 src/features/panels/UrdfDebug/jointStateMapping.ts create mode 100644 src/features/panels/UrdfDebug/meshBaseStatus.test.ts create mode 100644 src/features/panels/UrdfDebug/meshBaseStatus.ts create mode 100644 src/features/panels/UrdfDebug/meshResolver.ts create mode 100644 src/features/panels/UrdfDebug/recipe.ts create mode 100644 src/features/panels/UrdfDebug/schema.ts create mode 100644 src/features/panels/UrdfDebug/scriptTemplates.test.ts create mode 100644 src/features/panels/UrdfDebug/scriptTemplates.ts create mode 100644 src/features/panels/UrdfDebug/urdfAnalysis.test.ts create mode 100644 src/features/panels/UrdfDebug/urdfAnalysis.ts create mode 100644 src/features/panels/UrdfDebug/urdfVisualCorrection.test.ts create mode 100644 src/features/panels/UrdfDebug/urdfVisualCorrection.ts create mode 100644 src/shared/jointstate2tf/index.ts create mode 100644 src/shared/ui/file-drop-zone.tsx diff --git a/package-lock.json b/package-lock.json index 4e233be..6164294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "flatbuffers": "^25.9.23", "fzstd": "^0.1.1", "globals": "^17.4.0", + "happy-dom": "^20.9.0", "intervals-fn": "^3.0.3", "lucide-react": "^0.474.0", "lz4js": "^0.2.0", @@ -4017,6 +4018,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -5985,6 +6003,24 @@ "lodash": "^4.17.15" } }, + "node_modules/happy-dom": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", + "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8733,6 +8769,16 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8776,6 +8822,28 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index fbad304..899e96b 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "flatbuffers": "^25.9.23", "fzstd": "^0.1.1", "globals": "^17.4.0", + "happy-dom": "^20.9.0", "intervals-fn": "^3.0.3", "lucide-react": "^0.474.0", "lz4js": "^0.2.0", diff --git a/scripts/acceptance-urdf-debug.mjs b/scripts/acceptance-urdf-debug.mjs new file mode 100644 index 0000000..835d932 --- /dev/null +++ b/scripts/acceptance-urdf-debug.mjs @@ -0,0 +1,130 @@ +/** + * One-off acceptance script for URDF Debug panel (run against dev server). + * Usage: node scripts/acceptance-urdf-debug.mjs + */ +import { chromium } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); +const baseUrl = process.env.ROS_STUDIO_URL ?? 'http://localhost:5174'; +const urdfPath = path.join(root, 'public/examples/xArm7.urdf'); + +async function openUrdfDebugPanel(page) { + await page.goto(`${baseUrl}/?url=/examples/test_5s.mcap`, { waitUntil: 'networkidle' }); + await page.getByRole('button', { name: 'Open add panel menu' }).last().click(); + await page.getByRole('menuitem', { name: 'UrdfDebug' }).hover(); + await page.waitForTimeout(300); + await page.getByRole('menuitem', { name: 'In this group (tab)' }).evaluate((el) => { + el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + el.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await page.getByText(/Mesh resources|Mesh 资源|输入/).first().waitFor({ timeout: 15000 }); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const errors = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + await openUrdfDebugPanel(page); + + // Narrow viewport: settings and preview should remain side-by-side + await page.setViewportSize({ width: 480, height: 800 }); + await page.waitForTimeout(300); + const settingsBox = await page.getByText(/Joint pose|关节姿态|関節姿勢/).boundingBox(); + const previewBox = await page.getByText(/URDF Preview|URDF 预览|URDF プレビュー/i).first().boundingBox(); + if (!settingsBox || !previewBox) { + throw new Error('Settings or preview region not visible in narrow viewport'); + } + if (Math.abs(settingsBox.y - previewBox.y) > 40) { + throw new Error('Layout should stay horizontal in narrow viewport (not stacked vertically)'); + } + + const resizeHandle = page.getByRole('separator', { name: /Resize settings|调整调参|設定パネル/i }); + await resizeHandle.waitFor({ timeout: 5000 }); + + // Upload URDF via hidden input (drop zone) + const urdfInput = page.getByTestId('urdf-debug-urdf-upload'); + await urdfInput.setInputFiles(urdfPath); + await page.getByText('xArm7.urdf').waitFor({ timeout: 5000 }); + + await page.getByText(/Mesh resources|Mesh 资源/).waitFor(); + await page.getByText(/Resolved mesh URLs|Mesh 解析结果/).waitFor({ timeout: 10000 }); + + // Joint sliders + await page.getByText(/Joint pose|关节姿态|関節姿勢/).waitFor(); + const firstSlider = page.getByRole('slider').first(); + await firstSlider.waitFor({ timeout: 5000 }); + const valueBefore = await firstSlider.getAttribute('aria-valuenow'); + await firstSlider.focus(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.waitForTimeout(200); + const valueAfter = await firstSlider.getAttribute('aria-valuenow'); + if (valueBefore === valueAfter) { + throw new Error(`Slider did not change: before=${valueBefore} after=${valueAfter}`); + } + + // Follow MCAP toggle disables sliders + await page.getByRole('checkbox', { name: /Follow MCAP|跟随 MCAP|MCAP JointState/i }).check(); + await page.waitForTimeout(300); + const disabled = await firstSlider.isDisabled(); + if (!disabled) { + throw new Error('Slider should be disabled when Follow MCAP is enabled'); + } + + // Remote base URL flow + await page.getByRole('radio', { name: /Remote base URL|远程 Base URL/i }).check(); + const remoteInput = page.getByPlaceholder(/your-host\/resources/i); + await remoteInput.fill('not-a-valid-url'); + await page.getByRole('button', { name: /Apply|应用/ }).click(); + await page.getByText(/valid http:\/\/ or https:\/\/ URL|有效的 http/i).waitFor(); + + const testBase = `${baseUrl}/examples`; + await remoteInput.fill(testBase); + await page.getByRole('button', { name: /Apply|应用/ }).click(); + await page.getByText(/Applied base URL|已应用 Base URL/i).waitFor(); + await page.getByText(testBase).waitFor(); + + // Wait for mesh status check to finish + await page.waitForTimeout(2000); + const summary = await page.getByText(/\d+ \/ \d+.*(reachable|可访问)/).textContent(); + const resolvedBlock = await page.locator('text=package://').first().textContent(); + + // rotate_mesh toggle + await page.getByRole('checkbox', { name: /Rotate mesh visuals|旋转 mesh/i }).check(); + await page.getByText(/rotate_mesh: (ON|开)/i).waitFor({ timeout: 5000 }); + + console.log('ACCEPTANCE OK'); + console.log('- Narrow viewport horizontal layout: OK'); + console.log('- Resize handle present: OK'); + console.log('- URDF uploaded: xArm7.urdf'); + console.log('- Joint slider moved:', valueBefore, '->', valueAfter); + console.log('- Follow MCAP disables sliders: OK'); + console.log('- Mesh summary:', summary?.trim()); + console.log('- Sample resolved path:', resolvedBlock?.trim()); + console.log('- rotate_mesh overlay: ON'); + + await page.screenshot({ + path: path.join(root, 'public/examples/urdf-debug-acceptance.png'), + fullPage: true, + }); + console.log('- Screenshot: public/examples/urdf-debug-acceptance.png'); + + if (errors.length > 0) { + console.log('Console errors (non-fatal):', errors.slice(0, 5)); + } + + await browser.close(); +} + +main().catch((error) => { + console.error('ACCEPTANCE FAILED:', error); + process.exit(1); +}); diff --git a/scripts/gen-test-mcap.mjs b/scripts/gen-test-mcap.mjs index b5531f5..2b131ea 100644 --- a/scripts/gen-test-mcap.mjs +++ b/scripts/gen-test-mcap.mjs @@ -33,7 +33,12 @@ class BufferWritable { } const writable = new BufferWritable(); -const writer = new McapWriter({ writable }); +const writer = new McapWriter({ + writable, + useStatistics: true, + useChunks: true, + useChunkIndex: true, +}); await writer.start({ profile: 'ros2', library: 'rosview-gen' }); @@ -61,6 +66,44 @@ for (const [idx, ts] of messageTimes.entries()) { }); } +const jointSchemaId = await writer.registerSchema({ + name: 'sensor_msgs/msg/JointState', + encoding: 'jsonschema', + data: new TextEncoder().encode('{"type":"object"}'), +}); + +const jointChannelId = await writer.registerChannel({ + schemaId: jointSchemaId, + topic: '/joint_states', + messageEncoding: 'json', + metadata: new Map(), +}); + +const jointNames = ['joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7', 'drive_joint']; +const jointPositions = [ + [0, 0, 0, 0, 0, 0, 0, 0], + [0.3, -0.2, 0.5, 0.1, -0.4, 0.2, 0.1, 0.42], + [0.6, -0.4, 1.0, 0.2, -0.8, 0.4, 0.2, 0.85], +]; + +for (const [idx, ts] of messageTimes.entries()) { + await writer.addMessage({ + channelId: jointChannelId, + sequence: idx + 1, + logTime: ts, + publishTime: ts, + data: new TextEncoder().encode( + JSON.stringify({ + header: { stamp: { sec: Number(ts / 1_000_000_000n), nanosec: Number(ts % 1_000_000_000n) }, frame_id: '' }, + name: jointNames, + position: jointPositions[idx], + velocity: [], + effort: [], + }), + ), + }); +} + await writer.end(); const outPath = path.join(__dirname, '../public/examples/test_5s.mcap'); diff --git a/src/features/panels/UrdfDebug/Component.tsx b/src/features/panels/UrdfDebug/Component.tsx new file mode 100644 index 0000000..16395b6 --- /dev/null +++ b/src/features/panels/UrdfDebug/Component.tsx @@ -0,0 +1,445 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import type { Player } from '@/core/types/player'; +import type { MessagePipelineState } from '@/core/pipeline/store'; +import { useMessagePipeline } from '@/core/pipeline/useMessagePipeline'; +import { messageBus } from '@/core/pipeline/messageBus'; +import { scheduleFrame } from '@/shared/utils/rafScheduler'; +import { FileDropZone } from '@/shared/ui/file-drop-zone'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/shared/ui/resizable'; +import type { UrdfDebugConfig } from './defaults'; +import { + MAX_SETTINGS_PANEL_PERCENT, + MIN_SETTINGS_PANEL_PERCENT, +} from './defaults'; +import { UrdfDebugPreview } from './Preview'; +import { MeshBaseSection } from './MeshBaseSection'; +import { JointPoseSection } from './JointPoseSection'; +import { pickUrdfFile } from './fileDropUtils'; +import { extractPackageNameFromUrdf } from './meshBaseStatus'; +import type { JointStateLike } from './jointStateMapping'; +import { buildPreviewJointState } from './jointPose'; +import { + buildLocalMeshUrlMap, + createMeshResolver, + revokeMeshUrlMap, +} from './meshResolver'; +import { configToRecipe, downloadJson, downloadText } from './recipe'; +import { + analyzeUrdfText, + createDefaultManualPositions, + extractUrdfJointDescriptors, + pickJointStateTopic, + prepareUrdfForPreview, +} from './urdfAnalysis'; +import { buildCliCommands, generatePythonScript, generateTypeScriptScript } from './scriptTemplates'; + +export interface UrdfDebugPanelProps { + player: Player; + panelId: string; + config: UrdfDebugConfig; + setConfig: (next: UrdfDebugConfig | ((prev: UrdfDebugConfig) => UrdfDebugConfig)) => void; +} + +function readJointStateFromTopic(topic: string): JointStateLike | null { + const last = messageBus.getLastMessage(topic); + if (!last?.message || typeof last.message !== 'object') return null; + const msg = last.message as Record; + const name = msg.name; + const position = msg.position; + if (!Array.isArray(name) || !Array.isArray(position)) return null; + if (!name.every((entry) => typeof entry === 'string')) return null; + return { + name, + position: Array.from(position as ArrayLike, (v) => Number(v) || 0), + }; +} + +function clampSettingsPanelPercent(value: number): number { + return Math.min(MAX_SETTINGS_PANEL_PERCENT, Math.max(MIN_SETTINGS_PANEL_PERCENT, value)); +} + +export const UrdfDebugPanel: React.FC = ({ + player, + panelId, + config, + setConfig, +}) => { + const { formatMessage } = useIntl(); + const topics = useMessagePipeline((state: MessagePipelineState) => state.sortedTopics); + const [meshFiles, setMeshFiles] = useState([]); + const [localMeshUrls, setLocalMeshUrls] = useState>(() => new Map()); + const [lastError, setLastError] = useState(null); + const [urdfUploadError, setUrdfUploadError] = useState(null); + const [rawJointState, setRawJointState] = useState(null); + const meshUrlMapRef = useRef>(new Map()); + const layoutWriteTimerRef = useRef(null); + + const settingsPanelPercent = clampSettingsPanelPercent(config.settingsPanelPercent); + + const jointStateTopic = useMemo(() => { + return pickJointStateTopic(topics, config.jointStateTopic); + }, [topics, config.jointStateTopic]); + + useEffect(() => { + if (!config.followLiveJointState || !jointStateTopic) { + player.unregisterSubscriptions(panelId); + return; + } + player.registerSubscriptions(panelId, [{ topic: jointStateTopic, subscriberId: panelId }]); + return () => player.unregisterSubscriptions(panelId); + }, [player, panelId, jointStateTopic, config.followLiveJointState]); + + useEffect(() => { + if (!config.followLiveJointState || !jointStateTopic) { + setRawJointState(null); + return; + } + const applyLatest = () => { + setRawJointState(readJointStateFromTopic(jointStateTopic)); + }; + applyLatest(); + let cancelPending: (() => void) | null = null; + const unsubscribe = messageBus.subscribeTopic(jointStateTopic, () => { + if (cancelPending) return; + cancelPending = scheduleFrame(() => { + cancelPending = null; + applyLatest(); + }); + }); + return () => { + unsubscribe(); + cancelPending?.(); + }; + }, [jointStateTopic, config.followLiveJointState]); + + useEffect(() => { + revokeMeshUrlMap(meshUrlMapRef.current); + const map = buildLocalMeshUrlMap(meshFiles); + meshUrlMapRef.current = map; + setLocalMeshUrls(map); + return () => revokeMeshUrlMap(meshUrlMapRef.current); + }, [meshFiles]); + + useEffect( + () => () => { + if (layoutWriteTimerRef.current != null) { + window.clearTimeout(layoutWriteTimerRef.current); + } + }, + [], + ); + + const preparedUrdfResult = useMemo(() => { + if (!config.urdfFileContent) return { urdf: '', error: null as string | null }; + try { + return { + urdf: prepareUrdfForPreview( + config.urdfFileContent, + config.rotateMeshVisuals, + config.visualRpyOffset, + ), + error: null, + }; + } catch (error) { + return { + urdf: config.urdfFileContent, + error: error instanceof Error ? error.message : String(error), + }; + } + }, [config.urdfFileContent, config.rotateMeshVisuals, config.visualRpyOffset]); + + const preparedUrdf = preparedUrdfResult.urdf; + + useEffect(() => { + if (preparedUrdfResult.error) setLastError(preparedUrdfResult.error); + }, [preparedUrdfResult.error]); + + const urdfAnalysis = useMemo(() => { + if (!preparedUrdf) return null; + return analyzeUrdfText(preparedUrdf); + }, [preparedUrdf]); + + const jointDescriptorsResult = useMemo(() => { + if (!preparedUrdf) return { descriptors: [] as ReturnType, error: null as string | null }; + try { + return { descriptors: extractUrdfJointDescriptors(preparedUrdf), error: null }; + } catch (error) { + return { + descriptors: [] as ReturnType, + error: error instanceof Error ? error.message : String(error), + }; + } + }, [preparedUrdf]); + + const jointDescriptors = jointDescriptorsResult.descriptors; + + useEffect(() => { + if (jointDescriptorsResult.error) setLastError(jointDescriptorsResult.error); + }, [jointDescriptorsResult.error]); + + const resolveMeshUrl = useMemo( + () => + createMeshResolver({ + strategy: config.meshStrategy, + packageName: config.packageName, + packageBaseUrl: config.packageBaseUrl, + localUrls: localMeshUrls, + }), + [config.meshStrategy, config.packageName, config.packageBaseUrl, localMeshUrls], + ); + + const handleUrdfUpload = useCallback( + (text: string, fileName: string) => { + setLastError(null); + setUrdfUploadError(null); + const detectedPackage = extractPackageNameFromUrdf(text); + let descriptors: ReturnType = []; + try { + const prepared = prepareUrdfForPreview( + text, + config.rotateMeshVisuals, + config.visualRpyOffset, + ); + descriptors = extractUrdfJointDescriptors(prepared); + } catch { + descriptors = []; + } + setConfig((prev) => ({ + ...prev, + urdfFileName: fileName, + urdfFileContent: text, + packageName: prev.packageName || detectedPackage || '', + manualJointPositions: createDefaultManualPositions(descriptors), + })); + }, + [setConfig, config.rotateMeshVisuals, config.visualRpyOffset], + ); + + const handleUrdfFiles = useCallback( + (files: File[]) => { + const urdfFile = pickUrdfFile(files); + if (!urdfFile) { + setUrdfUploadError(formatMessage({ id: 'urdfDebug.upload.invalidUrdf' })); + return; + } + void urdfFile.text().then((text) => handleUrdfUpload(text, urdfFile.name)); + }, + [formatMessage, handleUrdfUpload], + ); + + const handleSettingsLayoutChanged = useCallback( + (layout: Record) => { + const nextPercent = layout['urdf-settings']; + if (typeof nextPercent !== 'number' || !Number.isFinite(nextPercent)) return; + const clamped = clampSettingsPanelPercent(nextPercent); + if (layoutWriteTimerRef.current != null) { + window.clearTimeout(layoutWriteTimerRef.current); + } + layoutWriteTimerRef.current = window.setTimeout(() => { + layoutWriteTimerRef.current = null; + setConfig((prev) => + prev.settingsPanelPercent === clamped ? prev : { ...prev, settingsPanelPercent: clamped }, + ); + }, 120); + }, + [setConfig], + ); + + const handleMeshIssue = useCallback((_meshUrl: string, _reason: string) => { + // Mesh issues are surfaced in the 3D preview overlay only. + }, []); + + const jointStateForPreview = useMemo( + () => + buildPreviewJointState({ + descriptors: jointDescriptors, + manualPositions: config.manualJointPositions, + liveJointState: rawJointState, + followLive: config.followLiveJointState, + mimicJoints: urdfAnalysis?.mimicJoints ?? [], + }), + [ + jointDescriptors, + config.manualJointPositions, + config.followLiveJointState, + rawJointState, + urdfAnalysis?.mimicJoints, + ], + ); + + const recipe = useMemo( + () => configToRecipe(config, urdfAnalysis?.robotName), + [config, urdfAnalysis?.robotName], + ); + + const cli = buildCliCommands(); + + const settingsPanel = ( + + ); + + return ( + + + {settingsPanel} + + + +
+ +
+
+
+ ); +}; + +const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+
{title}
+ {children} +
+); + +const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( + +); + +const ActionButton: React.FC<{ onClick: () => void; children: React.ReactNode }> = ({ onClick, children }) => ( + +); diff --git a/src/features/panels/UrdfDebug/JointPoseSection.tsx b/src/features/panels/UrdfDebug/JointPoseSection.tsx new file mode 100644 index 0000000..be5fcb5 --- /dev/null +++ b/src/features/panels/UrdfDebug/JointPoseSection.tsx @@ -0,0 +1,235 @@ +import React, { useMemo, useState } from 'react'; +import type { IntlShape } from 'react-intl'; +import { Slider } from '@/shared/ui/slider'; +import type { UrdfDebugConfig } from './defaults'; +import { getDisplayedJointValue } from './jointPose'; +import type { JointStateLike } from './jointStateMapping'; +import { + createDefaultManualPositions, + filterJointStateTopics, + pickJointStateTopic, + type UrdfJointDescriptor, +} from './urdfAnalysis'; + +type JointPoseSectionProps = { + descriptors: UrdfJointDescriptor[]; + config: UrdfDebugConfig; + setConfig: (next: UrdfDebugConfig | ((prev: UrdfDebugConfig) => UrdfDebugConfig)) => void; + topics: ReadonlyArray<{ name: string; type: string }>; + jointStateTopic: string; + liveJointState: JointStateLike | null; + formatMessage: IntlShape['formatMessage']; +}; + +const UNSUPPORTED_TYPES = new Set(['planar', 'floating']); + +function jointTypeLabel( + jointType: UrdfJointDescriptor['jointType'], + formatMessage: IntlShape['formatMessage'], +): string { + const id = `urdfDebug.jointType.${jointType}`; + return formatMessage({ id, defaultMessage: jointType }); +} + +const JointSliderRow: React.FC<{ + descriptor: UrdfJointDescriptor; + value: number; + disabled: boolean; + formatMessage: IntlShape['formatMessage']; + onChange: (value: number) => void; +}> = ({ descriptor, value, disabled, formatMessage, onChange }) => { + const unsupported = UNSUPPORTED_TYPES.has(descriptor.jointType); + + return ( +
+
+ + {descriptor.name} + + + {jointTypeLabel(descriptor.jointType, formatMessage)} + +
+ {descriptor.sliderEnabled ? ( +
+ 0 ? descriptor.step : 0.01} + value={value} + disabled={disabled} + onChange={onChange} + /> + + {value.toFixed(3)} {descriptor.valueUnit} + +
+ ) : ( +

+ {unsupported + ? formatMessage({ id: 'urdfDebug.joints.manualUnsupported' }) + : formatMessage({ id: 'urdfDebug.joints.fixedJoint' })} +

+ )} +
+ ); +}; + +export const JointPoseSection: React.FC = ({ + descriptors, + config, + setConfig, + topics, + jointStateTopic, + liveJointState, + formatMessage, +}) => { + const [filter, setFilter] = useState(''); + + const jointStateTopics = useMemo(() => filterJointStateTopics(topics), [topics]); + + const selectedJointStateTopic = useMemo(() => { + if (jointStateTopics.some((topic) => topic.name === config.jointStateTopic)) { + return config.jointStateTopic; + } + return jointStateTopic; + }, [config.jointStateTopic, jointStateTopic, jointStateTopics]); + + const filteredDescriptors = useMemo(() => { + const query = filter.trim().toLowerCase(); + if (!query) return descriptors; + return descriptors.filter((d) => d.name.toLowerCase().includes(query)); + }, [descriptors, filter]); + + const handleJointChange = (jointName: string, value: number) => { + setConfig((prev) => ({ + ...prev, + manualJointPositions: { ...prev.manualJointPositions, [jointName]: value }, + })); + }; + + const handleResetAll = () => { + setConfig((prev) => ({ + ...prev, + manualJointPositions: createDefaultManualPositions(descriptors), + })); + }; + + const handleFollowLiveChange = (followLive: boolean) => { + setConfig((prev) => { + if (!followLive) { + return { ...prev, followLiveJointState: false }; + } + const resolved = pickJointStateTopic(topics, prev.jointStateTopic); + return { + ...prev, + followLiveJointState: true, + jointStateTopic: resolved || prev.jointStateTopic, + }; + }); + }; + + if (descriptors.length === 0) { + return ( +

+ {formatMessage({ id: 'urdfDebug.joints.uploadUrdfHint' })} +

+ ); + } + + const slidersDisabled = config.followLiveJointState; + const followLiveActive = config.followLiveJointState; + + return ( +
+
+ + + +
+ + {followLiveActive && jointStateTopics.length === 0 && ( +

+ {formatMessage({ id: 'urdfDebug.joints.noJointStateTopics' })} +

+ )} + {followLiveActive && jointStateTopics.length > 0 && !selectedJointStateTopic && ( +

+ {formatMessage({ id: 'urdfDebug.joints.selectJointStateTopicHint' })} +

+ )} + {followLiveActive && selectedJointStateTopic && !liveJointState && ( +

+ {formatMessage( + { id: 'urdfDebug.joints.waitingForJointState' }, + { topic: selectedJointStateTopic }, + )} +

+ )} + + setFilter(event.target.value)} + /> + +
+ {filteredDescriptors.length === 0 ? ( +

+ {formatMessage({ id: 'urdfDebug.joints.noMatch' })} +

+ ) : ( + filteredDescriptors.map((descriptor) => { + const value = getDisplayedJointValue( + descriptor, + config.manualJointPositions, + liveJointState, + config.followLiveJointState, + ); + return ( + handleJointChange(descriptor.name, next)} + /> + ); + }) + )} +
+
+ ); +}; diff --git a/src/features/panels/UrdfDebug/MeshBaseSection.tsx b/src/features/panels/UrdfDebug/MeshBaseSection.tsx new file mode 100644 index 0000000..4f9bd06 --- /dev/null +++ b/src/features/panels/UrdfDebug/MeshBaseSection.tsx @@ -0,0 +1,318 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { IntlShape } from 'react-intl'; +import { FileDropZone } from '@/shared/ui/file-drop-zone'; +import type { UrdfDebugConfig } from './defaults'; +import { pickMeshFiles } from './fileDropUtils'; +import type { UrdfAnalysis } from './urdfAnalysis'; +import { + buildMeshReferenceStatuses, + extractPackageNameFromUrdf, + normalizeRemoteBaseUrl, + summarizeMeshStatuses, + type MeshProbeStatus, + type MeshReferenceStatus, +} from './meshBaseStatus'; + +type MeshBaseSectionProps = { + config: UrdfDebugConfig; + setConfig: (next: UrdfDebugConfig | ((prev: UrdfDebugConfig) => UrdfDebugConfig)) => void; + urdfAnalysis: UrdfAnalysis | null; + urdfFileContent: string; + meshFiles: File[]; + setMeshFiles: (files: File[]) => void; + resolveMeshUrl: (rawPath: string) => string; + formatMessage: IntlShape['formatMessage']; +}; + +const STATUS_CLASS: Record = { + pending: 'text-muted-foreground', + ok: 'text-emerald-600', + local: 'text-emerald-600', + missing: 'text-amber-600', + error: 'text-red-500', + cors: 'text-amber-600', + unchecked: 'text-muted-foreground', +}; + +function statusLabel(status: MeshProbeStatus, formatMessage: IntlShape['formatMessage']): string { + return formatMessage({ id: `urdfDebug.meshStatus.${status}` }); +} + +function folderLabelFromFiles(files: File[]): string | null { + if (files.length === 0) return null; + const relative = files[0]?.webkitRelativePath; + if (!relative) return null; + const slash = relative.indexOf('/'); + return slash >= 0 ? relative.slice(0, slash) : relative; +} + +export const MeshBaseSection: React.FC = ({ + config, + setConfig, + urdfAnalysis, + urdfFileContent, + meshFiles, + setMeshFiles, + resolveMeshUrl, + formatMessage, +}) => { + const [remoteDraft, setRemoteDraft] = useState(config.packageBaseUrl); + const [remoteApplyError, setRemoteApplyError] = useState(null); + const [meshUploadError, setMeshUploadError] = useState(null); + const [meshStatuses, setMeshStatuses] = useState([]); + const [meshStatusLoading, setMeshStatusLoading] = useState(false); + const [statusRefreshKey, setStatusRefreshKey] = useState(0); + + useEffect(() => { + setRemoteDraft(config.packageBaseUrl); + }, [config.packageBaseUrl]); + + const folderLabel = useMemo(() => folderLabelFromFiles(meshFiles), [meshFiles]); + const meshSummary = useMemo(() => summarizeMeshStatuses(meshStatuses), [meshStatuses]); + + const refreshMeshStatuses = useCallback(async () => { + if (!urdfAnalysis?.meshReferences.length) { + setMeshStatuses([]); + return; + } + setMeshStatusLoading(true); + try { + const statuses = await buildMeshReferenceStatuses({ + meshReferences: urdfAnalysis.meshReferences, + resolveMeshUrl, + strategy: config.meshStrategy, + }); + setMeshStatuses(statuses); + } finally { + setMeshStatusLoading(false); + } + }, [urdfAnalysis, resolveMeshUrl, config.meshStrategy]); + + useEffect(() => { + let cancelled = false; + void (async () => { + if (!urdfAnalysis?.meshReferences.length) { + if (!cancelled) setMeshStatuses([]); + return; + } + if (!cancelled) setMeshStatusLoading(true); + try { + const statuses = await buildMeshReferenceStatuses({ + meshReferences: urdfAnalysis.meshReferences, + resolveMeshUrl, + strategy: config.meshStrategy, + }); + if (!cancelled) setMeshStatuses(statuses); + } finally { + if (!cancelled) setMeshStatusLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [ + urdfAnalysis, + resolveMeshUrl, + config.meshStrategy, + config.packageBaseUrl, + meshFiles, + statusRefreshKey, + ]); + + const handleStrategyChange = (strategy: UrdfDebugConfig['meshStrategy']) => { + setRemoteApplyError(null); + setConfig((prev) => ({ ...prev, meshStrategy: strategy })); + }; + + const applyMeshFiles = useCallback( + (files: File[]) => { + const meshes = pickMeshFiles(files); + if (meshes.length === 0) { + setMeshUploadError(formatMessage({ id: 'urdfDebug.upload.invalidMesh' })); + return; + } + setMeshUploadError(null); + setMeshFiles(meshes); + setRemoteApplyError(null); + setConfig((prev) => ({ ...prev, meshStrategy: 'localUpload' })); + }, + [formatMessage, setConfig, setMeshFiles], + ); + + const handleApplyRemoteBase = () => { + const normalized = normalizeRemoteBaseUrl(remoteDraft); + if (!normalized) { + setRemoteApplyError(formatMessage({ id: 'urdfDebug.meshBase.remoteInvalid' })); + return; + } + setRemoteApplyError(null); + setConfig((prev) => ({ + ...prev, + meshStrategy: 'packageBaseUrl', + packageBaseUrl: normalized, + })); + setStatusRefreshKey((key) => key + 1); + }; + + const handleAutoPackageName = () => { + const detected = extractPackageNameFromUrdf(urdfFileContent); + if (!detected) return; + setConfig((prev) => ({ ...prev, packageName: detected })); + }; + + return ( +
+
+ {formatMessage({ id: 'urdfDebug.meshBase.hint' })} +
+ +
+ {( + [ + ['localUpload', 'urdfDebug.meshBase.mode.localFolder'], + ['packageBaseUrl', 'urdfDebug.meshBase.mode.remoteUrl'], + ['leaveAsIs', 'urdfDebug.meshStrategy.leaveAsIs'], + ] as const + ).map(([value, labelId]) => ( + + ))} +
+ + {config.meshStrategy === 'localUpload' && ( + 0 + ? formatMessage( + { id: 'urdfDebug.meshBase.folderSelected' }, + { folder: folderLabel ?? '-', count: meshFiles.length }, + ) + : undefined + } + error={meshUploadError} + testId="urdf-debug-mesh-upload" + onFiles={applyMeshFiles} + /> + )} + + {config.meshStrategy === 'packageBaseUrl' && ( +
+
+ { + setRemoteDraft(event.target.value); + setRemoteApplyError(null); + }} + placeholder={formatMessage({ id: 'urdfDebug.meshBase.remotePlaceholder' })} + /> + +
+ {remoteApplyError &&
{remoteApplyError}
} + {config.packageBaseUrl ? ( +
+ + {formatMessage({ id: 'urdfDebug.meshBase.applied' })}:{' '} + + {config.packageBaseUrl} +
+ ) : ( +
+ {formatMessage({ id: 'urdfDebug.meshBase.remoteNotApplied' })} +
+ )} +
+ )} + + + + {urdfAnalysis && urdfAnalysis.meshReferences.length > 0 && ( +
+
+
+ {formatMessage({ id: 'urdfDebug.meshBase.resolvedTitle' })} +
+ +
+
+ {meshStatusLoading + ? formatMessage({ id: 'urdfDebug.meshBase.checking' }) + : formatMessage( + { id: 'urdfDebug.meshBase.summary' }, + { + ok: meshSummary.ok, + failed: meshSummary.failed, + total: meshSummary.total, + }, + )} +
+
+ {meshStatuses.map((entry) => ( +
+
+ {entry.rawPath} +
+
+ → {entry.resolvedUrl} +
+
+ {statusLabel(entry.status, formatMessage)} + {entry.error ? `: ${entry.error}` : ''} +
+
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/features/panels/UrdfDebug/Preview.tsx b/src/features/panels/UrdfDebug/Preview.tsx new file mode 100644 index 0000000..0385ade --- /dev/null +++ b/src/features/panels/UrdfDebug/Preview.tsx @@ -0,0 +1,299 @@ +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Canvas, useThree } from '@react-three/fiber'; +import { OrbitControls, GizmoHelper, GizmoViewport } from '@react-three/drei'; +import * as THREE from 'three'; +import type { Player } from '@/core/types/player'; +import type { Time } from '@/core/types/ros'; +import { useRosViewTheme } from '@/features/viewer/RosViewProvider'; +import { scheduleFrame } from '@/shared/utils/rafScheduler'; +import { + applyFramePoses, + applyJointStates, + buildRobotRenderable, + disposeRobotRenderable, + type MeshLoadProgress, + type RobotRenderable, +} from '../ThreeD/foxglove-core/renderables'; +import type { JointStateMsg } from '../ThreeD/foxglove-core/types'; + +const Z_UP = new THREE.Vector3(0, 0, 1); +const GIZMO_MARGIN: [number, number] = [80, 80]; +const GIZMO_AXIS_COLORS: [string, string, string] = ['#ff3653', '#0adb46', '#2c8fff']; +const CANVAS_CAMERA = { + position: [3, -3, 2] as [number, number, number], + up: [0, 0, 1] as [number, number, number], + fov: 45, + near: 0.5, + far: 5000, +}; + +type ThemeColors = { + panelBackgroundClassName: string; + overlayClassName: string; + sceneBackground: THREE.ColorRepresentation; + gridPrimary: string; + gridSecondary: string; + gizmoLabelColor: string; + fallbackMeshColor: string; + meshOutlineColor: string; +}; + +function getThemeColors(resolvedTheme: 'light' | 'dark'): ThemeColors { + if (resolvedTheme === 'light') { + return { + panelBackgroundClassName: 'bg-slate-50', + overlayClassName: 'bg-white/80 text-slate-800 border border-slate-200', + sceneBackground: '#f8fafc', + gridPrimary: '#cbd5e1', + gridSecondary: '#e2e8f0', + gizmoLabelColor: '#0f172a', + fallbackMeshColor: '#cbd5e1', + meshOutlineColor: '#1e293b', + }; + } + return { + panelBackgroundClassName: 'bg-[#111]', + overlayClassName: 'bg-black/50 text-white border border-white/10', + sceneBackground: '#111111', + gridPrimary: '#666', + gridSecondary: '#444', + gizmoLabelColor: 'white', + fallbackMeshColor: '#cbd5e1', + meshOutlineColor: '#94a3b8', + }; +} + +const CameraSetup: React.FC = () => { + const { camera, invalidate } = useThree(); + useEffect(() => { + camera.up.copy(Z_UP); + camera.position.set(3, -3, 2); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + invalidate(); + }, [camera, invalidate]); + return null; +}; + +const ZUpGrid: React.FC<{ colors: ThemeColors }> = ({ colors }) => { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.rotation.x = -Math.PI / 2; + }, []); + return ; +}; + +interface RobotPreviewProps { + player: Player; + urdf: string; + jointState: JointStateMsg | null; + resolveMeshUrl: (rawPath: string) => string; + fallbackMeshColor: string; + meshOutlineColor: string; + onMeshLoadProgressChange?: (progress: MeshLoadProgress | null) => void; + onMeshIssue?: (meshUrl: string, reason: string) => void; +} + +const RobotPreview: React.FC = ({ + player, + urdf, + jointState, + resolveMeshUrl, + fallbackMeshColor, + meshOutlineColor, + onMeshLoadProgressChange, + onMeshIssue, +}) => { + const [robotModel, setRobotModel] = useState(null); + const jointStateRef = useRef(jointState); + const jointStateDirtyRef = useRef(false); + const applyPendingRef = useRef(false); + const cancelApplyFrameRef = useRef<(() => void) | null>(null); + const playbackTimeRef = useRef(0n); + const { invalidate } = useThree(); + + useEffect(() => { + jointStateRef.current = jointState; + }, [jointState]); + + const schedulePoseApply = useCallback(() => { + if (!robotModel || applyPendingRef.current) return; + applyPendingRef.current = true; + cancelApplyFrameRef.current = scheduleFrame(() => { + applyPendingRef.current = false; + cancelApplyFrameRef.current = null; + if (jointStateDirtyRef.current) { + jointStateDirtyRef.current = false; + applyJointStates(robotModel, jointStateRef.current); + } + applyFramePoses(robotModel, playbackTimeRef.current); + invalidate(); + }); + }, [robotModel, invalidate]); + + useEffect(() => { + return () => { + cancelApplyFrameRef.current?.(); + cancelApplyFrameRef.current = null; + applyPendingRef.current = false; + }; + }, [robotModel]); + + useEffect(() => { + const unsubscribe = player.subscribeCurrentTime((time: Time) => { + playbackTimeRef.current = BigInt(time.sec) * 1_000_000_000n + BigInt(time.nsec); + schedulePoseApply(); + }); + return unsubscribe; + }, [player, schedulePoseApply]); + + /* eslint-disable react-hooks/exhaustive-deps -- jointState updates via schedulePoseApply below, not full rebuild */ + useEffect(() => { + let cancelled = false; + onMeshLoadProgressChange?.(null); + void (async () => { + try { + const model = await buildRobotRenderable(urdf, { + resolveMeshUrl, + warn: (meshUrl, reason) => onMeshIssue?.(meshUrl, reason), + fallbackMeshColor, + outlineColor: meshOutlineColor, + onMeshLoadProgress: (progress) => { + if (!cancelled) onMeshLoadProgressChange?.(progress); + }, + }); + if (cancelled) { + disposeRobotRenderable(model); + return; + } + applyJointStates(model, jointState); + applyFramePoses(model, playbackTimeRef.current); + setRobotModel(model); + onMeshLoadProgressChange?.(null); + } catch (error) { + onMeshIssue?.('urdf', error instanceof Error ? error.message : String(error)); + onMeshLoadProgressChange?.(null); + } + })(); + return () => { + cancelled = true; + onMeshLoadProgressChange?.(null); + setRobotModel((current) => { + disposeRobotRenderable(current); + return null; + }); + }; + }, [urdf, fallbackMeshColor, meshOutlineColor, resolveMeshUrl, onMeshIssue, onMeshLoadProgressChange]); + /* eslint-enable react-hooks/exhaustive-deps */ + + useEffect(() => { + if (!robotModel) return; + jointStateDirtyRef.current = true; + schedulePoseApply(); + }, [robotModel, jointState, schedulePoseApply]); + + return robotModel ? : null; +}; + +export interface UrdfDebugPreviewProps { + player: Player; + urdfText: string; + jointState: JointStateMsg | null; + resolveMeshUrl: (rawPath: string) => string; + fallbackMeshColor: string; + showGrid: boolean; + showAxes: boolean; + rotateMeshVisuals?: boolean; + onMeshLoadProgressChange?: (progress: MeshLoadProgress | null) => void; + onMeshIssue?: (meshUrl: string, reason: string) => void; +} + +export const UrdfDebugPreview: React.FC = ({ + player, + urdfText, + jointState, + resolveMeshUrl, + fallbackMeshColor, + showGrid, + showAxes, + rotateMeshVisuals = false, + onMeshLoadProgressChange, + onMeshIssue, +}) => { + const { formatMessage } = useIntl(); + const { resolvedTheme } = useRosViewTheme(); + const colors = useMemo(() => getThemeColors(resolvedTheme), [resolvedTheme]); + const [meshLoadProgress, setMeshLoadProgress] = useState(null); + const isMeshLoading = + meshLoadProgress !== null && + meshLoadProgress.total > 0 && + meshLoadProgress.loaded < meshLoadProgress.total; + + const handleMeshProgress = useCallback( + (progress: MeshLoadProgress | null) => { + setMeshLoadProgress(progress); + onMeshLoadProgressChange?.(progress); + }, + [onMeshLoadProgressChange], + ); + + if (!urdfText) { + return ( +
+ {formatMessage({ id: 'urdfDebug.preview.empty' })} +
+ ); + } + + const overlayLabel = isMeshLoading && meshLoadProgress + ? formatMessage( + { id: 'urdfDebug.preview.loadingMesh' }, + { loaded: meshLoadProgress.loaded, total: meshLoadProgress.total }, + ) + : formatMessage( + { id: 'urdfDebug.preview.title' }, + { + rotateMesh: formatMessage({ + id: rotateMeshVisuals ? 'urdfDebug.preview.rotateMeshOn' : 'urdfDebug.preview.rotateMeshOff', + }), + }, + ); + + return ( +
+
+ {overlayLabel} +
+ + + + + + + {showGrid && } + {showAxes && } + + + + + + + + +
+ ); +}; diff --git a/src/features/panels/UrdfDebug/defaults.ts b/src/features/panels/UrdfDebug/defaults.ts new file mode 100644 index 0000000..eefaf8f --- /dev/null +++ b/src/features/panels/UrdfDebug/defaults.ts @@ -0,0 +1,41 @@ +import type { MeshStrategy } from './recipe'; + +export interface UrdfDebugConfig { + jointStateTopic: string; + urdfFileName: string; + urdfFileContent: string; + meshStrategy: MeshStrategy; + packageName: string; + packageBaseUrl: string; + framePrefix: string; + rotateMeshVisuals: boolean; + visualRpyOffset: [number, number, number]; + fallbackMeshColor: string; + showGrid: boolean; + showAxes: boolean; + manualJointPositions: Record; + followLiveJointState: boolean; + settingsPanelPercent: number; +} + +export const DEFAULT_SETTINGS_PANEL_PERCENT = 35; +export const MIN_SETTINGS_PANEL_PERCENT = 22; +export const MAX_SETTINGS_PANEL_PERCENT = 58; + +export const defaultUrdfDebugConfig = (): UrdfDebugConfig => ({ + jointStateTopic: '', + urdfFileName: '', + urdfFileContent: '', + meshStrategy: 'localUpload', + packageName: '', + packageBaseUrl: '', + framePrefix: '', + rotateMeshVisuals: false, + visualRpyOffset: [0, 0, 0], + fallbackMeshColor: '#94a3b8', + showGrid: true, + showAxes: true, + manualJointPositions: {}, + followLiveJointState: false, + settingsPanelPercent: DEFAULT_SETTINGS_PANEL_PERCENT, +}); diff --git a/src/features/panels/UrdfDebug/definition.tsx b/src/features/panels/UrdfDebug/definition.tsx new file mode 100644 index 0000000..313628e --- /dev/null +++ b/src/features/panels/UrdfDebug/definition.tsx @@ -0,0 +1,89 @@ +import { lazy } from 'react'; +import type { PanelDefinition } from '../framework/types'; +import { PanelSuspense } from '../framework/panelSuspense'; +import { + FileInput, + SettingsField, + SettingsSection, + SettingsSwitch, + SettingsText, + TopicAutocomplete, +} from '../framework/settings'; +import { defaultUrdfDebugConfig, type UrdfDebugConfig } from './defaults'; +import { parseUrdfDebugConfig } from './schema'; + +const UrdfDebugPanel = lazy(async () => { + const m = await import('./Component'); + return { default: m.UrdfDebugPanel }; +}); + +export const urdfDebugPanelDefinition: PanelDefinition = { + type: 'UrdfDebug', + defaultTitle: 'URDF Debug', + createDefaultConfig: defaultUrdfDebugConfig, + configSchema: { version: 1, parse: parseUrdfDebugConfig }, + schemaSupport: { + supportedSchemas: ['sensor_msgs/msg/JointState'], + }, + render: ({ player, panelId, config, setConfig }) => ( + + + + ), + renderSettings: ({ config, setConfig, topics }) => ( +
+ + + setConfig({ ...config, jointStateTopic })} + topics={topics} + typeIncludes={['sensor_msgs/msg/JointState']} + placeholder="/joint_states" + /> + + + setConfig({ ...config, framePrefix })} + /> + + + + + setConfig({ ...config, showGrid })} + /> + + + setConfig({ ...config, showAxes })} + /> + + + setConfig({ ...config, rotateMeshVisuals })} + /> + + + + + + setConfig({ + ...config, + urdfFileContent: text, + urdfFileName: file.name, + }) + } + /> + + +
+ ), +}; diff --git a/src/features/panels/UrdfDebug/diagnostics.ts b/src/features/panels/UrdfDebug/diagnostics.ts new file mode 100644 index 0000000..aa3dbe8 --- /dev/null +++ b/src/features/panels/UrdfDebug/diagnostics.ts @@ -0,0 +1,87 @@ +import type { TopicInfo } from '@/core/types/ros'; +import type { JointStateLike } from './jointStateMapping'; +import type { JointMappingRule } from './recipe'; +import type { UrdfAnalysis } from './urdfAnalysis'; +import { topicExists } from './urdfAnalysis'; + +export type UrdfDebugDiagnostics = { + hasJointStateTopic: boolean; + hasExistingTf: boolean; + hasExistingRobotDescription: boolean; + urdfLoaded: boolean; + robotName: string; + linkCount: number; + jointCount: number; + meshReferenceCount: number; + matchCoverage: string; + matchedCount: number; + movableJointCount: number; + unmatchedInputJoints: string[]; + missingUrdfJoints: string[]; + generatedTfCount: number; + meshLoadIssues: string[]; + playbackHealthy: boolean; + lastError: string | null; +}; + +export function computeDiagnostics(args: { + topics: ReadonlyArray; + jointStateTopic: string; + urdfAnalysis: UrdfAnalysis | null; + rawJointState: JointStateLike | null; + mappedJointState: JointStateLike | null; + rules: JointMappingRule[]; + generatedTfCount: number; + meshLoadIssues: string[]; + lastError: string | null; +}): UrdfDebugDiagnostics { + const { + topics, + jointStateTopic, + urdfAnalysis, + rawJointState, + mappedJointState, + generatedTfCount, + meshLoadIssues, + lastError, + } = args; + + const hasJointStateTopic = + jointStateTopic.length > 0 && topics.some((topic) => topic.name === jointStateTopic); + const hasExistingTf = topicExists( + topics, + (_name, type) => type.includes('TFMessage') || type.includes('tf2_msgs') || type.includes('tf/tfMessage'), + ); + const hasExistingRobotDescription = topicExists( + topics, + (name) => name.includes('robot_description'), + ); + + const movable = urdfAnalysis?.movableJointNames ?? []; + const mappedNames = new Set(mappedJointState?.name ?? []); + const matchedCount = movable.filter((name) => mappedNames.has(name)).length; + const rawNames = rawJointState?.name ?? []; + const unmatchedInputJoints = rawNames.filter((name) => !mappedNames.has(name)); + const missingUrdfJoints = movable.filter((name) => !mappedNames.has(name)); + + return { + hasJointStateTopic, + hasExistingTf, + hasExistingRobotDescription, + urdfLoaded: urdfAnalysis != null, + robotName: urdfAnalysis?.robotName ?? '-', + linkCount: urdfAnalysis?.linkCount ?? 0, + jointCount: urdfAnalysis?.jointCount ?? 0, + meshReferenceCount: urdfAnalysis?.meshReferences.length ?? 0, + matchCoverage: + movable.length > 0 ? `${matchedCount} / ${movable.length}` : mappedNames.size > 0 ? `${mappedNames.size}` : '0 / 0', + matchedCount, + movableJointCount: movable.length, + unmatchedInputJoints, + missingUrdfJoints, + generatedTfCount, + meshLoadIssues, + playbackHealthy: hasJointStateTopic && mappedJointState != null && mappedJointState.name.length > 0, + lastError, + }; +} diff --git a/src/features/panels/UrdfDebug/embedded/fkEngine.js b/src/features/panels/UrdfDebug/embedded/fkEngine.js new file mode 100644 index 0000000..3f7e102 --- /dev/null +++ b/src/features/panels/UrdfDebug/embedded/fkEngine.js @@ -0,0 +1,233 @@ +/** Standalone jointstate2tf FK engine for exported MCAP scripts. */ + +function vec3(x = 0, y = 0, z = 0) { + return { x, y, z }; +} + +function quatIdentity() { + return { x: 0, y: 0, z: 0, w: 1 }; +} + +function vec3Add(a, b) { + return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }; +} + +function vec3Scale(a, s) { + return { x: a.x * s, y: a.y * s, z: a.z * s }; +} + +function vec3Length(a) { + return Math.hypot(a.x, a.y, a.z); +} + +function vec3Normalize(a) { + const len = vec3Length(a) || 1; + return { x: a.x / len, y: a.y / len, z: a.z / len }; +} + +function quatMultiply(a, b) { + return { + w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z, + x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, + y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, + z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, + }; +} + +function quatFromAxisAngle(axis, angle) { + const n = vec3Normalize(axis); + const h = angle * 0.5; + const s = Math.sin(h); + return { x: n.x * s, y: n.y * s, z: n.z * s, w: Math.cos(h) }; +} + +function quatFromRPY(roll, pitch, yaw) { + const cx = Math.cos(roll * 0.5); + const sx = Math.sin(roll * 0.5); + const cy = Math.cos(pitch * 0.5); + const sy = Math.sin(pitch * 0.5); + const cz = Math.cos(yaw * 0.5); + const sz = Math.sin(yaw * 0.5); + return { + w: cz * cy * cx + sz * sy * sx, + x: cz * cy * sx - sz * sy * cx, + y: cz * sy * cx + sz * cy * sx, + z: sz * cy * cx - cz * sy * sx, + }; +} + +function vec3RotateByQuat(v, q) { + const { x, y, z } = v; + const qx = q.x; + const qy = q.y; + const qz = q.z; + const qw = q.w; + const uvx = qy * z - qz * y; + const uvy = qz * x - qx * z; + const uvz = qx * y - qy * x; + const uuvx = qy * uvz - qz * uvy; + const uuvy = qz * uvx - qx * uvz; + const uuvz = qx * uvy - qy * uvx; + return { + x: x + 2 * (qw * uvx + uuvx), + y: y + 2 * (qw * uvy + uuvy), + z: z + 2 * (qw * uvz + uuvz), + }; +} + +function composeTR(a, b) { + return { + r: quatMultiply(a.r, b.r), + t: vec3Add(a.t, vec3RotateByQuat(b.t, a.r)), + }; +} + +export class JointState2TF { + constructor(model) { + this.model = model; + } + + static fromXml(opts) { + return new JointState2TF(parseUrdf(opts.xml)); + } + + setJointState(jointState) { + const nameToPos = new Map(); + jointState.name.forEach((n, i) => nameToPos.set(n, jointState.position[i] ?? 0)); + nameToPos.forEach((pos, name) => { + const j = this.model.jointsByName.get(name); + if (j) j.q = pos; + }); + } + + compute(options = {}) { + const transforms = []; + const publishTimeNs = options.publishTimeNs != null ? Number(options.publishTimeNs) : null; + this.model.jointsByName.forEach((joint) => { + const motion = jointMotionTR(joint); + const rel = composeTR(joint.origin, motion); + const sec = publishTimeNs != null ? Math.trunc(publishTimeNs / 1e9) : 0; + const nanosec = publishTimeNs != null ? Math.trunc(publishTimeNs % 1e9) : 0; + transforms.push({ + header: { stamp: { sec, nanosec }, frame_id: joint.parent }, + child_frame_id: joint.child, + transform: { + translation: { x: rel.t.x, y: rel.t.y, z: rel.t.z }, + rotation: { x: rel.r.x, y: rel.r.y, z: rel.r.z, w: rel.r.w }, + }, + }); + }); + return { transforms }; + } + + computeFromJointState(jointState, options = {}) { + this.setJointState(jointState); + return this.compute(options); + } +} + +function parseUrdf(xml) { + const joints = extractJointBlocks(xml).map(parseJointBlock).filter(Boolean); + const jointsByName = new Map(); + const jointsByParentLink = new Map(); + const linkParent = new Map(); + for (const j of joints) { + jointsByName.set(j.name, j); + linkParent.set(j.child, j.parent); + const arr = jointsByParentLink.get(j.parent) ?? []; + arr.push(j); + jointsByParentLink.set(j.parent, arr); + } + return { jointsByName, jointsByParentLink, linkParent }; +} + +function extractJointBlocks(xml) { + const blocks = []; + const re = //g; + let m; + while ((m = re.exec(xml)) !== null) blocks.push(m[0]); + return blocks; +} + +function parseJointBlock(block) { + const openMatch = /]*)>/.exec(block); + if (!openMatch) return null; + const openAttrs = parseAttrs(openMatch[1]); + const name = (openAttrs.name ?? '').trim(); + const type = (openAttrs.type ?? 'fixed').trim(); + const parentLink = parseSingleTagAttr(block, 'parent', 'link'); + const childLink = parseSingleTagAttr(block, 'child', 'link'); + if (!name || !parentLink || !childLink) return null; + const originAttrs = parseFirstSelfOrOpenTag(block, 'origin'); + const originT = parseXyz(originAttrs?.xyz); + const originRpy = parseRpy(originAttrs?.rpy); + const origin = { r: originRpy, t: originT }; + let axis = vec3(1, 0, 0); + const axisAttrs = parseFirstSelfOrOpenTag(block, 'axis'); + if (axisAttrs?.xyz) axis = parseXyzVec(axisAttrs.xyz); + const movable = ['revolute', 'continuous', 'prismatic', 'fixed']; + return { + name, + type: movable.includes(type) ? type : 'fixed', + parent: parentLink, + child: childLink, + origin, + axis: vec3Normalize(axis), + q: 0, + }; +} + +function parseAttrs(s) { + const out = {}; + const re = /(\w+)\s*=\s*"([^"]*)"/g; + let m; + while ((m = re.exec(s)) !== null) out[m[1]] = m[2]; + return out; +} + +function parseSingleTagAttr(block, tag, attr) { + const re = new RegExp(`<${tag}\\b([^>]*)\\/>`); + const m = re.exec(block); + if (!m) return null; + const attrs = parseAttrs(m[1] ?? ''); + const v = attrs[attr]; + return typeof v === 'string' ? v.trim() : null; +} + +function parseFirstSelfOrOpenTag(block, tag) { + let re = new RegExp(`<${tag}\\b([^>]*)\\/>`); + let m = re.exec(block); + if (m) return parseAttrs(m[1] ?? ''); + re = new RegExp(`<${tag}\\b([^>]*)>`); + m = re.exec(block); + if (m) return parseAttrs(m[1] ?? ''); + return null; +} + +function parseXyz(s) { + if (!s) return vec3(0, 0, 0); + const [x, y, z] = s.split(/\s+/).map(Number); + return vec3(x || 0, y || 0, z || 0); +} + +function parseXyzVec(s) { + return parseXyz(s); +} + +function parseRpy(s) { + if (!s) return quatIdentity(); + const [r, p, y] = s.split(/\s+/).map(Number); + return quatFromRPY(r || 0, p || 0, y || 0); +} + +function jointMotionTR(j) { + switch (j.type) { + case 'revolute': + case 'continuous': + return { r: quatFromAxisAngle(j.axis, j.q), t: vec3(0, 0, 0) }; + case 'prismatic': + return { r: quatIdentity(), t: vec3Scale(j.axis, j.q) }; + default: + return { r: quatIdentity(), t: vec3(0, 0, 0) }; + } +} diff --git a/src/features/panels/UrdfDebug/fileDropUtils.test.ts b/src/features/panels/UrdfDebug/fileDropUtils.test.ts new file mode 100644 index 0000000..6221d3e --- /dev/null +++ b/src/features/panels/UrdfDebug/fileDropUtils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { pickMeshFiles, pickUrdfFile } from './fileDropUtils'; + +function mockFile(name: string): File { + return new File([''], name, { type: 'application/octet-stream' }); +} + +describe('fileDropUtils', () => { + it('pickUrdfFile selects urdf or xml', () => { + const files = [mockFile('mesh.stl'), mockFile('robot.urdf'), mockFile('other.txt')]; + expect(pickUrdfFile(files)?.name).toBe('robot.urdf'); + expect(pickUrdfFile([mockFile('model.xml')])?.name).toBe('model.xml'); + expect(pickUrdfFile([mockFile('readme.txt')])).toBeNull(); + }); + + it('pickMeshFiles filters mesh extensions', () => { + const files = [mockFile('a.stl'), mockFile('b.dae'), mockFile('c.obj'), mockFile('d.urdf')]; + expect(pickMeshFiles(files).map((f) => f.name)).toEqual(['a.stl', 'b.dae', 'c.obj']); + }); +}); diff --git a/src/features/panels/UrdfDebug/fileDropUtils.ts b/src/features/panels/UrdfDebug/fileDropUtils.ts new file mode 100644 index 0000000..e20d16c --- /dev/null +++ b/src/features/panels/UrdfDebug/fileDropUtils.ts @@ -0,0 +1,14 @@ +const URDF_EXT = /\.(urdf|xml)$/i; +const MESH_EXT = /\.(stl|dae|obj)$/i; + +export function pickUrdfFile(files: File[]): File | null { + return files.find((file) => URDF_EXT.test(file.name)) ?? null; +} + +export function pickMeshFiles(files: File[]): File[] { + return files.filter((file) => MESH_EXT.test(file.name)); +} + +export function filesFromDataTransfer(dataTransfer: DataTransfer): File[] { + return Array.from(dataTransfer.files); +} diff --git a/src/features/panels/UrdfDebug/foxgloveAdapter.ts b/src/features/panels/UrdfDebug/foxgloveAdapter.ts new file mode 100644 index 0000000..05ec10a --- /dev/null +++ b/src/features/panels/UrdfDebug/foxgloveAdapter.ts @@ -0,0 +1,73 @@ +import { + collectExtras, + FOXGLOVE_PANEL_TITLE_KEY, + mergeWithExtras, + type FoxgloveAdapterDecoded, + type FoxgloveAdapterState, + type FoxgloveConfig, + type PanelFoxgloveAdapter, +} from '../framework/foxgloveAdapter'; +import { type UrdfDebugConfig } from './defaults'; +import { parseUrdfDebugConfig } from './schema'; + +const KNOWN_KEYS = [ + 'jointStateTopic', + 'urdfFileName', + 'urdfFileContent', + 'meshStrategy', + 'packageName', + 'packageBaseUrl', + 'framePrefix', + 'rotateMeshVisuals', + 'visualRpyOffset', + 'fallbackMeshColor', + 'showGrid', + 'showAxes', + 'manualJointPositions', + 'followLiveJointState', + 'settingsPanelPercent', +] as const; + +function fromConfig(config: FoxgloveConfig): FoxgloveAdapterDecoded { + const title = + typeof config[FOXGLOVE_PANEL_TITLE_KEY] === 'string' + ? config[FOXGLOVE_PANEL_TITLE_KEY] + : undefined; + return { + config: parseUrdfDebugConfig(config), + extras: collectExtras(config, KNOWN_KEYS), + title, + }; +} + +function toConfig(state: FoxgloveAdapterState): FoxgloveConfig { + const known: FoxgloveConfig = { + jointStateTopic: state.config.jointStateTopic, + urdfFileName: state.config.urdfFileName, + urdfFileContent: state.config.urdfFileContent, + meshStrategy: state.config.meshStrategy, + packageName: state.config.packageName, + packageBaseUrl: state.config.packageBaseUrl, + framePrefix: state.config.framePrefix, + rotateMeshVisuals: state.config.rotateMeshVisuals, + visualRpyOffset: state.config.visualRpyOffset, + fallbackMeshColor: state.config.fallbackMeshColor, + showGrid: state.config.showGrid, + showAxes: state.config.showAxes, + manualJointPositions: state.config.manualJointPositions, + followLiveJointState: state.config.followLiveJointState, + settingsPanelPercent: state.config.settingsPanelPercent, + }; + if (state.title && state.title.length > 0) { + known[FOXGLOVE_PANEL_TITLE_KEY] = state.title; + } + return mergeWithExtras(state.extras, known); +} + +export const urdfDebugFoxgloveAdapter: PanelFoxgloveAdapter = { + internalType: 'UrdfDebug', + foxgloveTypes: ['UrdfDebug'], + defaultFoxgloveType: 'UrdfDebug', + fromConfig, + toConfig, +}; diff --git a/src/features/panels/UrdfDebug/index.ts b/src/features/panels/UrdfDebug/index.ts new file mode 100644 index 0000000..cbc61cd --- /dev/null +++ b/src/features/panels/UrdfDebug/index.ts @@ -0,0 +1,2 @@ +export { urdfDebugPanelDefinition } from './definition'; +export type { UrdfDebugConfig } from './defaults'; diff --git a/src/features/panels/UrdfDebug/jointPose.test.ts b/src/features/panels/UrdfDebug/jointPose.test.ts new file mode 100644 index 0000000..542f5a5 --- /dev/null +++ b/src/features/panels/UrdfDebug/jointPose.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { buildPreviewJointState, getDisplayedJointValue } from './jointPose'; +import type { UrdfJointDescriptor } from './urdfAnalysis'; + +const descriptors: UrdfJointDescriptor[] = [ + { + name: 'j1', + jointType: 'revolute', + lower: -1, + upper: 1, + step: 0.02, + defaultValue: 0, + sliderEnabled: true, + valueUnit: 'rad', + }, + { + name: 'j2', + jointType: 'revolute', + lower: 0, + upper: 2, + step: 0.02, + defaultValue: 0, + sliderEnabled: true, + valueUnit: 'rad', + }, + { + name: 'fixed_joint', + jointType: 'fixed', + lower: 0, + upper: 0, + step: 0, + defaultValue: 0, + sliderEnabled: false, + valueUnit: 'rad', + }, +]; + +describe('buildPreviewJointState', () => { + it('uses manual positions when not following live', () => { + const out = buildPreviewJointState({ + descriptors, + manualPositions: { j1: 0.5, j2: 1.2 }, + liveJointState: null, + followLive: false, + mimicJoints: [], + }); + expect(out?.name).toEqual(['j1', 'j2']); + expect(out?.position).toEqual([0.5, 1.2]); + }); + + it('prefers live values when followLive is on', () => { + const out = buildPreviewJointState({ + descriptors, + manualPositions: { j1: 0.5, j2: 1.2 }, + liveJointState: { name: ['j1'], position: [0.8] }, + followLive: true, + mimicJoints: [], + }); + expect(out).not.toBeNull(); + if (!out) return; + expect(out.position[out.name.indexOf('j1')]).toBe(0.8); + expect(out.position[out.name.indexOf('j2')]).toBe(1.2); + }); + + it('applies mimic joints after base values', () => { + const out = buildPreviewJointState({ + descriptors, + manualPositions: { j1: 0.4, j2: 0 }, + liveJointState: null, + followLive: false, + mimicJoints: [{ jointName: 'j2', sourceJoint: 'j1', multiplier: 2, offset: 0 }], + }); + expect(out).not.toBeNull(); + if (!out) return; + expect(out.position[out.name.indexOf('j2')]).toBe(0.8); + }); + + it('clamps live values to joint limits', () => { + const value = getDisplayedJointValue( + descriptors[0], + { j1: 0.1 }, + { name: ['j1'], position: [5] }, + true, + ); + expect(value).toBe(1); + }); +}); diff --git a/src/features/panels/UrdfDebug/jointPose.ts b/src/features/panels/UrdfDebug/jointPose.ts new file mode 100644 index 0000000..862f743 --- /dev/null +++ b/src/features/panels/UrdfDebug/jointPose.ts @@ -0,0 +1,91 @@ +import type { JointStateMsg } from '../ThreeD/foxglove-core/types'; +import type { JointStateLike } from './jointStateMapping'; +import type { UrdfJointDescriptor, UrdfMimicJoint } from './urdfAnalysis'; + +export type BuildPreviewJointStateArgs = { + descriptors: UrdfJointDescriptor[]; + manualPositions: Record; + liveJointState: JointStateLike | null; + followLive: boolean; + mimicJoints: UrdfMimicJoint[]; +}; + +function clamp(value: number, lower: number, upper: number): number { + return Math.max(lower, Math.min(upper, value)); +} + +function readLiveValue(live: JointStateLike | null, jointName: string): number | undefined { + if (!live) return undefined; + const index = live.name.indexOf(jointName); + if (index < 0) return undefined; + return live.position[index] ?? 0; +} + +function resolveBaseValue( + descriptor: UrdfJointDescriptor, + manualPositions: Record, + liveJointState: JointStateLike | null, + followLive: boolean, +): number { + if (followLive) { + const liveValue = readLiveValue(liveJointState, descriptor.name); + if (liveValue != null) { + return clamp(liveValue, descriptor.lower, descriptor.upper); + } + } + const manual = manualPositions[descriptor.name]; + if (manual != null && Number.isFinite(manual)) { + return clamp(manual, descriptor.lower, descriptor.upper); + } + return descriptor.defaultValue; +} + +function applyMimicJoints( + positions: Map, + mimicJoints: UrdfMimicJoint[], + descriptors: UrdfJointDescriptor[], +): void { + const limits = new Map(descriptors.map((d) => [d.name, d])); + for (const mimic of mimicJoints) { + const source = positions.get(mimic.sourceJoint); + if (source == null) continue; + const limit = limits.get(mimic.jointName); + const value = source * mimic.multiplier + mimic.offset; + positions.set( + mimic.jointName, + limit ? clamp(value, limit.lower, limit.upper) : value, + ); + } +} + +export function buildPreviewJointState(args: BuildPreviewJointStateArgs): JointStateMsg | null { + const { descriptors, manualPositions, liveJointState, followLive, mimicJoints } = args; + if (descriptors.length === 0) return null; + + const positions = new Map(); + for (const descriptor of descriptors) { + if (!descriptor.sliderEnabled) continue; + positions.set( + descriptor.name, + resolveBaseValue(descriptor, manualPositions, liveJointState, followLive), + ); + } + + applyMimicJoints(positions, mimicJoints, descriptors); + + const names = [...positions.keys()]; + if (names.length === 0) return null; + return { + name: names, + position: names.map((name) => positions.get(name) ?? 0), + }; +} + +export function getDisplayedJointValue( + descriptor: UrdfJointDescriptor, + manualPositions: Record, + liveJointState: JointStateLike | null, + followLive: boolean, +): number { + return resolveBaseValue(descriptor, manualPositions, liveJointState, followLive); +} diff --git a/src/features/panels/UrdfDebug/jointStateMapping.test.ts b/src/features/panels/UrdfDebug/jointStateMapping.test.ts new file mode 100644 index 0000000..535a43a --- /dev/null +++ b/src/features/panels/UrdfDebug/jointStateMapping.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + applyJointMapping, + buildAutoMatchRules, + buildGripperNormalizedRule, + buildInvertRule, + buildMimicRulesFromUrdf, + buildSymmetricPairFromHeuristic, + buildSymmetricPairRule, + buildXArm851Rule, +} from './jointStateMapping'; + +describe('applyJointMapping', () => { + it('renames joints', () => { + const out = applyJointMapping( + { name: ['a'], position: [1] }, + [{ kind: 'rename', from: 'a', to: 'joint_a' }], + ); + expect(out.name).toEqual(['joint_a']); + expect(out.position[0]).toBe(1); + }); + + it('applies linear scale and offset', () => { + const out = applyJointMapping( + { name: ['gripper'], position: [100] }, + [{ kind: 'linear', from: 'gripper', to: 'drive', scale: 0.001, offset: -0.1 }], + ); + expect(out.position[0]).toBeCloseTo(0); + }); + + it('inverts joint direction', () => { + const out = applyJointMapping( + { name: ['j1'], position: [0.5] }, + [buildInvertRule('j1', 'j1')], + ); + expect(out.position[0]).toBe(-0.5); + }); + + it('duplicates one input to symmetric outputs', () => { + const out = applyJointMapping( + { name: ['gripper'], position: [0.02] }, + [ + { + kind: 'duplicate', + from: 'gripper', + outputs: [ + { to: 'left', scale: -1, offset: 0 }, + { to: 'right', scale: 1, offset: 0 }, + ], + }, + ], + ); + expect(out.name.sort()).toEqual(['left', 'right']); + expect(out.position[out.name.indexOf('left')]).toBe(-0.02); + expect(out.position[out.name.indexOf('right')]).toBe(0.02); + }); + + it('writes constant and mimic joints', () => { + const out = applyJointMapping( + { name: ['source'], position: [0.3] }, + [ + { kind: 'constant', to: 'fixed_joint', value: 0.1 }, + { kind: 'mimic', source: 'source', to: 'follower', multiplier: 2, offset: 0.01 }, + ], + ); + expect(out.name).toContain('fixed_joint'); + expect(out.name).toContain('follower'); + expect(out.position[out.name.indexOf('fixed_joint')]).toBe(0.1); + expect(out.position[out.name.indexOf('follower')]).toBeCloseTo(0.61); + }); + + it('ignores passthrough for ignored joints', () => { + const out = applyJointMapping( + { name: ['keep', 'drop'], position: [1, 2] }, + [{ kind: 'ignore', from: 'drop' }], + ); + expect(out.name).toEqual(['keep']); + expect(out.position[0]).toBe(1); + }); + + it('passthroughs matching names when no rule applies', () => { + const out = applyJointMapping({ name: ['j1'], position: [0.7] }, []); + expect(out.name).toEqual(['j1']); + expect(out.position[0]).toBe(0.7); + }); +}); + +describe('rule builders', () => { + it('builds auto match rename rules', () => { + const rules = buildAutoMatchRules(['Arm_J1'], ['arm_j1']); + expect(rules).toEqual([{ kind: 'rename', from: 'Arm_J1', to: 'arm_j1' }]); + }); + + it('builds gripper normalized rule', () => { + const rule = buildGripperNormalizedRule('gripper', 'drive', true, 0, 0.85); + const out = applyJointMapping({ name: ['gripper'], position: [1] }, [rule]); + expect(out.position[0]).toBe(0); + }); + + it('builds xArm851 rule', () => { + const rule = buildXArm851Rule('gripper', 'drive_joint'); + const out = applyJointMapping({ name: ['gripper'], position: [851] }, [rule]); + expect(out.position[0]).toBeCloseTo(0, 3); + }); + + it('builds symmetric pair rules', () => { + const rules = buildSymmetricPairRule('gripper', 'left', 'right', 1, 0); + const out = applyJointMapping({ name: ['gripper'], position: [0.034] }, rules); + expect(out.name.sort()).toEqual(['left', 'right']); + }); + + it('builds symmetric pair from heuristic', () => { + const rules = buildSymmetricPairFromHeuristic( + ['drive_joint'], + ['left_finger_joint', 'right_finger_joint', 'joint1'], + ['joint1'], + ); + expect(rules).not.toBeNull(); + const out = applyJointMapping({ name: ['drive_joint'], position: [0.02] }, rules!); + expect(out.name.sort()).toEqual(['left_finger_joint', 'right_finger_joint']); + }); + + it('builds mimic rules from urdf metadata', () => { + const rules = buildMimicRulesFromUrdf( + [{ jointName: 'left_finger_joint', sourceJoint: 'drive_joint', multiplier: 1, offset: 0 }], + ['drive_joint'], + [], + ); + expect(rules).toHaveLength(1); + const out = applyJointMapping({ name: ['drive_joint'], position: [0.5] }, rules); + expect(out.name).toContain('left_finger_joint'); + expect(out.position[out.name.indexOf('left_finger_joint')]).toBe(0.5); + }); +}); diff --git a/src/features/panels/UrdfDebug/jointStateMapping.ts b/src/features/panels/UrdfDebug/jointStateMapping.ts new file mode 100644 index 0000000..7fc0091 --- /dev/null +++ b/src/features/panels/UrdfDebug/jointStateMapping.ts @@ -0,0 +1,240 @@ +import type { JointMappingRule } from './recipe'; + +export type JointStateLike = { + name: string[]; + position: number[]; +}; + +function readPosition(positions: ArrayLike, index: number): number { + const value = positions[index]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function clampValue(value: number, min?: number, max?: number): number { + let out = value; + if (min != null && Number.isFinite(min)) out = Math.max(min, out); + if (max != null && Number.isFinite(max)) out = Math.min(max, out); + return out; +} + +function applyLinear(value: number, scale: number, offset: number, min?: number, max?: number): number { + return clampValue(value * scale + offset, min, max); +} + +export function applyJointMapping(input: JointStateLike, rules: JointMappingRule[]): JointStateLike { + const inputMap = new Map(); + for (let i = 0; i < input.name.length; i += 1) { + const jointName = input.name[i]; + if (typeof jointName !== 'string' || jointName.length === 0) continue; + inputMap.set(jointName, readPosition(input.position, i)); + } + + const ignored = new Set( + rules.filter((rule): rule is Extract => rule.kind === 'ignore').map((r) => r.from), + ); + const consumedInputs = new Set(); + const output = new Map(); + + for (const rule of rules) { + switch (rule.kind) { + case 'ignore': + consumedInputs.add(rule.from); + output.delete(rule.from); + break; + case 'rename': { + if (!inputMap.has(rule.from)) break; + const value = inputMap.get(rule.from)!; + consumedInputs.add(rule.from); + if (rule.from !== rule.to) { + output.delete(rule.from); + } + output.set(rule.to, value); + break; + } + case 'linear': { + if (!inputMap.has(rule.from)) break; + const value = inputMap.get(rule.from)!; + consumedInputs.add(rule.from); + output.set( + rule.to, + applyLinear(value, rule.scale, rule.offset, rule.min, rule.max), + ); + break; + } + case 'duplicate': { + if (!inputMap.has(rule.from)) break; + const value = inputMap.get(rule.from)!; + consumedInputs.add(rule.from); + output.delete(rule.from); + for (const out of rule.outputs) { + output.set(out.to, applyLinear(value, out.scale, out.offset, out.min, out.max)); + } + break; + } + case 'mimic': { + const sourceValue = inputMap.get(rule.source); + if (sourceValue == null) break; + output.set(rule.to, applyLinear(sourceValue, rule.multiplier, rule.offset)); + break; + } + case 'constant': + output.set(rule.to, rule.value); + break; + default: + break; + } + } + + for (const [name, value] of inputMap) { + if (ignored.has(name) || consumedInputs.has(name)) continue; + if (!output.has(name)) { + output.set(name, value); + } + } + + const names = [...output.keys()]; + return { + name: names, + position: names.map((name) => output.get(name) ?? 0), + }; +} + +export function buildAutoMatchRules(inputNames: string[], urdfJointNames: string[]): JointMappingRule[] { + const urdfSet = new Set(urdfJointNames); + const rules: JointMappingRule[] = []; + for (const name of inputNames) { + if (urdfSet.has(name)) continue; + const normalized = name.toLowerCase().replace(/_/g, ''); + const match = urdfJointNames.find( + (candidate) => candidate.toLowerCase().replace(/_/g, '') === normalized, + ); + if (match && match !== name) { + rules.push({ kind: 'rename', from: name, to: match }); + } + } + return rules; +} + +export function buildInvertRule(from: string, to: string): JointMappingRule { + return { kind: 'linear', from, to, scale: -1, offset: 0 }; +} + +export function buildGripperNormalizedRule( + from: string, + to: string, + oneMeansClose: boolean, + closedValue: number, + openValue: number, +): JointMappingRule { + return oneMeansClose + ? { + kind: 'linear', + from, + to, + scale: closedValue - openValue, + offset: openValue, + min: Math.min(closedValue, openValue), + max: Math.max(closedValue, openValue), + } + : { + kind: 'linear', + from, + to, + scale: openValue - closedValue, + offset: closedValue, + min: Math.min(closedValue, openValue), + max: Math.max(closedValue, openValue), + }; +} + +export function buildXArm851Rule(from: string, to: string, maxRadians = 0.85): JointMappingRule { + return { + kind: 'linear', + from, + to, + scale: -maxRadians / 851, + offset: maxRadians, + min: 0, + max: maxRadians, + }; +} + +export function buildSymmetricPairRule( + from: string, + leftJoint: string, + rightJoint: string, + scale: number, + offset = 0, +): JointMappingRule[] { + return [ + { + kind: 'duplicate', + from, + outputs: [ + { to: leftJoint, scale: -scale, offset }, + { to: rightJoint, scale, offset }, + ], + }, + ]; +} + +export function buildSymmetricPairFromHeuristic( + inputNames: string[], + urdfJointNames: string[], + mappedNames: string[], +): JointMappingRule[] | null { + const from = + inputNames.find((n) => /drive_joint|gripper/i.test(n)) ?? + inputNames.find((n) => mappedNames.includes(n)) ?? + inputNames[0]; + if (!from) return null; + + const missing = urdfJointNames.filter((n) => !mappedNames.includes(n)); + const leftCandidates = missing.filter((n) => /\bleft\b|left_/i.test(n)); + const rightCandidates = missing.filter((n) => /\bright\b|right_/i.test(n)); + + const leftFinger = leftCandidates.find((n) => n.includes('finger')); + const rightFinger = rightCandidates.find((n) => n.includes('finger')); + if (leftFinger && rightFinger) { + return buildSymmetricPairRule(from, leftFinger, rightFinger, 1, 0); + } + + const leftKnuckle = leftCandidates.find((n) => n.includes('knuckle')); + const rightKnuckle = rightCandidates.find((n) => n.includes('knuckle')); + if (leftKnuckle && rightKnuckle) { + return buildSymmetricPairRule(from, leftKnuckle, rightKnuckle, 1, 0); + } + + if (leftCandidates[0] && rightCandidates[0]) { + return buildSymmetricPairRule(from, leftCandidates[0], rightCandidates[0], 1, 0); + } + return null; +} + +export function buildMimicRulesFromUrdf( + mimicJoints: Array<{ + jointName: string; + sourceJoint: string; + multiplier: number; + offset: number; + }>, + sourceJointNames: string[], + existingTargetNames: string[], +): JointMappingRule[] { + const sources = new Set(sourceJointNames); + const targets = new Set(existingTargetNames); + const rules: JointMappingRule[] = []; + for (const mimic of mimicJoints) { + if (!sources.has(mimic.sourceJoint)) continue; + if (targets.has(mimic.jointName)) continue; + rules.push({ + kind: 'mimic', + source: mimic.sourceJoint, + to: mimic.jointName, + multiplier: mimic.multiplier, + offset: mimic.offset, + }); + targets.add(mimic.jointName); + } + return rules; +} diff --git a/src/features/panels/UrdfDebug/meshBaseStatus.test.ts b/src/features/panels/UrdfDebug/meshBaseStatus.test.ts new file mode 100644 index 0000000..11939ac --- /dev/null +++ b/src/features/panels/UrdfDebug/meshBaseStatus.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMeshReferenceStatuses, + dedupeMeshReferences, + extractPackageNameFromUrdf, + inferProbeStatusBeforeFetch, + normalizeRemoteBaseUrl, + summarizeMeshStatuses, +} from './meshBaseStatus'; +import { createMeshResolver } from './meshResolver'; + +describe('meshBaseStatus', () => { + it('normalizes remote base URL', () => { + expect(normalizeRemoteBaseUrl('https://example.com/meshes/')).toBe('https://example.com/meshes'); + expect(normalizeRemoteBaseUrl('ftp://x.com')).toBeNull(); + expect(normalizeRemoteBaseUrl(' ')).toBeNull(); + }); + + it('extracts package name from URDF', () => { + const urdf = ''; + expect(extractPackageNameFromUrdf(urdf)).toBe('xArm7'); + }); + + it('dedupes mesh references', () => { + expect(dedupeMeshReferences(['a.stl', 'a.stl', ''])).toEqual(['a.stl']); + }); + + it('marks missing local files', () => { + const inferred = inferProbeStatusBeforeFetch( + 'package://Robot/meshes/base.stl', + 'package://Robot/meshes/base.stl', + 'localUpload', + ); + expect(inferred.status).toBe('missing'); + }); + + it('resolves remote URLs and reports pending probe', async () => { + const resolver = createMeshResolver({ + strategy: 'packageBaseUrl', + packageBaseUrl: 'https://cdn.example.com/xArm7/meshes', + localUrls: new Map(), + }); + const statuses = await buildMeshReferenceStatuses({ + meshReferences: ['package://xArm7/meshes/base.stl'], + resolveMeshUrl: resolver, + strategy: 'packageBaseUrl', + }); + expect(statuses[0]?.resolvedUrl).toBe('https://cdn.example.com/xArm7/meshes/meshes/base.stl'); + expect(['pending', 'ok', 'error', 'cors']).toContain(statuses[0]?.status); + }); + + it('summarizes mesh statuses', () => { + const summary = summarizeMeshStatuses([ + { rawPath: 'a', resolvedUrl: 'blob:x', status: 'local' }, + { rawPath: 'b', resolvedUrl: 'https://x', status: 'error' }, + ]); + expect(summary).toEqual({ ok: 1, failed: 1, total: 2 }); + }); +}); diff --git a/src/features/panels/UrdfDebug/meshBaseStatus.ts b/src/features/panels/UrdfDebug/meshBaseStatus.ts new file mode 100644 index 0000000..dd600ba --- /dev/null +++ b/src/features/panels/UrdfDebug/meshBaseStatus.ts @@ -0,0 +1,111 @@ +import type { MeshStrategy } from './recipe'; + +export type MeshProbeStatus = + | 'pending' + | 'ok' + | 'local' + | 'missing' + | 'error' + | 'cors' + | 'unchecked'; + +export type MeshReferenceStatus = { + rawPath: string; + resolvedUrl: string; + status: MeshProbeStatus; + error?: string; +}; + +export function normalizeRemoteBaseUrl(input: string): string | null { + const trimmed = input.trim(); + if (!/^https?:\/\/.+/i.test(trimmed)) return null; + return trimmed.replace(/\/+$/, ''); +} + +export function extractPackageNameFromUrdf(urdfText: string): string | null { + const match = /package:\/\/([^/\s"']+)\//.exec(urdfText); + return match?.[1] ?? null; +} + +export function dedupeMeshReferences(paths: string[]): string[] { + return [...new Set(paths.filter((path) => path.length > 0))]; +} + +export function inferProbeStatusBeforeFetch( + rawPath: string, + resolvedUrl: string, + strategy: MeshStrategy, +): Pick { + if (strategy === 'leaveAsIs') { + return { status: 'unchecked' }; + } + if (resolvedUrl.startsWith('blob:')) { + return { status: 'local' }; + } + if (!resolvedUrl || resolvedUrl === rawPath) { + if (rawPath.startsWith('package://') || strategy === 'localUpload') { + return { status: 'missing', error: 'No matching local file' }; + } + } + if (strategy === 'localUpload' && !resolvedUrl.startsWith('blob:')) { + return { status: 'missing', error: 'No matching local file' }; + } + if (strategy === 'packageBaseUrl') { + if (!/^https?:\/\//i.test(resolvedUrl)) { + return { status: 'missing', error: 'Base URL not applied or invalid' }; + } + } + if (/^https?:\/\//i.test(resolvedUrl)) { + return { status: 'pending' }; + } + return { status: 'unchecked' }; +} + +export async function probeRemoteMeshUrl(url: string): Promise> { + try { + let response = await fetch(url, { method: 'HEAD', mode: 'cors' }); + if (response.ok) return { status: 'ok' }; + if (response.status === 405 || response.status === 501) { + response = await fetch(url, { method: 'GET', headers: { Range: 'bytes=0-0' } }); + if (response.ok || response.status === 206) return { status: 'ok' }; + } + return { status: 'error', error: `HTTP ${response.status}` }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/failed to fetch|cors|network/i.test(message)) { + return { status: 'cors', error: message }; + } + return { status: 'error', error: message }; + } +} + +export async function buildMeshReferenceStatuses(args: { + meshReferences: string[]; + resolveMeshUrl: (rawPath: string) => string; + strategy: MeshStrategy; +}): Promise { + const refs = dedupeMeshReferences(args.meshReferences); + const preliminary = refs.map((rawPath) => { + const resolvedUrl = args.resolveMeshUrl(rawPath); + const inferred = inferProbeStatusBeforeFetch(rawPath, resolvedUrl, args.strategy); + return { rawPath, resolvedUrl, ...inferred }; + }); + + return Promise.all( + preliminary.map(async (entry) => { + if (entry.status !== 'pending') return entry; + const remote = await probeRemoteMeshUrl(entry.resolvedUrl); + return { ...entry, ...remote }; + }), + ); +} + +export function summarizeMeshStatuses(entries: MeshReferenceStatus[]): { + ok: number; + failed: number; + total: number; +} { + const okStatuses: MeshProbeStatus[] = ['ok', 'local', 'unchecked']; + const ok = entries.filter((entry) => okStatuses.includes(entry.status)).length; + return { ok, failed: entries.length - ok, total: entries.length }; +} diff --git a/src/features/panels/UrdfDebug/meshResolver.ts b/src/features/panels/UrdfDebug/meshResolver.ts new file mode 100644 index 0000000..e663845 --- /dev/null +++ b/src/features/panels/UrdfDebug/meshResolver.ts @@ -0,0 +1,135 @@ +import type { MeshStrategy } from './recipe'; + +export type MeshResolverOptions = { + strategy: MeshStrategy; + packageName?: string; + packageBaseUrl?: string; + localUrls: Map; + defaultRemoteBase?: string; +}; + +const DEFAULT_REMOTE_BASE = 'https://assets.embodiflow.com/resources'; + +function normalizeBase(base: string): string { + return base.endsWith('/') ? base.slice(0, -1) : base; +} + +function basename(path: string): string { + const clean = path.split('?')[0] ?? path; + const parts = clean.split('/'); + return parts[parts.length - 1] ?? clean; +} + +function lookupLocal(localUrls: Map, rawPath: string): string | undefined { + const file = basename(rawPath); + if (localUrls.has(rawPath)) return localUrls.get(rawPath); + if (localUrls.has(file)) return localUrls.get(file); + const meshSuffix = rawPath.includes('meshes/') ? rawPath.slice(rawPath.indexOf('meshes/')) : undefined; + if (meshSuffix && localUrls.has(meshSuffix)) return localUrls.get(meshSuffix); + return undefined; +} + +export function buildLocalMeshUrlMap(files: File[]): Map { + const map = new Map(); + for (const file of files) { + const url = URL.createObjectURL(file); + map.set(file.name, url); + if (file.webkitRelativePath) { + map.set(file.webkitRelativePath, url); + const meshIdx = file.webkitRelativePath.indexOf('meshes/'); + if (meshIdx >= 0) { + map.set(file.webkitRelativePath.slice(meshIdx), url); + } + } + } + return map; +} + +export function revokeMeshUrlMap(map: Map): void { + for (const url of map.values()) { + URL.revokeObjectURL(url); + } + map.clear(); +} + +export function createMeshResolver(options: MeshResolverOptions): (rawPath: string) => string { + const globalConfig = typeof window !== 'undefined' + ? (window as Window & { + __ROS_STUDIO_URDF_PACKAGE_BASE__?: string; + __ROS_STUDIO_URDF_PACKAGE_BASES__?: Record; + }) + : undefined; + const envBase = import.meta.env.VITE_ROS_STUDIO_URDF_PACKAGE_BASE?.trim(); + const defaultBase = normalizeBase( + options.defaultRemoteBase ?? + globalConfig?.__ROS_STUDIO_URDF_PACKAGE_BASE__ ?? + envBase ?? + DEFAULT_REMOTE_BASE, + ); + + return (rawPath: string) => { + if (/^https?:\/\//i.test(rawPath)) return rawPath; + + const remoteBaseApplied = + options.strategy === 'packageBaseUrl' && Boolean(options.packageBaseUrl?.trim()); + + if (options.strategy === 'localUpload') { + const local = lookupLocal(options.localUrls, rawPath); + if (local) return local; + } + + if (options.strategy === 'leaveAsIs' && !rawPath.startsWith('package://')) { + return rawPath; + } + + if (rawPath.startsWith('/')) { + const absolutePath = rawPath.replace(/^\/+/, ''); + if (remoteBaseApplied && options.packageBaseUrl) { + return `${normalizeBase(options.packageBaseUrl)}/${absolutePath}`; + } + if (options.strategy !== 'packageBaseUrl') { + return `${defaultBase}/${absolutePath}`; + } + return rawPath; + } + + if (rawPath.startsWith('package://')) { + const packagePath = rawPath.slice('package://'.length); + const firstSlash = packagePath.indexOf('/'); + const packageName = firstSlash >= 0 ? packagePath.slice(0, firstSlash) : packagePath; + const insidePackagePath = firstSlash >= 0 ? packagePath.slice(firstSlash + 1) : ''; + + if (options.strategy === 'localUpload') { + const local = lookupLocal(options.localUrls, insidePackagePath); + if (local) return local; + } + + if (remoteBaseApplied && options.packageBaseUrl) { + return insidePackagePath + ? `${normalizeBase(options.packageBaseUrl)}/${insidePackagePath}` + : normalizeBase(options.packageBaseUrl); + } + + if (options.strategy === 'packageBaseUrl') { + return rawPath; + } + + const packageBase = globalConfig?.__ROS_STUDIO_URDF_PACKAGE_BASES__?.[packageName]; + if (packageBase) { + const resolvedBase = normalizeBase(packageBase); + return insidePackagePath ? `${resolvedBase}/${insidePackagePath}` : resolvedBase; + } + + if (options.packageName && packageName !== options.packageName) { + return `${defaultBase}/${packagePath}`; + } + return `${defaultBase}/${packagePath}`; + } + + if (remoteBaseApplied && options.packageBaseUrl) { + return `${normalizeBase(options.packageBaseUrl)}/${rawPath}`; + } + + return rawPath; + }; +} diff --git a/src/features/panels/UrdfDebug/recipe.ts b/src/features/panels/UrdfDebug/recipe.ts new file mode 100644 index 0000000..0cf4b11 --- /dev/null +++ b/src/features/panels/UrdfDebug/recipe.ts @@ -0,0 +1,168 @@ +import type { UrdfDebugConfig } from './defaults'; + +export type MeshStrategy = 'localUpload' | 'packageBaseUrl' | 'leaveAsIs'; + +export type JointMappingRule = + | { kind: 'rename'; from: string; to: string } + | { + kind: 'linear'; + from: string; + to: string; + scale: number; + offset: number; + min?: number; + max?: number; + } + | { + kind: 'duplicate'; + from: string; + outputs: Array<{ + to: string; + scale: number; + offset: number; + min?: number; + max?: number; + }>; + } + | { kind: 'mimic'; source: string; to: string; multiplier: number; offset: number } + | { kind: 'constant'; to: string; value: number } + | { kind: 'ignore'; from: string }; + +export type UrdfDebugRecipe = { + version: 1; + jointStateTopic: string; + outputTfTopic: '/tf'; + outputRobotDescriptionTopic: '/robot_description'; + urdf: { + fileName?: string; + robotName?: string; + framePrefix?: string; + rotateMeshVisuals?: boolean; + visualRpyOffset?: [number, number, number]; + }; + meshes: { + strategy: MeshStrategy; + packageName?: string; + packageBaseUrl?: string; + }; + rules: JointMappingRule[]; +}; + +export function configToRecipe(config: UrdfDebugConfig, robotName?: string): UrdfDebugRecipe { + return { + version: 1, + jointStateTopic: config.jointStateTopic, + outputTfTopic: '/tf', + outputRobotDescriptionTopic: '/robot_description', + urdf: { + fileName: config.urdfFileName || undefined, + robotName, + framePrefix: config.framePrefix || undefined, + rotateMeshVisuals: config.rotateMeshVisuals, + visualRpyOffset: [...config.visualRpyOffset], + }, + meshes: { + strategy: config.meshStrategy, + packageName: config.packageName || undefined, + packageBaseUrl: config.packageBaseUrl || undefined, + }, + rules: [], + }; +} + +export function parseRecipe(input: unknown): UrdfDebugRecipe | null { + if (!input || typeof input !== 'object') return null; + const rec = input as Record; + if (rec.version !== 1) return null; + if (typeof rec.jointStateTopic !== 'string') return null; + const rules = Array.isArray(rec.rules) ? rec.rules : []; + const urdfRec = + rec.urdf && typeof rec.urdf === 'object' ? (rec.urdf as Record) : {}; + const meshRec = + rec.meshes && typeof rec.meshes === 'object' ? (rec.meshes as Record) : {}; + const visualRpy = urdfRec.visualRpyOffset; + return { + version: 1, + jointStateTopic: rec.jointStateTopic, + outputTfTopic: '/tf', + outputRobotDescriptionTopic: '/robot_description', + urdf: { + fileName: typeof urdfRec.fileName === 'string' ? urdfRec.fileName : undefined, + robotName: typeof urdfRec.robotName === 'string' ? urdfRec.robotName : undefined, + framePrefix: typeof urdfRec.framePrefix === 'string' ? urdfRec.framePrefix : undefined, + rotateMeshVisuals: + typeof urdfRec.rotateMeshVisuals === 'boolean' ? urdfRec.rotateMeshVisuals : false, + visualRpyOffset: + Array.isArray(visualRpy) && visualRpy.length === 3 + ? [Number(visualRpy[0]) || 0, Number(visualRpy[1]) || 0, Number(visualRpy[2]) || 0] + : [0, 0, 0], + }, + meshes: { + strategy: + meshRec.strategy === 'localUpload' || + meshRec.strategy === 'packageBaseUrl' || + meshRec.strategy === 'leaveAsIs' + ? meshRec.strategy + : 'localUpload', + packageName: typeof meshRec.packageName === 'string' ? meshRec.packageName : undefined, + packageBaseUrl: + typeof meshRec.packageBaseUrl === 'string' ? meshRec.packageBaseUrl : undefined, + }, + rules: rules.filter(isJointMappingRule), + }; +} + +function isJointMappingRule(value: unknown): value is JointMappingRule { + if (!value || typeof value !== 'object') return false; + const rec = value as Record; + const kind = rec.kind; + if (kind === 'rename') { + return typeof rec.from === 'string' && typeof rec.to === 'string'; + } + if (kind === 'linear') { + return ( + typeof rec.from === 'string' && + typeof rec.to === 'string' && + typeof rec.scale === 'number' && + typeof rec.offset === 'number' + ); + } + if (kind === 'duplicate') { + return typeof rec.from === 'string' && Array.isArray(rec.outputs); + } + if (kind === 'mimic') { + return ( + typeof rec.source === 'string' && + typeof rec.to === 'string' && + typeof rec.multiplier === 'number' && + typeof rec.offset === 'number' + ); + } + if (kind === 'constant') { + return typeof rec.to === 'string' && typeof rec.value === 'number'; + } + if (kind === 'ignore') { + return typeof rec.from === 'string'; + } + return false; +} + +export function downloadJson(filename: string, data: unknown): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function downloadText(filename: string, content: string): void { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/src/features/panels/UrdfDebug/schema.ts b/src/features/panels/UrdfDebug/schema.ts new file mode 100644 index 0000000..27d5641 --- /dev/null +++ b/src/features/panels/UrdfDebug/schema.ts @@ -0,0 +1,71 @@ +import { isRecord } from '../framework/types'; +import { + defaultUrdfDebugConfig, + MAX_SETTINGS_PANEL_PERCENT, + MIN_SETTINGS_PANEL_PERCENT, + type UrdfDebugConfig, +} from './defaults'; +import type { MeshStrategy } from './recipe'; + +const MESH_STRATEGIES: readonly MeshStrategy[] = ['localUpload', 'packageBaseUrl', 'leaveAsIs']; + +function parseRpy(input: unknown, fallback: [number, number, number]): [number, number, number] { + if (!Array.isArray(input) || input.length !== 3) return fallback; + return [ + typeof input[0] === 'number' && Number.isFinite(input[0]) ? input[0] : fallback[0], + typeof input[1] === 'number' && Number.isFinite(input[1]) ? input[1] : fallback[1], + typeof input[2] === 'number' && Number.isFinite(input[2]) ? input[2] : fallback[2], + ]; +} + +function parseManualJointPositions(input: unknown): Record { + if (!isRecord(input)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (typeof value === 'number' && Number.isFinite(value)) { + out[key] = value; + } + } + return out; +} + +function clampSettingsPanelPercent(value: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(MAX_SETTINGS_PANEL_PERCENT, Math.max(MIN_SETTINGS_PANEL_PERCENT, value)); +} + +export function parseUrdfDebugConfig(input: unknown): UrdfDebugConfig { + const base = defaultUrdfDebugConfig(); + if (!isRecord(input)) return base; + const meshStrategy = MESH_STRATEGIES.includes(input.meshStrategy as MeshStrategy) + ? (input.meshStrategy as MeshStrategy) + : base.meshStrategy; + return { + jointStateTopic: typeof input.jointStateTopic === 'string' ? input.jointStateTopic : base.jointStateTopic, + urdfFileName: typeof input.urdfFileName === 'string' ? input.urdfFileName : base.urdfFileName, + urdfFileContent: + typeof input.urdfFileContent === 'string' ? input.urdfFileContent : base.urdfFileContent, + meshStrategy, + packageName: typeof input.packageName === 'string' ? input.packageName : base.packageName, + packageBaseUrl: typeof input.packageBaseUrl === 'string' ? input.packageBaseUrl : base.packageBaseUrl, + framePrefix: typeof input.framePrefix === 'string' ? input.framePrefix : base.framePrefix, + rotateMeshVisuals: + typeof input.rotateMeshVisuals === 'boolean' ? input.rotateMeshVisuals : base.rotateMeshVisuals, + visualRpyOffset: parseRpy(input.visualRpyOffset, base.visualRpyOffset), + fallbackMeshColor: + typeof input.fallbackMeshColor === 'string' && input.fallbackMeshColor.length > 0 + ? input.fallbackMeshColor + : base.fallbackMeshColor, + showGrid: typeof input.showGrid === 'boolean' ? input.showGrid : base.showGrid, + showAxes: typeof input.showAxes === 'boolean' ? input.showAxes : base.showAxes, + manualJointPositions: parseManualJointPositions(input.manualJointPositions), + followLiveJointState: + typeof input.followLiveJointState === 'boolean' + ? input.followLiveJointState + : base.followLiveJointState, + settingsPanelPercent: clampSettingsPanelPercent( + typeof input.settingsPanelPercent === 'number' ? input.settingsPanelPercent : base.settingsPanelPercent, + base.settingsPanelPercent, + ), + }; +} diff --git a/src/features/panels/UrdfDebug/scriptTemplates.test.ts b/src/features/panels/UrdfDebug/scriptTemplates.test.ts new file mode 100644 index 0000000..7df1bfa --- /dev/null +++ b/src/features/panels/UrdfDebug/scriptTemplates.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { McapStreamReader, McapWriter } from '@mcap/core'; +import { generatePythonScript, generateTypeScriptScript } from './scriptTemplates'; +import type { UrdfDebugRecipe } from './recipe'; + +const FIXTURE_URDF = ` + + + + + + + + +`; + +class BufferWritable { + #chunks: Buffer[] = []; + #pos = 0n; + + position() { + return this.#pos; + } + + write(buffer: Uint8Array) { + const b = Buffer.from(buffer); + this.#chunks.push(b); + this.#pos += BigInt(b.byteLength); + return Promise.resolve(); + } + + getBuffer() { + return Buffer.concat(this.#chunks); + } +} + +async function writeFixtureMcap(outputPath: string): Promise { + const writable = new BufferWritable(); + const writer = new McapWriter({ + writable, + useStatistics: true, + useChunks: true, + useChunkIndex: true, + }); + + await writer.start({ profile: 'ros2', library: 'rosview-test-fixture' }); + + const jointSchemaId = await writer.registerSchema({ + name: 'sensor_msgs/msg/JointState', + encoding: 'jsonschema', + data: new TextEncoder().encode('{"type":"object"}'), + }); + + const jointChannelId = await writer.registerChannel({ + schemaId: jointSchemaId, + topic: '/joint_states', + messageEncoding: 'json', + metadata: new Map(), + }); + + const logTime = 1_000_000_000n; + await writer.addMessage({ + channelId: jointChannelId, + sequence: 1, + logTime, + publishTime: logTime, + data: new TextEncoder().encode( + JSON.stringify({ + header: { stamp: { sec: 1, nanosec: 0 }, frame_id: '' }, + name: ['joint1'], + position: [0.5], + velocity: [], + effort: [], + }), + ), + }); + + await writer.end(); + writeFileSync(outputPath, writable.getBuffer()); +} + +function listTopics(mcapPath: string): string[] { + const reader = new McapStreamReader(); + reader.append(readFileSync(mcapPath)); + const topics = new Set(); + for (let record = reader.nextRecord(); record; record = reader.nextRecord()) { + if (record.type === 'Channel') { + topics.add(record.topic); + } + } + return [...topics].sort(); +} + +const sampleRecipe: UrdfDebugRecipe = { + version: 1, + jointStateTopic: '/joint_states', + outputTfTopic: '/tf', + outputRobotDescriptionTopic: '/robot_description', + urdf: { rotateMeshVisuals: false, visualRpyOffset: [0, 0, 0] }, + meshes: { strategy: 'localUpload' }, + rules: [], +}; + +describe('scriptTemplates', () => { + it('generates TypeScript MCAP processor with FK and mapping', () => { + const script = generateTypeScriptScript(sampleRecipe); + expect(script).toContain('processMcap'); + expect(script).toContain('applyJointMapping'); + expect(script).toContain('JointState2TF'); + expect(script).toContain('/robot_description'); + expect(script).not.toContain('.pending'); + }); + + it('generates Python MCAP processor with FK and mapping', () => { + const script = generatePythonScript(sampleRecipe); + expect(script).toContain('process_mcap'); + expect(script).toContain('apply_joint_mapping'); + expect(script).toContain('JointState2TF'); + expect(script).not.toContain('.pending'); + }); + + it('TypeScript processor rewrites test MCAP with /tf and /robot_description', async () => { + const dir = mkdtempSync(join(process.cwd(), '.tmp-urdf-debug-')); + const inputPath = join(dir, 'input.mcap'); + const outputPath = join(dir, 'out.mcap'); + const urdfPath = join(dir, 'test.urdf'); + const scriptPath = join(dir, 'process.mjs'); + const recipePath = join(dir, 'recipe.json'); + + await writeFixtureMcap(inputPath); + writeFileSync(urdfPath, FIXTURE_URDF); + writeFileSync(recipePath, JSON.stringify(sampleRecipe, null, 2)); + writeFileSync(scriptPath, generateTypeScriptScript(sampleRecipe)); + + execFileSync( + 'node', + [scriptPath, inputPath, outputPath, recipePath, urdfPath, '--overwrite-topics'], + { stdio: 'pipe', cwd: process.cwd() }, + ); + + const topics = listTopics(outputPath); + expect(topics).toContain('/tf'); + expect(topics).toContain('/robot_description'); + expect(topics).toContain('/joint_states'); + }); +}); diff --git a/src/features/panels/UrdfDebug/scriptTemplates.ts b/src/features/panels/UrdfDebug/scriptTemplates.ts new file mode 100644 index 0000000..b0ebcfb --- /dev/null +++ b/src/features/panels/UrdfDebug/scriptTemplates.ts @@ -0,0 +1,823 @@ +import type { UrdfDebugRecipe } from './recipe'; +import fkEngineJs from './embedded/fkEngine.js?raw'; +import { URDF_VISUAL_CORRECTION_JS } from './urdfVisualCorrection'; + +const MAPPING_CORE_JS = ` +function clampValue(value, min, max) { + let out = value; + if (min != null && Number.isFinite(min)) out = Math.max(min, out); + if (max != null && Number.isFinite(max)) out = Math.min(max, out); + return out; +} + +function applyLinear(value, scale, offset, min, max) { + return clampValue(value * scale + offset, min, max); +} + +function applyJointMapping(input, rules) { + const inputMap = new Map(); + for (let i = 0; i < input.name.length; i += 1) { + const jointName = input.name[i]; + if (typeof jointName !== 'string' || !jointName) continue; + inputMap.set(jointName, input.position[i] ?? 0); + } + const ignored = new Set(rules.filter((r) => r.kind === 'ignore').map((r) => r.from)); + const consumedInputs = new Set(); + const output = new Map(); + for (const rule of rules) { + switch (rule.kind) { + case 'ignore': + consumedInputs.add(rule.from); + output.delete(rule.from); + break; + case 'rename': + if (!inputMap.has(rule.from)) break; + consumedInputs.add(rule.from); + if (rule.from !== rule.to) output.delete(rule.from); + output.set(rule.to, inputMap.get(rule.from)); + break; + case 'linear': + if (!inputMap.has(rule.from)) break; + consumedInputs.add(rule.from); + output.set(rule.to, applyLinear(inputMap.get(rule.from), rule.scale, rule.offset, rule.min, rule.max)); + break; + case 'duplicate': + if (!inputMap.has(rule.from)) break; + consumedInputs.add(rule.from); + output.delete(rule.from); + for (const out of rule.outputs) { + output.set(out.to, applyLinear(inputMap.get(rule.from), out.scale, out.offset, out.min, out.max)); + } + break; + case 'mimic': + if (!inputMap.has(rule.source)) break; + output.set(rule.to, applyLinear(inputMap.get(rule.source), rule.multiplier, rule.offset)); + break; + case 'constant': + output.set(rule.to, rule.value); + break; + default: + break; + } + } + for (const [name, value] of inputMap) { + if (ignored.has(name) || consumedInputs.has(name)) continue; + if (!output.has(name)) output.set(name, value); + } + const names = [...output.keys()]; + return { name: names, position: names.map((name) => output.get(name) ?? 0) }; +} +`.trim(); + +const MAPPING_CORE_PY = ` +def clamp_value(value, min_v=None, max_v=None): + out = value + if min_v is not None: + out = max(min_v, out) + if max_v is not None: + out = min(max_v, out) + return out + +def apply_linear(value, scale, offset, min_v=None, max_v=None): + return clamp_value(value * scale + offset, min_v, max_v) + +def apply_joint_mapping(input_state, rules): + input_map = {} + for i, name in enumerate(input_state.get('name', [])): + if not name: + continue + positions = input_state.get('position', []) + input_map[name] = positions[i] if i < len(positions) else 0.0 + ignored = {r['from'] for r in rules if r.get('kind') == 'ignore'} + consumed = set() + output = {} + for rule in rules: + kind = rule.get('kind') + if kind == 'ignore': + consumed.add(rule['from']) + output.pop(rule['from'], None) + elif kind == 'rename': + if rule['from'] not in input_map: + continue + consumed.add(rule['from']) + output.pop(rule['from'], None) + output[rule['to']] = input_map[rule['from']] + elif kind == 'linear': + if rule['from'] not in input_map: + continue + consumed.add(rule['from']) + output[rule['to']] = apply_linear( + input_map[rule['from']], rule['scale'], rule['offset'], rule.get('min'), rule.get('max') + ) + elif kind == 'duplicate': + if rule['from'] not in input_map: + continue + consumed.add(rule['from']) + output.pop(rule['from'], None) + for out in rule.get('outputs', []): + output[out['to']] = apply_linear( + input_map[rule['from']], out['scale'], out['offset'], out.get('min'), out.get('max') + ) + elif kind == 'mimic': + if rule['source'] not in input_map: + continue + output[rule['to']] = apply_linear( + input_map[rule['source']], rule['multiplier'], rule['offset'] + ) + elif kind == 'constant': + output[rule['to']] = rule['value'] + for name, value in input_map.items(): + if name in ignored or name in consumed: + continue + output.setdefault(name, value) + names = list(output.keys()) + return {'name': names, 'position': [output[name] for name in names]} +`.trim(); + +const FK_ENGINE_JS = fkEngineJs.replace(/^export class JointState2TF/, 'class JointState2TF'); + +const ROS2_DEFINITIONS_JS = ` +const ROS2_DEFINITIONS = [ + { name: 'builtin_interfaces/msg/Time', definitions: [{ name: 'sec', type: 'int32' }, { name: 'nanosec', type: 'uint32' }] }, + { name: 'std_msgs/msg/Header', definitions: [{ name: 'stamp', type: 'builtin_interfaces/msg/Time', isComplex: true }, { name: 'frame_id', type: 'string' }] }, + { name: 'geometry_msgs/msg/Vector3', definitions: [{ name: 'x', type: 'float64' }, { name: 'y', type: 'float64' }, { name: 'z', type: 'float64' }] }, + { name: 'geometry_msgs/msg/Quaternion', definitions: [{ name: 'x', type: 'float64' }, { name: 'y', type: 'float64' }, { name: 'z', type: 'float64' }, { name: 'w', type: 'float64' }] }, + { name: 'geometry_msgs/msg/Transform', definitions: [{ name: 'translation', type: 'geometry_msgs/msg/Vector3', isComplex: true }, { name: 'rotation', type: 'geometry_msgs/msg/Quaternion', isComplex: true }] }, + { name: 'geometry_msgs/msg/TransformStamped', definitions: [{ name: 'header', type: 'std_msgs/msg/Header', isComplex: true }, { name: 'child_frame_id', type: 'string' }, { name: 'transform', type: 'geometry_msgs/msg/Transform', isComplex: true }] }, + { name: 'tf2_msgs/msg/TFMessage', definitions: [{ name: 'transforms', type: 'geometry_msgs/msg/TransformStamped', isArray: true, isComplex: true }] }, + { name: 'std_msgs/msg/String', definitions: [{ name: 'data', type: 'string' }] }, + { name: 'sensor_msgs/msg/JointState', definitions: [{ name: 'header', type: 'std_msgs/msg/Header', isComplex: true }, { name: 'name', type: 'string', isArray: true }, { name: 'position', type: 'float64', isArray: true }, { name: 'velocity', type: 'float64', isArray: true }, { name: 'effort', type: 'float64', isArray: true }] }, +]; +`.trim(); + +const MCAP_PROCESSOR_JS = ` +class BufferReadable { + constructor(buffer) { + this.buffer = buffer; + } + size() { + return BigInt(this.buffer.byteLength); + } + async read(offset, size) { + const start = Number(offset); + return this.buffer.subarray(start, start + Number(size)); + } +} + +class BufferWritable { + constructor() { + this.#chunks = []; + this.#pos = 0n; + } + #chunks; + #pos; + position() { + return this.#pos; + } + async write(buffer) { + this.#chunks.push(Buffer.from(buffer)); + this.#pos += BigInt(buffer.byteLength); + } + toBuffer() { + return Buffer.concat(this.#chunks); + } +} + +${URDF_VISUAL_CORRECTION_JS} + +function normalizeJointState(raw) { + const name = Array.isArray(raw?.name) ? raw.name.map(String) : []; + const position = Array.isArray(raw?.position) ? raw.position.map((v) => Number(v) || 0) : []; + const header = raw?.header && typeof raw.header === 'object' + ? raw.header + : { stamp: { sec: 0, nanosec: 0 }, frame_id: '' }; + return { header, name, position }; +} + +function buildChannelDeserializer(channel, schema) { + if (channel.messageEncoding === 'json') { + const decoder = new TextDecoder(); + return (data) => JSON.parse(decoder.decode(data)); + } + if (!schema?.data?.length) { + throw new Error(\`Missing schema for \${channel.topic}\`); + } + const text = new TextDecoder().decode(schema.data); + const reader = new MessageReader(parseMessageDefinition(text)); + return (data) => reader.readMessage(data); +} + +function buildChannelSerializer(schemaName, writers) { + const writer = writers[schemaName]; + if (!writer) throw new Error(\`Missing writer for \${schemaName}\`); + return (msg) => writer.writeMessage(msg); +} + +async function processMcap({ inputPath, outputPath, recipe, urdfXml, overwriteTopics }) { + const tfTopic = recipe.outputTfTopic ?? '/tf'; + const robotDescTopic = recipe.outputRobotDescriptionTopic ?? '/robot_description'; + const jointTopic = recipe.jointStateTopic; + if (!jointTopic) throw new Error('recipe.jointStateTopic is required'); + + const inputBuffer = readFileSync(inputPath); + const reader = await McapIndexedReader.Initialize({ + readable: new BufferReadable(inputBuffer), + }); + + let hasTf = false; + let hasRobotDesc = false; + let jointChannel = null; + for (const channel of reader.channelsById.values()) { + if (channel.topic === tfTopic) hasTf = true; + if (channel.topic === robotDescTopic) hasRobotDesc = true; + if (channel.topic === jointTopic) jointChannel = channel; + } + if (!jointChannel) throw new Error(\`JointState topic not found: \${jointTopic}\`); + if (!overwriteTopics && (hasTf || hasRobotDesc)) { + throw new Error('Input already contains /tf or /robot_description. Pass --overwrite-topics to replace them.'); + } + + const writable = new BufferWritable(); + const writer = new McapWriter({ writable }); + await writer.start({ + profile: reader.header?.profile ?? 'ros2', + library: 'urdf-debug-processor', + }); + + const schemaMap = new Map(); + const channelMap = new Map(); + for (const schema of reader.schemasById.values()) { + schemaMap.set(schema.id, await writer.registerSchema(schema)); + } + for (const channel of reader.channelsById.values()) { + if (overwriteTopics && (channel.topic === tfTopic || channel.topic === robotDescTopic)) continue; + const mapped = { ...channel, schemaId: schemaMap.get(channel.schemaId) ?? 0 }; + channelMap.set(channel.id, await writer.registerChannel(mapped)); + } + + const outEncoding = jointChannel.messageEncoding ?? 'json'; + const preparedUrdf = prepareUrdfXml(urdfXml, recipe); + const fkEngine = JointState2TF.fromXml({ xml: preparedUrdf }); + const jointSchema = reader.schemasById.get(jointChannel.schemaId); + const deserializeJoint = buildChannelDeserializer(jointChannel, jointSchema); + + const writers = { + 'tf2_msgs/msg/TFMessage': new MessageWriter(ROS2_DEFINITIONS), + 'std_msgs/msg/String': new MessageWriter(ROS2_DEFINITIONS), + }; + + let tfChannelId; + let robotDescChannelId; + if (outEncoding === 'json') { + const tfSchemaId = await writer.registerSchema({ + name: 'tf2_msgs/msg/TFMessage', + encoding: 'jsonschema', + data: new TextEncoder().encode('{"type":"object"}'), + }); + const robotSchemaId = await writer.registerSchema({ + name: 'std_msgs/msg/String', + encoding: 'jsonschema', + data: new TextEncoder().encode('{"type":"object"}'), + }); + tfChannelId = await writer.registerChannel({ schemaId: tfSchemaId, topic: tfTopic, messageEncoding: 'json', metadata: new Map() }); + robotDescChannelId = await writer.registerChannel({ schemaId: robotSchemaId, topic: robotDescTopic, messageEncoding: 'json', metadata: new Map() }); + } else { + const tfSchemaId = await writer.registerSchema({ + name: 'tf2_msgs/msg/TFMessage', + encoding: 'ros2msg', + data: new TextEncoder().encode('geometry_msgs/TransformStamped[] transforms\\n'), + }); + const robotSchemaId = await writer.registerSchema({ + name: 'std_msgs/msg/String', + encoding: 'ros2msg', + data: new TextEncoder().encode('string data\\n'), + }); + tfChannelId = await writer.registerChannel({ schemaId: tfSchemaId, topic: tfTopic, messageEncoding: 'cdr', metadata: new Map() }); + robotDescChannelId = await writer.registerChannel({ schemaId: robotSchemaId, topic: robotDescTopic, messageEncoding: 'cdr', metadata: new Map() }); + } + + const serializeTf = outEncoding === 'json' + ? (msg) => new TextEncoder().encode(JSON.stringify(msg)) + : buildChannelSerializer('tf2_msgs/msg/TFMessage', writers); + const serializeString = outEncoding === 'json' + ? (msg) => new TextEncoder().encode(JSON.stringify(msg)) + : buildChannelSerializer('std_msgs/msg/String', writers); + + let robotDescWritten = false; + let tfSeq = 0; + let processedJointStates = 0; + + for await (const message of reader.readMessages()) { + const channel = reader.channelsById.get(message.channelId); + if (!channel) continue; + if (overwriteTopics && (channel.topic === tfTopic || channel.topic === robotDescTopic)) continue; + + const mappedChannelId = channelMap.get(message.channelId); + if (mappedChannelId != null) { + await writer.addMessage({ ...message, channelId: mappedChannelId }); + } + + if (channel.id !== jointChannel.id) continue; + + const rawJoint = normalizeJointState(deserializeJoint(message.data)); + const mapped = applyJointMapping( + { name: rawJoint.name, position: rawJoint.position }, + recipe.rules ?? [], + ); + const tfMsg = fkEngine.computeFromJointState( + { header: rawJoint.header, name: mapped.name, position: mapped.position }, + { publishTimeNs: message.logTime }, + ); + + if (!robotDescWritten) { + await writer.addMessage({ + channelId: robotDescChannelId, + sequence: 0, + logTime: message.logTime, + publishTime: message.publishTime, + data: serializeString({ data: preparedUrdf }), + }); + robotDescWritten = true; + } + + tfSeq += 1; + await writer.addMessage({ + channelId: tfChannelId, + sequence: tfSeq, + logTime: message.logTime, + publishTime: message.publishTime, + data: serializeTf(tfMsg), + }); + processedJointStates += 1; + } + + await writer.end(); + writeFileSync(outputPath, writable.toBuffer()); + return processedJointStates; +} +`.trim(); + +const FK_ENGINE_PY = ` +import math +import re +from typing import Any + +def _vec3(x=0.0, y=0.0, z=0.0): + return {'x': x, 'y': y, 'z': z} + +def _quat_identity(): + return {'x': 0.0, 'y': 0.0, 'z': 0.0, 'w': 1.0} + +def _vec3_add(a, b): + return {'x': a['x'] + b['x'], 'y': a['y'] + b['y'], 'z': a['z'] + b['z']} + +def _vec3_scale(a, s): + return {'x': a['x'] * s, 'y': a['y'] * s, 'z': a['z'] * s} + +def _vec3_length(a): + return math.hypot(a['x'], a['y'], a['z']) + +def _vec3_normalize(a): + length = _vec3_length(a) or 1.0 + return {'x': a['x'] / length, 'y': a['y'] / length, 'z': a['z'] / length} + +def _quat_multiply(a, b): + return { + 'w': a['w'] * b['w'] - a['x'] * b['x'] - a['y'] * b['y'] - a['z'] * b['z'], + 'x': a['w'] * b['x'] + a['x'] * b['w'] + a['y'] * b['z'] - a['z'] * b['y'], + 'y': a['w'] * b['y'] - a['x'] * b['z'] + a['y'] * b['w'] + a['z'] * b['x'], + 'z': a['w'] * b['z'] + a['x'] * b['y'] - a['y'] * b['x'] + a['z'] * b['w'], + } + +def _quat_from_axis_angle(axis, angle): + n = _vec3_normalize(axis) + h = angle * 0.5 + s = math.sin(h) + return {'x': n['x'] * s, 'y': n['y'] * s, 'z': n['z'] * s, 'w': math.cos(h)} + +def _quat_from_rpy(roll, pitch, yaw): + cx, sx = math.cos(roll * 0.5), math.sin(roll * 0.5) + cy, sy = math.cos(pitch * 0.5), math.sin(pitch * 0.5) + cz, sz = math.cos(yaw * 0.5), math.sin(yaw * 0.5) + return { + 'w': cz * cy * cx + sz * sy * sx, + 'x': cz * cy * sx - sz * sy * cx, + 'y': cz * sy * cx + sz * cy * sx, + 'z': sz * cy * cx - cz * sy * sx, + } + +def _vec3_rotate_by_quat(v, q): + x, y, z = v['x'], v['y'], v['z'] + qx, qy, qz, qw = q['x'], q['y'], q['z'], q['w'] + uvx = qy * z - qz * y + uvy = qz * x - qx * z + uvz = qx * y - qy * x + uuvx = qy * uvz - qz * uvy + uuvy = qz * uvx - qx * uvz + uuvz = qx * uvy - qy * uvx + return {'x': x + 2 * (qw * uvx + uuvx), 'y': y + 2 * (qw * uvy + uuvy), 'z': z + 2 * (qw * uvz + uuvz)} + +def _compose_tr(a, b): + return {'r': _quat_multiply(a['r'], b['r']), 't': _vec3_add(a['t'], _vec3_rotate_by_quat(b['t'], a['r']))} + +class JointState2TF: + def __init__(self, model): + self.model = model + + @classmethod + def from_xml(cls, xml): + return cls(_parse_urdf(xml)) + + def set_joint_state(self, joint_state): + name_to_pos = {name: joint_state['position'][i] if i < len(joint_state['position']) else 0.0 for i, name in enumerate(joint_state.get('name', []))} + for name, pos in name_to_pos.items(): + joint = self.model['joints_by_name'].get(name) + if joint is not None: + joint['q'] = pos + + def compute(self, publish_time_ns=None): + transforms = [] + sec = int(publish_time_ns // 1_000_000_000) if publish_time_ns else 0 + nanosec = int(publish_time_ns % 1_000_000_000) if publish_time_ns else 0 + for joint in self.model['joints_by_name'].values(): + motion = _joint_motion_tr(joint) + rel = _compose_tr(joint['origin'], motion) + transforms.append({ + 'header': {'stamp': {'sec': sec, 'nanosec': nanosec}, 'frame_id': joint['parent']}, + 'child_frame_id': joint['child'], + 'transform': { + 'translation': {'x': rel['t']['x'], 'y': rel['t']['y'], 'z': rel['t']['z']}, + 'rotation': {'x': rel['r']['x'], 'y': rel['r']['y'], 'z': rel['r']['z'], 'w': rel['r']['w']}, + }, + }) + return {'transforms': transforms} + + def compute_from_joint_state(self, joint_state, publish_time_ns=None): + self.set_joint_state(joint_state) + return self.compute(publish_time_ns) + +def _parse_attrs(text): + return dict(re.findall(r'(\\w+)\\s*=\\s*"([^"]*)"', text)) + +def _parse_xyz(text): + if not text: + return _vec3() + parts = [float(v or 0) for v in text.split()] + while len(parts) < 3: + parts.append(0.0) + return _vec3(parts[0], parts[1], parts[2]) + +def _parse_rpy(text): + if not text: + return _quat_identity() + parts = [float(v or 0) for v in text.split()] + while len(parts) < 3: + parts.append(0.0) + return _quat_from_rpy(parts[0], parts[1], parts[2]) + +def _parse_joint_block(block): + open_match = re.search(r']*)>', block) + if not open_match: + return None + attrs = _parse_attrs(open_match.group(1)) + name = (attrs.get('name') or '').strip() + joint_type = (attrs.get('type') or 'fixed').strip() + parent_match = re.search(r']*link="([^"]+)"', block) + child_match = re.search(r']*link="([^"]+)"', block) + if not name or not parent_match or not child_match: + return None + origin_match = re.search(r']*)/?>', block) + origin_attrs = _parse_attrs(origin_match.group(1)) if origin_match else {} + axis_match = re.search(r']*)/?>', block) + axis_attrs = _parse_attrs(axis_match.group(1)) if axis_match else {} + origin = {'r': _parse_rpy(origin_attrs.get('rpy')), 't': _parse_xyz(origin_attrs.get('xyz'))} + axis = _vec3_normalize(_parse_xyz(axis_attrs.get('xyz', '1 0 0'))) + if joint_type not in {'revolute', 'continuous', 'prismatic', 'fixed'}: + joint_type = 'fixed' + return {'name': name, 'type': joint_type, 'parent': parent_match.group(1), 'child': child_match.group(1), 'origin': origin, 'axis': axis, 'q': 0.0} + +def _parse_urdf(xml): + joints = [j for j in (_parse_joint_block(block) for block in re.findall(r'', xml)) if j] + joints_by_name = {j['name']: j for j in joints} + return {'joints_by_name': joints_by_name} + +def _joint_motion_tr(joint): + if joint['type'] in {'revolute', 'continuous'}: + return {'r': _quat_from_axis_angle(joint['axis'], joint['q']), 't': _vec3()} + if joint['type'] == 'prismatic': + return {'r': _quat_identity(), 't': _vec3_scale(joint['axis'], joint['q'])} + return {'r': _quat_identity(), 't': _vec3()} +`.trim(); + +const MCAP_PROCESSOR_PY = ` +def prepare_urdf_xml(xml, recipe): + urdf = recipe.get('urdf') or {} + rotate = bool(urdf.get('rotateMeshVisuals')) + offset = urdf.get('visualRpyOffset') or [0, 0, 0] + if not rotate and all(v == 0 for v in offset): + return xml + import math + import re + + def rotation_matrix_from_rpy(roll, pitch, yaw): + cx, sx = math.cos(roll), math.sin(roll) + cy, sy = math.cos(pitch), math.sin(pitch) + cz, sz = math.cos(yaw), math.sin(yaw) + return [ + [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx], + [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx], + [-sy, cy * sx, cy * cx], + ] + + def rpy_from_rotation_matrix(m): + sy = -m[2][0] + if abs(sy) < 1 - 1e-6: + pitch = math.asin(sy) + roll = math.atan2(m[2][1], m[2][2]) + yaw = math.atan2(m[1][0], m[0][0]) + return [roll, pitch, yaw] + pitch = math.pi / 2 if sy > 0 else -math.pi / 2 + roll = math.atan2(-m[0][1], m[1][1]) + return [roll, pitch, 0.0] + + def multiply_mat3(a, b): + out = [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]] + for i in range(3): + for j in range(3): + out[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + return out + + def transform_visual_origin_rpy(rpy): + matrix = rotation_matrix_from_rpy(rpy[0], rpy[1], rpy[2]) + if rotate: + matrix = multiply_mat3(matrix, rotation_matrix_from_rpy(-math.pi / 2, 0.0, 0.0)) + if not all(v == 0 for v in offset): + matrix = multiply_mat3(matrix, rotation_matrix_from_rpy(offset[0], offset[1], offset[2])) + return rpy_from_rotation_matrix(matrix) + + def repl(match): + prefix, rpy_raw, suffix = match.group(1), match.group(2) or '0 0 0', match.group(3) + parts = [float(v or 0) for v in rpy_raw.split()] + while len(parts) < 3: + parts.append(0.0) + next_rpy = transform_visual_origin_rpy(parts[:3]) + return f'{prefix}{next_rpy[0]} {next_rpy[1]} {next_rpy[2]}{suffix}' + + return re.sub(r'(]*\\brpy=")([^"]*)(")', repl, xml) + +def normalize_joint_state(raw): + if not isinstance(raw, dict): + raw = {} + name = [str(v) for v in raw.get('name', [])] + position = [float(v or 0) for v in raw.get('position', [])] + header = raw.get('header') if isinstance(raw.get('header'), dict) else {'stamp': {'sec': 0, 'nanosec': 0}, 'frame_id': ''} + return {'header': header, 'name': name, 'position': position} + +def process_mcap(input_path, output_path, recipe, urdf_xml, overwrite): + from mcap.reader import make_reader + from mcap.writer import Writer + + tf_topic = recipe.get('outputTfTopic') or '/tf' + robot_desc_topic = recipe.get('outputRobotDescriptionTopic') or '/robot_description' + joint_topic = recipe.get('jointStateTopic') + if not joint_topic: + raise SystemExit('recipe.jointStateTopic is required') + + decoder_factory = None + try: + from mcap_ros2.decoder import DecoderFactory + decoder_factory = DecoderFactory() + except ImportError: + decoder_factory = None + + with open(input_path, 'rb') as input_file, open(output_path, 'wb') as output_file: + reader = make_reader(input_file, decoder_factories=[decoder_factory] if decoder_factory else []) + summary = reader.get_summary() + if summary is None: + raise SystemExit('Input MCAP must be indexed. Run: mcap recover input.mcap -o input.indexed.mcap') + + has_tf = any(ch.topic == tf_topic for ch in summary.channels.values()) + has_robot = any(ch.topic == robot_desc_topic for ch in summary.channels.values()) + joint_channel = next((ch for ch in summary.channels.values() if ch.topic == joint_topic), None) + if joint_channel is None: + raise SystemExit(f'JointState topic not found: {joint_topic}') + if not overwrite and (has_tf or has_robot): + raise SystemExit('Input already contains /tf or /robot_description. Pass --overwrite-topics.') + + writer = Writer(output_file) + writer.start(profile='ros2', library='urdf-debug-processor') + + schema_map = {} + for schema_id, schema in summary.schemas.items(): + schema_map[schema_id] = writer.register_schema(name=schema.name, encoding=schema.encoding, data=schema.data) + + channel_map = {} + for channel_id, channel in summary.channels.items(): + if overwrite and channel.topic in {tf_topic, robot_desc_topic}: + continue + channel_map[channel_id] = writer.register_channel( + topic=channel.topic, + message_encoding=channel.message_encoding, + schema_id=schema_map.get(channel.schema_id, 0), + metadata=channel.metadata, + ) + + out_encoding = joint_channel.message_encoding or 'json' + prepared_urdf = prepare_urdf_xml(urdf_xml, recipe) + fk_engine = JointState2TF.from_xml(prepared_urdf) + + tf_schema_id = writer.register_schema( + name='tf2_msgs/msg/TFMessage', + encoding='jsonschema' if out_encoding == 'json' else 'ros2msg', + data=b'{}' if out_encoding == 'json' else b'geometry_msgs/TransformStamped[] transforms\\n', + ) + robot_schema_id = writer.register_schema( + name='std_msgs/msg/String', + encoding='jsonschema' if out_encoding == 'json' else 'ros2msg', + data=b'{}' if out_encoding == 'json' else b'string data\\n', + ) + tf_channel_id = writer.register_channel(topic=tf_topic, message_encoding=out_encoding, schema_id=tf_schema_id) + robot_channel_id = writer.register_channel(topic=robot_desc_topic, message_encoding=out_encoding, schema_id=robot_schema_id) + + def serialize_payload(msg): + return json.dumps(msg).encode('utf-8') + + robot_desc_written = False + tf_seq = 0 + processed = 0 + + message_iter = reader.iter_decoded_messages() if decoder_factory else reader.iter_messages() + for item in message_iter: + if decoder_factory: + schema, channel, message, decoded = item + else: + schema, channel, message = item + decoded = None + + if overwrite and channel.topic in {tf_topic, robot_desc_topic}: + continue + + mapped = channel_map.get(channel.id) + if mapped is not None: + writer.add_message( + channel_id=mapped, + log_time=message.log_time, + data=message.data, + publish_time=message.publish_time, + sequence=message.sequence, + ) + + if channel.topic != joint_topic: + continue + + if channel.message_encoding == 'json': + raw_joint = normalize_joint_state(json.loads(message.data.decode('utf-8'))) + elif decoded is not None: + raw_joint = normalize_joint_state({ + 'header': { + 'stamp': { + 'sec': int(getattr(decoded.header.stamp, 'sec', 0)), + 'nanosec': int(getattr(decoded.header.stamp, 'nanosec', 0)), + }, + 'frame_id': str(getattr(decoded.header, 'frame_id', '')), + }, + 'name': list(getattr(decoded, 'name', [])), + 'position': list(getattr(decoded, 'position', [])), + }) + else: + raise SystemExit('CDR joint_states requires: pip install mcap-ros2-support') + + mapped_js = apply_joint_mapping( + {'name': raw_joint['name'], 'position': raw_joint['position']}, + recipe.get('rules', []), + ) + tf_msg = fk_engine.compute_from_joint_state( + {'header': raw_joint['header'], 'name': mapped_js['name'], 'position': mapped_js['position']}, + message.log_time, + ) + + if not robot_desc_written: + writer.add_message( + channel_id=robot_channel_id, + log_time=message.log_time, + data=serialize_payload({'data': prepared_urdf}), + publish_time=message.publish_time, + sequence=0, + ) + robot_desc_written = True + + tf_seq += 1 + writer.add_message( + channel_id=tf_channel_id, + log_time=message.log_time, + data=serialize_payload(tf_msg), + publish_time=message.publish_time, + sequence=tf_seq, + ) + processed += 1 + + writer.finish() + return processed +`.trim(); + +function recipeLiteral(recipe: UrdfDebugRecipe): string { + return JSON.stringify(recipe, null, 2); +} + +export function generateTypeScriptScript(recipe: UrdfDebugRecipe): string { + const recipeJson = recipeLiteral(recipe); + return `#!/usr/bin/env node +/** + * URDF Debug MCAP processor (TypeScript) + * + * Usage: + * npm i @mcap/core @foxglove/rosmsg @foxglove/rosmsg2-serialization + * node process_mcap_tf.mjs input.mcap output.mcap recipe.json robot.urdf [--overwrite-topics] + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { McapIndexedReader, McapWriter } from '@mcap/core'; +import { MessageReader, MessageWriter } from '@foxglove/rosmsg2-serialization'; +import rosmsg from '@foxglove/rosmsg'; +const { parseMessageDefinition } = rosmsg; + +const args = process.argv.slice(2); +if (args.length < 4) { + console.error('Usage: node process_mcap_tf.mjs input.mcap output.mcap recipe.json robot.urdf [--overwrite-topics]'); + process.exit(1); +} +const [inputPath, outputPath, recipePath, urdfPath, ...flags] = args; +const overwriteTopics = flags.includes('--overwrite-topics'); +const recipe = JSON.parse(readFileSync(recipePath, 'utf8')); +const urdfXml = readFileSync(urdfPath, 'utf8'); + +${MAPPING_CORE_JS} + +${FK_ENGINE_JS} + +${ROS2_DEFINITIONS_JS} + +${MCAP_PROCESSOR_JS} + +async function main() { + const processed = await processMcap({ inputPath, outputPath, recipe, urdfXml, overwriteTopics }); + console.log('Wrote', outputPath, 'with', processed, 'joint state frame(s) expanded to /tf'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); + +/* +Embedded recipe snapshot: +${recipeJson} +*/ +`; +} + +export function generatePythonScript(recipe: UrdfDebugRecipe): string { + const recipeJson = recipeLiteral(recipe); + return `#!/usr/bin/env python3 +"""URDF Debug MCAP processor (Python) + +Usage: + pip install mcap mcap-ros2-support + python process_mcap_tf.py input.mcap output.mcap recipe.json robot.urdf [--overwrite-topics] +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +${MAPPING_CORE_PY} + +${FK_ENGINE_PY} + +${MCAP_PROCESSOR_PY} + +def main() -> None: + if len(sys.argv) < 5: + print('Usage: python process_mcap_tf.py input.mcap output.mcap recipe.json robot.urdf [--overwrite-topics]', file=sys.stderr) + sys.exit(1) + input_path, output_path, recipe_path, urdf_path, *flags = sys.argv[1:] + overwrite = '--overwrite-topics' in flags + recipe = json.loads(Path(recipe_path).read_text(encoding='utf-8')) + urdf_xml = Path(urdf_path).read_text(encoding='utf-8') + processed = process_mcap(input_path, output_path, recipe, urdf_xml, overwrite) + print('Wrote', output_path, 'with', processed, 'joint state frame(s) expanded to /tf') + +if __name__ == '__main__': + main() + +# Embedded recipe snapshot: +# ${recipeJson.replace(/\n/g, '\n# ')} +`; +} + +export function buildCliCommands(): { ts: string; py: string } { + return { + ts: 'node process_mcap_tf.mjs input.mcap output.mcap recipe.json robot.urdf', + py: 'python process_mcap_tf.py input.mcap output.mcap recipe.json robot.urdf', + }; +} diff --git a/src/features/panels/UrdfDebug/urdfAnalysis.test.ts b/src/features/panels/UrdfDebug/urdfAnalysis.test.ts new file mode 100644 index 0000000..43829a1 --- /dev/null +++ b/src/features/panels/UrdfDebug/urdfAnalysis.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it } from 'vitest'; +import { + extractUrdfJointDescriptors, + extractUrdfMimicJoints, + filterJointStateTopics, + isJointStateTopicType, + pickJointStateTopic, +} from './urdfAnalysis'; + +describe('extractUrdfJointDescriptors', () => { + it('parses revolute limits and step granularity', () => { + const urdf = ` + + + + + + `; + const joints = extractUrdfJointDescriptors(urdf); + expect(joints).toHaveLength(1); + expect(joints[0].name).toBe('j1'); + expect(joints[0].jointType).toBe('revolute'); + expect(joints[0].lower).toBe(-1); + expect(joints[0].upper).toBe(1); + expect(joints[0].step).toBeCloseTo(0.02, 5); + expect(joints[0].sliderEnabled).toBe(true); + }); + + it('uses default range for continuous joints', () => { + const urdf = ` + + + + + `; + const joints = extractUrdfJointDescriptors(urdf); + expect(joints[0].sliderEnabled).toBe(true); + expect(joints[0].lower).toBeCloseTo(-Math.PI, 5); + expect(joints[0].upper).toBeCloseTo(Math.PI, 5); + }); + + it('disables slider for fixed joints', () => { + const urdf = ` + + + + + `; + const joints = extractUrdfJointDescriptors(urdf); + expect(joints[0].sliderEnabled).toBe(false); + }); +}); + +describe('extractUrdfMimicJoints', () => { + it('parses mimic tags from joint blocks', () => { + const urdf = ` + + + + + + + `; + const mimics = extractUrdfMimicJoints(urdf); + expect(mimics).toEqual([ + { jointName: 'left_finger_joint', sourceJoint: 'drive_joint', multiplier: 1, offset: 0 }, + ]); + }); +}); + +describe('joint state topic helpers', () => { + const topics = [ + { name: '/camera/image', type: 'sensor_msgs/msg/Image' }, + { name: '/joint_states', type: 'sensor_msgs/msg/JointState' }, + { name: '/legacy_js', type: 'sensor_msgs/JointState' }, + { name: '/not_really_joint_states', type: 'sensor_msgs/msg/Image' }, + ]; + + it('isJointStateTopicType matches JointState schemas only', () => { + expect(isJointStateTopicType('sensor_msgs/msg/JointState')).toBe(true); + expect(isJointStateTopicType('sensor_msgs/JointState')).toBe(true); + expect(isJointStateTopicType('sensor_msgs/msg/Image')).toBe(false); + }); + + it('filterJointStateTopics excludes non-JointState topics', () => { + expect(filterJointStateTopics(topics).map((t) => t.name)).toEqual([ + '/joint_states', + '/legacy_js', + ]); + }); + + it('pickJointStateTopic ignores preferred non-JointState topic', () => { + expect(pickJointStateTopic(topics, '/camera/image')).toBe('/joint_states'); + }); + + it('pickJointStateTopic prefers valid saved JointState topic', () => { + expect(pickJointStateTopic(topics, '/legacy_js')).toBe('/legacy_js'); + }); + + it('pickJointStateTopic does not match joint_states suffix on wrong type', () => { + expect(pickJointStateTopic([topics[0], topics[3]])).toBe(''); + }); +}); diff --git a/src/features/panels/UrdfDebug/urdfAnalysis.ts b/src/features/panels/UrdfDebug/urdfAnalysis.ts new file mode 100644 index 0000000..74d9712 --- /dev/null +++ b/src/features/panels/UrdfDebug/urdfAnalysis.ts @@ -0,0 +1,203 @@ +import { parseUrdf } from '../ThreeD/foxglove-core/urdf'; +import type { UrdfJoint } from '../ThreeD/foxglove-core/types'; +import { applyUrdfVisualCorrection } from './urdfVisualCorrection'; + +export { TELEOP_ROTATE_MESH_RPY, applyUrdfVisualCorrection } from './urdfVisualCorrection'; + +const SLIDER_GRANULARITY = 100; +const DEFAULT_REVOLUTE_RANGE = Math.PI; +const DEFAULT_PRISMATIC_RANGE = 0.05; + +export type UrdfJointDescriptor = { + name: string; + jointType: UrdfJoint['jointType']; + lower: number; + upper: number; + step: number; + defaultValue: number; + sliderEnabled: boolean; + valueUnit: 'rad' | 'm'; +}; + +export type UrdfMimicJoint = { + jointName: string; + sourceJoint: string; + multiplier: number; + offset: number; +}; + +export type UrdfAnalysis = { + robotName: string; + linkCount: number; + jointCount: number; + movableJointNames: string[]; + meshReferences: string[]; + mimicJoints: UrdfMimicJoint[]; +}; + +export function analyzeUrdfText(urdfText: string): UrdfAnalysis | null { + try { + const parsed = parseUrdf(urdfText); + const movableJointNames = Array.from(parsed.robot.joints.values()) + .filter((joint) => joint.jointType !== 'fixed') + .map((joint) => joint.name); + const meshReferences: string[] = []; + for (const link of parsed.robot.links.values()) { + for (const visual of link.visuals) { + if (visual.geometry.geometryType === 'mesh') { + meshReferences.push(visual.geometry.filename); + } + } + } + return { + robotName: parsed.robot.name, + linkCount: parsed.robot.links.size, + jointCount: parsed.robot.joints.size, + movableJointNames, + meshReferences, + mimicJoints: extractUrdfMimicJoints(urdfText), + }; + } catch { + return null; + } +} + +/** Apply teleop_tf rotate_mesh visual correction and optional RPY offset. */ +export function prepareUrdfForPreview( + urdfText: string, + rotateMeshVisuals: boolean, + visualRpyOffset: [number, number, number], +): string { + return applyUrdfVisualCorrection(urdfText, { rotateMeshVisuals, visualRpyOffset }); +} + +export function isJointStateTopicType(type: string): boolean { + return type.includes('JointState'); +} + +export function filterJointStateTopics( + topics: ReadonlyArray<{ name: string; type: string }>, +): Array<{ name: string; type: string }> { + return topics.filter((topic) => isJointStateTopicType(topic.type)); +} + +export function pickJointStateTopic( + topics: ReadonlyArray<{ name: string; type: string }>, + preferred?: string, +): string { + const jointStateTopics = filterJointStateTopics(topics); + if (preferred) { + const preferredTopic = jointStateTopics.find((topic) => topic.name === preferred); + if (preferredTopic) return preferredTopic.name; + } + const bySuffix = jointStateTopics.find( + (topic) => topic.name.endsWith('/joint_states') || topic.name.includes('joint_states'), + ); + if (bySuffix) return bySuffix.name; + return jointStateTopics[0]?.name ?? ''; +} + +function clamp(value: number, lower: number, upper: number): number { + return Math.max(lower, Math.min(upper, value)); +} + +function hasValidLimit(limit: UrdfJoint['limit']): boolean { + if (!limit) return false; + return Number.isFinite(limit.lower) && Number.isFinite(limit.upper) && limit.lower < limit.upper; +} + +function resolveJointRange(joint: UrdfJoint): { lower: number; upper: number; sliderEnabled: boolean } { + switch (joint.jointType) { + case 'revolute': + case 'prismatic': { + if (hasValidLimit(joint.limit)) { + return { lower: joint.limit!.lower, upper: joint.limit!.upper, sliderEnabled: true }; + } + if (joint.jointType === 'prismatic') { + return { + lower: -DEFAULT_PRISMATIC_RANGE, + upper: DEFAULT_PRISMATIC_RANGE, + sliderEnabled: true, + }; + } + return { + lower: -DEFAULT_REVOLUTE_RANGE, + upper: DEFAULT_REVOLUTE_RANGE, + sliderEnabled: true, + }; + } + case 'continuous': + return { + lower: -DEFAULT_REVOLUTE_RANGE, + upper: DEFAULT_REVOLUTE_RANGE, + sliderEnabled: true, + }; + case 'fixed': + return { lower: 0, upper: 0, sliderEnabled: false }; + default: + return { lower: 0, upper: 0, sliderEnabled: false }; + } +} + +/** Extract joint metadata for manual pose sliders (document order). */ +export function extractUrdfJointDescriptors(urdfText: string): UrdfJointDescriptor[] { + const parsed = parseUrdf(urdfText); + return Array.from(parsed.robot.joints.values()).map((joint) => { + const { lower, upper, sliderEnabled } = resolveJointRange(joint); + const span = upper - lower; + const step = span > 0 ? span / SLIDER_GRANULARITY : 0; + const defaultValue = clamp(0, lower, upper); + return { + name: joint.name, + jointType: joint.jointType, + lower, + upper, + step, + defaultValue, + sliderEnabled, + valueUnit: joint.jointType === 'prismatic' ? 'm' : 'rad', + }; + }); +} + +export function createDefaultManualPositions(descriptors: UrdfJointDescriptor[]): Record { + const out: Record = {}; + for (const joint of descriptors) { + if (joint.sliderEnabled) { + out[joint.name] = joint.defaultValue; + } + } + return out; +} + +/** Parse `` tags from URDF joint blocks. */ +export function extractUrdfMimicJoints(urdfText: string): UrdfMimicJoint[] { + const out: UrdfMimicJoint[] = []; + const jointRe = //g; + let blockMatch: RegExpExecArray | null; + while ((blockMatch = jointRe.exec(urdfText)) !== null) { + const block = blockMatch[0]; + const nameMatch = /]*\bname="([^"]+)"/.exec(block); + const mimicMatch = /]*)\/>/.exec(block); + if (!nameMatch || !mimicMatch) continue; + const attrs = mimicMatch[1]; + const sourceMatch = /\bjoint="([^"]+)"/.exec(attrs); + if (!sourceMatch) continue; + const multiplierMatch = /\bmultiplier="([^"]+)"/.exec(attrs); + const offsetMatch = /\boffset="([^"]+)"/.exec(attrs); + out.push({ + jointName: nameMatch[1].trim(), + sourceJoint: sourceMatch[1].trim(), + multiplier: multiplierMatch ? Number(multiplierMatch[1]) || 1 : 1, + offset: offsetMatch ? Number(offsetMatch[1]) || 0 : 0, + }); + } + return out; +} + +export function topicExists( + topics: ReadonlyArray<{ name: string; type: string }>, + matcher: (name: string, type: string) => boolean, +): boolean { + return topics.some((topic) => matcher(topic.name, topic.type)); +} diff --git a/src/features/panels/UrdfDebug/urdfVisualCorrection.test.ts b/src/features/panels/UrdfDebug/urdfVisualCorrection.test.ts new file mode 100644 index 0000000..6238305 --- /dev/null +++ b/src/features/panels/UrdfDebug/urdfVisualCorrection.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + applyUrdfVisualCorrection, + multiplyMat3, + rotationMatrixFromRpy, + rpyFromRotationMatrix, + TELEOP_ROTATE_MESH_RPY, + transformVisualOriginRpy, +} from './urdfVisualCorrection'; + +const SAMPLE_URDF = ` + + + + + + + + + + + + + +`; + +describe('urdfVisualCorrection', () => { + it('matches teleop_tf rotate_mesh for identity origin', () => { + const next = transformVisualOriginRpy([0, 0, 0], { + rotateMeshVisuals: true, + visualRpyOffset: [0, 0, 0], + }); + expect(next[0]).toBeCloseTo(TELEOP_ROTATE_MESH_RPY[0], 5); + expect(next[1]).toBeCloseTo(0, 5); + expect(next[2]).toBeCloseTo(0, 5); + }); + + it('post-multiplies rotation matrices like teleop_tf (not additive roll)', () => { + const original = [0.1, 0.2, 0.3] as [number, number, number]; + const originalMatrix = rotationMatrixFromRpy(...original); + const fixMatrix = rotationMatrixFromRpy(...TELEOP_ROTATE_MESH_RPY); + const expected = rpyFromRotationMatrix(multiplyMat3(originalMatrix, fixMatrix)); + + const next = transformVisualOriginRpy(original, { + rotateMeshVisuals: true, + visualRpyOffset: [0, 0, 0], + }); + expect(next[0]).toBeCloseTo(expected[0], 5); + expect(next[1]).toBeCloseTo(expected[1], 5); + expect(next[2]).toBeCloseTo(expected[2], 5); + + // Old additive-roll shortcut would differ when pitch/yaw are non-zero. + expect(next[0]).not.toBeCloseTo(original[0] + Math.PI / 2, 3); + }); + + it('leaves URDF unchanged when rotate_mesh is off and offset is zero', () => { + expect( + applyUrdfVisualCorrection(SAMPLE_URDF, { + rotateMeshVisuals: false, + visualRpyOffset: [0, 0, 0], + }), + ).toBe(SAMPLE_URDF); + }); + + it('updates every visual origin rpy when rotate_mesh is on', () => { + const corrected = applyUrdfVisualCorrection(SAMPLE_URDF, { + rotateMeshVisuals: true, + visualRpyOffset: [0, 0, 0], + }); + expect(corrected).toContain('rpy="-1.5707963267948966 0 0"'); + expect(corrected).toMatch(/arm[\s\S]*?rpy="[^"]+"/); + expect(corrected).not.toContain('rpy="0.1 0.2 0.3"'); + }); + + it('applies visualRpyOffset after rotate_mesh', () => { + const withOffset = transformVisualOriginRpy([0, 0, 0], { + rotateMeshVisuals: true, + visualRpyOffset: [0.1, 0, 0], + }); + const rotateOnly = transformVisualOriginRpy([0, 0, 0], { + rotateMeshVisuals: true, + visualRpyOffset: [0, 0, 0], + }); + expect(withOffset[0]).not.toBeCloseTo(rotateOnly[0], 3); + }); +}); diff --git a/src/features/panels/UrdfDebug/urdfVisualCorrection.ts b/src/features/panels/UrdfDebug/urdfVisualCorrection.ts new file mode 100644 index 0000000..a5efa04 --- /dev/null +++ b/src/features/panels/UrdfDebug/urdfVisualCorrection.ts @@ -0,0 +1,161 @@ +/** teleop_tf `rotate_mesh`: post-multiply visual origin by R(-π/2, 0, 0). */ +export const TELEOP_ROTATE_MESH_RPY: [number, number, number] = [-Math.PI / 2, 0, 0]; + +export type UrdfVisualCorrectionOptions = { + rotateMeshVisuals: boolean; + visualRpyOffset: [number, number, number]; +}; + +type Mat3 = [[number, number, number], [number, number, number], [number, number, number]]; + +/** URDF / scipy extrinsic xyz: R = Rz(yaw) * Ry(pitch) * Rx(roll). */ +export function rotationMatrixFromRpy(roll: number, pitch: number, yaw: number): Mat3 { + const cx = Math.cos(roll); + const sx = Math.sin(roll); + const cy = Math.cos(pitch); + const sy = Math.sin(pitch); + const cz = Math.cos(yaw); + const sz = Math.sin(yaw); + return [ + [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx], + [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx], + [-sy, cy * sx, cy * cx], + ]; +} + +export function rpyFromRotationMatrix(m: Mat3): [number, number, number] { + const sy = -m[2][0]; + if (Math.abs(sy) < 1 - 1e-6) { + const pitch = Math.asin(sy); + const roll = Math.atan2(m[2][1], m[2][2]); + const yaw = Math.atan2(m[1][0], m[0][0]); + return [roll, pitch, yaw]; + } + const pitch = sy > 0 ? Math.PI / 2 : -Math.PI / 2; + const roll = Math.atan2(-m[0][1], m[1][1]); + return [roll, pitch, 0]; +} + +export function multiplyMat3(a: Mat3, b: Mat3): Mat3 { + const out: number[][] = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; + for (let i = 0; i < 3; i += 1) { + for (let j = 0; j < 3; j += 1) { + out[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]; + } + } + return out as Mat3; +} + +/** Match teleop_tf `update_rpy_in_xml`: origin' = origin @ R(-π/2, 0, 0), then optional offset. */ +export function transformVisualOriginRpy( + rpy: [number, number, number], + options: UrdfVisualCorrectionOptions, +): [number, number, number] { + let matrix = rotationMatrixFromRpy(rpy[0], rpy[1], rpy[2]); + if (options.rotateMeshVisuals) { + const fix = rotationMatrixFromRpy(...TELEOP_ROTATE_MESH_RPY); + matrix = multiplyMat3(matrix, fix); + } + if (!options.visualRpyOffset.every((value) => value === 0)) { + const offset = rotationMatrixFromRpy( + options.visualRpyOffset[0], + options.visualRpyOffset[1], + options.visualRpyOffset[2], + ); + matrix = multiplyMat3(matrix, offset); + } + return rpyFromRotationMatrix(matrix); +} + +export function applyUrdfVisualCorrection( + urdfText: string, + options: UrdfVisualCorrectionOptions, +): string { + if (!options.rotateMeshVisuals && options.visualRpyOffset.every((value) => value === 0)) { + return urdfText; + } + return urdfText.replace( + /(]*\brpy=")([^"]*)(")/g, + (_match, prefix: string, rpyRaw: string, suffix: string) => { + const parts = rpyRaw.split(/\s+/).map(Number); + const roll = Number.isFinite(parts[0]) ? parts[0] : 0; + const pitch = Number.isFinite(parts[1]) ? parts[1] : 0; + const yaw = Number.isFinite(parts[2]) ? parts[2] : 0; + const next = transformVisualOriginRpy([roll, pitch, yaw], options); + return `${prefix}${next[0]} ${next[1]} ${next[2]}${suffix}`; + }, + ); +} + +/** Embedded into exported MCAP scripts (keep in sync with applyUrdfVisualCorrection). */ +export const URDF_VISUAL_CORRECTION_JS = ` +const TELEOP_ROTATE_MESH_RPY = [-Math.PI / 2, 0, 0]; + +function rotationMatrixFromRpy(roll, pitch, yaw) { + const cx = Math.cos(roll), sx = Math.sin(roll); + const cy = Math.cos(pitch), sy = Math.sin(pitch); + const cz = Math.cos(yaw), sz = Math.sin(yaw); + return [ + [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx], + [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx], + [-sy, cy * sx, cy * cx], + ]; +} + +function rpyFromRotationMatrix(m) { + const sy = -m[2][0]; + if (Math.abs(sy) < 1 - 1e-6) { + const pitch = Math.asin(sy); + const roll = Math.atan2(m[2][1], m[2][2]); + const yaw = Math.atan2(m[1][0], m[0][0]); + return [roll, pitch, yaw]; + } + const pitch = sy > 0 ? Math.PI / 2 : -Math.PI / 2; + const roll = Math.atan2(-m[0][1], m[1][1]); + return [roll, pitch, 0]; +} + +function multiplyMat3(a, b) { + const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; + for (let i = 0; i < 3; i += 1) { + for (let j = 0; j < 3; j += 1) { + out[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]; + } + } + return out; +} + +function transformVisualOriginRpy(rpy, options) { + let matrix = rotationMatrixFromRpy(rpy[0], rpy[1], rpy[2]); + if (options.rotateMeshVisuals) { + matrix = multiplyMat3(matrix, rotationMatrixFromRpy(...TELEOP_ROTATE_MESH_RPY)); + } + const offset = options.visualRpyOffset ?? [0, 0, 0]; + if (!offset.every((value) => value === 0)) { + matrix = multiplyMat3(matrix, rotationMatrixFromRpy(offset[0], offset[1], offset[2])); + } + return rpyFromRotationMatrix(matrix); +} + +function prepareUrdfXml(xml, recipe) { + const urdf = recipe.urdf ?? {}; + const options = { + rotateMeshVisuals: !!urdf.rotateMeshVisuals, + visualRpyOffset: Array.isArray(urdf.visualRpyOffset) ? urdf.visualRpyOffset : [0, 0, 0], + }; + if (!options.rotateMeshVisuals && options.visualRpyOffset.every((value) => value === 0)) { + return xml; + } + return xml.replace( + /(]*\\brpy=")([^"]*)(")/g, + (_match, prefix, rpyRaw, suffix) => { + const parts = rpyRaw.split(/\\s+/).map(Number); + const roll = Number.isFinite(parts[0]) ? parts[0] : 0; + const pitch = Number.isFinite(parts[1]) ? parts[1] : 0; + const yaw = Number.isFinite(parts[2]) ? parts[2] : 0; + const next = transformVisualOriginRpy([roll, pitch, yaw], options); + return \`\${prefix}\${next[0]} \${next[1]} \${next[2]}\${suffix}\`; + }, + ); +} +`.trim(); diff --git a/src/features/panels/framework/PanelRuntimeShell.tsx b/src/features/panels/framework/PanelRuntimeShell.tsx index 3544164..3e0bf91 100644 --- a/src/features/panels/framework/PanelRuntimeShell.tsx +++ b/src/features/panels/framework/PanelRuntimeShell.tsx @@ -136,7 +136,7 @@ export function PanelRuntimeShell({ return ( -
+
{definition.render({ player, panelId, diff --git a/src/features/panels/framework/panelMessageSlug.ts b/src/features/panels/framework/panelMessageSlug.ts index 16b4641..5414944 100644 --- a/src/features/panels/framework/panelMessageSlug.ts +++ b/src/features/panels/framework/panelMessageSlug.ts @@ -14,5 +14,6 @@ export const PANEL_TYPE_MESSAGE_SLUG: Record = { TopicGraph: 'topicGraph', Align: 'align', Audio: 'audio', + UrdfDebug: 'urdfDebug', Unavailable: 'unavailable', }; diff --git a/src/features/panels/framework/types.ts b/src/features/panels/framework/types.ts index b20583b..dc0fb48 100644 --- a/src/features/panels/framework/types.ts +++ b/src/features/panels/framework/types.ts @@ -17,6 +17,7 @@ export type PanelType = | 'TopicGraph' | 'Align' | 'Audio' + | 'UrdfDebug' | 'Unavailable'; /** diff --git a/src/features/panels/registry/index.ts b/src/features/panels/registry/index.ts index 1a2934a..e86db6e 100644 --- a/src/features/panels/registry/index.ts +++ b/src/features/panels/registry/index.ts @@ -22,6 +22,8 @@ import { alignPanelDefinition } from '../Align'; import { alignFoxgloveAdapter } from '../Align/foxgloveAdapter'; import { audioPanelDefinition } from '../Audio'; import { audioFoxgloveAdapter } from '../Audio/foxgloveAdapter'; +import { urdfDebugPanelDefinition } from '../UrdfDebug'; +import { urdfDebugFoxgloveAdapter } from '../UrdfDebug/foxgloveAdapter'; import { unavailablePanelDefinition } from '../Unavailable'; import { unavailableFoxgloveAdapter } from '../Unavailable/foxgloveAdapter'; @@ -35,6 +37,7 @@ const definitions = [ topicGraphPanelDefinition, alignPanelDefinition, audioPanelDefinition, + urdfDebugPanelDefinition, unavailablePanelDefinition, ] as unknown as readonly PanelDefinition[]; @@ -61,6 +64,7 @@ const foxgloveAdapters = new Map>([ ['TopicGraph', topicGraphFoxgloveAdapter], ['Align', alignFoxgloveAdapter], ['Audio', audioFoxgloveAdapter], + ['UrdfDebug', urdfDebugFoxgloveAdapter], ['Unavailable', unavailableFoxgloveAdapter], ]); diff --git a/src/shared/intl/messages/en/panels.json b/src/shared/intl/messages/en/panels.json index ecdf570..2739007 100644 --- a/src/shared/intl/messages/en/panels.json +++ b/src/shared/intl/messages/en/panels.json @@ -237,5 +237,105 @@ "panels.image.settings.enum.colorMode.rgbaFields": "RGBA separate fields", "panels.image.settings.enum.colorMap.turbo": "Turbo", "panels.image.settings.enum.colorMap.rainbow": "Rainbow", - "panels.jointStatePlot.filter.partial": "{current}/{total}" + "panels.jointStatePlot.filter.partial": "{current}/{total}", + "urdfDebug.defaultTitle": "URDF Debug", + "urdfDebug.section.input": "Input", + "urdfDebug.section.appearance": "Appearance", + "urdfDebug.section.joints": "Joint pose", + "urdfDebug.section.export": "Export", + "urdfDebug.field.jointTopic": "JointState topic", + "urdfDebug.field.urdfRequired": "URDF file (required)", + "urdfDebug.upload.dropUrdfTitle": "Drop URDF file here", + "urdfDebug.upload.dropUrdfHint": "Supports .urdf and .xml — drag and drop or click to browse", + "urdfDebug.upload.dropMeshTitle": "Drop mesh folder here", + "urdfDebug.upload.dropMeshHint": "Drag a folder containing .stl, .dae, or .obj files, or click to browse", + "urdfDebug.upload.browse": "Browse files", + "urdfDebug.upload.invalidUrdf": "Please drop a .urdf or .xml file.", + "urdfDebug.upload.invalidMesh": "No mesh files (.stl, .dae, .obj) found in the selection.", + "urdfDebug.resizeSettings": "Resize settings panel", + "urdfDebug.field.meshOptional": "Mesh files (optional)", + "urdfDebug.field.meshStrategy": "Mesh strategy", + "urdfDebug.field.packageBaseUrl": "Package base URL", + "urdfDebug.field.packageName": "Package name", + "urdfDebug.field.visualRpyOffset": "Visual RPY offset", + "urdfDebug.selectTopic": "Select topic", + "urdfDebug.selectJointStateTopic": "Select JointState topic", + "urdfDebug.section.meshResources": "Mesh resources", + "urdfDebug.meshBase.hint": "Resolve package:// mesh paths in the URDF. Pick a local folder containing meshes/, or enter a remote base URL and click Apply.", + "urdfDebug.meshBase.mode.localFolder": "Local folder", + "urdfDebug.meshBase.mode.remoteUrl": "Remote base URL (HTTP/HTTPS)", + "urdfDebug.meshBase.pickFolder": "Choose folder…", + "urdfDebug.meshBase.folderSelected": "Folder “{folder}” · {count} mesh files", + "urdfDebug.meshBase.folderEmpty": "No folder selected yet.", + "urdfDebug.meshBase.remotePlaceholder": "https://your-host/resources/Robot/meshes", + "urdfDebug.meshBase.apply": "Apply", + "urdfDebug.meshBase.applied": "Applied base URL", + "urdfDebug.meshBase.remoteNotApplied": "Enter a URL and click Apply to resolve mesh links.", + "urdfDebug.meshBase.remoteInvalid": "Enter a valid http:// or https:// URL.", + "urdfDebug.meshBase.detectPackage": "Detect from URDF", + "urdfDebug.meshBase.resolvedTitle": "Resolved mesh URLs", + "urdfDebug.meshBase.refresh": "Recheck", + "urdfDebug.meshBase.checking": "Checking mesh URLs…", + "urdfDebug.meshBase.summary": "{ok} / {total} reachable · {failed} issues", + "urdfDebug.meshStatus.pending": "Checking", + "urdfDebug.meshStatus.ok": "Reachable", + "urdfDebug.meshStatus.local": "Loaded from folder", + "urdfDebug.meshStatus.missing": "Not resolved", + "urdfDebug.meshStatus.error": "Load error", + "urdfDebug.meshStatus.cors": "CORS blocked (may still render in 3D)", + "urdfDebug.meshStatus.unchecked": "Not checked", + "urdfDebug.meshStrategy.packageBaseUrl": "Package base URL", + "urdfDebug.meshStrategy.leaveAsIs": "Leave as-is", + "urdfDebug.rotateMeshVisuals": "Rotate mesh visuals (teleop_tf rotate_mesh)", + "urdfDebug.rotateMeshVisualsHint": "Aligns mesh orientation with the web viewer when the URDF export uses a different frame convention. Same as teleop_tf rotate_mesh: post-multiply each visual origin by R(-90° roll). Toggle to compare in the 3D preview.", + "urdfDebug.preview.empty": "Upload a URDF file to preview the robot.", + "urdfDebug.preview.title": "URDF Preview · rotate_mesh: {rotateMesh}", + "urdfDebug.preview.rotateMeshOn": "ON", + "urdfDebug.preview.rotateMeshOff": "OFF", + "urdfDebug.preview.loadingMesh": "Loading mesh {loaded}/{total}", + "urdfDebug.showGrid": "Show grid", + "urdfDebug.showAxes": "Show axes", + "urdfDebug.joints.followLive": "Follow MCAP JointState", + "urdfDebug.joints.noJointStateTopics": "No JointState topics found in this recording.", + "urdfDebug.joints.selectJointStateTopicHint": "Choose a JointState topic to drive the preview.", + "urdfDebug.joints.waitingForJointState": "Subscribed to {topic}; waiting for JointState messages…", + "urdfDebug.joints.resetAll": "Reset all", + "urdfDebug.joints.filter": "Filter joints…", + "urdfDebug.joints.uploadUrdfHint": "Upload a URDF to configure joint sliders.", + "urdfDebug.joints.noMatch": "No joints match the filter.", + "urdfDebug.joints.manualUnsupported": "Manual tuning not supported for this joint type.", + "urdfDebug.joints.fixedJoint": "Fixed joint (no slider).", + "urdfDebug.jointType.revolute": "revolute", + "urdfDebug.jointType.prismatic": "prismatic", + "urdfDebug.jointType.continuous": "continuous", + "urdfDebug.jointType.fixed": "fixed", + "urdfDebug.jointType.planar": "planar", + "urdfDebug.jointType.floating": "floating", + "urdfDebug.action.autoMatch": "Auto match", + "urdfDebug.action.symmetricPair": "Symmetric pair", + "urdfDebug.action.mimicFill": "Mimic fill", + "urdfDebug.action.invertFirst": "Invert first", + "urdfDebug.action.gripper01": "Gripper 0-1", + "urdfDebug.action.xArm851": "xArm 851", + "urdfDebug.noRules": "No rules yet.", + "urdfDebug.diag.jointTopic": "Joint topic", + "urdfDebug.diag.existingTf": "Existing /tf", + "urdfDebug.diag.existingRobotDescription": "Existing /robot_description", + "urdfDebug.diag.robot": "Robot", + "urdfDebug.diag.linksJoints": "Links / joints", + "urdfDebug.diag.matchCoverage": "Match coverage", + "urdfDebug.diag.generatedTfCount": "Generated TF count", + "urdfDebug.diag.unmatchedInputJoints": "Unmatched input joints", + "urdfDebug.diag.missingUrdfJoints": "Missing URDF joints", + "urdfDebug.diag.meshIssues": "Mesh issues", + "urdfDebug.diag.missing": "Missing", + "urdfDebug.yes": "Yes", + "urdfDebug.no": "No", + "urdfDebug.rule.inputJoint": "Input joint", + "urdfDebug.rule.urdfJoint": "URDF joint", + "urdfDebug.rule.rename": "Rename", + "urdfDebug.rule.linear": "Linear", + "urdfDebug.rule.constant": "Constant", + "urdfDebug.rule.ignore": "Ignore", + "urdfDebug.help.body": "Upload a URDF and tune joint mapping until the preview matches your recording. Toggle rotate_mesh when mesh orientation looks wrong (common for CAD exports). Use Symmetric pair for left/right gripper joints and Mimic fill to apply URDF mimic tags. Export recipe.json or a processing script to rewrite MCAP with /robot_description and /tf." } diff --git a/src/shared/intl/messages/ja/panels.json b/src/shared/intl/messages/ja/panels.json index e6e2796..ddcc48f 100644 --- a/src/shared/intl/messages/ja/panels.json +++ b/src/shared/intl/messages/ja/panels.json @@ -237,5 +237,105 @@ "panels.image.settings.enum.colorMode.rgbaFields": "RGBA separate fields", "panels.image.settings.enum.colorMap.turbo": "Turbo", "panels.image.settings.enum.colorMap.rainbow": "Rainbow", - "panels.jointStatePlot.filter.partial": "{current}/{total}" + "panels.jointStatePlot.filter.partial": "{current}/{total}", + "urdfDebug.defaultTitle": "URDF デバッグ", + "urdfDebug.section.input": "入力", + "urdfDebug.section.appearance": "外観", + "urdfDebug.section.joints": "関節姿勢", + "urdfDebug.section.export": "エクスポート", + "urdfDebug.field.jointTopic": "JointState トピック", + "urdfDebug.field.urdfRequired": "URDF ファイル(必須)", + "urdfDebug.upload.dropUrdfTitle": "URDF ファイルをここにドロップ", + "urdfDebug.upload.dropUrdfHint": ".urdf / .xml に対応 — ドラッグ&ドロップまたはクリック", + "urdfDebug.upload.dropMeshTitle": "Mesh フォルダをここにドロップ", + "urdfDebug.upload.dropMeshHint": ".stl / .dae / .obj を含むフォルダをドロップ、またはクリック", + "urdfDebug.upload.browse": "ファイルを参照", + "urdfDebug.upload.invalidUrdf": ".urdf または .xml ファイルをドロップしてください。", + "urdfDebug.upload.invalidMesh": "選択に mesh ファイル(.stl / .dae / .obj)が見つかりません。", + "urdfDebug.resizeSettings": "設定パネルの幅を変更", + "urdfDebug.field.meshOptional": "Mesh ファイル(任意)", + "urdfDebug.field.meshStrategy": "Mesh 戦略", + "urdfDebug.field.packageBaseUrl": "Package ベース URL", + "urdfDebug.field.packageName": "Package 名", + "urdfDebug.field.visualRpyOffset": "Visual RPY オフセット", + "urdfDebug.selectTopic": "トピックを選択", + "urdfDebug.selectJointStateTopic": "JointState トピックを選択", + "urdfDebug.section.meshResources": "Mesh リソース", + "urdfDebug.meshBase.hint": "URDF 内の package:// mesh パスを解決します。ローカル meshes フォルダを選ぶか、リモート Base URL を入力して「適用」をクリックしてください。", + "urdfDebug.meshBase.mode.localFolder": "ローカルフォルダ", + "urdfDebug.meshBase.mode.remoteUrl": "リモート Base URL(HTTP/HTTPS)", + "urdfDebug.meshBase.pickFolder": "フォルダを選択…", + "urdfDebug.meshBase.folderSelected": "フォルダ「{folder}」· {count} 個の mesh", + "urdfDebug.meshBase.folderEmpty": "フォルダ未選択。", + "urdfDebug.meshBase.remotePlaceholder": "https://your-host/resources/Robot/meshes", + "urdfDebug.meshBase.apply": "適用", + "urdfDebug.meshBase.applied": "適用済み Base URL", + "urdfDebug.meshBase.remoteNotApplied": "URL を入力し「適用」をクリックすると mesh リンクが解決されます。", + "urdfDebug.meshBase.remoteInvalid": "有効な http:// または https:// URL を入力してください。", + "urdfDebug.meshBase.detectPackage": "URDF から検出", + "urdfDebug.meshBase.resolvedTitle": "Mesh 解決結果", + "urdfDebug.meshBase.refresh": "再チェック", + "urdfDebug.meshBase.checking": "Mesh URL を確認中…", + "urdfDebug.meshBase.summary": "{ok} / {total} 到達可能 · {failed} 件の問題", + "urdfDebug.meshStatus.pending": "確認中", + "urdfDebug.meshStatus.ok": "到達可能", + "urdfDebug.meshStatus.local": "ローカルから読込", + "urdfDebug.meshStatus.missing": "未解決", + "urdfDebug.meshStatus.error": "読込エラー", + "urdfDebug.meshStatus.cors": "CORS 制限(3D では表示される場合あり)", + "urdfDebug.meshStatus.unchecked": "未確認", + "urdfDebug.meshStrategy.packageBaseUrl": "Package ベース URL", + "urdfDebug.meshStrategy.leaveAsIs": "そのまま", + "urdfDebug.rotateMeshVisuals": "Mesh ビジュアルを回転(teleop_tf rotate_mesh)", + "urdfDebug.rotateMeshVisualsHint": "URDF 出力の座標系が Web プレビューと異なる場合に mesh の向きを合わせます。teleop_tf の rotate_mesh と同じく、各 visual origin に R(-90° roll) を右乗算します。3D プレビューで切り替えて確認できます。", + "urdfDebug.preview.empty": "URDF ファイルをアップロードしてロボットをプレビューします。", + "urdfDebug.preview.title": "URDF プレビュー · rotate_mesh: {rotateMesh}", + "urdfDebug.preview.rotateMeshOn": "ON", + "urdfDebug.preview.rotateMeshOff": "OFF", + "urdfDebug.preview.loadingMesh": "Mesh 読込 {loaded}/{total}", + "urdfDebug.showGrid": "グリッド表示", + "urdfDebug.showAxes": "軸表示", + "urdfDebug.joints.followLive": "MCAP JointState に追従", + "urdfDebug.joints.noJointStateTopics": "この録画に JointState トピックがありません。", + "urdfDebug.joints.selectJointStateTopicHint": "プレビューを駆動する JointState トピックを選択してください。", + "urdfDebug.joints.waitingForJointState": "{topic} を購読中 — JointState メッセージを待っています…", + "urdfDebug.joints.resetAll": "すべてリセット", + "urdfDebug.joints.filter": "関節をフィルタ…", + "urdfDebug.joints.uploadUrdfHint": "URDF をアップロードすると関節スライダーが使えます。", + "urdfDebug.joints.noMatch": "一致する関節がありません。", + "urdfDebug.joints.manualUnsupported": "この関節タイプは手動調整に未対応です。", + "urdfDebug.joints.fixedJoint": "固定関節(スライダーなし)。", + "urdfDebug.jointType.revolute": "回転", + "urdfDebug.jointType.prismatic": "直線", + "urdfDebug.jointType.continuous": "連続回転", + "urdfDebug.jointType.fixed": "固定", + "urdfDebug.jointType.planar": "平面", + "urdfDebug.jointType.floating": "浮動", + "urdfDebug.action.autoMatch": "自動マッチ", + "urdfDebug.action.symmetricPair": "対称関節ペア", + "urdfDebug.action.mimicFill": "Mimic 補完", + "urdfDebug.action.invertFirst": "先頭を反転", + "urdfDebug.action.gripper01": "グリッパ 0-1", + "urdfDebug.action.xArm851": "xArm 851", + "urdfDebug.noRules": "ルールがありません。", + "urdfDebug.diag.jointTopic": "Joint トピック", + "urdfDebug.diag.existingTf": "既存 /tf", + "urdfDebug.diag.existingRobotDescription": "既存 /robot_description", + "urdfDebug.diag.robot": "ロボット", + "urdfDebug.diag.linksJoints": "Links / joints", + "urdfDebug.diag.matchCoverage": "マッチ率", + "urdfDebug.diag.generatedTfCount": "生成 TF 数", + "urdfDebug.diag.unmatchedInputJoints": "未マッチ入力関節", + "urdfDebug.diag.missingUrdfJoints": "不足 URDF 関節", + "urdfDebug.diag.meshIssues": "Mesh 問題", + "urdfDebug.diag.missing": "なし", + "urdfDebug.yes": "はい", + "urdfDebug.no": "いいえ", + "urdfDebug.rule.inputJoint": "入力関節", + "urdfDebug.rule.urdfJoint": "URDF 関節", + "urdfDebug.rule.rename": "リネーム", + "urdfDebug.rule.linear": "線形", + "urdfDebug.rule.constant": "定数", + "urdfDebug.rule.ignore": "無視", + "urdfDebug.help.body": "URDF をアップロードし、プレビューが録画データと一致するまで関節マッピングを調整します。mesh の向きが合わない場合(CAD 出力でよくある)は rotate_mesh を切り替えてください。対称関節ペアは左右グリッパー用、Mimic 補完は URDF の mimic タグに従い従動関節を補完します。recipe.json または処理スクリプトをエクスポートすると、/robot_description と /tf を含む MCAP をローカルで再生成できます。" } diff --git a/src/shared/intl/messages/zh/panels.json b/src/shared/intl/messages/zh/panels.json index c95ed7d..1a7fcca 100644 --- a/src/shared/intl/messages/zh/panels.json +++ b/src/shared/intl/messages/zh/panels.json @@ -237,5 +237,105 @@ "panels.image.settings.enum.colorMode.rgbaFields": "RGBA separate fields", "panels.image.settings.enum.colorMap.turbo": "Turbo", "panels.image.settings.enum.colorMap.rainbow": "Rainbow", - "panels.jointStatePlot.filter.partial": "{current}/{total}" + "panels.jointStatePlot.filter.partial": "{current}/{total}", + "urdfDebug.defaultTitle": "URDF 调试", + "urdfDebug.section.input": "输入", + "urdfDebug.section.appearance": "外观", + "urdfDebug.section.joints": "关节姿态", + "urdfDebug.section.export": "导出", + "urdfDebug.field.jointTopic": "JointState 话题", + "urdfDebug.field.urdfRequired": "URDF 文件(必填)", + "urdfDebug.upload.dropUrdfTitle": "拖放 URDF 文件到此处", + "urdfDebug.upload.dropUrdfHint": "支持 .urdf / .xml,可拖放或点击浏览", + "urdfDebug.upload.dropMeshTitle": "拖放 Mesh 文件夹到此处", + "urdfDebug.upload.dropMeshHint": "拖入含 .stl、.dae、.obj 的文件夹,或点击浏览", + "urdfDebug.upload.browse": "浏览文件", + "urdfDebug.upload.invalidUrdf": "请拖入 .urdf 或 .xml 文件。", + "urdfDebug.upload.invalidMesh": "所选内容中未找到 mesh 文件(.stl / .dae / .obj)。", + "urdfDebug.resizeSettings": "调整调参面板宽度", + "urdfDebug.field.meshOptional": "Mesh 文件(可选)", + "urdfDebug.field.meshStrategy": "Mesh 策略", + "urdfDebug.field.packageBaseUrl": "Package 基础 URL", + "urdfDebug.field.packageName": "Package 名称", + "urdfDebug.field.visualRpyOffset": "Visual RPY 偏移", + "urdfDebug.selectTopic": "选择话题", + "urdfDebug.selectJointStateTopic": "选择 JointState 话题", + "urdfDebug.section.meshResources": "Mesh 资源", + "urdfDebug.meshBase.hint": "解析 URDF 中的 package:// mesh 路径。可选择本地 meshes 文件夹,或填写远程 Base URL 后点击「应用」。", + "urdfDebug.meshBase.mode.localFolder": "本地文件夹", + "urdfDebug.meshBase.mode.remoteUrl": "远程 Base URL(HTTP/HTTPS)", + "urdfDebug.meshBase.pickFolder": "选择文件夹…", + "urdfDebug.meshBase.folderSelected": "文件夹「{folder}」· {count} 个 mesh 文件", + "urdfDebug.meshBase.folderEmpty": "尚未选择文件夹。", + "urdfDebug.meshBase.remotePlaceholder": "https://your-host/resources/Robot/meshes", + "urdfDebug.meshBase.apply": "应用", + "urdfDebug.meshBase.applied": "已应用 Base URL", + "urdfDebug.meshBase.remoteNotApplied": "请输入 URL 并点击「应用」后才会解析 mesh 链接。", + "urdfDebug.meshBase.remoteInvalid": "请输入有效的 http:// 或 https:// URL。", + "urdfDebug.meshBase.detectPackage": "从 URDF 检测", + "urdfDebug.meshBase.resolvedTitle": "Mesh 解析结果", + "urdfDebug.meshBase.refresh": "重新检测", + "urdfDebug.meshBase.checking": "正在检测 mesh URL…", + "urdfDebug.meshBase.summary": "{ok} / {total} 可访问 · {failed} 个问题", + "urdfDebug.meshStatus.pending": "检测中", + "urdfDebug.meshStatus.ok": "可访问", + "urdfDebug.meshStatus.local": "已从本地加载", + "urdfDebug.meshStatus.missing": "未解析", + "urdfDebug.meshStatus.error": "加载失败", + "urdfDebug.meshStatus.cors": "CORS 受限(3D 预览仍可能成功)", + "urdfDebug.meshStatus.unchecked": "未检测", + "urdfDebug.meshStrategy.packageBaseUrl": "Package 基础 URL", + "urdfDebug.meshStrategy.leaveAsIs": "保持原样", + "urdfDebug.rotateMeshVisuals": "旋转 mesh 视觉(teleop_tf rotate_mesh)", + "urdfDebug.rotateMeshVisualsHint": "当 URDF 导出工具的坐标系与网页预览不一致时,开启此项修正 mesh 朝向。与 teleop_tf 的 rotate_mesh 相同:对每个 visual origin 右乘 R(-90° roll)。可实时切换对比 3D 预览效果。", + "urdfDebug.preview.empty": "上传 URDF 文件以预览机器人。", + "urdfDebug.preview.title": "URDF 预览 · rotate_mesh: {rotateMesh}", + "urdfDebug.preview.rotateMeshOn": "开", + "urdfDebug.preview.rotateMeshOff": "关", + "urdfDebug.preview.loadingMesh": "加载 mesh {loaded}/{total}", + "urdfDebug.showGrid": "显示网格", + "urdfDebug.showAxes": "显示坐标轴", + "urdfDebug.joints.followLive": "跟随 MCAP JointState", + "urdfDebug.joints.noJointStateTopics": "当前录制中没有 JointState 话题。", + "urdfDebug.joints.selectJointStateTopicHint": "请选择一个 JointState 话题以驱动预览。", + "urdfDebug.joints.waitingForJointState": "已订阅 {topic},等待 JointState 消息…", + "urdfDebug.joints.resetAll": "全部归零", + "urdfDebug.joints.filter": "过滤关节…", + "urdfDebug.joints.uploadUrdfHint": "上传 URDF 后可使用关节 Slider 调试姿态。", + "urdfDebug.joints.noMatch": "没有匹配的关节。", + "urdfDebug.joints.manualUnsupported": "该关节类型暂不支持手动调试。", + "urdfDebug.joints.fixedJoint": "固定关节(无 Slider)。", + "urdfDebug.jointType.revolute": "旋转", + "urdfDebug.jointType.prismatic": "移动", + "urdfDebug.jointType.continuous": "连续旋转", + "urdfDebug.jointType.fixed": "固定", + "urdfDebug.jointType.planar": "平面", + "urdfDebug.jointType.floating": "浮动", + "urdfDebug.action.autoMatch": "自动匹配", + "urdfDebug.action.symmetricPair": "对称关节对", + "urdfDebug.action.mimicFill": "Mimic 填充", + "urdfDebug.action.invertFirst": "反转首个", + "urdfDebug.action.gripper01": "夹爪 0-1", + "urdfDebug.action.xArm851": "xArm 851", + "urdfDebug.noRules": "暂无规则。", + "urdfDebug.diag.jointTopic": "Joint 话题", + "urdfDebug.diag.existingTf": "已有 /tf", + "urdfDebug.diag.existingRobotDescription": "已有 /robot_description", + "urdfDebug.diag.robot": "机器人", + "urdfDebug.diag.linksJoints": "Links / joints", + "urdfDebug.diag.matchCoverage": "匹配覆盖率", + "urdfDebug.diag.generatedTfCount": "生成 TF 数量", + "urdfDebug.diag.unmatchedInputJoints": "未匹配输入关节", + "urdfDebug.diag.missingUrdfJoints": "缺失 URDF 关节", + "urdfDebug.diag.meshIssues": "Mesh 问题", + "urdfDebug.diag.missing": "缺失", + "urdfDebug.yes": "是", + "urdfDebug.no": "否", + "urdfDebug.rule.inputJoint": "输入关节", + "urdfDebug.rule.urdfJoint": "URDF 关节", + "urdfDebug.rule.rename": "重命名", + "urdfDebug.rule.linear": "线性", + "urdfDebug.rule.constant": "常量", + "urdfDebug.rule.ignore": "忽略", + "urdfDebug.help.body": "上传 URDF 并调整关节映射,直到预览与录制数据一致。若 mesh 朝向不对(常见于 CAD 导出),可切换 rotate_mesh 修正。对称关节对用于左右夹爪;Mimic 填充会根据 URDF 的 mimic 标签补齐从动关节。导出 recipe.json 或处理脚本后,可在本地重写 MCAP,写入 /robot_description 与 /tf。" } diff --git a/src/shared/jointstate2tf/index.ts b/src/shared/jointstate2tf/index.ts new file mode 100644 index 0000000..59ac1b6 --- /dev/null +++ b/src/shared/jointstate2tf/index.ts @@ -0,0 +1,341 @@ +// Internal, dependency-free minimal math + URDF parser for FK computation + +// ----------------------------- +// Public Types +// ----------------------------- + +export type Time = { sec: number; nanosec: number }; +export type Header = { stamp: Time; frame_id: string }; + +export type JointState = { + header: Header; + name: string[]; + position: number[]; + velocity?: number[]; + effort?: number[]; +}; + +export type Quaternion = { x: number; y: number; z: number; w: number }; +export type Vector3 = { x: number; y: number; z: number }; +export type Transform = { translation: Vector3; rotation: Quaternion }; +export type TransformStamped = { header: Header; child_frame_id: string; transform: Transform }; +export type TFMessage = { transforms: TransformStamped[] }; + +export type CreateFromUrlOptions = { url: string }; +export type CreateFromXmlOptions = { xml: string }; +export type ComputeOptions = { publishTimeNs?: number }; + +// ----------------------------- +// Minimal math (dependency-free) +// ----------------------------- + +type Float = number; + +type MathVec3 = { x: Float; y: Float; z: Float }; +type MathQuat = { x: Float; y: Float; z: Float; w: Float }; + +function vec3(x = 0, y = 0, z = 0): MathVec3 { + return { x, y, z }; +} + +function quatIdentity(): MathQuat { + return { x: 0, y: 0, z: 0, w: 1 }; +} + +function vec3Add(a: MathVec3, b: MathVec3): MathVec3 { + return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }; +} + +function vec3Scale(a: MathVec3, s: Float): MathVec3 { + return { x: a.x * s, y: a.y * s, z: a.z * s }; +} + +function vec3Length(a: MathVec3): number { + return Math.hypot(a.x, a.y, a.z); +} + +function vec3Normalize(a: MathVec3): MathVec3 { + const len = vec3Length(a) || 1; + return { x: a.x / len, y: a.y / len, z: a.z / len }; +} + +function quatMultiply(a: MathQuat, b: MathQuat): MathQuat { + // Returns a*b (apply b, then a) + return { + w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z, + x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, + y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, + z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, + }; +} + +function quatFromAxisAngle(axis: MathVec3, angle: number): MathQuat { + const n = vec3Normalize(axis); + const h = angle * 0.5; + const s = Math.sin(h); + return { x: n.x * s, y: n.y * s, z: n.z * s, w: Math.cos(h) }; +} + +function quatFromRPY(roll: number, pitch: number, yaw: number): MathQuat { + // URDF rpy applied as fixed axes X(roll) -> Y(pitch) -> Z(yaw) + const cx = Math.cos(roll * 0.5), sx = Math.sin(roll * 0.5); + const cy = Math.cos(pitch * 0.5), sy = Math.sin(pitch * 0.5); + const cz = Math.cos(yaw * 0.5), sz = Math.sin(yaw * 0.5); + // R = Rz(yaw) * Ry(pitch) * Rx(roll) + return { + w: cz * cy * cx + sz * sy * sx, + x: cz * cy * sx - sz * sy * cx, + y: cz * sy * cx + sz * cy * sx, + z: sz * cy * cx - cz * sy * sx, + }; +} + +function vec3RotateByQuat(v: MathVec3, q: MathQuat): MathVec3 { + // Rotate vector v by quaternion q using q*v*q^-1 (optimized) + const { x, y, z } = v; + const qx = q.x, qy = q.y, qz = q.z, qw = q.w; + // v' = v + 2*q_vec x (q_vec x v + qw*v) + const uvx = qy * z - qz * y; + const uvy = qz * x - qx * z; + const uvz = qx * y - qy * x; + const uuvx = qy * uvz - qz * uvy; + const uuvy = qz * uvx - qx * uvz; + const uuvz = qx * uvy - qy * uvx; + return { + x: x + 2 * (qw * uvx + uuvx), + y: y + 2 * (qw * uvy + uuvy), + z: z + 2 * (qw * uvz + uuvz), + }; +} + +type TransformTR = { r: MathQuat; t: MathVec3 }; + +function composeTR(a: TransformTR, b: TransformTR): TransformTR { + // a followed by b + return { + r: quatMultiply(a.r, b.r), + t: vec3Add(a.t, vec3RotateByQuat(b.t, a.r)), + }; +} + +// ----------------------------- +// JointState2TF: minimal, fast URDF FK engine +// ----------------------------- + +/** + * JointState -> TF converter backed by URDF. The URDF is parsed once when the + * instance is created, so repeated computations only set joint values and + * read relative transforms, optimizing for high-frequency updates. + */ +type UrdfJoint = { + name: string; + type: 'revolute' | 'continuous' | 'prismatic' | 'fixed'; + parent: string; + child: string; + origin: TransformTR; // from parent link frame to joint frame + axis: MathVec3; // joint axis in joint frame + q: number; // current joint value (rad or meters) +}; + +type UrdfModel = { + jointsByName: Map; + jointsByParentLink: Map; + linkParent: Map; // child link -> parent link +}; + +export default class JointState2TF { + private readonly model: UrdfModel; + + private constructor(model: UrdfModel) { + this.model = model; + } + + /** Create an instance by loading URDF from a URL. */ + static async fromUrl(opts: CreateFromUrlOptions): Promise { + const xml = await fetchText(opts.url); + const model = parseUrdf(xml); + return new JointState2TF(model); + } + + /** Create an instance by parsing URDF XML content. */ + static fromXml(opts: CreateFromXmlOptions): JointState2TF { + const model = parseUrdf(opts.xml); + return new JointState2TF(model); + } + + /** Set joint values on the internal model. */ + setJointState(jointState: JointState): void { + const nameToPos = new Map(); + jointState.name.forEach((n, i) => nameToPos.set(n, jointState.position[i] ?? 0)); + nameToPos.forEach((pos, name) => { + const j = this.model.jointsByName.get(name); + if (j) j.q = pos; + }); + } + + /** Compute TF for all child->parent pairs defined by the URDF (relative transforms). */ + compute(options: ComputeOptions = {}): TFMessage { + const transforms: TransformStamped[] = []; + + // For each joint, compute parent->child transform: T = origin * motion(q) + this.model.jointsByName.forEach((joint) => { + const motion = jointMotionTR(joint); + const rel = composeTR(joint.origin, motion); + + const sec = options.publishTimeNs ? Math.trunc(options.publishTimeNs / 1e9) : 0; + const nanosec = options.publishTimeNs ? Math.trunc(options.publishTimeNs % 1e9) : 0; + + transforms.push({ + header: { stamp: { sec, nanosec }, frame_id: joint.parent }, + child_frame_id: joint.child, + transform: { + translation: { x: rel.t.x, y: rel.t.y, z: rel.t.z }, + rotation: { x: rel.r.x, y: rel.r.y, z: rel.r.z, w: rel.r.w }, + }, + }); + }); + + return { transforms }; + } + + /** Convenience: set joint state then compute TF in a single call. */ + computeFromJointState(jointState: JointState, options: ComputeOptions = {}): TFMessage { + this.setJointState(jointState); + return this.compute(options); + } +} + +// ----------------------------- +// Internal helpers +// ----------------------------- + +// ----------------------------- +// Internal helpers: URDF parsing and I/O +// ----------------------------- + +async function fetchText(url: string): Promise { + const { fetch } = globalThis; + if (typeof fetch !== 'function') { + throw new Error('fetch is not available in this environment; provide XML via fromXml().'); + } + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch URDF: ${res.status} ${res.statusText}`); + return await res.text(); +} + +function parseUrdf(xml: string): UrdfModel { + const joints = extractJointBlocks(xml).map(parseJointBlock).filter((j): j is UrdfJoint => !!j); + + const jointsByName = new Map(); + const jointsByParentLink = new Map(); + const linkParent = new Map(); + for (const j of joints) { + jointsByName.set(j.name, j); + linkParent.set(j.child, j.parent); + const arr = jointsByParentLink.get(j.parent) ?? []; + arr.push(j); + jointsByParentLink.set(j.parent, arr); + } + return { jointsByName, jointsByParentLink, linkParent }; +} + +function extractJointBlocks(xml: string): string[] { + const blocks: string[] = []; + const re = //g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) blocks.push(m[0]); + return blocks; +} + +function parseJointBlock(block: string): UrdfJoint | null { + const openMatch = /]*)>/.exec(block); + if (!openMatch) return null; + const openAttrs = parseAttrs(openMatch[1]); + const name = (openAttrs['name'] ?? '').trim(); + const type = (openAttrs['type'] ?? 'fixed').trim() as UrdfJoint['type']; + + const parentLink = parseSingleTagAttr(block, 'parent', 'link'); + const childLink = parseSingleTagAttr(block, 'child', 'link'); + if (!name || !parentLink || !childLink) return null; + + const originAttrs = parseFirstSelfOrOpenTag(block, 'origin'); + const originT = parseXyz(originAttrs?.xyz); + const originRpy = parseRpy(originAttrs?.rpy); + const origin: TransformTR = { r: originRpy, t: originT }; + + let axis = vec3(1, 0, 0); + const axisAttrs = parseFirstSelfOrOpenTag(block, 'axis'); + if (axisAttrs?.xyz) axis = parseXyzVec(axisAttrs.xyz); + + return { + name, + type: (['revolute', 'continuous', 'prismatic', 'fixed'] as const).includes(type) ? type : 'fixed', + parent: parentLink, + child: childLink, + origin, + axis: vec3Normalize(axis), + q: 0, + }; +} + +function parseAttrs(s: string): Record { + const out: Record = {}; + const re = /(\w+)\s*=\s*"([^"]*)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(s)) !== null) out[m[1]] = m[2]; + return out; +} + +function parseSingleTagAttr(block: string, tag: string, attr: string): string | null { + const re = new RegExp(`<${tag}\\b([^>]*)\\/>`); + const m = re.exec(block); + if (!m) return null; + const attrs = parseAttrs(m[1] ?? ''); + const v = attrs[attr]; + return typeof v === 'string' ? v.trim() : null; +} + +function parseFirstSelfOrOpenTag(block: string, tag: string): Record | null { + // Prefer self-closing, else opening tag + let re = new RegExp(`<${tag}\\b([^>]*)\\/>`); + let m = re.exec(block); + if (m) return parseAttrs(m[1] ?? ''); + re = new RegExp(`<${tag}\\b([^>]*)>`); + m = re.exec(block); + if (m) return parseAttrs(m[1] ?? ''); + return null; +} + +function parseXyz(s?: string): MathVec3 { + if (!s) return vec3(0, 0, 0); + const [x, y, z] = s.split(/\s+/).map(Number); + return vec3(x || 0, y || 0, z || 0); +} + +function parseXyzVec(s: string): MathVec3 { + return parseXyz(s); +} + +function parseRpy(s?: string): MathQuat { + if (!s) return quatIdentity(); + const [r, p, y] = s.split(/\s+/).map(Number); + return quatFromRPY(r || 0, p || 0, y || 0); +} + +function jointMotionTR(j: UrdfJoint): TransformTR { + switch (j.type) { + case 'revolute': + case 'continuous': { + const r = quatFromAxisAngle(j.axis, j.q); + return { r, t: vec3(0, 0, 0) }; + } + case 'prismatic': { + const t = vec3Scale(j.axis, j.q); + return { r: quatIdentity(), t }; + } + case 'fixed': + default: + return { r: quatIdentity(), t: vec3(0, 0, 0) }; + } +} + diff --git a/src/shared/ui/file-drop-zone.tsx b/src/shared/ui/file-drop-zone.tsx new file mode 100644 index 0000000..8202a41 --- /dev/null +++ b/src/shared/ui/file-drop-zone.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { Upload } from 'lucide-react'; + +import { cn } from '@/shared/lib/utils'; +import { Button } from '@/shared/ui/button'; + +export type FileDropZoneProps = { + accept?: string; + multiple?: boolean; + directory?: boolean; + disabled?: boolean; + onFiles: (files: File[]) => void; + title: string; + hint?: string; + browseLabel: string; + selectedLabel?: string; + error?: string | null; + testId?: string; + className?: string; +}; + +export const FileDropZone: React.FC = ({ + accept, + multiple = false, + directory = false, + disabled = false, + onFiles, + title, + hint, + browseLabel, + selectedLabel, + error, + testId, + className, +}) => { + const inputRef = React.useRef(null); + const dragDepthRef = React.useRef(0); + const [dragActive, setDragActive] = React.useState(false); + + const clearDragState = React.useCallback(() => { + dragDepthRef.current = 0; + setDragActive(false); + }, []); + + const emitFiles = React.useCallback( + (files: File[]) => { + if (disabled || files.length === 0) return; + onFiles(files); + }, + [disabled, onFiles], + ); + + const handleInputChange = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + emitFiles(files); + event.target.value = ''; + }; + + const handleDragEnter = (event: React.DragEvent) => { + if (disabled || !Array.from(event.dataTransfer.types).includes('Files')) return; + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current += 1; + setDragActive(true); + }; + + const handleDragOver = (event: React.DragEvent) => { + if (disabled || !Array.from(event.dataTransfer.types).includes('Files')) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragLeave = (event: React.DragEvent) => { + if (disabled) return; + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setDragActive(false); + } + }; + + const handleDrop = (event: React.DragEvent) => { + if (disabled || !Array.from(event.dataTransfer.types).includes('Files')) return; + event.preventDefault(); + event.stopPropagation(); + clearDragState(); + emitFiles(Array.from(event.dataTransfer.files)); + }; + + const openPicker = () => { + if (disabled) return; + inputRef.current?.click(); + }; + + return ( +
+
{ + if (disabled) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openPicker(); + } + }} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + +
+
{title}
+ {hint ?
{hint}
: null} +
+ + ) + : {})} + onChange={handleInputChange} + onClick={(event) => event.stopPropagation()} + /> +
+ {selectedLabel ? ( +
+ {selectedLabel} +
+ ) : null} + {error ?
{error}
: null} +
+ ); +}; From 5d0b8b5a802d31d72383e68c65a3073f0fd54cb9 Mon Sep 17 00:00:00 2001 From: joaner Date: Sun, 24 May 2026 13:54:31 +0800 Subject: [PATCH 2/3] chore: remove vite-plugin-top-level-await and upgrade build toolchain Drop top-level-await polyfill in favor of esnext workers; bump Vite to 8.0.14 and React to 19.2.6. Update ARCHITECTURE docs accordingly. --- docs/ARCHITECTURE.md | 21 +- docs/ARCHITECTURE.zh.md | 26 +- package-lock.json | 685 +++++++++++----------------------------- package.json | 9 +- vite.config.ts | 3 +- vite.lib.config.ts | 4 +- 6 files changed, 216 insertions(+), 532 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2aa95d0..1be4891 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -631,18 +631,18 @@ function PlaybackProgressSlider() { ```typescript export default defineConfig({ - plugins: [react(), wasm(), topLevelAwait()], + plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src') } }, - worker: { format: 'es', plugins: () => [wasm(), topLevelAwait()] }, + worker: { format: 'es', plugins: () => [wasm()] }, build: { - rollupOptions: { + target: 'esnext', + rolldownOptions: { output: { - manualChunks: { - 'vendor-dockview': ['dockview'], - 'vendor-three': ['three', '@react-three/fiber', '@react-three/drei'], - 'vendor-uplot': ['uplot'], - 'vendor-mcap': ['@mcap/core'], - 'vendor-rosbag': ['@foxglove/rosbag', '@foxglove/rosbag2-web'], + codeSplitting: true, + manualChunks(id) { + if (id.includes('dockview')) return 'vendor-dockview'; + if (id.includes('three')) return 'vendor-three'; + // ... }, }, }, @@ -650,6 +650,8 @@ export default defineConfig({ }); ``` +> Worker bundles (including `@ioai/hdf5` Emscripten glue with native top-level await) rely on `build.target: 'esnext'` and ES module workers — no `vite-plugin-top-level-await` polyfill is required for Chrome/Edge targets. + ### 7.2 Library Build (Embeddable Component) `vite.lib.config.ts` — outputs an ESM library bundle for npm. **Type declarations are emitted in the same `vite build` run** via `vite-plugin-dts`. With `rollupTypes: true`, API Extractor rolls declarations up to a single `dist-lib/rosview.d.ts` (no separate post-build script). @@ -665,7 +667,6 @@ export default defineConfig({ plugins: [ react(), wasm(), - topLevelAwait(), dts({ compilerOptions: { rootDir: path.join(packageDir, 'src') }, include: ['src/**/*.ts', 'src/**/*.tsx'], diff --git a/docs/ARCHITECTURE.zh.md b/docs/ARCHITECTURE.zh.md index 41ccd4c..b5b23df 100644 --- a/docs/ARCHITECTURE.zh.md +++ b/docs/ARCHITECTURE.zh.md @@ -635,27 +635,26 @@ function PlaybackProgressSlider() { import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import wasm from 'vite-plugin-wasm'; -import topLevelAwait from 'vite-plugin-top-level-await'; import path from 'path'; export default defineConfig({ - plugins: [react(), wasm(), topLevelAwait()], + plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src') }, }, worker: { format: 'es', - plugins: () => [wasm(), topLevelAwait()], + plugins: () => [wasm()], }, build: { - rollupOptions: { + target: 'esnext', + rolldownOptions: { output: { - manualChunks: { - 'vendor-dockview': ['dockview'], - 'vendor-three': ['three', '@react-three/fiber', '@react-three/drei'], - 'vendor-uplot': ['uplot'], - 'vendor-mcap': ['@mcap/core'], - 'vendor-rosbag': ['@foxglove/rosbag', '@foxglove/rosbag2-web'], + codeSplitting: true, + manualChunks(id) { + if (id.includes('dockview')) return 'vendor-dockview'; + if (id.includes('three')) return 'vendor-three'; + // ... }, }, }, @@ -663,6 +662,8 @@ export default defineConfig({ }); ``` +> Worker bundle(含 `@ioai/hdf5` Emscripten glue 的原生 top-level await)依赖 `build.target: 'esnext'` 与 ES Module Worker,面向 Chrome/Edge 目标时无需 `vite-plugin-top-level-await` polyfill。 + ### 7.2 库构建(嵌入式组件) `vite.lib.config.ts` — 构建为可被 `app/` 引入的 ESM 库;**类型声明在同一轮 `vite build` 内**由 `vite-plugin-dts` 生成,`rollupTypes: true` 借助 API Extractor 合并为单一 `dist-lib/rosview.d.ts`(无需额外脚本)。 @@ -674,7 +675,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import wasm from 'vite-plugin-wasm'; -import topLevelAwait from 'vite-plugin-top-level-await'; import path from 'path'; import { fileURLToPath } from 'node:url'; @@ -685,7 +685,6 @@ export default defineConfig({ plugins: [ react(), wasm(), - topLevelAwait(), dts({ compilerOptions: { rootDir: path.join(packageDir, 'src') }, include: ['src/**/*.ts', 'src/**/*.tsx'], @@ -703,7 +702,7 @@ export default defineConfig({ }, worker: { format: 'es', - plugins: () => [wasm(), topLevelAwait()], + plugins: () => [wasm()], }, build: { outDir: 'dist-lib', @@ -927,7 +926,6 @@ rosview/ "typescript-eslint": "^8.58.0", "vite": "^8.0.4", "vite-plugin-dts": "^4.5.4", - "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.5.0", "vitest": "^4.0.0" } diff --git a/package-lock.json b/package-lock.json index 6164294..ba6ef50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,8 +37,8 @@ "@types/cytoscape": "^3.21.9", "@types/cytoscape-dagre": "^2.3.4", "@types/node": "^25.6.0", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@types/three": "^0.173.0", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.23", @@ -65,6 +65,8 @@ "lz4js": "^0.2.0", "postcss": "^8.5.6", "protobufjs": "^8.0.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", "react-intl": "^10.1.2", "react-resizable-panels": "^4.10.0", "rollup": "^4.52.5", @@ -75,9 +77,8 @@ "typescript": "6.0.3", "typescript-eslint": "^8.58.0", "uplot": "^1.6.31", - "vite": "^8.0.9", + "vite": "^8.0.14", "vite-plugin-dts": "^4.5.4", - "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.5.0", "vitest": "^4.1.4", "zustand": "^5.0.3" @@ -1050,9 +1051,9 @@ "license": "MIT" }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "3.5.9", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.9.tgz", - "integrity": "sha512-PZm6O9JI/gUPtQV9r2eaMuLb4yWqV2vz+ot03ORHWTKO343LSpZi0TqeXLB2ZZGDXLCw2SbfgsQ0GxoxXMl79g==", + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.10.tgz", + "integrity": "sha512-XeJihYLy1lCe19xfK1KWKG/betBOK2rB0luL8lSkjfvJj0zP+LTJvkC+RKd0jsFI8mWxN71LrarHSrEXE8xxOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1067,15 +1068,15 @@ "license": "MIT" }, "node_modules/@formatjs/intl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-4.1.11.tgz", - "integrity": "sha512-CB0mZTbnVCgnZDpOwDUGp6tC6cRl8C8gbpqvP0aatwkcRdazgXctCppljnVU3nzG0yuOABO1+DDRekESiitNmA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-4.1.12.tgz", + "integrity": "sha512-r288ut+p+CUQZSg+0gAT+D0i6xgrnoxE0B4HTbPY2zei/AtYmFhlu87BKjgCf1CweH4pZIbr152JFjxO8jVb1A==", "dev": true, "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "3.1.5", - "@formatjs/icu-messageformat-parser": "3.5.9", - "intl-messageformat": "11.2.6" + "@formatjs/icu-messageformat-parser": "3.5.10", + "intl-messageformat": "11.2.7" } }, "node_modules/@foxglove/cdr": { @@ -1205,9 +1206,9 @@ } }, "node_modules/@foxglove/rosmsg-serialization": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@foxglove/rosmsg-serialization/-/rosmsg-serialization-2.1.0.tgz", - "integrity": "sha512-FU7VIdVCJ7kDAiLhhGlQgE3Qcc8U8T3GIs51Zb87nuPU6KrppjoQuLmHhB7VsdO04CUEFhQhvYpFxwCITeR71w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@foxglove/rosmsg-serialization/-/rosmsg-serialization-2.1.1.tgz", + "integrity": "sha512-RDIZP0M6zC5IVEnHiSJdE01ES07nueUy3GiGGVCx8DbbCgzr7wDWR73qNdEUaVbTnpGvle4S2NwRpc6Ef4F28w==", "dev": true, "license": "MIT", "dependencies": { @@ -1218,9 +1219,9 @@ } }, "node_modules/@foxglove/rosmsg2-serialization": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@foxglove/rosmsg2-serialization/-/rosmsg2-serialization-3.1.0.tgz", - "integrity": "sha512-N43XcoYEwd2bCTWwx49VjN9UuK1+3njhWvbXNBrrHY83KBkgj7BHkDQqnDbr69R0nFAJR0ufTFqJoj054tuO7g==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@foxglove/rosmsg2-serialization/-/rosmsg2-serialization-3.1.1.tgz", + "integrity": "sha512-SOhgu7mprRMosSXICODoDwjMlqnnexDgTGnSXS13mQEUCshDOlY5wIwiwk4SpMcSg1Uyw8h7HzpgSrpdEpKIcw==", "dev": true, "license": "MIT", "dependencies": { @@ -1624,9 +1625,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -2756,9 +2757,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -2773,9 +2774,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -2790,9 +2791,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -2807,9 +2808,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -2824,9 +2825,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -2841,9 +2842,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -2858,9 +2859,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -2875,9 +2876,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -2892,9 +2893,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -2909,9 +2910,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -2926,9 +2927,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -2943,9 +2944,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -2960,9 +2961,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -2979,9 +2980,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -2996,9 +2997,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -3019,24 +3020,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/plugin-virtual": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", - "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -3592,275 +3575,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz", - "integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.26" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.33", - "@swc/core-darwin-x64": "1.15.33", - "@swc/core-linux-arm-gnueabihf": "1.15.33", - "@swc/core-linux-arm64-gnu": "1.15.33", - "@swc/core-linux-arm64-musl": "1.15.33", - "@swc/core-linux-ppc64-gnu": "1.15.33", - "@swc/core-linux-s390x-gnu": "1.15.33", - "@swc/core-linux-x64-gnu": "1.15.33", - "@swc/core-linux-x64-musl": "1.15.33", - "@swc/core-win32-arm64-msvc": "1.15.33", - "@swc/core-win32-ia32-msvc": "1.15.33", - "@swc/core-win32-x64-msvc": "1.15.33" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz", - "integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz", - "integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz", - "integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz", - "integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz", - "integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz", - "integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz", - "integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz", - "integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz", - "integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz", - "integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz", - "integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz", - "integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@swc/types": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", - "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, - "node_modules/@swc/wasm": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.33.tgz", - "integrity": "sha512-uZPBvYMwjvTtyNm018KFV6ino5ZL4z9riN/tBsfTSgbfONW9Jn+ca88+UeEIdMOZY5Dm+y2OBf6o0kxa1wfD0A==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -3943,9 +3657,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", - "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3960,9 +3674,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4263,9 +3977,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -4377,16 +4091,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4395,13 +4109,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4432,9 +4146,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -4445,13 +4159,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -4459,14 +4173,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4475,9 +4189,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -4485,13 +4199,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4858,9 +4572,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", - "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5256,9 +4970,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.3", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", - "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "version": "3.33.4", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", + "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", "dev": true, "license": "MIT", "engines": { @@ -5373,22 +5087,22 @@ "license": "MIT" }, "node_modules/dockview": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/dockview/-/dockview-6.3.0.tgz", - "integrity": "sha512-8HtpQZFeFQSAs8XmSlLnmbbTyZZqpUK0QMMVrUSz/vLp6Vm19C23zZYZaVQPtCSaPhUXos0DHm36hEOKIFYtRw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/dockview/-/dockview-6.5.0.tgz", + "integrity": "sha512-RkCJgxM6BIhS3mlULbsutecGFfjXkyeZszMQX0fEeOg0NpziU6TE4q8qsoocvwD0q8ya2ecJQsNKF3a4E5FPNg==", "dev": true, "license": "MIT", "dependencies": { - "dockview-core": "^6.3.0" + "dockview-core": "^6.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/dockview-core": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-6.3.0.tgz", - "integrity": "sha512-KY4goMIcVrMh+LDtU+bwANkwK8FRxRUS6EKD/QH1OPNcj8wq0MjGKes5PojM4N0/1NxAsI4Bem/TFV4jiiOKGg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-6.5.0.tgz", + "integrity": "sha512-ywdlF95ISqk0IiYot6dg9oyRyri4xA++SJTGSiegkn6KUPKVxyL8hXnqHWuV5U23eNvUNyiEP3CsoGSozy0SUA==", "dev": true, "license": "MIT" }, @@ -5400,9 +5114,9 @@ "license": "Apache-2.0" }, "node_modules/electron-to-chromium": { - "version": "1.5.359", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.359.tgz", - "integrity": "sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "dev": true, "license": "ISC" }, @@ -6181,14 +5895,14 @@ } }, "node_modules/intl-messageformat": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.6.tgz", - "integrity": "sha512-afAN2yNN7zjB77G1ZC5L8GtLrEshyBvOQXz88flxCO/ocTIQist98gu0r/O6H/SSiQhQsOOtWPxmCEvtDABXXQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.7.tgz", + "integrity": "sha512-+q6Ktg119nULZEpZ8YTuGOst9MyEzFtjD63FTGBlN1mLz0Z/MOUYDIvnpVKwq17eezIEh+cfJIebfJoCetpiNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@formatjs/fast-memoize": "3.1.5", - "@formatjs/icu-messageformat-parser": "3.5.9" + "@formatjs/icu-messageformat-parser": "3.5.10" } }, "node_modules/is-binary-path": { @@ -6700,9 +6414,9 @@ "license": "MIT" }, "node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6948,11 +6662,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -7375,9 +7092,9 @@ } }, "node_modules/protobufjs": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz", - "integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.2.tgz", + "integrity": "sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -7447,8 +7164,8 @@ "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7457,8 +7174,8 @@ "version": "19.2.6", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7467,15 +7184,15 @@ } }, "node_modules/react-intl": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-10.1.8.tgz", - "integrity": "sha512-+iBzzkykXkZ2/9zI8LnDmfCobgz33J+r+24QbX4dJJ4f6rO1LuFwqv39kSTloZdPm9yYcnJOrvz0MqyCa6XeXQ==", + "version": "10.1.9", + "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-10.1.9.tgz", + "integrity": "sha512-FOWpTmwnZnAfz8JegRzyGnjUiuzLW3xJFjp/o8VR4HZAQ+Eg++YaeMhoUULLY+LMx7gZm3czaZEqcU4hntwerw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@formatjs/icu-messageformat-parser": "3.5.9", - "@formatjs/intl": "4.1.11", - "intl-messageformat": "11.2.6" + "@formatjs/icu-messageformat-parser": "3.5.10", + "@formatjs/intl": "4.1.12", + "intl-messageformat": "11.2.7" }, "peerDependencies": { "@types/react": "19", @@ -7658,13 +7375,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7674,21 +7391,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rollup": { @@ -7771,6 +7488,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -8472,32 +8190,17 @@ "node": ">= 4" } }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -8592,22 +8295,6 @@ } } }, - "node_modules/vite-plugin-top-level-await": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", - "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/plugin-virtual": "^3.0.2", - "@swc/core": "^1.12.14", - "@swc/wasm": "^1.12.14", - "uuid": "10.0.0" - }, - "peerDependencies": { - "vite": ">=2.8" - } - }, "node_modules/vite-plugin-wasm": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", @@ -8647,19 +8334,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8687,12 +8374,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 899e96b..2edd28c 100644 --- a/package.json +++ b/package.json @@ -110,8 +110,8 @@ "@types/cytoscape": "^3.21.9", "@types/cytoscape-dagre": "^2.3.4", "@types/node": "^25.6.0", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@types/three": "^0.173.0", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.23", @@ -138,6 +138,8 @@ "lz4js": "^0.2.0", "postcss": "^8.5.6", "protobufjs": "^8.0.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", "react-intl": "^10.1.2", "react-resizable-panels": "^4.10.0", "rollup": "^4.52.5", @@ -148,9 +150,8 @@ "typescript": "6.0.3", "typescript-eslint": "^8.58.0", "uplot": "^1.6.31", - "vite": "^8.0.9", + "vite": "^8.0.14", "vite-plugin-dts": "^4.5.4", - "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.5.0", "vitest": "^4.1.4", "zustand": "^5.0.3" diff --git a/vite.config.ts b/vite.config.ts index c924b19..de7a209 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import wasm from 'vite-plugin-wasm'; -import topLevelAwait from 'vite-plugin-top-level-await'; import path from 'path'; export default defineConfig({ @@ -54,7 +53,7 @@ export default defineConfig({ }, worker: { format: 'es', - plugins: () => [wasm(), topLevelAwait()], + plugins: () => [wasm()], rolldownOptions: { onLog(level, log, defaultHandler) { if (level === 'warn' && typeof log !== 'string') { diff --git a/vite.lib.config.ts b/vite.lib.config.ts index b47641b..61c0d24 100644 --- a/vite.lib.config.ts +++ b/vite.lib.config.ts @@ -16,7 +16,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import wasm from 'vite-plugin-wasm'; -import topLevelAwait from 'vite-plugin-top-level-await'; import path from 'path'; import { fileURLToPath } from 'node:url'; @@ -39,7 +38,6 @@ export default defineConfig({ plugins: [ react(), wasm(), - topLevelAwait(), dts({ /** Align with TS `rootDir: src` so declarations are not mirrored under `dist-lib/src/...` (insertTypesEntry vs emittedFiles). */ compilerOptions: { @@ -69,7 +67,7 @@ export default defineConfig({ optimizeDeps: {}, worker: { format: 'es', - plugins: () => [wasm(), topLevelAwait()], + plugins: () => [wasm()], rollupOptions: { output: { sourcemap: false, From 770142127bcdbf3b3d8be02fef68b4af26d3c493 Mon Sep 17 00:00:00 2001 From: joaner Date: Sun, 24 May 2026 13:54:33 +0800 Subject: [PATCH 3/3] chore: release v1.1.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba6ef50..454772d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/package.json b/package.json index 2edd28c..46af6fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "1.0.1", + "version": "1.1.0", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros",