synchronize source..

This commit is contained in:
2026-08-11 14:23:29 +09:00
parent a88f0920cf
commit f6be625a8f
18 changed files with 1485 additions and 273 deletions
+105 -13
View File
@@ -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))
+2 -1
View File
@@ -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
View File
@@ -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}
+2 -1
View File
@@ -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'