synchronize source..
This commit is contained in:
+105
-13
@@ -352,7 +352,11 @@ async function syncWorkspaceSavedMapArtifacts(
|
||||
}
|
||||
await Promise.all(artifacts.map(async (artifact) => {
|
||||
const normalized = validateSavedMapDownload(artifact)
|
||||
await downloadArtifact(normalized.url, resolve(mapsDirectory, normalized.fileName))
|
||||
const targetPath = resolve(mapsDirectory, normalized.fileName)
|
||||
// SLAM containers can leave an artifact owned by root. Removing it first
|
||||
// lets the download recreate the file with the desktop user's ownership.
|
||||
await rm(targetPath, { force: true })
|
||||
await downloadArtifact(normalized.url, targetPath)
|
||||
}))
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -1335,6 +1339,45 @@ async function readRemoteSavedMapArtifacts(
|
||||
}))
|
||||
}
|
||||
|
||||
async function copyRemoteActiveRtabmapDatabaseToLocal(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
localWorkspaceRoot: string | null | undefined,
|
||||
destinationPath: string,
|
||||
): Promise<{ databasePath: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const destinationFileName = savedMapArtifactFileName('database', destinationPath)
|
||||
const sourcePath = `${target.workspaceRoot}/maps/rtabmap-active.db`
|
||||
const snapshotScript = [
|
||||
'import sqlite3, sys',
|
||||
'source, destination = sys.argv[1:3]',
|
||||
'source_db = sqlite3.connect(f"file:{source}?mode=ro", uri=True)',
|
||||
'destination_db = sqlite3.connect(destination)',
|
||||
'source_db.backup(destination_db)',
|
||||
'destination_db.close()',
|
||||
'source_db.close()',
|
||||
].join('; ')
|
||||
const remoteCommand = [
|
||||
'set -e',
|
||||
`SOURCE=${quoteShellSingle(sourcePath)}`,
|
||||
'test -f "$SOURCE" || { echo "3D SLAM DB를 찾을 수 없습니다: $SOURCE" >&2; exit 1; }',
|
||||
'SNAPSHOT=$(mktemp)',
|
||||
`python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`,
|
||||
'base64 -w0 "$SNAPSHOT"',
|
||||
'rm -f "$SNAPSHOT"',
|
||||
].join('\n')
|
||||
const base64 = (await runSshOutput(target, remoteCommand, 120_000)).trim()
|
||||
if (!base64) throw new Error('3D SLAM DB 스냅샷을 읽지 못했습니다.')
|
||||
|
||||
await syncWorkspaceSavedMapArtifacts(localWorkspaceRoot, [])
|
||||
const root = expandUserPath(localWorkspaceRoot)
|
||||
if (!root) throw new Error('로컬 워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
const mapsDirectory = resolve(root, 'maps')
|
||||
const destination = resolve(mapsDirectory, destinationFileName)
|
||||
if (!destination.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.')
|
||||
await writeFile(destination, Buffer.from(base64, 'base64'))
|
||||
return { databasePath: `/workspace/maps/${destinationFileName}` }
|
||||
}
|
||||
|
||||
async function syncRemoteSavedMapArtifacts(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
artifacts: SavedMapArtifactDownload[],
|
||||
@@ -1347,7 +1390,13 @@ async function syncRemoteSavedMapArtifacts(
|
||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||
`MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`,
|
||||
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then',
|
||||
' IMAGE="$(docker image inspect future-nav2:humble-jetson_orin --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble-jetson_orin || docker image inspect future-nav2:humble --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble || true)"',
|
||||
' if docker image inspect future-nav2:humble-jetson_orin >/dev/null 2>&1; then',
|
||||
' IMAGE="future-nav2:humble-jetson_orin"',
|
||||
' elif docker image inspect future-nav2:humble >/dev/null 2>&1; then',
|
||||
' IMAGE="future-nav2:humble"',
|
||||
' else',
|
||||
' IMAGE=""',
|
||||
' fi',
|
||||
' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi',
|
||||
' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"',
|
||||
'fi',
|
||||
@@ -1460,7 +1509,7 @@ async function startRemoteVisualizationBridge(
|
||||
`-e ROBOT_STACK=${quoteShellSingle(stack === 'autoware' ? 'autoware' : 'nav2')}`,
|
||||
'-e ODOM_TOPIC=/odom -e SCAN_TOPIC=/scan -e MAP_TOPIC=/map -e CMD_VEL_TOPIC=/cmd_vel -e NAVIGATE_TO_POSE_ACTION=/navigate_to_pose',
|
||||
...(stack === 'slam3d'
|
||||
? ['-e POINT_CLOUD_TOPIC=/cloud_map -e FALLBACK_POINT_CLOUD_TOPIC=/lidar_filtered -e ICP_ODOM_TOPIC=/icp_odom -e POINT_CLOUD_LIMIT=50000 -e READ_ONLY=true -e RTABMAP_MAP_SAVE_ENABLED=true']
|
||||
? ['-e POINT_CLOUD_TOPIC=/cloud_map -e FALLBACK_POINT_CLOUD_TOPIC=/lidar_filtered -e ICP_ODOM_TOPIC=/icp_odom -e POINT_CLOUD_LIMIT=50000 -e RTABMAP_MAP_SAVE_ENABLED=true']
|
||||
: []),
|
||||
bridgeEnvArgs,
|
||||
'-e WORLD_LAYOUT_PATH=/workspace/world-layout.json -e SAVED_MAPS_PATH=/workspace/maps -e RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
|
||||
@@ -2450,7 +2499,7 @@ function bridgeContainerCommand(stack: VisualizationStack): string {
|
||||
return [
|
||||
'source /opt/ros/humble/setup.bash',
|
||||
setupWorkspace,
|
||||
`python3 -c "import fastapi, uvicorn, websockets" || python3 -m pip install --no-cache-dir -r ${requirementPath}`,
|
||||
`(python3 -c "import fastapi, uvicorn, websockets" || python3 -m pip install --no-cache-dir -r ${requirementPath})`,
|
||||
`python3 ${bridgePath}`
|
||||
].filter(Boolean).join(' && ')
|
||||
}
|
||||
@@ -2739,6 +2788,7 @@ async function startVisualizationBridge(
|
||||
rosDomainId?: string | null,
|
||||
environmentProfile?: EnvironmentProfileConfig | null,
|
||||
rosNetwork?: RosNetworkConfig | null,
|
||||
savedMapDatabasePath?: string | null,
|
||||
): Promise<VisualizationBridgeStatus> {
|
||||
const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput)
|
||||
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
|
||||
@@ -2840,6 +2890,10 @@ async function startVisualizationBridge(
|
||||
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
|
||||
)
|
||||
if (stack === 'slam3d') args.push('-e', 'RTABMAP_MAP_SAVE_ENABLED=true')
|
||||
const savedMapFileName = savedMapDatabasePath ? safeSavedMapFileName(basename(savedMapDatabasePath)) : null
|
||||
if (savedMapFileName?.endsWith('.db') && existsSync(resolve(layoutWorkspaceRoot, 'maps', savedMapFileName))) {
|
||||
args.push('-e', `SAVED_MAP_DATABASE_PATH=/workspace/maps/${savedMapFileName}`)
|
||||
}
|
||||
}
|
||||
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, requestedRosDomainId)
|
||||
if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) {
|
||||
@@ -3101,8 +3155,15 @@ async function installRemoteWorkspaceModule(
|
||||
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
||||
' if expected:',
|
||||
' import hashlib',
|
||||
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
|
||||
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
|
||||
' def checksum(file_path):',
|
||||
' with open(file_path, "rb") as fp: return hashlib.sha256(fp.read()).hexdigest().lower()',
|
||||
' actual = checksum(path)',
|
||||
' if actual != expected:',
|
||||
' # Stable artifact URLs may have a newer object than the local cache.',
|
||||
' os.unlink(path)',
|
||||
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
||||
' actual = checksum(path)',
|
||||
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
|
||||
' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)',
|
||||
' for entry in entries.splitlines():',
|
||||
' entry = entry.strip()',
|
||||
@@ -3512,13 +3573,13 @@ function buildNav2DockerRunScript(): string {
|
||||
'esac',
|
||||
'fi',
|
||||
'',
|
||||
'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* ]]; then',
|
||||
'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* || "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* ]]; then',
|
||||
' mkdir -p "$MODULE_ROOT/maps"',
|
||||
' has_database_path=false',
|
||||
' for arg in "$@"; do',
|
||||
' if [[ "$arg" == database_path:=* ]]; then has_database_path=true; break; fi',
|
||||
' done',
|
||||
' if [ "$has_database_path" = false ]; then',
|
||||
' if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* && "$has_database_path" = false ]] || [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* && "$has_database_path" = false ]]; then',
|
||||
' set -- "database_path:=/modules/maps/rtabmap-active.db" "$@"',
|
||||
' fi',
|
||||
'fi',
|
||||
@@ -3621,11 +3682,19 @@ function buildNav2DockerBuildScript(): string {
|
||||
' set -e',
|
||||
' source /opt/ros/humble/setup.bash',
|
||||
' mapfile -t paths < <(colcon list --base-paths src --paths-only)',
|
||||
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \\",
|
||||
' ROSDEP_SKIP_ARGS=()',
|
||||
' if ! apt-cache show ros-humble-gazebo-ros >/dev/null 2>&1; then',
|
||||
' ROSDEP_SKIP_ARGS=(--skip-keys gazebo_ros)',
|
||||
' fi',
|
||||
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \"${ROSDEP_SKIP_ARGS[@]}\" \\",
|
||||
" --dependency-types buildtool --dependency-types build \\",
|
||||
' --dependency-types build_export --dependency-types exec -r -y',
|
||||
' BUILD_TARGETS=("$2")',
|
||||
' if [[ "$1" == *lms-nav2-gazebo-slam* && "$1" != *lms-nav2-gazebo-slam3d* ]]; then',
|
||||
' BUILD_TARGETS=(slam_pkg slam2d_pkg slam3d_pkg)',
|
||||
' fi',
|
||||
" colcon --log-base log build --base-paths src --build-base build --install-base install \\",
|
||||
' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "$2"',
|
||||
' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "${BUILD_TARGETS[@]}"',
|
||||
"' bash \"$MODULE_FOLDER\" \"$TARGET\"",
|
||||
''
|
||||
].join('\n')
|
||||
@@ -3785,6 +3854,22 @@ async function findRunningNav2SimulatorContainer(): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function findNav2SimulatorRobotModel(containerName: string): Promise<string> {
|
||||
// Query Gazebo directly first. The ROS GetModelList service can be slow or
|
||||
// unavailable while DDS participants are still discovering each other, even
|
||||
// though the simulator itself is already able to accept a pose update.
|
||||
const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger']
|
||||
for (const modelName of knownModels) {
|
||||
const pose = await runCommandOutput(
|
||||
'docker',
|
||||
['exec', containerName, 'gz', 'model', '-m', modelName, '-p'],
|
||||
undefined,
|
||||
undefined,
|
||||
5_000,
|
||||
).catch(() => '')
|
||||
const values = pose.trim().split(/\s+/).map(Number)
|
||||
if (values.length === 6 && values.every(Number.isFinite)) return modelName
|
||||
}
|
||||
|
||||
const output = await runCommandOutput(
|
||||
'docker',
|
||||
[
|
||||
@@ -3798,7 +3883,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
|
||||
const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? ''
|
||||
const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1])
|
||||
const robotModel = modelNames.find((modelName) => (
|
||||
/turtlebot|burger|robot/i.test(modelName) && !/world/i.test(modelName)
|
||||
/turtlebot|burger|mkmini|mk-mini|robot/i.test(modelName) && !/world/i.test(modelName)
|
||||
))
|
||||
if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.')
|
||||
return robotModel
|
||||
@@ -3815,6 +3900,10 @@ async function setNav2SimulatorPose(
|
||||
const containerName = await findRunningNav2SimulatorContainer()
|
||||
if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.')
|
||||
const robotModel = await findNav2SimulatorRobotModel(containerName)
|
||||
// MK-mini's model origin is higher than TurtleBot's. Teleporting it at the
|
||||
// TurtleBot ground height puts its chassis and wheels into the floor, which
|
||||
// makes Gazebo eject or tip the vehicle while resolving the collision.
|
||||
const spawnHeight = /^mkmini/i.test(robotModel) ? '0.16' : '0.01'
|
||||
|
||||
await runCommand(
|
||||
'docker',
|
||||
@@ -3823,7 +3912,7 @@ async function setNav2SimulatorPose(
|
||||
'gz', 'model', '-m', robotModel,
|
||||
'-x', String(x),
|
||||
'-y', String(y),
|
||||
'-z', '0.01',
|
||||
'-z', spawnHeight,
|
||||
'-R', '0',
|
||||
'-P', '0',
|
||||
'-Y', String(yaw),
|
||||
@@ -5141,6 +5230,9 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => (
|
||||
readRemoteSavedMapArtifacts(target, artifacts)
|
||||
))
|
||||
ipcMain.handle('remote-workspace:maps:copy-active-rtabmap-database', (_event, target: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string) => (
|
||||
copyRemoteActiveRtabmapDatabaseToLocal(target, localWorkspaceRoot, destinationPath)
|
||||
))
|
||||
ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => (
|
||||
syncRemoteSavedMapArtifacts(target, artifacts)
|
||||
))
|
||||
@@ -5204,7 +5296,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus())
|
||||
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork))
|
||||
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath))
|
||||
ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile))
|
||||
ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath))
|
||||
ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
|
||||
|
||||
@@ -220,7 +220,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
||||
},
|
||||
visualization: {
|
||||
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
|
||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath),
|
||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
||||
},
|
||||
git: {
|
||||
@@ -266,6 +266,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||
},
|
||||
maps: {
|
||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
|
||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
||||
},
|
||||
modules: {
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+108
-73
@@ -428,6 +428,7 @@ function LocalPcWorkspaceMockup({
|
||||
platformEnvironmentProfiles,
|
||||
platformEnvironmentConfigOverride,
|
||||
platformOptions,
|
||||
workspaceVisible,
|
||||
skipInitialLoading,
|
||||
visualizationOpen,
|
||||
onVisualizationOpenChange,
|
||||
@@ -476,6 +477,7 @@ function LocalPcWorkspaceMockup({
|
||||
platformEnvironmentProfiles?: EnvironmentProfile[]
|
||||
platformEnvironmentConfigOverride?: Record<string, unknown> | null
|
||||
platformOptions: PlatformOption[]
|
||||
workspaceVisible: boolean
|
||||
skipInitialLoading?: boolean
|
||||
visualizationOpen: boolean
|
||||
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
|
||||
@@ -527,6 +529,7 @@ function LocalPcWorkspaceMockup({
|
||||
clearQaConversation
|
||||
} = useQaChat({
|
||||
accessToken,
|
||||
courseId: courseKey,
|
||||
weekNumber,
|
||||
apiBaseUrl: LMS_API_BASE_URL,
|
||||
socketBaseUrl: LMS_SOCKET_ORIGIN,
|
||||
@@ -804,6 +807,9 @@ function LocalPcWorkspaceMockup({
|
||||
const configuredMaps = (curriculumWeek.maps ?? [])
|
||||
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
|
||||
const baseMaps = [...configuredMaps]
|
||||
const usesThreeDimensionalSlam = (curriculumWeek.modules ?? []).some((module) => (
|
||||
/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')
|
||||
))
|
||||
const platformSlamMap: WorkspaceMapOption = {
|
||||
key: 'nav2-platform-slam',
|
||||
platformKey: 'NAV2',
|
||||
@@ -812,7 +818,7 @@ function LocalPcWorkspaceMockup({
|
||||
worldFile: '',
|
||||
viewerModel: 'turtlebot3_world',
|
||||
remoteSlam: true,
|
||||
runtimeKind: 'PLATFORM_2D',
|
||||
runtimeKind: usesThreeDimensionalSlam ? 'PLATFORM_3D' : 'PLATFORM_2D',
|
||||
}
|
||||
const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps]
|
||||
const keys = new Set<string>()
|
||||
@@ -821,7 +827,7 @@ function LocalPcWorkspaceMockup({
|
||||
keys.add(map.key)
|
||||
return true
|
||||
})
|
||||
}, [curriculumWeek.maps, savedSlamMaps])
|
||||
}, [curriculumWeek.maps, curriculumWeek.modules, savedSlamMaps])
|
||||
useEffect(() => {
|
||||
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
|
||||
?? window.localStorage.getItem(simulationMapStorageKey)
|
||||
@@ -839,6 +845,9 @@ function LocalPcWorkspaceMockup({
|
||||
})
|
||||
}, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey])
|
||||
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0]
|
||||
const prepareVisualizationSavedMapRef = useRef<(
|
||||
() => Promise<{ savedMapDatabasePath?: string }>
|
||||
) | null>(null)
|
||||
const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D'
|
||||
|| selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
||||
|| selectedWorldMap?.remoteSlam === true
|
||||
@@ -1004,6 +1013,11 @@ function LocalPcWorkspaceMockup({
|
||||
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
|
||||
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
|
||||
platform: visualizationPlatform,
|
||||
threeDimensional: Boolean(
|
||||
selectedWorldMap.databasePath || selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||
),
|
||||
savedMapDatabasePath: selectedWorldMap.databasePath,
|
||||
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
|
||||
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|
||||
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||
|| selectedWorldMap.remoteSlam
|
||||
@@ -1023,11 +1037,16 @@ function LocalPcWorkspaceMockup({
|
||||
if (initializedVisualizationMapRef.current === mapIdentity) return
|
||||
initializedVisualizationMapRef.current = mapIdentity
|
||||
const navigationMap = Boolean(selectedWorldMap?.saved)
|
||||
const threeDimensionalMap = Boolean(
|
||||
selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
||||
)
|
||||
// Saved RTAB-Map databases are navigable maps. Keep the 3D viewer while
|
||||
// enabling the same pose and goal controls used by NAV2 navigation.
|
||||
const nextMode = navigationMap ? 'navigation' : 'slam'
|
||||
const nextStack = navigationMap
|
||||
? 'nav2'
|
||||
: selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
||||
? 'slam3d'
|
||||
const nextStack = threeDimensionalMap
|
||||
? 'slam3d'
|
||||
: navigationMap
|
||||
? 'nav2'
|
||||
: 'slam'
|
||||
if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
|
||||
if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode)
|
||||
@@ -1108,7 +1127,14 @@ function LocalPcWorkspaceMockup({
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/maps?courseId=${encodeURIComponent(courseKey)}&week=${weekNumber}`)
|
||||
if (!response.ok) throw new Error(`saved_maps_failed_${response.status}`)
|
||||
const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }>
|
||||
setSavedSlamMaps(maps.map((map) => ({
|
||||
setSavedSlamMaps(maps.map((map) => {
|
||||
const isThreeDimensional = map.runtimeKind === 'PLATFORM_3D'
|
||||
|| Boolean(map.databasePath || map.databaseUrl)
|
||||
// Older 3D map rows can omit databasePath even though the database
|
||||
// artifact follows the YAML filename in the workspace maps directory.
|
||||
const databasePath = map.databasePath
|
||||
?? (isThreeDimensional ? map.yamlPath.replace(/\.yaml$/i, '.db') : undefined)
|
||||
return {
|
||||
key: `saved-slam-${map.id}`,
|
||||
platformKey: 'NAV2',
|
||||
name: map.name,
|
||||
@@ -1124,11 +1150,12 @@ function LocalPcWorkspaceMockup({
|
||||
yamlUrl: map.yamlUrl ?? undefined,
|
||||
pgmUrl: map.pgmUrl ?? undefined,
|
||||
previewPgm: map.previewPgm ?? undefined,
|
||||
databasePath: map.databasePath ?? undefined,
|
||||
databasePath,
|
||||
databaseUrl: map.databaseUrl ?? undefined,
|
||||
runtimeKind: map.runtimeKind
|
||||
?? (map.databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
|
||||
})))
|
||||
?? (databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
|
||||
}
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn('[studio] saved SLAM maps load failed', error)
|
||||
} finally {
|
||||
@@ -1150,20 +1177,28 @@ function LocalPcWorkspaceMockup({
|
||||
message?: string
|
||||
}
|
||||
if (payload?.source !== 'autonomy-slam-viewer') return
|
||||
if (payload.type === 'navigation-init') {
|
||||
const isPlatformMap = selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|
||||
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||
|| selectedWorldMap.remoteSlam
|
||||
// Only the Gazebo simulation has a model pose to reset. A platform
|
||||
// map can also be opened from the local workspace, where this IPC
|
||||
// would incorrectly fail because no Simulator container is running.
|
||||
if (remoteWorkspace || isPlatformMap) return
|
||||
void Promise.all([
|
||||
window.localWorkspace.nav2.setSimulatorPose({ x: -2, y: -0.5, yaw: 0 }),
|
||||
window.localWorkspace.nav2.clearRuntimeObstacles(),
|
||||
]).catch((error) => {
|
||||
console.warn('[studio] nav2 initialization failed', error)
|
||||
onTitlebarNotice('NAV2 초기화에 실패했습니다.', 'error')
|
||||
if (payload.type === 'simulator-pose-request' && payload.requestId && payload.pose) {
|
||||
const reply = (ok: boolean, message?: string) => {
|
||||
const target = event.source as Window | null
|
||||
target?.postMessage({
|
||||
source: 'autonomy-studio',
|
||||
type: 'simulator-pose-result',
|
||||
requestId: payload.requestId,
|
||||
ok,
|
||||
message,
|
||||
}, '*')
|
||||
}
|
||||
if (remoteWorkspace) {
|
||||
reply(false, '원격 플랫폼의 로봇 위치는 시각화에서 변경할 수 없습니다.')
|
||||
return
|
||||
}
|
||||
void window.localWorkspace.nav2.setSimulatorPose({
|
||||
x: Number(payload.pose.x),
|
||||
y: Number(payload.pose.y),
|
||||
yaw: Number(payload.pose.yaw ?? 0),
|
||||
}).then(() => reply(true)).catch((error) => {
|
||||
console.warn('[studio] nav2 simulator pose update failed', error)
|
||||
reply(false, error instanceof Error ? error.message : '가제보 로봇 위치 적용에 실패했습니다.')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1191,42 +1226,6 @@ function LocalPcWorkspaceMockup({
|
||||
})
|
||||
return
|
||||
}
|
||||
if (payload.type === 'initial-pose-selected') {
|
||||
const x = Number(payload.pose?.x)
|
||||
const y = Number(payload.pose?.y)
|
||||
const yaw = Number(payload.pose?.yaw ?? 0)
|
||||
if (![x, y, yaw].every(Number.isFinite)) return
|
||||
if (remoteWorkspace) {
|
||||
;(event.source as WindowProxy | null)?.postMessage({
|
||||
source: 'autonomy-lms-shell',
|
||||
type: 'initial-pose-applied',
|
||||
requestId: payload.requestId,
|
||||
ok: true,
|
||||
}, '*')
|
||||
return
|
||||
}
|
||||
void window.localWorkspace.nav2.setSimulatorPose({ x, y, yaw })
|
||||
.then(() => {
|
||||
(event.source as WindowProxy | null)?.postMessage({
|
||||
source: 'autonomy-lms-shell',
|
||||
type: 'initial-pose-applied',
|
||||
requestId: payload.requestId,
|
||||
ok: true,
|
||||
}, '*')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[studio] nav2 simulator pose update failed', error)
|
||||
onTitlebarNotice('Gazebo 로봇 위치 적용에 실패했습니다.', 'error')
|
||||
;(event.source as WindowProxy | null)?.postMessage({
|
||||
source: 'autonomy-lms-shell',
|
||||
type: 'initial-pose-applied',
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : 'Gazebo 로봇 위치 적용에 실패했습니다.',
|
||||
}, '*')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (payload.type === 'map-save-failed') {
|
||||
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
|
||||
return
|
||||
@@ -1234,14 +1233,33 @@ function LocalPcWorkspaceMockup({
|
||||
if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return
|
||||
void (async () => {
|
||||
try {
|
||||
let databasePath = payload.map.databasePath ?? null
|
||||
const savesThreeDimensionalMap = remoteWorkspace
|
||||
&& visualizationBridgeLocation === 'local'
|
||||
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||
if (savesThreeDimensionalMap && !databasePath) {
|
||||
const databaseFileName = payload.map.yamlPath.replace(/\.yaml$/i, '.db')
|
||||
const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal(
|
||||
getRemoteWorkspaceTarget(),
|
||||
localVisualizationWorkspaceRoot,
|
||||
databaseFileName,
|
||||
)
|
||||
databasePath = copied.databasePath
|
||||
}
|
||||
const sourceArtifacts = [
|
||||
{ name: 'yaml' as const, path: payload.map.yamlPath },
|
||||
{ name: 'pgm' as const, path: payload.map.pgmPath },
|
||||
...(payload.map.databasePath ? [{ name: 'database' as const, path: payload.map.databasePath }] : []),
|
||||
...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []),
|
||||
]
|
||||
const artifacts = remoteWorkspace
|
||||
// A remote ROS session can still use a bridge running on this PC.
|
||||
// Read saved artifacts from the bridge host, not merely from the
|
||||
// currently selected workspace.
|
||||
const artifacts = remoteWorkspace && visualizationBridgeLocation === 'platform'
|
||||
? await window.remoteWorkspace.maps.readArtifacts(getRemoteWorkspaceTarget(), sourceArtifacts)
|
||||
: await window.localWorkspace.maps.readArtifacts(clonePathPreview, sourceArtifacts)
|
||||
: await window.localWorkspace.maps.readArtifacts(
|
||||
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
|
||||
sourceArtifacts,
|
||||
)
|
||||
const form = new FormData()
|
||||
form.set('courseId', courseKey)
|
||||
form.set('week', String(weekNumber))
|
||||
@@ -1249,12 +1267,12 @@ function LocalPcWorkspaceMockup({
|
||||
form.set('worldFile', selectedWorldMap.worldFile)
|
||||
form.set('viewerModel', selectedWorldMap.viewerModel)
|
||||
form.set('runtimeKind', remoteWorkspace
|
||||
? (payload.map.databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
|
||||
? (databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
|
||||
: 'SIMULATION')
|
||||
form.set('yamlPath', payload.map.yamlPath)
|
||||
form.set('pgmPath', payload.map.pgmPath)
|
||||
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
|
||||
if (payload.map.databasePath) form.set('databasePath', payload.map.databasePath)
|
||||
if (databasePath) form.set('databasePath', databasePath)
|
||||
artifacts.forEach((artifact) => {
|
||||
const binary = atob(artifact.base64)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
@@ -1281,7 +1299,7 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
window.addEventListener('message', handleViewerMessage)
|
||||
return () => window.removeEventListener('message', handleViewerMessage)
|
||||
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, weekNumber])
|
||||
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, localVisualizationWorkspaceRoot, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, visualizationBridgeLocation, weekNumber])
|
||||
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
|
||||
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
|
||||
const hasRestorableWorkspaceUi = Boolean(
|
||||
@@ -1290,7 +1308,10 @@ function LocalPcWorkspaceMockup({
|
||||
)
|
||||
const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(!skipInitialLoading && hasRestorableWorkspaceUi)
|
||||
const workspaceRestoreStartedAtRef = useRef(Date.now())
|
||||
const getModuleParameterStorageKey = (module: ModuleRow): string => makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name)
|
||||
const moduleParameterScope = remoteWorkspace ? `platform:${platformId}` : 'local'
|
||||
const getModuleParameterStorageKey = (module: ModuleRow): string => (
|
||||
makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name, moduleParameterScope)
|
||||
)
|
||||
const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => {
|
||||
const storedParameters = readStoredModuleParameters()
|
||||
return modules.map((module) => {
|
||||
@@ -1304,7 +1325,7 @@ function LocalPcWorkspaceMockup({
|
||||
: defaultParameters.length ? defaultParameters : module.parameters
|
||||
}
|
||||
})
|
||||
}, [courseKey, runtimeModules, weekNumber])
|
||||
}, [courseKey, moduleParameterScope, runtimeModules, weekNumber])
|
||||
const curriculumModuleKey = runtimeModules
|
||||
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
|
||||
.join('|')
|
||||
@@ -1312,6 +1333,7 @@ function LocalPcWorkspaceMockup({
|
||||
availableModules,
|
||||
setAvailableModules,
|
||||
markModuleState,
|
||||
removeModule,
|
||||
modulesLoading,
|
||||
moduleListError,
|
||||
clearModuleListError,
|
||||
@@ -2408,16 +2430,21 @@ function LocalPcWorkspaceMockup({
|
||||
artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl })
|
||||
databasePath = `/workspace/maps/${databaseFileName}`
|
||||
}
|
||||
if (remoteWorkspace) {
|
||||
const syncToPlatformWorkspace = remoteWorkspace && visualizationBridgeLocation === 'platform'
|
||||
if (syncToPlatformWorkspace) {
|
||||
await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts)
|
||||
} else {
|
||||
await window.localWorkspace.maps.sync(clonePathPreview, artifacts)
|
||||
await window.localWorkspace.maps.sync(
|
||||
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
|
||||
artifacts,
|
||||
)
|
||||
}
|
||||
return {
|
||||
savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
|
||||
savedMapDatabasePath: databasePath,
|
||||
}
|
||||
}, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace, selectedWorldMap])
|
||||
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
|
||||
prepareVisualizationSavedMapRef.current = prepareSavedMap
|
||||
const vehicleRuntimeProfiles = useMemo(() => {
|
||||
const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>()
|
||||
platformOptions.forEach((platform) => {
|
||||
@@ -2447,6 +2474,8 @@ function LocalPcWorkspaceMockup({
|
||||
weekNumber,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
moduleParameterScope,
|
||||
workspaceActive: workspaceVisible,
|
||||
sourceSettings: runtimeSourceSettings,
|
||||
rosNetwork: moduleRosNetworkConfig,
|
||||
simulatorWorldFile: selectedWorldMap.worldFile,
|
||||
@@ -2483,7 +2512,7 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
if (map.saved) {
|
||||
setNav2VisualizationMode('navigation')
|
||||
setVisualizationStack('nav2')
|
||||
setVisualizationStack(map.databasePath ? 'slam3d' : 'nav2')
|
||||
} else {
|
||||
setNav2VisualizationMode('slam')
|
||||
if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d')
|
||||
@@ -2575,7 +2604,11 @@ function LocalPcWorkspaceMockup({
|
||||
delete nextRoots[module.name]
|
||||
return nextRoots
|
||||
})
|
||||
await loadAvailableModules()
|
||||
if (module.sourceType === 'CUSTOM') {
|
||||
removeModule(module)
|
||||
} else {
|
||||
markModuleState(module, 'uninstalled')
|
||||
}
|
||||
onTitlebarNotice(options.deleteRepository ? `[${module.name}] 모듈과 저장소가 삭제되었습니다` : `[${module.name}] 삭제되었습니다`, 'info')
|
||||
} catch (error) {
|
||||
console.error('[modules] delete failed', error)
|
||||
@@ -4178,6 +4211,7 @@ export default function App(): React.JSX.Element {
|
||||
deleteAiConversation: deleteGuideAiConversation
|
||||
} = useQaChat({
|
||||
accessToken: authSession?.accessToken ?? '',
|
||||
courseId: selectedCourseId,
|
||||
weekNumber: 0,
|
||||
globalScope: true,
|
||||
apiBaseUrl: LMS_API_BASE_URL,
|
||||
@@ -5605,6 +5639,7 @@ export default function App(): React.JSX.Element {
|
||||
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
|
||||
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
|
||||
platformOptions={platformOptions}
|
||||
workspaceVisible={localPcLayoutOpen}
|
||||
skipInitialLoading={skipWorkspaceInitialLoading}
|
||||
visualizationOpen={workspaceVisualizationOpen}
|
||||
onVisualizationOpenChange={setWorkspaceVisualizationOpen}
|
||||
|
||||
Vendored
+2
-1
@@ -235,7 +235,7 @@ interface Window {
|
||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
||||
message?: string
|
||||
}>
|
||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{
|
||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => Promise<{
|
||||
ok: boolean
|
||||
running: boolean
|
||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
||||
@@ -280,6 +280,7 @@ interface Window {
|
||||
}
|
||||
maps: {
|
||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
||||
}
|
||||
modules: {
|
||||
|
||||
@@ -338,7 +338,7 @@ function WorkspaceSourceControlView({
|
||||
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
|
||||
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
|
||||
>
|
||||
파일 열기
|
||||
열기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -212,6 +212,12 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
||||
{maps.map((map) => {
|
||||
const pending = map.key === pendingMap?.key
|
||||
const selected = map.key === selectedMapKey
|
||||
// "새 SLAM" is a launch target, not a saved map yet. Its 2D/3D
|
||||
// runtime is chosen by the active SLAM stack, so only persisted
|
||||
// map results receive a dimensional badge.
|
||||
const mapDimension = map.saved
|
||||
? (map.runtimeKind === 'PLATFORM_3D' || Boolean(map.databasePath) ? '3D' : '2D')
|
||||
: null
|
||||
return (
|
||||
<button
|
||||
key={map.key}
|
||||
@@ -224,7 +230,15 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
||||
>
|
||||
<WorkspaceMapPreview map={map} selected={pending} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-bold">{map.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[14px] font-bold">{map.name}</span>
|
||||
{mapDimension && (
|
||||
<span className={[
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold',
|
||||
mapDimension === '3D' ? 'bg-cyan-400/15 text-cyan-200' : 'bg-white/10 text-white/55',
|
||||
].join(' ')}>{mapDimension}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
|
||||
</span>
|
||||
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
|
||||
|
||||
@@ -54,6 +54,11 @@ export function storeModuleParameters(key: string, parameters: ModuleParameterPa
|
||||
}
|
||||
}
|
||||
|
||||
export function makeModuleParameterKey(courseKey: string, week: number, folderName?: string): string {
|
||||
return `${courseKey || 'default-course'}:week-${week}:${folderName || 'module'}`
|
||||
export function makeModuleParameterKey(
|
||||
courseKey: string,
|
||||
week: number,
|
||||
folderName?: string,
|
||||
workspaceScope = 'local'
|
||||
): string {
|
||||
return `${courseKey || 'default-course'}:week-${week}:${workspaceScope}:${folderName || 'module'}`
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type ChatNotificationPayload = {
|
||||
|
||||
type UseQaChatOptions = {
|
||||
accessToken: string
|
||||
courseId: string
|
||||
weekNumber: number
|
||||
globalScope?: boolean
|
||||
apiBaseUrl: string
|
||||
@@ -73,6 +74,7 @@ const qaVisibleMessageLimit = 20
|
||||
|
||||
export function useQaChat({
|
||||
accessToken,
|
||||
courseId,
|
||||
weekNumber,
|
||||
globalScope = false,
|
||||
apiBaseUrl,
|
||||
@@ -100,9 +102,12 @@ export function useQaChat({
|
||||
const aiRevealCancelRef = useRef(false)
|
||||
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const normalizedCourseId = courseId.trim()
|
||||
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
|
||||
const chatAvailable = globalScope || validChatWeek !== null
|
||||
const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : ''
|
||||
const chatAvailable = Boolean(normalizedCourseId) && (globalScope || validChatWeek !== null)
|
||||
const chatQuery = new URLSearchParams({ courseId: normalizedCourseId })
|
||||
if (validChatWeek) chatQuery.set('week', String(validChatWeek))
|
||||
const chatQueryString = chatQuery.toString()
|
||||
|
||||
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
||||
const requestAccessToken = accessToken ? await getFreshAccessToken() : null
|
||||
@@ -166,7 +171,7 @@ export function useQaChat({
|
||||
let cancelled = false
|
||||
|
||||
setAiMessagesLoading(true)
|
||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`)
|
||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=ai&includeArchived=true`)
|
||||
.then((payload) => {
|
||||
if (cancelled) return
|
||||
const conversations = payload.threads.map((thread) => ({
|
||||
@@ -191,7 +196,7 @@ export function useQaChat({
|
||||
})
|
||||
|
||||
setTeacherMessagesLoading(true)
|
||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`)
|
||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=teacher`)
|
||||
.then((payload) => {
|
||||
if (cancelled) return
|
||||
const thread = payload.threads[0]
|
||||
@@ -211,7 +216,7 @@ export function useQaChat({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery])
|
||||
}, [accessToken, chatAvailable, chatQueryString, chatRefreshSignal])
|
||||
|
||||
useEffect(() => {
|
||||
const target = workspaceChatScrollRef.current
|
||||
@@ -264,7 +269,7 @@ export function useQaChat({
|
||||
setAiPending(false)
|
||||
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {})
|
||||
body: JSON.stringify({ courseId: normalizedCourseId, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
||||
}).catch(() => {})
|
||||
setAiMessages((messages) => [
|
||||
...messages,
|
||||
@@ -294,7 +299,7 @@ export function useQaChat({
|
||||
try {
|
||||
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
||||
body: JSON.stringify({ courseId: normalizedCourseId, mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
||||
})
|
||||
setTeacherMessages((messages) => messages.map((message) => (
|
||||
message.id === userMessage.id
|
||||
@@ -333,6 +338,7 @@ export function useQaChat({
|
||||
const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
courseId: normalizedCourseId,
|
||||
content,
|
||||
...(validChatWeek ? { week: validChatWeek } : {}),
|
||||
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
|
||||
@@ -429,7 +435,7 @@ export function useQaChat({
|
||||
setAiTypingMessageId(null)
|
||||
}
|
||||
|
||||
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, {
|
||||
await requestChatApi<{ deletedCount: number }>(`/chat/threads?${chatQueryString}&mode=${qaMode}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ModuleRow } from './types'
|
||||
import type { GitStatusInfo, GitSubmissionInfo } from './gitUtils'
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useSourceControl({
|
||||
const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('')
|
||||
const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false)
|
||||
const [gitSubmitting, setGitSubmitting] = useState(false)
|
||||
const gitFileSelectionRequestRef = useRef(0)
|
||||
|
||||
const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).join('|')
|
||||
const moduleFolderKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}`).join('|')
|
||||
@@ -87,8 +88,30 @@ export function useSourceControl({
|
||||
const selectGitFile = useCallback(async (moduleName: string, filePath: string): Promise<void> => {
|
||||
const target = { moduleName, filePath }
|
||||
setSelectedGitTarget(target)
|
||||
await refreshGitStatuses(target)
|
||||
}, [refreshGitStatuses])
|
||||
const cachedStatus = gitModuleStatuses[moduleName]
|
||||
if (cachedStatus?.selectedFilePath === filePath) return
|
||||
|
||||
const module = installedGitModules.find((item) => item.name === moduleName)
|
||||
if (!module) return
|
||||
|
||||
const requestId = ++gitFileSelectionRequestRef.current
|
||||
setGitLoading(true)
|
||||
setGitError(null)
|
||||
try {
|
||||
// Selecting a file used to refresh every module's status and history.
|
||||
// Only the selected module needs its diff refreshed here.
|
||||
const status = await readGitStatus(module, filePath)
|
||||
if (requestId !== gitFileSelectionRequestRef.current) return
|
||||
setGitModuleStatuses((statuses) => ({ ...statuses, [moduleName]: status }))
|
||||
if (!status.ok) setGitError(status.message ?? '파일 변경 내용을 불러오지 못했습니다.')
|
||||
} catch (error) {
|
||||
if (requestId !== gitFileSelectionRequestRef.current) return
|
||||
console.error('[git] selected file status failed', error)
|
||||
setGitError(error instanceof Error ? error.message : '파일 변경 내용을 불러오지 못했습니다.')
|
||||
} finally {
|
||||
if (requestId === gitFileSelectionRequestRef.current) setGitLoading(false)
|
||||
}
|
||||
}, [gitModuleStatuses, installedGitModules, readGitStatus])
|
||||
|
||||
const refreshGitSubmissions = useCallback(async (): Promise<void> => {
|
||||
if (!installedGitModules.length) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { LMS_API_BASE_URL } from '../../config/appConfig'
|
||||
import type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types'
|
||||
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
|
||||
@@ -40,6 +40,8 @@ type UseWorkspaceModuleControlOptions = {
|
||||
weekNumber: number
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
moduleParameterScope: string
|
||||
workspaceActive?: boolean
|
||||
sourceSettings: StudioSourceSettings
|
||||
rosNetwork?: RosNetworkConfig | null
|
||||
simulatorWorldFile?: string | null
|
||||
@@ -80,6 +82,8 @@ export function useWorkspaceModuleControl({
|
||||
weekNumber,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
moduleParameterScope,
|
||||
workspaceActive = true,
|
||||
sourceSettings,
|
||||
rosNetwork,
|
||||
simulatorWorldFile,
|
||||
@@ -99,7 +103,23 @@ export function useWorkspaceModuleControl({
|
||||
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
|
||||
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
|
||||
const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const queuedModuleNamesRef = useRef(new Set<string>())
|
||||
const queuedModuleActionsRef = useRef(new Map<string, number>())
|
||||
const moduleActionIdRef = useRef(0)
|
||||
const controlGenerationRef = useRef(0)
|
||||
const workspaceActiveRef = useRef(workspaceActive)
|
||||
|
||||
useEffect(() => {
|
||||
workspaceActiveRef.current = workspaceActive
|
||||
if (workspaceActive) return
|
||||
|
||||
// Workspace pages remain mounted while another LMS page is shown. Do not
|
||||
// leave their control buttons locked behind a detached IPC request.
|
||||
controlGenerationRef.current += 1
|
||||
queuedModuleActionsRef.current.clear()
|
||||
moduleActionQueueRef.current = Promise.resolve()
|
||||
setControllingModuleNames([])
|
||||
setInstallingModuleName(null)
|
||||
}, [workspaceActive])
|
||||
const isExcludedFromBulkControl = useCallback((module: ModuleRow): boolean => (
|
||||
bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
|
||||
), [bulkExcludedModuleKeys])
|
||||
@@ -120,9 +140,18 @@ export function useWorkspaceModuleControl({
|
||||
!parameter.startsWith('runtime_wheelbase:=')
|
||||
&& !parameter.startsWith('runtime_max_speed:=')
|
||||
&& !parameter.startsWith('runtime_max_steering_angle:=')
|
||||
&& (remoteWorkspace || (
|
||||
!parameter.startsWith('sim_mode:=')
|
||||
&& !parameter.startsWith('simulator:=')
|
||||
))
|
||||
))
|
||||
))
|
||||
if (isMkMiniModule && mkMiniProfile) {
|
||||
if (!remoteWorkspace) {
|
||||
// The local workspace pairs MK-mini with NAV2-Gazebo Simulator. Do not
|
||||
// let the real-platform module default start hardware CAN/LiDAR here.
|
||||
parameters.push('sim_mode:=true', 'simulator:=gazebo')
|
||||
}
|
||||
parameters.push(`runtime_wheelbase:=${mkMiniProfile.wheelbaseMeters}`)
|
||||
parameters.push(`runtime_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`)
|
||||
parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`)
|
||||
@@ -134,12 +163,15 @@ export function useWorkspaceModuleControl({
|
||||
parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)
|
||||
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
}
|
||||
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '') && mapDatabasePath) {
|
||||
parameters.push(`database_path:=${mapDatabasePath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
parameters.push('localization:=true')
|
||||
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
|
||||
const databasePath = mapDatabasePath
|
||||
?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
|
||||
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
|
||||
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true')
|
||||
}
|
||||
return parameters.join(' ')
|
||||
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
|
||||
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
|
||||
|
||||
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
|
||||
if (!module.folderName || !module.packageName || !module.launchFile) {
|
||||
@@ -427,6 +459,7 @@ export function useWorkspaceModuleControl({
|
||||
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
||||
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
||||
if (!workspaceActiveRef.current) return
|
||||
// The parking area only opts modules out of bulk actions; those modules
|
||||
// must remain individually controllable while the rest are stopping.
|
||||
const canControlExcludedModuleDuringBulkStop = (
|
||||
@@ -435,14 +468,22 @@ export function useWorkspaceModuleControl({
|
||||
)
|
||||
if (
|
||||
(!canControlExcludedModuleDuringBulkStop && installingModuleName)
|
||||
|| queuedModuleNamesRef.current.has(module.name)
|
||||
|| queuedModuleActionsRef.current.has(module.name)
|
||||
) return
|
||||
|
||||
queuedModuleNamesRef.current.add(module.name)
|
||||
const actionId = ++moduleActionIdRef.current
|
||||
const controlGeneration = controlGenerationRef.current
|
||||
queuedModuleActionsRef.current.set(module.name, actionId)
|
||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||
setModuleInstallError(null)
|
||||
const execute = async (): Promise<void> => {
|
||||
const isCurrentControl = (): boolean => (
|
||||
workspaceActiveRef.current
|
||||
&& controlGenerationRef.current === controlGeneration
|
||||
&& queuedModuleActionsRef.current.get(module.name) === actionId
|
||||
)
|
||||
try {
|
||||
if (!isCurrentControl()) return
|
||||
const savedMapOverrides = action === 'start' || action === 'restart'
|
||||
? await prepareSavedMap?.()
|
||||
: undefined
|
||||
@@ -462,6 +503,7 @@ export function useWorkspaceModuleControl({
|
||||
? await remoteModuleStart(module, savedMapOverrides)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
if (!isCurrentControl()) return
|
||||
|
||||
if (action === 'start' || action === 'restart') {
|
||||
markModuleState(module, 'running')
|
||||
@@ -480,7 +522,9 @@ export function useWorkspaceModuleControl({
|
||||
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
|
||||
}
|
||||
}
|
||||
if (!isCurrentControl()) return
|
||||
await loadAvailableModules({ silent: true })
|
||||
if (!isCurrentControl()) return
|
||||
const actionLabel = action === 'stop'
|
||||
? '종료되었습니다'
|
||||
: action === 'build'
|
||||
@@ -491,6 +535,7 @@ export function useWorkspaceModuleControl({
|
||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
|
||||
} catch (error) {
|
||||
console.error(`[modules] ${action} failed`, error)
|
||||
if (!isCurrentControl()) return
|
||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||
const actionLabel = action === 'stop'
|
||||
? '종료 실패'
|
||||
@@ -502,8 +547,10 @@ export function useWorkspaceModuleControl({
|
||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
|
||||
await loadAvailableModules({ silent: true })
|
||||
} finally {
|
||||
queuedModuleNamesRef.current.delete(module.name)
|
||||
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
||||
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
|
||||
queuedModuleActionsRef.current.delete(module.name)
|
||||
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,14 +587,17 @@ export function useWorkspaceModuleControl({
|
||||
const normalizedParameters = parameters
|
||||
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
|
||||
.filter((parameter) => parameter.name)
|
||||
storeModuleParameters(makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name), normalizedParameters)
|
||||
storeModuleParameters(
|
||||
makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name, moduleParameterScope),
|
||||
normalizedParameters
|
||||
)
|
||||
const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean)
|
||||
setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item))
|
||||
} catch (error) {
|
||||
console.error('[modules] update parameters failed', error)
|
||||
setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패')
|
||||
}
|
||||
}, [courseKey, setAvailableModules, weekNumber])
|
||||
}, [courseKey, moduleParameterScope, setAvailableModules, weekNumber])
|
||||
|
||||
return {
|
||||
installingModuleName,
|
||||
|
||||
@@ -23,6 +23,7 @@ type UseWorkspaceModuleListResult = {
|
||||
availableModules: ModuleRow[]
|
||||
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
|
||||
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
|
||||
removeModule: (module: ModuleRow) => void
|
||||
modulesLoading: boolean
|
||||
moduleListError: string | null
|
||||
clearModuleListError: () => void
|
||||
@@ -130,6 +131,16 @@ export function useWorkspaceModuleList({
|
||||
})
|
||||
}, [moduleCacheKey])
|
||||
|
||||
const removeModule = useCallback((module: ModuleRow): void => {
|
||||
const key = moduleStateKey(module)
|
||||
pendingModuleStatesRef.current.delete(key)
|
||||
setAvailableModules((current) => {
|
||||
const next = current.filter((item) => moduleStateKey(item) !== key)
|
||||
storeRemoteModuleCache(moduleCacheKey, next)
|
||||
return next
|
||||
})
|
||||
}, [moduleCacheKey])
|
||||
|
||||
useEffect(() => {
|
||||
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
|
||||
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
|
||||
@@ -219,6 +230,7 @@ export function useWorkspaceModuleList({
|
||||
availableModules,
|
||||
setAvailableModules,
|
||||
markModuleState,
|
||||
removeModule,
|
||||
modulesLoading,
|
||||
moduleListError,
|
||||
clearModuleListError: () => setModuleListError(null),
|
||||
|
||||
@@ -73,6 +73,9 @@ type UseWorkspaceVisualizationOptions = {
|
||||
rosNetwork?: RosNetworkConfig | null
|
||||
mapKey?: string | null
|
||||
platform?: string | null
|
||||
threeDimensional?: boolean
|
||||
savedMapDatabasePath?: string | null
|
||||
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string }>
|
||||
visualizationRuntime?: 'real' | 'simulation'
|
||||
worldLayoutVersion?: number
|
||||
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
||||
@@ -114,6 +117,9 @@ export function useWorkspaceVisualization({
|
||||
rosNetwork,
|
||||
mapKey,
|
||||
platform,
|
||||
threeDimensional = false,
|
||||
savedMapDatabasePath,
|
||||
prepareSavedMap,
|
||||
visualizationRuntime,
|
||||
worldLayoutVersion,
|
||||
onTitlebarNotice
|
||||
@@ -165,7 +171,10 @@ export function useWorkspaceVisualization({
|
||||
const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
||||
const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack
|
||||
const pointCloudQuery = visualizationStack === 'slam3d' ? '&pointCloud=1' : ''
|
||||
// A saved RTAB-Map database can be selected while the profile itself still
|
||||
// resolves to the generic NAV2 viewer. Keep the viewer mode tied to the map,
|
||||
// not only to the profile label, so 3D state is never rendered as /scan.
|
||||
const pointCloudQuery = visualizationStack === 'slam3d' || threeDimensional ? '&pointCloud=1' : ''
|
||||
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation')
|
||||
const localBridgeProfile = useMemo(() => {
|
||||
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
|
||||
@@ -184,7 +193,7 @@ export function useWorkspaceVisualization({
|
||||
}
|
||||
}, [activeEnvironmentConfig, visualizationRuntimeStack])
|
||||
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2'
|
||||
? `./slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
|
||||
? `./slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
|
||||
: `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
||||
|
||||
useEffect(() => {
|
||||
@@ -244,11 +253,15 @@ export function useWorkspaceVisualization({
|
||||
setVisualizationBusy(true)
|
||||
setVisualizationStatusMessage('시작 중')
|
||||
try {
|
||||
// Download the selected map artifacts before starting the bridge. The
|
||||
// bridge only receives workspace-local paths, never an R2 URL.
|
||||
const preparedMap = await prepareSavedMap?.()
|
||||
const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath
|
||||
const status = bridgeRunsOnPlatform
|
||||
? bridgeTarget?.host
|
||||
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
|
||||
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
|
||||
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork)
|
||||
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork, bridgeSavedMapDatabasePath)
|
||||
if (!status.ok) throw new Error(status.message ?? '시작 실패')
|
||||
setVisualizationRunning(Boolean(status.running))
|
||||
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'
|
||||
|
||||
Reference in New Issue
Block a user